Files
thrilltrack-explorer/src/components/moderation/SubmissionReviewManager.tsx
gpt-engineer-app[bot] c7e18206b1 Persist transaction statuses to localStorage
Add persistence for transaction statuses to localStorage in ModerationQueue and SubmissionReviewManager components. This ensures that transaction statuses (processing, timeout, cached, completed, failed) are preserved across page refreshes, providing a more robust user experience during active transactions.
2025-11-07 16:17:34 +00:00

1077 lines
38 KiB
TypeScript

import { useState, useEffect, useReducer } from 'react';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { moderationReducer, canApprove, canReject, hasActiveLock } from '@/lib/moderationStateMachine';
import { useLockMonitor } from '@/lib/moderation/lockMonitor';
import { useTransactionResilience } from '@/hooks/useTransactionResilience';
import * as localStorage from '@/lib/localStorage';
import {
fetchSubmissionItems,
buildDependencyTree,
detectDependencyConflicts,
approveSubmissionItems,
rejectSubmissionItems,
checkSubmissionConflict,
type SubmissionItemWithDeps,
type DependencyConflict,
type ConflictCheckResult
} from '@/lib/submissionItemsService';
import { useModerationActions } from '@/hooks/moderation/useModerationActions';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp, History } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useIsMobile } from '@/hooks/use-mobile';
import { ItemReviewCard } from './ItemReviewCard';
import { DependencyVisualizer } from './DependencyVisualizer';
import { ConflictResolutionDialog } from './ConflictResolutionDialog';
import { EscalationDialog } from './EscalationDialog';
import { RejectionDialog } from './RejectionDialog';
import { ItemEditDialog } from './ItemEditDialog';
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';
interface SubmissionReviewManagerProps {
submissionId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
export function SubmissionReviewManager({
submissionId,
open,
onOpenChange,
onComplete
}: SubmissionReviewManagerProps) {
// State machine for moderation workflow
const [state, dispatch] = useReducer(moderationReducer, { status: 'idle' });
// UI-specific state (kept separate from state machine)
const [items, setItems] = useState<SubmissionItemWithDeps[]>([]);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const [conflicts, setConflicts] = useState<DependencyConflict[]>([]);
const [showConflictDialog, setShowConflictDialog] = useState(false);
const [showEscalationDialog, setShowEscalationDialog] = useState(false);
const [showRejectionDialog, setShowRejectionDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null);
const [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items');
const [submissionType, setSubmissionType] = useState<string>('submission');
const [showValidationBlockerDialog, setShowValidationBlockerDialog] = useState(false);
const [showWarningConfirmDialog, setShowWarningConfirmDialog] = useState(false);
const [validationResults, setValidationResults] = useState<Map<string, ValidationResult>>(new Map());
const [userConfirmedWarnings, setUserConfirmedWarnings] = useState(false);
const [hasBlockingErrors, setHasBlockingErrors] = useState(false);
const [globalValidationKey, setGlobalValidationKey] = useState(0);
const [conflictData, setConflictData] = useState<ConflictCheckResult | null>(null);
const [showConflictResolutionModal, setShowConflictResolutionModal] = useState(false);
const [lastModifiedTimestamp, setLastModifiedTimestamp] = useState<string | null>(null);
const [escalationError, setEscalationError] = useState<{
message: string;
errorId?: string;
} | null>(null);
const [transactionStatus, setTransactionStatus] = useState<'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'>(() => {
// Restore from localStorage on mount
const stored = localStorage.getJSON<{ status: string; message?: string }>(`moderation-transaction-status-${submissionId}`, { status: 'idle' });
const validStatuses = ['idle', 'processing', 'timeout', 'cached', 'completed', 'failed'];
return validStatuses.includes(stored.status) ? stored.status as 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed' : 'idle';
});
const [transactionMessage, setTransactionMessage] = useState<string | undefined>(() => {
// Restore from localStorage on mount
const stored = localStorage.getJSON<{ status: string; message?: string }>(`moderation-transaction-status-${submissionId}`, { status: 'idle' });
return stored.message;
});
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth();
const isMobile = useIsMobile();
const Container = isMobile ? Sheet : Dialog;
// Lock monitoring integration
const { extendLock } = useLockMonitor(state, dispatch, submissionId);
// Transaction resilience (timeout detection & auto-release)
const { executeTransaction } = useTransactionResilience({
submissionId,
timeoutMs: 30000, // 30s timeout
autoReleaseOnUnload: true,
autoReleaseOnInactivity: true,
inactivityMinutes: 10,
});
// Moderation actions
const { escalateSubmission } = useModerationActions({
user,
onActionStart: (itemId: string) => {
logger.log(`Starting escalation for ${itemId}`);
},
onActionComplete: () => {
logger.log('Escalation complete');
}
});
// Persist transaction status to localStorage
useEffect(() => {
localStorage.setJSON(`moderation-transaction-status-${submissionId}`, {
status: transactionStatus,
message: transactionMessage,
});
}, [transactionStatus, transactionMessage, submissionId]);
// Auto-claim on mount
useEffect(() => {
if (open && submissionId && state.status === 'idle') {
handleClaimSubmission();
}
}, [open, submissionId, state.status]);
const handleClaimSubmission = async () => {
dispatch({ type: 'CLAIM_ITEM', payload: { itemId: submissionId } });
try {
// Assume lock is acquired by parent component or moderation queue
const lockExpires = new Date(Date.now() + 15 * 60 * 1000).toISOString();
dispatch({ type: 'LOCK_ACQUIRED', payload: { lockExpires } });
// Load data
dispatch({ type: 'LOAD_DATA' });
const loadedItems = await loadSubmissionItems();
// Transition to reviewing state with actual loaded data
dispatch({ type: 'DATA_LOADED', payload: { reviewData: loadedItems || [] } });
} catch (error: unknown) {
dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, { action: 'Claim Submission', userId: user?.id });
}
};
const loadSubmissionItems = async () => {
// State machine already transitioned via handleClaimSubmission
// This just handles the data fetching
try {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch submission type and last_modified_at
const { data: submission } = await supabase
.from('content_submissions')
.select('submission_type, last_modified_at')
.eq('id', submissionId)
.single();
if (submission) {
setSubmissionType(submission.submission_type || 'submission');
setLastModifiedTimestamp(submission.last_modified_at);
}
const fetchedItems = await fetchSubmissionItems(submissionId);
// Protection 2: Detect empty submissions
if (!fetchedItems || fetchedItems.length === 0) {
setItems([]);
return;
}
const itemsWithDeps = buildDependencyTree(fetchedItems);
setItems(itemsWithDeps);
// Auto-select pending items
const pendingIds = fetchedItems
.filter(item => item.status === 'pending')
.map(item => item.id);
setSelectedItemIds(new Set(pendingIds));
return itemsWithDeps;
} catch (error: unknown) {
throw error; // Let handleClaimSubmission handle the error
}
};
const toggleItemSelection = (itemId: string) => {
setSelectedItemIds(prev => {
const next = new Set(prev);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
// Clear blocking errors and warning confirmation when selection changes
setHasBlockingErrors(false);
setValidationResults(new Map());
setUserConfirmedWarnings(false);
return next;
});
};
const handleCheckConflicts = async () => {
try {
const detectedConflicts = await detectDependencyConflicts(items, Array.from(selectedItemIds));
setConflicts(detectedConflicts);
if (detectedConflicts.length > 0) {
setShowConflictDialog(true);
} else {
// No conflicts, proceed with approval
handleApprove();
}
} catch (error: unknown) {
handleError(error, {
action: 'Check Dependency Conflicts',
userId: user?.id,
metadata: { submissionId, selectedCount: selectedItemIds.size }
});
}
};
const handleApprove = async () => {
// State machine validation
if (!canApprove(state)) {
toast({
title: 'Cannot Approve',
description: state.status === 'lock_expired'
? 'Your lock has expired. Please re-claim this submission.'
: `Invalid state for approval: ${state.status}`,
variant: 'destructive',
});
return;
}
if (!user?.id) {
toast({
title: 'Authentication Required',
description: 'You must be logged in to approve items',
variant: 'destructive',
});
return;
}
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
const selectedIds = Array.from(selectedItemIds);
// Transition: reviewing → approving
dispatch({ type: 'START_APPROVAL' });
try {
// Check for conflicts first (optimistic locking)
if (lastModifiedTimestamp) {
const conflictCheck = await checkSubmissionConflict(submissionId, lastModifiedTimestamp);
if (conflictCheck.hasConflict) {
setConflictData(conflictCheck);
setShowConflictResolutionModal(true);
dispatch({ type: 'RESET' }); // Return to reviewing state
return; // Block approval until conflict resolved
}
}
// Run validation on all selected items
let validationResultsMap: Map<string, any>;
try {
validationResultsMap = await validateMultipleItems(
selectedItems.map(item => ({
item_type: item.item_type,
item_data: item.item_data,
id: item.id
}))
);
setValidationResults(validationResultsMap);
// Check for blocking errors
const itemsWithBlockingErrors = selectedItems.filter(item => {
const result = validationResultsMap.get(item.id);
return result && result.blockingErrors.length > 0;
});
// CRITICAL: Blocking errors can NEVER be bypassed, regardless of warnings
if (itemsWithBlockingErrors.length > 0) {
// Log which items have blocking errors
itemsWithBlockingErrors.forEach(item => {
const result = validationResultsMap.get(item.id);
logger.error('Blocking validation errors prevent approval', {
submissionId,
itemId: item.id,
itemType: item.item_type,
errors: result?.blockingErrors
});
});
setHasBlockingErrors(true);
setShowValidationBlockerDialog(true);
dispatch({ type: 'ERROR', payload: { error: 'Validation failed' } });
return; // Block approval
}
} catch (error) {
// Validation itself failed (network error, bug, etc.)
const errorId = handleError(error, {
action: 'Validation System Error',
userId: user?.id,
metadata: {
submissionId,
selectedItemCount: selectedItems.length,
itemTypes: selectedItems.map(i => i.item_type)
}
});
toast({
title: 'Validation System Error',
description: (
<div className="space-y-2">
<p>Unable to validate submission. Please try again.</p>
<p className="text-xs font-mono">Ref: {errorId.slice(0, 8)}</p>
</div>
),
variant: 'destructive'
});
dispatch({ type: 'ERROR', payload: { error: 'Validation system error' } });
return;
}
// Check for warnings
const itemsWithWarnings = selectedItems.filter(item => {
const result = validationResultsMap.get(item.id);
return result && result.warnings.length > 0;
});
if (itemsWithWarnings.length > 0 && !userConfirmedWarnings) {
setShowWarningConfirmDialog(true);
dispatch({ type: 'RESET' }); // Reset to reviewing state
return; // Ask for confirmation
}
// Proceed with approval - wrapped with transaction resilience
setTransactionStatus('processing');
await executeTransaction(
'approval',
selectedIds,
async (idempotencyKey) => {
const { supabase } = await import('@/integrations/supabase/client');
// Call the edge function for backend processing
const { data, error, requestId } = await invokeWithTracking(
'process-selective-approval',
{
itemIds: selectedIds,
submissionId,
idempotencyKey, // Pass idempotency key to edge function
},
user?.id
);
if (error) {
throw new Error(error.message || 'Failed to process approval');
}
if (!data?.success) {
throw new Error(data?.error || 'Approval processing failed');
}
// Transition: approving → complete
dispatch({ type: 'COMPLETE', payload: { result: 'approved' } });
toast({
title: 'Items Approved',
description: `Successfully approved ${selectedIds.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
});
interface ApprovalResult { success: boolean; item_id: string; error?: string }
const successCount = data.results.filter((r: ApprovalResult) => r.success).length;
const failCount = data.results.filter((r: ApprovalResult) => !r.success).length;
const allFailed = failCount > 0 && successCount === 0;
const someFailed = failCount > 0 && successCount > 0;
toast({
title: allFailed ? 'Approval Failed' : someFailed ? 'Partial Approval' : 'Approval Complete',
description: failCount > 0
? `Approved ${successCount} item(s), ${failCount} failed`
: `Successfully approved ${successCount} item(s)`,
variant: allFailed ? 'destructive' : someFailed ? 'default' : 'default',
});
// Reset warning confirmation state after approval
setUserConfirmedWarnings(false);
// If ALL items failed, don't close dialog - show errors
if (allFailed) {
dispatch({ type: 'ERROR', payload: { error: 'All items failed' } });
return data;
}
// Reset warning confirmation state after approval
setUserConfirmedWarnings(false);
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',
userId: user?.id,
metadata: {
submissionId,
itemCount: selectedItemIds.size,
hasWarnings: userConfirmedWarnings,
hasBlockingErrors
}
});
}
};
const handleRejectSelected = async () => {
if (selectedItemIds.size === 0) {
toast({
title: 'No Items Selected',
description: 'Please select items to reject',
variant: 'destructive',
});
return;
}
if (!user?.id) {
toast({
title: 'Authentication Required',
description: 'You must be logged in to reject items',
variant: 'destructive',
});
return;
}
// Check if any selected items have dependents
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
const hasDependents = selectedItems.some(item =>
item.dependents && item.dependents.length > 0
);
setShowRejectionDialog(true);
};
const handleReject = async (reason: string, cascade: boolean) => {
// State machine validation
if (!canReject(state)) {
toast({
title: 'Cannot Reject',
description: 'Invalid state for rejection',
variant: 'destructive',
});
return;
}
if (!user?.id) return;
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
const selectedIds = selectedItems.map(item => item.id);
// Transition: reviewing → rejecting
dispatch({ type: 'START_REJECTION' });
try {
// Wrap rejection with transaction resilience
setTransactionStatus('processing');
await executeTransaction(
'rejection',
selectedIds,
async (idempotencyKey) => {
await rejectSubmissionItems(selectedItems, reason, user.id, cascade);
// Transition: rejecting → complete
dispatch({ type: 'COMPLETE', payload: { result: 'rejected' } });
toast({
title: 'Items Rejected',
description: `Successfully rejected ${selectedItems.length} item${selectedItems.length !== 1 ? 's' : ''}`,
});
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',
userId: user?.id,
metadata: {
submissionId,
itemCount: selectedItemIds.size,
cascade,
reason: reason.substring(0, 100)
}
});
}
};
const handleEscalate = async (reason: string) => {
if (!user?.id) {
toast({
title: 'Authentication Required',
description: 'You must be logged in to escalate submissions',
variant: 'destructive',
});
return;
}
try {
setEscalationError(null);
// Use consolidated action from useModerationActions
// This handles: edge function call, fallback, error logging, cache invalidation
await escalateSubmission(
{
id: submissionId,
submission_type: submissionType,
type: 'submission'
} as any,
reason
);
// Success - close dialog
onComplete();
onOpenChange(false);
} catch (error: any) {
// Track error for retry UI
setEscalationError({
message: getErrorMessage(error),
errorId: error.errorId
});
logger.error('Escalation failed in SubmissionReviewManager', {
submissionId,
error: getErrorMessage(error)
});
// Don't close dialog on error - let user retry
}
};
const handleEdit = (item: SubmissionItemWithDeps) => {
setEditingItem(item);
setShowEditDialog(true);
};
const handleEditComplete = async () => {
setShowEditDialog(false);
setEditingItem(null);
await loadSubmissionItems();
setGlobalValidationKey(prev => prev + 1);
};
const handleItemStatusChange = async (itemId: string, status: 'approved' | 'rejected') => {
if (!user?.id) {
toast({
title: 'Authentication Required',
description: 'You must be logged in to change item status',
variant: 'destructive',
});
return;
}
try {
if (status === 'approved') {
const { supabase } = await import('@/integrations/supabase/client');
const { data, error, requestId } = await invokeWithTracking(
'process-selective-approval',
{
itemIds: [itemId],
submissionId
},
user?.id
);
if (error || !data?.success) {
throw new Error(error?.message || data?.error || 'Failed to approve item');
}
toast({
title: 'Item Approved',
description: `Successfully approved the item${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
});
} else {
const item = items.find(i => i.id === itemId);
if (!item) {
throw new Error('Item not found');
}
await rejectSubmissionItems([item], 'Quick rejection from review', user.id, false);
toast({
title: 'Item Rejected',
description: 'Successfully rejected the item',
});
}
await loadSubmissionItems();
} catch (error: unknown) {
handleError(error, {
action: `${status === 'approved' ? 'Approve' : 'Reject'} Item`,
userId: user?.id,
metadata: {
submissionId,
itemId,
status
}
});
}
};
const pendingCount = items.filter(item => item.status === 'pending').length;
const selectedCount = selectedItemIds.size;
return (
<>
<Container open={open} onOpenChange={onOpenChange}>
<ModerationErrorBoundary submissionId={submissionId}>
{isMobile ? (
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
<SheetHeader>
<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>
</SheetHeader>
<ReviewContent />
</SheetContent>
) : (
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<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>
</DialogHeader>
<ReviewContent />
</DialogContent>
)}
</ModerationErrorBoundary>
</Container>
<ConflictResolutionDialog
open={showConflictDialog}
onOpenChange={setShowConflictDialog}
conflicts={conflicts}
items={items}
onResolve={async () => {
await loadSubmissionItems();
await handleApprove();
}}
/>
<EscalationDialog
open={showEscalationDialog}
onOpenChange={setShowEscalationDialog}
onEscalate={handleEscalate}
submissionType={submissionType}
error={escalationError}
/>
<RejectionDialog
open={showRejectionDialog}
onOpenChange={setShowRejectionDialog}
itemCount={selectedItemIds.size}
hasDependents={items.filter(item => selectedItemIds.has(item.id)).some(item =>
item.dependents && item.dependents.length > 0
)}
onReject={handleReject}
/>
<ItemEditDialog
item={editingItem}
open={showEditDialog}
onOpenChange={setShowEditDialog}
onComplete={handleEditComplete}
/>
<ValidationBlockerDialog
open={showValidationBlockerDialog}
onClose={() => setShowValidationBlockerDialog(false)}
blockingErrors={Array.from(validationResults.values()).flatMap(r => r.blockingErrors)}
itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => {
const name = typeof i.item_data === 'object' && i.item_data !== null && !Array.isArray(i.item_data) && 'name' in i.item_data
? String((i.item_data as Record<string, unknown>).name)
: i.item_type.replace('_', ' ');
return name;
})}
/>
<WarningConfirmDialog
open={showWarningConfirmDialog}
onClose={() => setShowWarningConfirmDialog(false)}
onProceed={() => {
setUserConfirmedWarnings(true);
setShowWarningConfirmDialog(false);
handleApprove();
}}
warnings={Array.from(validationResults.values()).flatMap(r => r.warnings)}
itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => {
const name = typeof i.item_data === 'object' && i.item_data !== null && !Array.isArray(i.item_data) && 'name' in i.item_data
? String((i.item_data as Record<string, unknown>).name)
: i.item_type.replace('_', ' ');
return name;
})}
/>
<ConflictResolutionModal
open={showConflictResolutionModal}
onOpenChange={setShowConflictResolutionModal}
conflictData={conflictData || {
hasConflict: false,
clientVersion: { last_modified_at: new Date().toISOString() }
}}
onResolve={async (strategy) => {
if (strategy === 'keep-mine') {
// Log conflict resolution using relational tables
const { supabase } = await import('@/integrations/supabase/client');
const { writeConflictDetailFields } = await import('@/lib/auditHelpers');
const { data: resolution, error } = await supabase
.from('conflict_resolutions')
.insert([{
submission_id: submissionId,
resolved_by: user?.id || null,
resolution_strategy: strategy,
}])
.select('id')
.single();
if (!error && resolution && conflictData) {
await writeConflictDetailFields(resolution.id, conflictData as any);
}
// Force override and proceed with approval
await handleApprove();
} else if (strategy === 'keep-theirs') {
// Reload data and discard local changes
await loadSubmissionItems();
toast({
title: 'Changes Discarded',
description: 'Loaded the latest version from the server',
});
} else if (strategy === 'reload') {
// Just reload without approving
await loadSubmissionItems();
toast({
title: 'Reloaded',
description: 'Viewing the latest version',
});
}
setShowConflictResolutionModal(false);
setConflictData(null);
}}
/>
</>
);
function ReviewContent() {
// Show loading states based on state machine
if (state.status === 'claiming' || state.status === 'loading_data') {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">
{state.status === 'claiming' ? 'Claiming submission...' : 'Loading items...'}
</p>
</div>
</div>
);
}
// Show error state
if (state.status === 'error') {
return (
<Alert variant="destructive" className="my-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{state.error || 'An error occurred while processing this submission'}
<div className="mt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
dispatch({ type: 'RESET' });
handleClaimSubmission();
}}
>
Try Again
</Button>
</div>
</AlertDescription>
</Alert>
);
}
// Show lock expired warning
if (state.status === 'lock_expired') {
return (
<Alert variant="destructive" className="my-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Your lock on this submission has expired. You need to re-claim it to continue.
<div className="mt-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
dispatch({ type: 'RESET' });
handleClaimSubmission();
}}
>
Re-claim Submission
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
</div>
</AlertDescription>
</Alert>
);
}
// Protection 2: UI detection of empty submissions
if (items.length === 0 && state.status === 'reviewing') {
return (
<Alert variant="destructive" className="my-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This submission has no items and appears to be corrupted or incomplete.
This usually happens when the submission creation process was interrupted.
<div className="mt-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const { supabase } = await import('@/integrations/supabase/client');
await supabase
.from('content_submissions')
.delete()
.eq('id', submissionId);
toast({
title: 'Submission Archived',
description: 'The corrupted submission has been removed',
});
onComplete();
onOpenChange(false);
} catch (error: unknown) {
handleError(error, {
action: 'Archive Corrupted Submission',
userId: user?.id,
metadata: { submissionId }
});
}
}}
>
Archive Submission
</Button>
</div>
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4 h-full">
<Tabs
value={activeTab}
onValueChange={(v) => {
if (v === 'items' || v === 'dependencies' || v === 'history') {
setActiveTab(v as 'items' | 'dependencies');
}
}}
className="flex-1 flex flex-col"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="items">
<CheckCircle2 className="w-4 h-4 mr-2" />
Items ({items.length})
</TabsTrigger>
<TabsTrigger value="dependencies">
<Network className="w-4 h-4 mr-2" />
Dependencies
</TabsTrigger>
<TabsTrigger value="history">
<History className="w-4 h-4 mr-2" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="items" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
{items.length === 0 && state.status === 'reviewing' && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
No items found in this submission
</AlertDescription>
</Alert>
)}
{items.map((item) => (
<div key={item.id} className="flex gap-3 items-start">
<Checkbox
checked={selectedItemIds.has(item.id)}
onCheckedChange={() => toggleItemSelection(item.id)}
disabled={item.status !== 'pending'}
/>
<ItemReviewCard
item={item}
onEdit={() => handleEdit(item)}
onStatusChange={async () => {
// Status changes handled via approve/reject actions
await loadSubmissionItems();
}}
submissionId={submissionId}
/>
</div>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
</TabsContent>
<TabsContent value="history" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<EditHistoryAccordion submissionId={submissionId} />
</ScrollArea>
</TabsContent>
</Tabs>
{/* Blocking error alert */}
{hasBlockingErrors && (
<Alert variant="destructive">
<AlertCircle className="w-4 h-4" />
<AlertDescription>
Cannot approve: Selected items have validation errors that must be fixed first.
</AlertDescription>
</Alert>
)}
{/* Action buttons */}
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t">
<Button
onClick={handleCheckConflicts}
disabled={
selectedCount === 0 ||
!canApprove(state) ||
hasBlockingErrors
}
className="flex-1"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
{state.status === 'approving' ? 'Approving...' : `Approve Selected (${selectedCount})`}
</Button>
<Button
onClick={handleRejectSelected}
disabled={
selectedCount === 0 ||
!canReject(state)
}
variant="destructive"
className="flex-1"
>
<XCircle className="w-4 h-4 mr-2" />
{state.status === 'rejecting' ? 'Rejecting...' : 'Reject Selected'}
</Button>
<Button
onClick={() => setShowEscalationDialog(true)}
variant="outline"
disabled={state.status !== 'reviewing'}
>
<ArrowUp className="w-4 h-4 mr-2" />
Escalate
</Button>
</div>
</div>
);
}
}