mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 21:11:15 -05:00
421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { useUserRole } from '@/hooks/useUserRole';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems';
|
|
import {
|
|
fetchSubmissionItems,
|
|
buildDependencyTree,
|
|
detectDependencyConflicts,
|
|
approveSubmissionItems,
|
|
rejectSubmissionItems,
|
|
escalateSubmission,
|
|
type SubmissionItemWithDeps,
|
|
type DependencyConflict
|
|
} from '@/lib/submissionItemsService';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp } from 'lucide-react';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { useIsMobile } from '@/hooks/use-mobile';
|
|
import { ItemReviewCard } from './ItemReviewCard';
|
|
import { DependencyVisualizer } from './DependencyVisualizer';
|
|
import { ConflictResolutionDialog } from './ConflictResolutionDialog';
|
|
import { EscalationDialog } from './EscalationDialog';
|
|
import { RejectionDialog } from './RejectionDialog';
|
|
import { ItemEditDialog } from './ItemEditDialog';
|
|
|
|
interface SubmissionReviewManagerProps {
|
|
submissionId: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onComplete: () => void;
|
|
}
|
|
|
|
export function SubmissionReviewManager({
|
|
submissionId,
|
|
open,
|
|
onOpenChange,
|
|
onComplete
|
|
}: SubmissionReviewManagerProps) {
|
|
const [items, setItems] = useState<SubmissionItemWithDeps[]>([]);
|
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
|
const [conflicts, setConflicts] = useState<DependencyConflict[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
|
const [showEscalationDialog, setShowEscalationDialog] = useState(false);
|
|
const [showRejectionDialog, setShowRejectionDialog] = useState(false);
|
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items');
|
|
|
|
const { toast } = useToast();
|
|
const { isAdmin, isSuperuser } = useUserRole();
|
|
const { user } = useAuth();
|
|
const isMobile = useIsMobile();
|
|
const Container = isMobile ? Sheet : Dialog;
|
|
|
|
// Set up realtime subscription for submission items
|
|
useRealtimeSubmissionItems({
|
|
submissionId,
|
|
onUpdate: (payload) => {
|
|
console.log('Submission item updated in real-time:', payload);
|
|
toast({
|
|
title: 'Item Updated',
|
|
description: 'A submission item was updated by another moderator',
|
|
});
|
|
loadSubmissionItems();
|
|
},
|
|
enabled: open && !!submissionId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (open && submissionId) {
|
|
loadSubmissionItems();
|
|
}
|
|
}, [open, submissionId]);
|
|
|
|
const loadSubmissionItems = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const fetchedItems = await fetchSubmissionItems(submissionId);
|
|
const itemsWithDeps = buildDependencyTree(fetchedItems);
|
|
setItems(itemsWithDeps);
|
|
|
|
// Auto-select pending items
|
|
const pendingIds = fetchedItems
|
|
.filter(item => item.status === 'pending')
|
|
.map(item => item.id);
|
|
setSelectedItemIds(new Set(pendingIds));
|
|
} catch (error: any) {
|
|
toast({
|
|
title: 'Error',
|
|
description: error.message || 'Failed to load submission items',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const toggleItemSelection = (itemId: string) => {
|
|
setSelectedItemIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(itemId)) {
|
|
next.delete(itemId);
|
|
} else {
|
|
next.add(itemId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleCheckConflicts = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const detectedConflicts = await detectDependencyConflicts(items, Array.from(selectedItemIds));
|
|
setConflicts(detectedConflicts);
|
|
|
|
if (detectedConflicts.length > 0) {
|
|
setShowConflictDialog(true);
|
|
} else {
|
|
// No conflicts, proceed with approval
|
|
handleApprove();
|
|
}
|
|
} catch (error: any) {
|
|
toast({
|
|
title: 'Error',
|
|
description: error.message || 'Failed to check dependencies',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleApprove = async () => {
|
|
if (!user?.id) {
|
|
toast({
|
|
title: 'Authentication Required',
|
|
description: 'You must be logged in to approve items',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
|
|
await approveSubmissionItems(selectedItems, user.id);
|
|
|
|
toast({
|
|
title: 'Success',
|
|
description: `Approved ${selectedItems.length} item(s)`,
|
|
});
|
|
|
|
onComplete();
|
|
onOpenChange(false);
|
|
} catch (error: any) {
|
|
toast({
|
|
title: 'Error',
|
|
description: error.message || 'Failed to approve items',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRejectSelected = async () => {
|
|
if (selectedItemIds.size === 0) {
|
|
toast({
|
|
title: 'No Items Selected',
|
|
description: 'Please select items to reject',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!user?.id) {
|
|
toast({
|
|
title: 'Authentication Required',
|
|
description: 'You must be logged in to reject items',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if any selected items have dependents
|
|
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
|
|
const hasDependents = selectedItems.some(item =>
|
|
item.dependents && item.dependents.length > 0
|
|
);
|
|
|
|
setShowRejectionDialog(true);
|
|
};
|
|
|
|
const handleReject = async (reason: string, cascade: boolean) => {
|
|
if (!user?.id) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
|
|
await rejectSubmissionItems(selectedItems, reason, user.id, cascade);
|
|
|
|
toast({
|
|
title: 'Items Rejected',
|
|
description: `Successfully rejected ${selectedItems.length} item${selectedItems.length !== 1 ? 's' : ''}`,
|
|
});
|
|
|
|
onComplete();
|
|
onOpenChange(false);
|
|
} catch (error: any) {
|
|
console.error('Error rejecting items:', error);
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to reject items. Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleEscalate = async (reason: string) => {
|
|
if (!user?.id) {
|
|
toast({
|
|
title: 'Authentication Required',
|
|
description: 'You must be logged in to escalate submissions',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
await escalateSubmission(submissionId, reason, user.id);
|
|
|
|
toast({
|
|
title: 'Escalated',
|
|
description: 'Submission escalated to admin for review',
|
|
});
|
|
|
|
onComplete();
|
|
onOpenChange(false);
|
|
} catch (error: any) {
|
|
toast({
|
|
title: 'Error',
|
|
description: error.message || 'Failed to escalate submission',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleEdit = (item: SubmissionItemWithDeps) => {
|
|
setEditingItem(item);
|
|
setShowEditDialog(true);
|
|
};
|
|
|
|
const handleEditComplete = async () => {
|
|
setShowEditDialog(false);
|
|
setEditingItem(null);
|
|
await loadSubmissionItems();
|
|
};
|
|
|
|
const pendingCount = items.filter(item => item.status === 'pending').length;
|
|
const selectedCount = selectedItemIds.size;
|
|
|
|
return (
|
|
<>
|
|
<Container open={open} onOpenChange={onOpenChange}>
|
|
{isMobile ? (
|
|
<SheetContent side="bottom" className="h-[90vh]">
|
|
<SheetHeader>
|
|
<SheetTitle>Review Submission</SheetTitle>
|
|
<SheetDescription>
|
|
{pendingCount} pending item(s) • {selectedCount} selected
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<ReviewContent />
|
|
</SheetContent>
|
|
) : (
|
|
<DialogContent className="max-w-5xl max-h-[90vh]">
|
|
<DialogHeader>
|
|
<DialogTitle>Review Submission</DialogTitle>
|
|
<DialogDescription>
|
|
{pendingCount} pending item(s) • {selectedCount} selected
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<ReviewContent />
|
|
</DialogContent>
|
|
)}
|
|
</Container>
|
|
|
|
<ConflictResolutionDialog
|
|
open={showConflictDialog}
|
|
onOpenChange={setShowConflictDialog}
|
|
conflicts={conflicts}
|
|
items={items}
|
|
onResolve={async () => {
|
|
await loadSubmissionItems();
|
|
await handleApprove();
|
|
}}
|
|
/>
|
|
|
|
<EscalationDialog
|
|
open={showEscalationDialog}
|
|
onOpenChange={setShowEscalationDialog}
|
|
onEscalate={handleEscalate}
|
|
/>
|
|
|
|
<RejectionDialog
|
|
open={showRejectionDialog}
|
|
onOpenChange={setShowRejectionDialog}
|
|
itemCount={selectedItemIds.size}
|
|
hasDependents={items.filter(item => selectedItemIds.has(item.id)).some(item =>
|
|
item.dependents && item.dependents.length > 0
|
|
)}
|
|
onReject={handleReject}
|
|
/>
|
|
|
|
<ItemEditDialog
|
|
item={editingItem}
|
|
open={showEditDialog}
|
|
onOpenChange={setShowEditDialog}
|
|
onComplete={handleEditComplete}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
function ReviewContent() {
|
|
return (
|
|
<div className="flex flex-col gap-4 h-full">
|
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col">
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="items">
|
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
Items ({items.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="dependencies">
|
|
<Network className="w-4 h-4 mr-2" />
|
|
Dependencies
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="items" className="flex-1 overflow-hidden">
|
|
<ScrollArea className="h-full pr-4">
|
|
<div className="space-y-4">
|
|
{items.length === 0 && !loading && (
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
No items found in this submission
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{items.map((item) => (
|
|
<div key={item.id} className="flex gap-3 items-start">
|
|
<Checkbox
|
|
checked={selectedItemIds.has(item.id)}
|
|
onCheckedChange={() => toggleItemSelection(item.id)}
|
|
disabled={item.status !== 'pending'}
|
|
/>
|
|
<ItemReviewCard
|
|
item={item}
|
|
onEdit={() => handleEdit(item)}
|
|
onStatusChange={(status) => {/* TODO: Update status */}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
|
|
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t">
|
|
<Button
|
|
onClick={handleCheckConflicts}
|
|
disabled={selectedCount === 0 || loading}
|
|
className="flex-1"
|
|
>
|
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
Approve Selected ({selectedCount})
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleRejectSelected}
|
|
disabled={selectedCount === 0 || loading}
|
|
variant="destructive"
|
|
className="flex-1"
|
|
>
|
|
<XCircle className="w-4 h-4 mr-2" />
|
|
Reject Selected
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => setShowEscalationDialog(true)}
|
|
variant="outline"
|
|
disabled={loading}
|
|
>
|
|
<ArrowUp className="w-4 h-4 mr-2" />
|
|
Escalate
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|