diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index a3576f16..2abb2e48 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -76,6 +76,7 @@ export const ModerationQueue = forwardRef>({}); + const [transactionStatuses, setTransactionStatuses] = useState>({}); const [photoModalOpen, setPhotoModalOpen] = useState(false); const [selectedPhotos, setSelectedPhotos] = useState([]); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0); @@ -196,6 +197,50 @@ export const ModerationQueue = forwardRef ({ ...prev, [id]: value })); }; + // Transaction status helpers + const setTransactionStatus = useCallback((submissionId: string, status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed', message?: string) => { + setTransactionStatuses(prev => ({ + ...prev, + [submissionId]: { status, message } + })); + + // Auto-clear completed/failed statuses after 5 seconds + if (status === 'completed' || status === 'failed') { + setTimeout(() => { + setTransactionStatuses(prev => { + const updated = { ...prev }; + if (updated[submissionId]?.status === status) { + updated[submissionId] = { status: 'idle' }; + } + return updated; + }); + }, 5000); + } + }, []); + + // Wrap performAction to track transaction status + const handlePerformAction = useCallback(async (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => { + setTransactionStatus(item.id, 'processing'); + try { + await queueManager.performAction(item, action, notes); + setTransactionStatus(item.id, 'completed'); + } catch (error: any) { + // Check for timeout + if (error?.type === 'timeout' || error?.message?.toLowerCase().includes('timeout')) { + setTransactionStatus(item.id, 'timeout', error.message); + } + // Check for cached/409 + else if (error?.status === 409 || error?.message?.toLowerCase().includes('duplicate')) { + setTransactionStatus(item.id, 'cached', 'Using cached result from duplicate request'); + } + // Generic failure + else { + setTransactionStatus(item.id, 'failed', error.message); + } + throw error; // Re-throw to allow normal error handling + } + }, [queueManager, setTransactionStatus]); + // Wrapped delete with confirmation const handleDeleteSubmission = useCallback((item: ModerationItem) => { setConfirmDialog({ @@ -495,8 +540,9 @@ export const ModerationQueue = forwardRef; onNoteChange: (id: string, value: string) => void; onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void; onResetToPending: (item: ModerationItem) => void; @@ -65,6 +66,7 @@ export const QueueItem = memo(({ isSuperuser, queueIsLoading, isInitialRender = false, + transactionStatuses, onNoteChange, onApprove, onResetToPending, @@ -82,6 +84,11 @@ export const QueueItem = memo(({ const [isClaiming, setIsClaiming] = useState(false); const [showRawData, setShowRawData] = useState(false); + // Get transaction status from props or default to idle + const transactionState = transactionStatuses?.[item.id] || { status: 'idle' as const }; + const transactionStatus = transactionState.status; + const transactionMessage = transactionState.message; + // Fetch relational photo data for photo submissions const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems( item.submission_type === 'photo' ? item.id : undefined @@ -145,6 +152,8 @@ export const QueueItem = memo(({ isLockedByOther={isLockedByOther} currentLockSubmissionId={currentLockSubmissionId} validationResult={validationResult} + transactionStatus={transactionStatus} + transactionMessage={transactionMessage} onValidationChange={handleValidationChange} onViewRawData={() => setShowRawData(true)} /> diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index c544c0c2..2ca96eee 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -39,6 +39,7 @@ import { ValidationBlockerDialog } from './ValidationBlockerDialog'; import { WarningConfirmDialog } from './WarningConfirmDialog'; import { ConflictResolutionModal } from './ConflictResolutionModal'; import { EditHistoryAccordion } from './EditHistoryAccordion'; +import { TransactionStatusIndicator } from './TransactionStatusIndicator'; import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas'; import { logger } from '@/lib/logger'; import { ModerationErrorBoundary } from '@/components/error'; @@ -83,6 +84,8 @@ export function SubmissionReviewManager({ message: string; errorId?: string; } | null>(null); + const [transactionStatus, setTransactionStatus] = useState<'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'>('idle'); + const [transactionMessage, setTransactionMessage] = useState(); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); @@ -337,6 +340,7 @@ export function SubmissionReviewManager({ } // Proceed with approval - wrapped with transaction resilience + setTransactionStatus('processing'); await executeTransaction( 'approval', selectedIds, @@ -400,10 +404,34 @@ export function SubmissionReviewManager({ onComplete(); onOpenChange(false); + setTransactionStatus('completed'); + setTimeout(() => setTransactionStatus('idle'), 3000); + return data; } ); } catch (error: unknown) { + // Check for timeout + if (error && typeof error === 'object' && 'type' in error && error.type === 'timeout') { + setTransactionStatus('timeout'); + setTransactionMessage(getErrorMessage(error)); + } + // Check for cached/409 + else if (error && typeof error === 'object' && ('status' in error && error.status === 409)) { + setTransactionStatus('cached'); + setTransactionMessage('Using cached result from duplicate request'); + } + // Generic failure + else { + setTransactionStatus('failed'); + setTransactionMessage(getErrorMessage(error)); + } + + setTimeout(() => { + setTransactionStatus('idle'); + setTransactionMessage(undefined); + }, 5000); + dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } }); handleError(error, { action: 'Approve Submission Items', @@ -467,6 +495,7 @@ export function SubmissionReviewManager({ try { // Wrap rejection with transaction resilience + setTransactionStatus('processing'); await executeTransaction( 'rejection', selectedIds, @@ -484,10 +513,34 @@ export function SubmissionReviewManager({ onComplete(); onOpenChange(false); + setTransactionStatus('completed'); + setTimeout(() => setTransactionStatus('idle'), 3000); + return { success: true }; } ); } catch (error: unknown) { + // Check for timeout + if (error && typeof error === 'object' && 'type' in error && error.type === 'timeout') { + setTransactionStatus('timeout'); + setTransactionMessage(getErrorMessage(error)); + } + // Check for cached/409 + else if (error && typeof error === 'object' && ('status' in error && error.status === 409)) { + setTransactionStatus('cached'); + setTransactionMessage('Using cached result from duplicate request'); + } + // Generic failure + else { + setTransactionStatus('failed'); + setTransactionMessage(getErrorMessage(error)); + } + + setTimeout(() => { + setTransactionStatus('idle'); + setTransactionMessage(undefined); + }, 5000); + dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } }); handleError(error, { action: 'Reject Submission Items', @@ -625,7 +678,10 @@ export function SubmissionReviewManager({ {isMobile ? ( - Review Submission +
+ Review Submission + +
{pendingCount} pending item(s) • {selectedCount} selected @@ -635,7 +691,10 @@ export function SubmissionReviewManager({ ) : ( - Review Submission +
+ Review Submission + +
{pendingCount} pending item(s) • {selectedCount} selected diff --git a/src/components/moderation/TransactionStatusIndicator.tsx b/src/components/moderation/TransactionStatusIndicator.tsx new file mode 100644 index 00000000..c38d944d --- /dev/null +++ b/src/components/moderation/TransactionStatusIndicator.tsx @@ -0,0 +1,109 @@ +import { memo } from 'react'; +import { Loader2, Clock, Database, CheckCircle2, XCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +export type TransactionStatus = + | 'idle' + | 'processing' + | 'timeout' + | 'cached' + | 'completed' + | 'failed'; + +interface TransactionStatusIndicatorProps { + status: TransactionStatus; + message?: string; + className?: string; + showLabel?: boolean; +} + +export const TransactionStatusIndicator = memo(({ + status, + message, + className, + showLabel = true, +}: TransactionStatusIndicatorProps) => { + if (status === 'idle') return null; + + const getStatusConfig = () => { + switch (status) { + case 'processing': + return { + icon: Loader2, + label: 'Processing', + description: 'Transaction in progress...', + variant: 'secondary' as const, + className: 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800', + iconClassName: 'animate-spin', + }; + case 'timeout': + return { + icon: Clock, + label: 'Timeout', + description: message || 'Transaction timed out. Lock may have been auto-released.', + variant: 'destructive' as const, + className: 'bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950 dark:text-orange-200 dark:border-orange-800', + iconClassName: '', + }; + case 'cached': + return { + icon: Database, + label: 'Cached', + description: message || 'Using cached result from duplicate request', + variant: 'outline' as const, + className: 'bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-200 dark:border-purple-800', + iconClassName: '', + }; + case 'completed': + return { + icon: CheckCircle2, + label: 'Completed', + description: 'Transaction completed successfully', + variant: 'default' as const, + className: 'bg-green-100 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800', + iconClassName: '', + }; + case 'failed': + return { + icon: XCircle, + label: 'Failed', + description: message || 'Transaction failed', + variant: 'destructive' as const, + className: '', + iconClassName: '', + }; + default: + return null; + } + }; + + const config = getStatusConfig(); + if (!config) return null; + + const Icon = config.icon; + + return ( + + + + + {showLabel && {config.label}} + + + +

{config.description}

+
+
+ ); +}); + +TransactionStatusIndicator.displayName = 'TransactionStatusIndicator'; diff --git a/src/components/moderation/renderers/QueueItemHeader.tsx b/src/components/moderation/renderers/QueueItemHeader.tsx index b47cd894..d74fcd7a 100644 --- a/src/components/moderation/renderers/QueueItemHeader.tsx +++ b/src/components/moderation/renderers/QueueItemHeader.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/ui/user-avatar'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ValidationSummary } from '../ValidationSummary'; +import { TransactionStatusIndicator, type TransactionStatus } from '../TransactionStatusIndicator'; import { format } from 'date-fns'; import type { ModerationItem } from '@/types/moderation'; import type { ValidationResult } from '@/lib/entityValidationSchemas'; @@ -16,6 +17,8 @@ interface QueueItemHeaderProps { isLockedByOther: boolean; currentLockSubmissionId?: string; validationResult: ValidationResult | null; + transactionStatus?: TransactionStatus; + transactionMessage?: string; onValidationChange: (result: ValidationResult) => void; onViewRawData?: () => void; } @@ -38,6 +41,8 @@ export const QueueItemHeader = memo(({ isLockedByOther, currentLockSubmissionId, validationResult, + transactionStatus = 'idle', + transactionMessage, onValidationChange, onViewRawData }: QueueItemHeaderProps) => { @@ -105,6 +110,11 @@ export const QueueItemHeader = memo(({ Claimed by You )} + {item.submission_items && item.submission_items.length > 0 && item.submission_items[0].item_data && (