diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index ea2b9548..d2a71048 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useImperativeHandle, forwardRef } from 'react'; -import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2, ListTree, RefreshCw, AlertCircle } from 'lucide-react'; +import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2, ListTree, RefreshCw, AlertCircle, Clock, Lock, Unlock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; @@ -19,6 +19,8 @@ import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; import { SubmissionItemsList } from './SubmissionItemsList'; import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { useAdminSettings } from '@/hooks/useAdminSettings'; +import { useModerationQueue } from '@/hooks/useModerationQueue'; +import { Progress } from '@/components/ui/progress'; interface ModerationItem { id: string; @@ -66,9 +68,11 @@ export const ModerationQueue = forwardRef((props, ref) => { const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0); const [reviewManagerOpen, setReviewManagerOpen] = useState(false); const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); + const [lockedSubmissions, setLockedSubmissions] = useState>(new Set()); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); + const queue = useModerationQueue(); // Get admin settings for polling configuration const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings(); @@ -380,6 +384,51 @@ export const ModerationQueue = forwardRef((props, ref) => { }; }, [user, refreshMode, pollInterval, activeEntityFilter, activeStatusFilter, isInitialLoad]); + // Real-time subscription for lock status + useEffect(() => { + if (!user) return; + + const channel = supabase + .channel('moderation-locks') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'content_submissions', + }, + (payload) => { + const newData = payload.new as any; + + // Track submissions locked by others + if (newData.assigned_to && newData.assigned_to !== user.id && newData.locked_until) { + const lockExpiry = new Date(newData.locked_until); + if (lockExpiry > new Date()) { + setLockedSubmissions((prev) => new Set(prev).add(newData.id)); + } else { + setLockedSubmissions((prev) => { + const next = new Set(prev); + next.delete(newData.id); + return next; + }); + } + } else { + // Lock released + setLockedSubmissions((prev) => { + const next = new Set(prev); + next.delete(newData.id); + return next; + }); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [user]); + const handleResetToPending = async (item: ModerationItem) => { setActionLoading(item.id); try { @@ -468,6 +517,11 @@ export const ModerationQueue = forwardRef((props, ref) => { } setActionLoading(item.id); + + // Release lock if this submission is claimed by current user + if (queue.currentLock?.submissionId === item.id) { + await queue.releaseLock(item.id); + } try { // Handle composite ride submissions with sequential entity creation if (action === 'approved' && item.type === 'content_submission' && @@ -1067,6 +1121,18 @@ export const ModerationQueue = forwardRef((props, ref) => { Needs Retry )} + {lockedSubmissions.has(item.id) && item.type === 'content_submission' && ( + + + Locked by Another Moderator + + )} + {queue.currentLock?.submissionId === item.id && item.type === 'content_submission' && ( + + + Claimed by You + + )}
@@ -1692,8 +1758,107 @@ export const ModerationQueue = forwardRef((props, ref) => { } }; + // Helper to format lock timer + const formatLockTimer = (ms: number): string => { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + // Handle claim next action + const handleClaimNext = async () => { + const claimedId = await queue.claimNext(); + if (claimedId) { + // Scroll to claimed submission or fetch to show it + fetchItems(activeEntityFilter, activeStatusFilter); + } + }; + return (
+ {/* Queue Statistics & Claim Button */} + {queue.queueStats && ( + + +
+ {/* Stats Grid */} +
+
+
{queue.queueStats.pendingCount}
+
Pending
+
+
+
{queue.queueStats.highPriorityCount}
+
High Priority
+
+
+
{queue.queueStats.assignedToMe}
+
Assigned to Me
+
+
+
+ {queue.queueStats.avgWaitHours.toFixed(1)}h +
+
Avg Wait
+
+
+ + {/* Claim/Lock Status */} +
+ {queue.currentLock ? ( + <> + {/* Lock Timer */} +
+ + + Lock: {formatLockTimer(queue.getTimeRemaining() || 0)} + +
+ + {/* Extend Lock Button (show when < 5 min left) */} + {(queue.getTimeRemaining() || 0) < 5 * 60 * 1000 && ( + + )} + + + ) : ( + + )} +
+
+
+
+ )} + {/* Filter Bar */}
diff --git a/src/hooks/useModerationQueue.ts b/src/hooks/useModerationQueue.ts new file mode 100644 index 00000000..57877a63 --- /dev/null +++ b/src/hooks/useModerationQueue.ts @@ -0,0 +1,297 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from './useAuth'; +import { useToast } from './use-toast'; + +interface QueuedSubmission { + submission_id: string; + submission_type: string; + priority: number; + waiting_time: string; // PostgreSQL interval format +} + +interface LockState { + submissionId: string; + expiresAt: Date; + autoReleaseTimer?: NodeJS.Timeout; +} + +interface QueueStats { + pendingCount: number; + assignedToMe: number; + avgWaitHours: number; + highPriorityCount: number; +} + +export const useModerationQueue = () => { + const [currentLock, setCurrentLock] = useState(null); + const [queueStats, setQueueStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const lockTimerRef = useRef(); + const { user } = useAuth(); + const { toast } = useToast(); + + // Auto-release expired locks periodically + useEffect(() => { + if (!user) return; + + // Call release_expired_locks every 2 minutes + const releaseInterval = setInterval(async () => { + try { + await supabase.rpc('release_expired_locks'); + } catch (error) { + console.error('Error auto-releasing expired locks:', error); + } + }, 120000); // 2 minutes + + return () => clearInterval(releaseInterval); + }, [user]); + + // Fetch queue statistics + const fetchStats = useCallback(async () => { + if (!user) return; + + try { + const { data: slaData } = await supabase + .from('moderation_sla_metrics') + .select('*'); + + const { count: assignedCount } = await supabase + .from('content_submissions') + .select('id', { count: 'exact', head: true }) + .eq('assigned_to', user.id) + .gt('locked_until', new Date().toISOString()); + + if (slaData) { + const totals = slaData.reduce( + (acc, row) => ({ + pendingCount: acc.pendingCount + (row.pending_count || 0), + avgWaitHours: acc.avgWaitHours + (row.avg_wait_hours || 0), + highPriorityCount: acc.highPriorityCount + (row.escalated_count || 0), + }), + { pendingCount: 0, avgWaitHours: 0, highPriorityCount: 0 } + ); + + setQueueStats({ + pendingCount: totals.pendingCount, + assignedToMe: assignedCount || 0, + avgWaitHours: slaData.length > 0 ? totals.avgWaitHours / slaData.length : 0, + highPriorityCount: totals.highPriorityCount, + }); + } + } catch (error) { + console.error('Error fetching queue stats:', error); + } + }, [user]); + + // Fetch stats on mount and periodically + useEffect(() => { + fetchStats(); + const interval = setInterval(fetchStats, 30000); // Every 30 seconds + return () => clearInterval(interval); + }, [fetchStats]); + + // Start countdown timer for lock expiry + const startLockTimer = useCallback((expiresAt: Date) => { + if (lockTimerRef.current) { + clearInterval(lockTimerRef.current); + } + + lockTimerRef.current = setInterval(() => { + const now = new Date(); + const timeLeft = expiresAt.getTime() - now.getTime(); + + if (timeLeft <= 0) { + // Lock expired + setCurrentLock(null); + if (lockTimerRef.current) { + clearInterval(lockTimerRef.current); + } + toast({ + title: 'Lock Expired', + description: 'Your submission lock has expired', + variant: 'destructive', + }); + } + }, 1000); // Check every second + }, [toast]); + + // Clean up timer on unmount + useEffect(() => { + return () => { + if (lockTimerRef.current) { + clearInterval(lockTimerRef.current); + } + }; + }, []); + + // Claim next submission from queue + const claimNext = useCallback(async (): Promise => { + if (!user?.id) { + toast({ + title: 'Authentication Required', + description: 'You must be logged in to claim submissions', + variant: 'destructive', + }); + return null; + } + + setIsLoading(true); + try { + const { data, error } = await supabase.rpc('claim_next_submission', { + moderator_id: user.id, + lock_duration: '15 minutes', + }); + + if (error) throw error; + + if (!data || data.length === 0) { + toast({ + title: 'Queue Empty', + description: 'No submissions available to review', + }); + return null; + } + + const claimed = data[0] as QueuedSubmission; + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + setCurrentLock({ + submissionId: claimed.submission_id, + expiresAt, + }); + + startLockTimer(expiresAt); + fetchStats(); + + toast({ + title: 'Submission Claimed', + description: `Priority ${claimed.priority} ${claimed.submission_type} (waiting ${formatInterval(claimed.waiting_time)})`, + }); + + return claimed.submission_id; + } catch (error: any) { + console.error('Error claiming submission:', error); + toast({ + title: 'Error', + description: error.message || 'Failed to claim submission', + variant: 'destructive', + }); + return null; + } finally { + setIsLoading(false); + } + }, [user, toast, startLockTimer, fetchStats]); + + // Extend current lock + const extendLock = useCallback(async (submissionId: string): Promise => { + if (!user?.id) return false; + + setIsLoading(true); + try { + const { data, error } = await supabase.rpc('extend_submission_lock', { + submission_id: submissionId, + moderator_id: user.id, + extension_duration: '15 minutes', + }); + + if (error) throw error; + + if (data) { + const newExpiresAt = new Date(data); + setCurrentLock((prev) => + prev?.submissionId === submissionId + ? { ...prev, expiresAt: newExpiresAt } + : prev + ); + startLockTimer(newExpiresAt); + + toast({ + title: 'Lock Extended', + description: 'Lock extended for 15 more minutes', + }); + + return true; + } + + return false; + } catch (error: any) { + console.error('Error extending lock:', error); + toast({ + title: 'Error', + description: error.message || 'Failed to extend lock', + variant: 'destructive', + }); + return false; + } finally { + setIsLoading(false); + } + }, [user, toast, startLockTimer]); + + // Release lock (manual or on completion) + const releaseLock = useCallback(async (submissionId: string): Promise => { + if (!user?.id) return false; + + try { + const { data, error } = await supabase.rpc('release_submission_lock', { + submission_id: submissionId, + moderator_id: user.id, + }); + + if (error) throw error; + + if (data) { + setCurrentLock((prev) => + prev?.submissionId === submissionId ? null : prev + ); + + if (lockTimerRef.current) { + clearInterval(lockTimerRef.current); + } + + fetchStats(); + return true; + } + + return false; + } catch (error: any) { + console.error('Error releasing lock:', error); + return false; + } + }, [user, fetchStats]); + + // Get time remaining on current lock + const getTimeRemaining = useCallback((): number | null => { + if (!currentLock) return null; + return Math.max(0, currentLock.expiresAt.getTime() - Date.now()); + }, [currentLock]); + + return { + currentLock, + queueStats, + isLoading, + claimNext, + extendLock, + releaseLock, + getTimeRemaining, + refreshStats: fetchStats, + }; +}; + +// Helper to format PostgreSQL interval +function formatInterval(interval: string): string { + const match = interval.match(/(\d+):(\d+):(\d+)/); + if (!match) return interval; + + const hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + + if (hours > 24) { + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } +}