mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:51:13 -05:00
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:
@@ -76,6 +76,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
|
||||
// UI-only state
|
||||
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 [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]);
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
|
||||
@@ -196,6 +197,50 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
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
|
||||
const handleDeleteSubmission = useCallback((item: ModerationItem) => {
|
||||
setConfirmDialog({
|
||||
@@ -495,8 +540,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
isAdmin={isAdmin()}
|
||||
isSuperuser={isSuperuser()}
|
||||
queueIsLoading={queueManager.queue.isLoading}
|
||||
transactionStatuses={transactionStatuses}
|
||||
onNoteChange={handleNoteChange}
|
||||
onApprove={queueManager.performAction}
|
||||
onApprove={handlePerformAction}
|
||||
onResetToPending={queueManager.resetToPending}
|
||||
onRetryFailed={queueManager.retryFailedItems}
|
||||
onOpenPhotos={handleOpenPhotos}
|
||||
@@ -557,8 +603,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
isAdmin={isAdmin()}
|
||||
isSuperuser={isSuperuser()}
|
||||
queueIsLoading={queueManager.queue.isLoading}
|
||||
transactionStatuses={transactionStatuses}
|
||||
onNoteChange={handleNoteChange}
|
||||
onApprove={queueManager.performAction}
|
||||
onApprove={handlePerformAction}
|
||||
onResetToPending={queueManager.resetToPending}
|
||||
onRetryFailed={queueManager.retryFailedItems}
|
||||
onOpenPhotos={handleOpenPhotos}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface QueueItemProps {
|
||||
isSuperuser: boolean;
|
||||
queueIsLoading: boolean;
|
||||
isInitialRender?: boolean;
|
||||
transactionStatuses?: Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>;
|
||||
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)}
|
||||
/>
|
||||
|
||||
@@ -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<string | undefined>();
|
||||
|
||||
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 ? (
|
||||
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Review Submission</SheetTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle>Review Submission</SheetTitle>
|
||||
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
|
||||
</div>
|
||||
<SheetDescription>
|
||||
{pendingCount} pending item(s) • {selectedCount} selected
|
||||
</SheetDescription>
|
||||
@@ -635,7 +691,10 @@ export function SubmissionReviewManager({
|
||||
) : (
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Submission</DialogTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Review Submission</DialogTitle>
|
||||
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{pendingCount} pending item(s) • {selectedCount} selected
|
||||
</DialogDescription>
|
||||
|
||||
109
src/components/moderation/TransactionStatusIndicator.tsx
Normal file
109
src/components/moderation/TransactionStatusIndicator.tsx
Normal 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';
|
||||
@@ -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
|
||||
</Badge>
|
||||
)}
|
||||
<TransactionStatusIndicator
|
||||
status={transactionStatus}
|
||||
message={transactionMessage}
|
||||
showLabel={!isMobile}
|
||||
/>
|
||||
{item.submission_items && item.submission_items.length > 0 && item.submission_items[0].item_data && (
|
||||
<ValidationSummary
|
||||
item={{
|
||||
|
||||
Reference in New Issue
Block a user