mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 13:31:12 -05:00
feat: Implement complete queue system
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
90
src/components/moderation/QueueStatsDashboard.tsx
Normal file
90
src/components/moderation/QueueStatsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
src/components/moderation/ReassignDialog.tsx
Normal file
170
src/components/moderation/ReassignDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user