From e4bcad9680c0f916690ae58bb188f49ef97b40dd Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:07:48 +0000 Subject: [PATCH] Add transaction status indicators to moderation UI Implement visual indicators in the moderation queue and review manager to display the status of ongoing transactions. This includes states for processing, timeout, and cached results, providing users with clearer feedback on the system's activity. --- src/components/moderation/ModerationQueue.tsx | 51 +++++++- src/components/moderation/QueueItem.tsx | 9 ++ .../moderation/SubmissionReviewManager.tsx | 63 +++++++++- .../moderation/TransactionStatusIndicator.tsx | 109 ++++++++++++++++++ .../moderation/renderers/QueueItemHeader.tsx | 10 ++ 5 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 src/components/moderation/TransactionStatusIndicator.tsx 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 && (