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.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-07 16:07:48 +00:00
parent b917232220
commit e4bcad9680
5 changed files with 238 additions and 4 deletions

View File

@@ -76,6 +76,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
// UI-only state // UI-only state
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
const [transactionStatuses, setTransactionStatuses] = useState<Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>>({});
const [photoModalOpen, setPhotoModalOpen] = useState(false); const [photoModalOpen, setPhotoModalOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]); const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
@@ -196,6 +197,50 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
setNotes(prev => ({ ...prev, [id]: value })); setNotes(prev => ({ ...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 // Wrapped delete with confirmation
const handleDeleteSubmission = useCallback((item: ModerationItem) => { const handleDeleteSubmission = useCallback((item: ModerationItem) => {
setConfirmDialog({ setConfirmDialog({
@@ -495,8 +540,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
isAdmin={isAdmin()} isAdmin={isAdmin()}
isSuperuser={isSuperuser()} isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading} queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange} onNoteChange={handleNoteChange}
onApprove={queueManager.performAction} onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending} onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems} onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos} onOpenPhotos={handleOpenPhotos}
@@ -557,8 +603,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
isAdmin={isAdmin()} isAdmin={isAdmin()}
isSuperuser={isSuperuser()} isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading} queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange} onNoteChange={handleNoteChange}
onApprove={queueManager.performAction} onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending} onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems} onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos} onOpenPhotos={handleOpenPhotos}

View File

@@ -37,6 +37,7 @@ interface QueueItemProps {
isSuperuser: boolean; isSuperuser: boolean;
queueIsLoading: boolean; queueIsLoading: boolean;
isInitialRender?: boolean; isInitialRender?: boolean;
transactionStatuses?: Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>;
onNoteChange: (id: string, value: string) => void; onNoteChange: (id: string, value: string) => void;
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void; onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
onResetToPending: (item: ModerationItem) => void; onResetToPending: (item: ModerationItem) => void;
@@ -65,6 +66,7 @@ export const QueueItem = memo(({
isSuperuser, isSuperuser,
queueIsLoading, queueIsLoading,
isInitialRender = false, isInitialRender = false,
transactionStatuses,
onNoteChange, onNoteChange,
onApprove, onApprove,
onResetToPending, onResetToPending,
@@ -82,6 +84,11 @@ export const QueueItem = memo(({
const [isClaiming, setIsClaiming] = useState(false); const [isClaiming, setIsClaiming] = useState(false);
const [showRawData, setShowRawData] = 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 // Fetch relational photo data for photo submissions
const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems( const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems(
item.submission_type === 'photo' ? item.id : undefined item.submission_type === 'photo' ? item.id : undefined
@@ -145,6 +152,8 @@ export const QueueItem = memo(({
isLockedByOther={isLockedByOther} isLockedByOther={isLockedByOther}
currentLockSubmissionId={currentLockSubmissionId} currentLockSubmissionId={currentLockSubmissionId}
validationResult={validationResult} validationResult={validationResult}
transactionStatus={transactionStatus}
transactionMessage={transactionMessage}
onValidationChange={handleValidationChange} onValidationChange={handleValidationChange}
onViewRawData={() => setShowRawData(true)} onViewRawData={() => setShowRawData(true)}
/> />

View File

@@ -39,6 +39,7 @@ import { ValidationBlockerDialog } from './ValidationBlockerDialog';
import { WarningConfirmDialog } from './WarningConfirmDialog'; import { WarningConfirmDialog } from './WarningConfirmDialog';
import { ConflictResolutionModal } from './ConflictResolutionModal'; import { ConflictResolutionModal } from './ConflictResolutionModal';
import { EditHistoryAccordion } from './EditHistoryAccordion'; import { EditHistoryAccordion } from './EditHistoryAccordion';
import { TransactionStatusIndicator } from './TransactionStatusIndicator';
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas'; import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { ModerationErrorBoundary } from '@/components/error'; import { ModerationErrorBoundary } from '@/components/error';
@@ -83,6 +84,8 @@ export function SubmissionReviewManager({
message: string; message: string;
errorId?: string; errorId?: string;
} | null>(null); } | null>(null);
const [transactionStatus, setTransactionStatus] = useState<'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'>('idle');
const [transactionMessage, setTransactionMessage] = useState<string | undefined>();
const { toast } = useToast(); const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole(); const { isAdmin, isSuperuser } = useUserRole();
@@ -337,6 +340,7 @@ export function SubmissionReviewManager({
} }
// Proceed with approval - wrapped with transaction resilience // Proceed with approval - wrapped with transaction resilience
setTransactionStatus('processing');
await executeTransaction( await executeTransaction(
'approval', 'approval',
selectedIds, selectedIds,
@@ -400,10 +404,34 @@ export function SubmissionReviewManager({
onComplete(); onComplete();
onOpenChange(false); onOpenChange(false);
setTransactionStatus('completed');
setTimeout(() => setTransactionStatus('idle'), 3000);
return data; return data;
} }
); );
} catch (error: unknown) { } 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) } }); dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, { handleError(error, {
action: 'Approve Submission Items', action: 'Approve Submission Items',
@@ -467,6 +495,7 @@ export function SubmissionReviewManager({
try { try {
// Wrap rejection with transaction resilience // Wrap rejection with transaction resilience
setTransactionStatus('processing');
await executeTransaction( await executeTransaction(
'rejection', 'rejection',
selectedIds, selectedIds,
@@ -484,10 +513,34 @@ export function SubmissionReviewManager({
onComplete(); onComplete();
onOpenChange(false); onOpenChange(false);
setTransactionStatus('completed');
setTimeout(() => setTransactionStatus('idle'), 3000);
return { success: true }; return { success: true };
} }
); );
} catch (error: unknown) { } 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) } }); dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, { handleError(error, {
action: 'Reject Submission Items', action: 'Reject Submission Items',
@@ -625,7 +678,10 @@ export function SubmissionReviewManager({
{isMobile ? ( {isMobile ? (
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto"> <SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle>Review Submission</SheetTitle> <div className="flex items-center justify-between">
<SheetTitle>Review Submission</SheetTitle>
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
</div>
<SheetDescription> <SheetDescription>
{pendingCount} pending item(s) {selectedCount} selected {pendingCount} pending item(s) {selectedCount} selected
</SheetDescription> </SheetDescription>
@@ -635,7 +691,10 @@ export function SubmissionReviewManager({
) : ( ) : (
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Review Submission</DialogTitle> <div className="flex items-center justify-between">
<DialogTitle>Review Submission</DialogTitle>
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
</div>
<DialogDescription> <DialogDescription>
{pendingCount} pending item(s) {selectedCount} selected {pendingCount} pending item(s) {selectedCount} selected
</DialogDescription> </DialogDescription>

View File

@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={config.variant}
className={cn(
'flex items-center gap-1.5 px-2 py-1',
config.className,
className
)}
>
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
{showLabel && <span className="text-xs font-medium">{config.label}</span>}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm">{config.description}</p>
</TooltipContent>
</Tooltip>
);
});
TransactionStatusIndicator.displayName = 'TransactionStatusIndicator';

View File

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