diff --git a/src/components/moderation/EscalationDialog.tsx b/src/components/moderation/EscalationDialog.tsx index b6d84135..e5ae8604 100644 --- a/src/components/moderation/EscalationDialog.tsx +++ b/src/components/moderation/EscalationDialog.tsx @@ -1,14 +1,29 @@ import { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { AlertTriangle } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; interface EscalationDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onEscalate: (reason: string) => void; + onEscalate: (reason: string) => Promise; + submissionType: string; } const escalationReasons = [ @@ -24,30 +39,40 @@ export function EscalationDialog({ open, onOpenChange, onEscalate, + submissionType, }: EscalationDialogProps) { const [selectedReason, setSelectedReason] = useState(''); const [additionalNotes, setAdditionalNotes] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); - const handleEscalate = () => { + const handleEscalate = async () => { const reason = selectedReason === 'Other' ? additionalNotes : `${selectedReason}${additionalNotes ? ': ' + additionalNotes : ''}`; - onEscalate(reason); - onOpenChange(false); - - // Reset form - setSelectedReason(''); - setAdditionalNotes(''); + if (!reason.trim()) return; + + setIsSubmitting(true); + try { + await onEscalate(reason); + setSelectedReason(''); + setAdditionalNotes(''); + onOpenChange(false); + } finally { + setIsSubmitting(false); + } }; return ( - Escalate to Admin + + + Escalate Submission + - This submission will be flagged for admin review. Please provide a reason. + Escalating this {submissionType} will mark it as high priority and notify senior moderators. @@ -75,16 +100,25 @@ export function EscalationDialog({ onChange={(e) => setAdditionalNotes(e.target.value)} placeholder="Provide any additional context..." rows={4} + className="resize-none" /> - - diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index d2a71048..558f6dcb 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, Clock, Lock, Unlock } from 'lucide-react'; +import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2, ListTree, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; @@ -21,6 +21,9 @@ import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useModerationQueue } from '@/hooks/useModerationQueue'; import { Progress } from '@/components/ui/progress'; +import { QueueStatsDashboard } from './QueueStatsDashboard'; +import { EscalationDialog } from './EscalationDialog'; +import { ReassignDialog } from './ReassignDialog'; interface ModerationItem { id: string; @@ -69,6 +72,9 @@ export const ModerationQueue = forwardRef((props, ref) => { const [reviewManagerOpen, setReviewManagerOpen] = useState(false); const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); const [lockedSubmissions, setLockedSubmissions] = useState>(new Set()); + const [escalationDialogOpen, setEscalationDialogOpen] = useState(false); + const [reassignDialogOpen, setReassignDialogOpen] = useState(false); + const [selectedItemForAction, setSelectedItemForAction] = useState(null); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); diff --git a/src/components/moderation/QueueStatsDashboard.tsx b/src/components/moderation/QueueStatsDashboard.tsx new file mode 100644 index 00000000..465eab95 --- /dev/null +++ b/src/components/moderation/QueueStatsDashboard.tsx @@ -0,0 +1,90 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Clock, AlertTriangle, CheckCircle, Users } from 'lucide-react'; +import { useModerationQueue } from '@/hooks/useModerationQueue'; + +export function QueueStatsDashboard() { + const { queueStats } = useModerationQueue(); + + if (!queueStats) { + return null; + } + + const getSLAStatus = (avgWaitHours: number) => { + if (avgWaitHours < 24) return 'good'; + if (avgWaitHours < 48) return 'warning'; + return 'critical'; + }; + + const slaStatus = getSLAStatus(queueStats.avgWaitHours); + + return ( +
+ + + Pending Queue + + + +
{queueStats.pendingCount}
+

+ Total submissions waiting +

+
+
+ + + + Assigned to Me + + + +
{queueStats.assignedToMe}
+

+ Currently locked by you +

+
+
+ + + + Avg Wait Time + + + +
+
+ {queueStats.avgWaitHours.toFixed(1)}h +
+ {slaStatus === 'warning' && ( + + Warning + + )} + {slaStatus === 'critical' && ( + Critical + )} +
+

+ Average time in queue +

+
+
+ + + + High Priority + + + +
+ {queueStats.highPriorityCount} +
+

+ Escalated submissions +

+
+
+
+ ); +} diff --git a/src/components/moderation/ReassignDialog.tsx b/src/components/moderation/ReassignDialog.tsx new file mode 100644 index 00000000..6375ae2a --- /dev/null +++ b/src/components/moderation/ReassignDialog.tsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from 'react'; +import { UserCog } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { supabase } from '@/integrations/supabase/client'; +import { useToast } from '@/hooks/use-toast'; + +interface Moderator { + user_id: string; + username: string; + display_name?: string; + role: string; +} + +interface ReassignDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onReassign: (moderatorId: string) => Promise; + submissionType: string; +} + +export function ReassignDialog({ + open, + onOpenChange, + onReassign, + submissionType, +}: ReassignDialogProps) { + const [selectedModerator, setSelectedModerator] = useState(''); + const [moderators, setModerators] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [loading, setLoading] = useState(true); + const { toast } = useToast(); + + useEffect(() => { + if (open) { + fetchModerators(); + } + }, [open]); + + const fetchModerators = async () => { + setLoading(true); + try { + const { data: roles, error: rolesError } = await supabase + .from('user_roles') + .select('user_id, role') + .in('role', ['moderator', 'admin', 'superuser']); + + if (rolesError) throw rolesError; + + if (!roles || roles.length === 0) { + setModerators([]); + return; + } + + const userIds = roles.map((r) => r.user_id); + + const { data: profiles, error: profilesError } = await supabase + .from('profiles') + .select('user_id, username, display_name') + .in('user_id', userIds); + + if (profilesError) throw profilesError; + + const moderatorsList = roles.map((role) => { + const profile = profiles?.find((p) => p.user_id === role.user_id); + return { + user_id: role.user_id, + username: profile?.username || 'Unknown', + display_name: profile?.display_name, + role: role.role, + }; + }); + + setModerators(moderatorsList); + } catch (error: any) { + console.error('Error fetching moderators:', error); + toast({ + title: 'Error', + description: 'Failed to load moderators list', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const handleReassign = async () => { + if (!selectedModerator) return; + + setIsSubmitting(true); + try { + await onReassign(selectedModerator); + setSelectedModerator(''); + onOpenChange(false); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + Reassign Submission + + + Assign this {submissionType} to another moderator. They will receive a lock for 15 minutes. + + + +
+
+ + {loading ? ( +
Loading moderators...
+ ) : moderators.length === 0 ? ( +
No moderators available
+ ) : ( + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index e7500253..a53d8ab7 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -52,6 +52,7 @@ export function SubmissionReviewManager({ const [showEditDialog, setShowEditDialog] = useState(false); const [editingItem, setEditingItem] = useState(null); const [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items'); + const [submissionType, setSubmissionType] = useState('submission'); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); @@ -68,6 +69,19 @@ export function SubmissionReviewManager({ const loadSubmissionItems = async () => { setLoading(true); try { + const { supabase } = await import('@/integrations/supabase/client'); + + // Fetch submission type + const { data: submission } = await supabase + .from('content_submissions') + .select('submission_type') + .eq('id', submissionId) + .single(); + + if (submission) { + setSubmissionType(submission.submission_type || 'submission'); + } + const fetchedItems = await fetchSubmissionItems(submissionId); const itemsWithDeps = buildDependencyTree(fetchedItems); setItems(itemsWithDeps); @@ -400,6 +414,7 @@ export function SubmissionReviewManager({ open={showEscalationDialog} onOpenChange={setShowEscalationDialog} onEscalate={handleEscalate} + submissionType={submissionType} /> { return Math.max(0, currentLock.expiresAt.getTime() - Date.now()); }, [currentLock]); + // Escalate submission + const escalateSubmission = useCallback(async (submissionId: string, reason: string): Promise => { + if (!user?.id) return false; + + setIsLoading(true); + try { + const { error } = await supabase + .from('content_submissions') + .update({ + escalated: true, + escalated_at: new Date().toISOString(), + escalated_by: user.id, + escalation_reason: reason, + priority: 10, // Max priority + }) + .eq('id', submissionId); + + if (error) throw error; + + toast({ + title: 'Submission Escalated', + description: 'This submission has been marked as high priority', + }); + + fetchStats(); + return true; + } catch (error: any) { + console.error('Error escalating submission:', error); + toast({ + title: 'Error', + description: error.message || 'Failed to escalate submission', + variant: 'destructive', + }); + return false; + } finally { + setIsLoading(false); + } + }, [user, toast, fetchStats]); + + // Reassign submission + const reassignSubmission = useCallback(async (submissionId: string, newModeratorId: string): Promise => { + if (!user?.id) return false; + + setIsLoading(true); + try { + const { error } = await supabase + .from('content_submissions') + .update({ + assigned_to: newModeratorId, + assigned_at: new Date().toISOString(), + locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + }) + .eq('id', submissionId); + + if (error) throw error; + + // If this was our lock, clear it + if (currentLock?.submissionId === submissionId) { + setCurrentLock(null); + if (lockTimerRef.current) { + clearInterval(lockTimerRef.current); + } + } + + toast({ + title: 'Submission Reassigned', + description: 'The submission has been assigned to another moderator', + }); + + fetchStats(); + return true; + } catch (error: any) { + console.error('Error reassigning submission:', error); + toast({ + title: 'Error', + description: error.message || 'Failed to reassign submission', + variant: 'destructive', + }); + return false; + } finally { + setIsLoading(false); + } + }, [user, currentLock, toast, fetchStats]); + return { currentLock, queueStats, @@ -274,6 +358,8 @@ export const useModerationQueue = () => { extendLock, releaseLock, getTimeRemaining, + escalateSubmission, + reassignSubmission, refreshStats: fetchStats, }; };