feat: Implement complete queue system

This commit is contained in:
gpt-engineer-app[bot]
2025-10-06 14:43:31 +00:00
parent d955037990
commit 3dec0b2a97
6 changed files with 417 additions and 16 deletions

View File

@@ -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<void>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Escalate to Admin</DialogTitle>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Escalate Submission
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -75,16 +100,25 @@ export function EscalationDialog({
onChange={(e) => setAdditionalNotes(e.target.value)}
placeholder="Provide any additional context..."
rows={4}
className="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleEscalate} disabled={!selectedReason}>
Escalate
<Button
variant="destructive"
onClick={handleEscalate}
disabled={!selectedReason || isSubmitting}
>
{isSubmitting ? 'Escalating...' : 'Escalate'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -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<ModerationQueueRef>((props, ref) => {
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const [lockedSubmissions, setLockedSubmissions] = useState<Set<string>>(new Set());
const [escalationDialogOpen, setEscalationDialogOpen] = useState(false);
const [reassignDialogOpen, setReassignDialogOpen] = useState(false);
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth();

View File

@@ -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 (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Queue</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{queueStats.pendingCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Total submissions waiting
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assigned to Me</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{queueStats.assignedToMe}</div>
<p className="text-xs text-muted-foreground mt-1">
Currently locked by you
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Wait Time</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold">
{queueStats.avgWaitHours.toFixed(1)}h
</div>
{slaStatus === 'warning' && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20">
Warning
</Badge>
)}
{slaStatus === 'critical' && (
<Badge variant="destructive">Critical</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Average time in queue
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">High Priority</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{queueStats.highPriorityCount}
</div>
<p className="text-xs text-muted-foreground mt-1">
Escalated submissions
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<void>;
submissionType: string;
}
export function ReassignDialog({
open,
onOpenChange,
onReassign,
submissionType,
}: ReassignDialogProps) {
const [selectedModerator, setSelectedModerator] = useState<string>('');
const [moderators, setModerators] = useState<Moderator[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCog className="h-5 w-5" />
Reassign Submission
</DialogTitle>
<DialogDescription>
Assign this {submissionType} to another moderator. They will receive a lock for 15 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="moderator">Select Moderator</Label>
{loading ? (
<div className="text-sm text-muted-foreground">Loading moderators...</div>
) : moderators.length === 0 ? (
<div className="text-sm text-muted-foreground">No moderators available</div>
) : (
<Select value={selectedModerator} onValueChange={setSelectedModerator}>
<SelectTrigger id="moderator">
<SelectValue placeholder="Choose a moderator" />
</SelectTrigger>
<SelectContent>
{moderators.map((mod) => (
<SelectItem key={mod.user_id} value={mod.user_id}>
{mod.display_name || mod.username} ({mod.role})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleReassign}
disabled={!selectedModerator || isSubmitting}
>
{isSubmitting ? 'Reassigning...' : 'Reassign'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -52,6 +52,7 @@ export function SubmissionReviewManager({
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 { 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}
/>
<RejectionDialog

View File

@@ -266,6 +266,90 @@ export const useModerationQueue = () => {
return Math.max(0, currentLock.expiresAt.getTime() - Date.now());
}, [currentLock]);
// Escalate submission
const escalateSubmission = useCallback(async (submissionId: string, reason: string): Promise<boolean> => {
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<boolean> => {
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,
};
};