Files
thrilltrack-explorer/src-old/components/moderation/SubmissionReviewManager.tsx

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>
);
}
}