diff --git a/src/components/moderation/AuditTrailViewer.tsx b/src/components/moderation/AuditTrailViewer.tsx new file mode 100644 index 00000000..e5482fe5 --- /dev/null +++ b/src/components/moderation/AuditTrailViewer.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'react'; +import { ChevronDown, ChevronRight, History, Eye, Lock, Unlock, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Skeleton } from '@/components/ui/skeleton'; +import { format } from 'date-fns'; +import { supabase } from '@/lib/supabaseClient'; +import { handleError } from '@/lib/errorHandler'; + +interface AuditLogEntry { + id: string; + action: string; + moderator_id: string; + submission_id: string | null; + previous_status: string | null; + new_status: string | null; + notes: string | null; + created_at: string; + is_test_data: boolean | null; +} + +interface AuditTrailViewerProps { + submissionId: string; +} + +export function AuditTrailViewer({ submissionId }: AuditTrailViewerProps) { + const [isOpen, setIsOpen] = useState(false); + const [auditLogs, setAuditLogs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (isOpen && auditLogs.length === 0) { + fetchAuditLogs(); + } + }, [isOpen, submissionId]); + + const fetchAuditLogs = async () => { + try { + setLoading(true); + + const { data, error } = await supabase + .from('moderation_audit_log') + .select('*') + .eq('submission_id', submissionId) + .order('created_at', { ascending: false }); + + if (error) throw error; + setAuditLogs(data || []); + } catch (error) { + handleError(error, { + action: 'Fetch Audit Trail', + metadata: { submissionId } + }); + } finally { + setLoading(false); + } + }; + + const getActionIcon = (action: string) => { + switch (action) { + case 'viewed': + return ; + case 'claimed': + case 'locked': + return ; + case 'released': + case 'unlocked': + return ; + case 'approved': + return ; + case 'rejected': + return ; + case 'escalated': + return ; + default: + return ; + } + }; + + const getActionColor = (action: string) => { + switch (action) { + case 'approved': + return 'text-green-600 dark:text-green-400'; + case 'rejected': + return 'text-red-600 dark:text-red-400'; + case 'escalated': + return 'text-orange-600 dark:text-orange-400'; + case 'claimed': + case 'locked': + return 'text-blue-600 dark:text-blue-400'; + default: + return 'text-muted-foreground'; + } + }; + + return ( + + + {isOpen ? : } + + Audit Trail + {auditLogs.length > 0 && ( + + {auditLogs.length} action{auditLogs.length !== 1 ? 's' : ''} + + )} + + + +
+ {loading ? ( +
+ + + +
+ ) : auditLogs.length === 0 ? ( +
+ No audit trail entries found +
+ ) : ( +
+ {auditLogs.map((entry) => ( +
+
+
+ {getActionIcon(entry.action)} +
+ +
+
+ + {entry.action.replace('_', ' ')} + + + {format(new Date(entry.created_at), 'MMM d, HH:mm:ss')} + +
+ + {(entry.previous_status || entry.new_status) && ( +
+ {entry.previous_status && ( + + {entry.previous_status} + + )} + {entry.previous_status && entry.new_status && ( + + )} + {entry.new_status && ( + + {entry.new_status} + + )} +
+ )} + + {entry.notes && ( +

+ {entry.notes} +

+ )} +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/moderation/QueueItem.tsx b/src/components/moderation/QueueItem.tsx index a7355e7e..eb6b0bb8 100644 --- a/src/components/moderation/QueueItem.tsx +++ b/src/components/moderation/QueueItem.tsx @@ -19,6 +19,8 @@ import { PhotoSubmissionDisplay } from './renderers/PhotoSubmissionDisplay'; import { EntitySubmissionDisplay } from './renderers/EntitySubmissionDisplay'; import { QueueItemContext } from './renderers/QueueItemContext'; import { QueueItemActions } from './renderers/QueueItemActions'; +import { SubmissionMetadataPanel } from './SubmissionMetadataPanel'; +import { AuditTrailViewer } from './AuditTrailViewer'; interface QueueItemProps { item: ModerationItem; @@ -309,6 +311,14 @@ export const QueueItem = memo(({ )} + {/* Metadata and Audit Trail */} + {item.type === 'content_submission' && ( +
+ + +
+ )} + + + #{item.order_index ?? 0} + + + {item.depends_on && ( + + Depends on: {item.depends_on.slice(0, 8)}... + + )} + + {(item as any).is_test_data && ( + + Test Data + + )} + + {item.created_at && ( + + Created: {format(new Date(item.created_at), 'MMM d, HH:mm:ss')} + + )} + + {item.updated_at && item.updated_at !== item.created_at && ( + + Updated: {format(new Date(item.updated_at), 'MMM d, HH:mm:ss')} + + )} + + ); + // Use summary view for compact display if (view === 'summary') { return ( - + <> + {itemMetadata} + + ); } // Use rich displays for detailed view if (item.item_type === 'park' && entityData) { return ( - + <> + {itemMetadata} + + ); } if (item.item_type === 'ride' && entityData) { return ( - + <> + {itemMetadata} + + ); } if ((['manufacturer', 'operator', 'designer', 'property_owner'] as const).some(type => type === item.item_type) && entityData) { return ( - + <> + {itemMetadata} + + ); } // Fallback to SubmissionChangesDisplay return ( - + <> + {itemMetadata} + + ); }; diff --git a/src/components/moderation/SubmissionMetadataPanel.tsx b/src/components/moderation/SubmissionMetadataPanel.tsx new file mode 100644 index 00000000..16a573e2 --- /dev/null +++ b/src/components/moderation/SubmissionMetadataPanel.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight, Flag, Clock, Edit2, Link2, TestTube } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { format } from 'date-fns'; +import type { ModerationItem } from '@/types/moderation'; +import { UserAvatar } from '@/components/ui/user-avatar'; + +interface SubmissionMetadataPanelProps { + item: ModerationItem; +} + +export function SubmissionMetadataPanel({ item }: SubmissionMetadataPanelProps) { + const [isOpen, setIsOpen] = useState(false); + + // Extract metadata from content_submissions + const metadata = { + // Workflow + approval_mode: (item as any).approval_mode || 'full', + escalated: item.escalated || false, + escalation_reason: (item as any).escalation_reason, + escalated_by: (item as any).escalated_by, + escalated_at: (item as any).escalated_at, + + // Review Tracking + first_reviewed_at: (item as any).first_reviewed_at, + review_count: (item as any).review_count || 0, + resolved_at: (item as any).resolved_at, + + // Modification Tracking + last_modified_at: (item as any).last_modified_at, + last_modified_by: (item as any).last_modified_by, + + // Relationships + original_submission_id: (item as any).original_submission_id, + + // Flags + is_test_data: (item as any).is_test_data || false, + }; + + const hasMetadata = metadata.escalated || + metadata.review_count > 0 || + metadata.last_modified_at || + metadata.original_submission_id || + metadata.is_test_data; + + if (!hasMetadata) return null; + + return ( + + + {isOpen ? : } + Submission Metadata + + {metadata.review_count} review{metadata.review_count !== 1 ? 's' : ''} + + + + +
+ {/* Workflow Section */} + {(metadata.escalated || metadata.approval_mode !== 'full') && ( +
+
+ + Workflow +
+ +
+
+ Approval Mode: + + {metadata.approval_mode === 'full' ? 'Full Approval' : 'Partial Approval'} + +
+ + {metadata.escalated && ( + <> +
+ Escalated: + Yes +
+ + {metadata.escalation_reason && ( +
+ Reason: +

+ {metadata.escalation_reason} +

+
+ )} + + {metadata.escalated_at && ( +
+ Escalated At: + + {format(new Date(metadata.escalated_at), 'MMM d, yyyy HH:mm:ss')} + +
+ )} + + )} +
+
+ )} + + {/* Review Tracking Section */} + {(metadata.first_reviewed_at || metadata.resolved_at || metadata.review_count > 0) && ( +
+
+ + Review Tracking +
+ +
+
+ Review Count: + {metadata.review_count} +
+ + {metadata.first_reviewed_at && ( +
+ First Reviewed: + + {format(new Date(metadata.first_reviewed_at), 'MMM d, yyyy HH:mm:ss')} + +
+ )} + + {metadata.resolved_at && ( +
+ Resolved At: + + {format(new Date(metadata.resolved_at), 'MMM d, yyyy HH:mm:ss')} + +
+ )} +
+
+ )} + + {/* Modification Tracking Section */} + {(metadata.last_modified_at || metadata.last_modified_by) && ( +
+
+ + Modification Tracking +
+ +
+ {metadata.last_modified_at && ( +
+ Last Modified: + + {format(new Date(metadata.last_modified_at), 'MMM d, yyyy HH:mm:ss')} + +
+ )} + + {metadata.last_modified_by && ( +
+ Modified By: + Moderator +
+ )} +
+
+ )} + + {/* Relationships Section */} + {metadata.original_submission_id && ( +
+
+ + Relationships +
+ + +
+ )} + + {/* Flags Section */} + {metadata.is_test_data && ( +
+
+ + Flags +
+ +
+
+ Test Data: + + Yes + +
+
+
+ )} +
+
+
+ ); +}