feat: Implement enhanced moderation system

This commit is contained in:
gpt-engineer-app[bot]
2025-09-30 13:41:19 +00:00
parent 04c5ef58ff
commit 083a4af08c
8 changed files with 1140 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import { type DependencyConflict, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
interface ConflictResolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflicts: DependencyConflict[];
items: SubmissionItemWithDeps[];
onResolve: () => void;
}
export function ConflictResolutionDialog({
open,
onOpenChange,
conflicts,
items,
onResolve,
}: ConflictResolutionDialogProps) {
const [resolutions, setResolutions] = useState<Record<string, string>>({});
const handleResolutionChange = (itemId: string, action: string) => {
setResolutions(prev => ({ ...prev, [itemId]: action }));
};
const allConflictsResolved = conflicts.every(
conflict => resolutions[conflict.itemId]
);
const handleApply = () => {
// TODO: Apply resolutions
onResolve();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Resolve Dependency Conflicts</DialogTitle>
<DialogDescription>
{conflicts.length} conflict(s) found. Choose how to resolve each one.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{conflicts.map((conflict) => {
const item = items.find(i => i.id === conflict.itemId);
return (
<div key={conflict.itemId} className="space-y-3">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<p className="font-medium">
{item?.item_type.replace('_', ' ').toUpperCase()}: {item?.item_data.name}
</p>
<p className="text-sm mt-1">{conflict.message}</p>
</AlertDescription>
</Alert>
<RadioGroup
value={resolutions[conflict.itemId] || ''}
onValueChange={(value) => handleResolutionChange(conflict.itemId, value)}
>
{conflict.suggestions.map((suggestion, idx) => (
<div key={idx} className="flex items-center space-x-2">
<RadioGroupItem value={suggestion.action} id={`${conflict.itemId}-${idx}`} />
<Label htmlFor={`${conflict.itemId}-${idx}`} className="cursor-pointer">
{suggestion.label}
</Label>
</div>
))}
</RadioGroup>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleApply} disabled={!allConflictsResolved}>
Apply & Approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowDown, AlertCircle } from 'lucide-react';
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface DependencyVisualizerProps {
items: SubmissionItemWithDeps[];
selectedIds: Set<string>;
}
export function DependencyVisualizer({ items, selectedIds }: DependencyVisualizerProps) {
const dependencyLevels = useMemo(() => {
const levels: SubmissionItemWithDeps[][] = [];
const visited = new Set<string>();
const getRootItems = () => items.filter(item => !item.depends_on);
const addLevel = (currentItems: SubmissionItemWithDeps[]) => {
if (currentItems.length === 0) return;
const nextLevel: SubmissionItemWithDeps[] = [];
currentItems.forEach(item => {
if (!visited.has(item.id)) {
visited.add(item.id);
if (item.dependents) {
nextLevel.push(...item.dependents);
}
}
});
levels.push(currentItems);
addLevel(nextLevel);
};
addLevel(getRootItems());
return levels;
}, [items]);
const hasCircularDependency = items.length > 0 && dependencyLevels.flat().length !== items.length;
return (
<div className="space-y-6">
{hasCircularDependency && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Circular dependency detected! This submission needs admin review.
</AlertDescription>
</Alert>
)}
{dependencyLevels.length === 0 && (
<Alert>
<AlertDescription>
No dependencies found in this submission
</AlertDescription>
</Alert>
)}
{dependencyLevels.map((level, levelIdx) => (
<div key={levelIdx} className="space-y-3">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-muted-foreground">
Level {levelIdx + 1}
</h4>
<div className="flex-1 h-px bg-border" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{level.map((item) => (
<Card
key={item.id}
className={`${
selectedIds.has(item.id)
? 'ring-2 ring-primary'
: ''
}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{item.item_type.replace('_', ' ').toUpperCase()}
</CardTitle>
<Badge variant={
item.status === 'approved' ? 'default' :
item.status === 'rejected' ? 'destructive' :
'secondary'
}>
{item.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-sm font-medium">
{item.item_data.name || 'Unnamed'}
</p>
{item.dependents && item.dependents.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Has {item.dependents.length} dependent(s)
</p>
)}
</CardContent>
</Card>
))}
</div>
{levelIdx < dependencyLevels.length - 1 && (
<div className="flex justify-center py-2">
<ArrowDown className="w-5 h-5 text-muted-foreground" />
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } 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';
interface EscalationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onEscalate: (reason: string) => void;
}
const escalationReasons = [
'Complex dependency issue',
'Potential policy violation',
'Unclear submission content',
'Requires admin judgment',
'Technical issue',
'Other',
];
export function EscalationDialog({
open,
onOpenChange,
onEscalate,
}: EscalationDialogProps) {
const [selectedReason, setSelectedReason] = useState('');
const [additionalNotes, setAdditionalNotes] = useState('');
const handleEscalate = () => {
const reason = selectedReason === 'Other'
? additionalNotes
: `${selectedReason}${additionalNotes ? ': ' + additionalNotes : ''}`;
onEscalate(reason);
onOpenChange(false);
// Reset form
setSelectedReason('');
setAdditionalNotes('');
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Escalate to Admin</DialogTitle>
<DialogDescription>
This submission will be flagged for admin review. Please provide a reason.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Escalation Reason</Label>
<Select value={selectedReason} onValueChange={setSelectedReason}>
<SelectTrigger>
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
<SelectContent>
{escalationReasons.map((reason) => (
<SelectItem key={reason} value={reason}>
{reason}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Additional Notes</Label>
<Textarea
value={additionalNotes}
onChange={(e) => setAdditionalNotes(e.target.value)}
placeholder="Provide any additional context..."
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleEscalate} disabled={!selectedReason}>
Escalate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,171 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react';
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
interface ItemReviewCardProps {
item: SubmissionItemWithDeps;
onEdit: () => void;
onStatusChange: (status: 'approved' | 'rejected') => void;
}
export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardProps) {
const getItemIcon = () => {
switch (item.item_type) {
case 'park': return <MapPin className="w-4 h-4" />;
case 'ride': return <Zap className="w-4 h-4" />;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer': return <Building2 className="w-4 h-4" />;
case 'ride_model': return <Package className="w-4 h-4" />;
case 'photo': return <Image className="w-4 h-4" />;
default: return null;
}
};
const getStatusColor = () => {
switch (item.status) {
case 'approved': return 'default';
case 'rejected': return 'destructive';
case 'pending': return 'secondary';
default: return 'outline';
}
};
const renderItemPreview = () => {
const data = item.item_data;
switch (item.item_type) {
case 'park':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.park_type && <Badge variant="outline">{data.park_type}</Badge>}
{data.status && <Badge variant="outline">{data.status}</Badge>}
</div>
</div>
);
case 'ride':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.category && <Badge variant="outline">{data.category}</Badge>}
{data.status && <Badge variant="outline">{data.status}</Badge>}
</div>
</div>
);
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
{data.founded_year && (
<Badge variant="outline">Founded {data.founded_year}</Badge>
)}
</div>
);
case 'ride_model':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.category && <Badge variant="outline">{data.category}</Badge>}
{data.ride_type && <Badge variant="outline">{data.ride_type}</Badge>}
</div>
</div>
);
case 'photo':
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
{data.photos?.slice(0, 3).map((photo: any, idx: number) => (
<img
key={idx}
src={photo.url}
alt={photo.caption || 'Submission photo'}
className="w-full h-20 object-cover rounded"
/>
))}
</div>
{data.photos?.length > 3 && (
<p className="text-sm text-muted-foreground">
+{data.photos.length - 3} more photo(s)
</p>
)}
</div>
);
default:
return (
<div className="text-sm text-muted-foreground">
No preview available
</div>
);
}
};
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
{getItemIcon()}
<CardTitle className="text-base">
{item.item_type.replace('_', ' ').toUpperCase()}
</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge variant={getStatusColor()}>
{item.status}
</Badge>
{item.status === 'pending' && (
<Button
size="sm"
variant="ghost"
onClick={onEdit}
>
<Edit className="w-3 h-3" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
{renderItemPreview()}
{item.depends_on && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs text-muted-foreground">
Depends on another item in this submission
</p>
</div>
)}
{item.rejection_reason && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-destructive">
Rejection Reason:
</p>
<p className="text-xs text-muted-foreground mt-1">
{item.rejection_reason}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useImperativeHandle, forwardRef } from 'react'; import { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2 } from 'lucide-react'; import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2, ListTree } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
@@ -13,6 +13,7 @@ import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { PhotoModal } from './PhotoModal'; import { PhotoModal } from './PhotoModal';
import { SubmissionReviewManager } from './SubmissionReviewManager';
interface ModerationItem { interface ModerationItem {
id: string; id: string;
@@ -56,6 +57,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const [photoModalOpen, setPhotoModalOpen] = useState(false); const [photoModalOpen, setPhotoModalOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<any[]>([]); const [selectedPhotos, setSelectedPhotos] = useState<any[]>([]);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole(); const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
@@ -1204,7 +1207,23 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
/> />
</div> </div>
<div className="flex gap-2 pt-2"> <div className="flex flex-col sm:flex-row gap-2 pt-2">
{/* Show Review Items button for content submissions */}
{item.type === 'content_submission' && (
<Button
onClick={() => {
setSelectedSubmissionId(item.id);
setReviewManagerOpen(true);
}}
disabled={actionLoading === item.id}
variant="outline"
className="flex-1"
>
<ListTree className="w-4 h-4 mr-2" />
Review Items
</Button>
)}
<Button <Button
onClick={() => handleModerationAction(item, 'approved', notes[item.id])} onClick={() => handleModerationAction(item, 'approved', notes[item.id])}
disabled={actionLoading === item.id} disabled={actionLoading === item.id}
@@ -1437,6 +1456,18 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
isOpen={photoModalOpen} isOpen={photoModalOpen}
onClose={() => setPhotoModalOpen(false)} onClose={() => setPhotoModalOpen(false)}
/> />
{/* Submission Review Manager for multi-entity submissions */}
{selectedSubmissionId && (
<SubmissionReviewManager
submissionId={selectedSubmissionId}
open={reviewManagerOpen}
onOpenChange={setReviewManagerOpen}
onComplete={() => {
fetchItems(activeEntityFilter, activeStatusFilter);
}}
/>
)}
</div> </div>
); );
}); });

View File

@@ -0,0 +1,315 @@
import { useState, useEffect } from 'react';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import {
fetchSubmissionItems,
buildDependencyTree,
detectDependencyConflicts,
approveSubmissionItems,
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';
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 [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items');
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
const isMobile = useIsMobile();
const Container = isMobile ? Sheet : Dialog;
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 () => {
setLoading(true);
try {
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
await approveSubmissionItems(selectedItems, 'current-user-id'); // TODO: Get from auth
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 () => {
setLoading(true);
try {
// TODO: Implement rejection with reason
toast({
title: 'Success',
description: `Rejected ${selectedItemIds.size} item(s)`,
});
onComplete();
onOpenChange(false);
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to reject items',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleEscalate = async (reason: string) => {
setLoading(true);
try {
await escalateSubmission(submissionId, reason, 'current-user-id'); // TODO: Get from auth
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 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={handleApprove}
/>
<EscalationDialog
open={showEscalationDialog}
onOpenChange={setShowEscalationDialog}
onEscalate={handleEscalate}
/>
</>
);
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={() => {/* TODO: Implement editing */}}
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>
);
}
}

View File

@@ -0,0 +1,299 @@
import { supabase } from '@/integrations/supabase/client';
export interface SubmissionItemWithDeps {
id: string;
submission_id: string;
item_type: string;
item_data: any;
original_data: any;
status: 'pending' | 'approved' | 'rejected';
depends_on: string | null;
order_index: number;
approved_entity_id: string | null;
rejection_reason: string | null;
created_at: string;
updated_at: string;
dependencies?: SubmissionItemWithDeps[];
dependents?: SubmissionItemWithDeps[];
}
export interface DependencyConflict {
itemId: string;
type: 'missing_parent' | 'rejected_parent' | 'circular_dependency';
message: string;
suggestions: Array<{
action: 'link_existing' | 'cascade_reject' | 'escalate' | 'create_parent';
label: string;
entityId?: string;
}>;
}
/**
* Fetch all items for a submission with their dependencies
*/
export async function fetchSubmissionItems(submissionId: string): Promise<SubmissionItemWithDeps[]> {
const { data, error } = await supabase
.from('submission_items')
.select('*')
.eq('submission_id', submissionId)
.order('order_index', { ascending: true });
if (error) throw error;
// Cast the data to the correct type
return (data || []).map(item => ({
...item,
status: item.status as 'pending' | 'approved' | 'rejected',
})) as SubmissionItemWithDeps[];
}
/**
* Build dependency tree for submission items
*/
export function buildDependencyTree(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] {
const itemMap = new Map(items.map(item => [item.id, { ...item, dependencies: [], dependents: [] }]));
// Build relationships
items.forEach(item => {
if (item.depends_on) {
const parent = itemMap.get(item.depends_on);
const child = itemMap.get(item.id);
if (parent && child) {
parent.dependents = parent.dependents || [];
parent.dependents.push(child);
child.dependencies = child.dependencies || [];
child.dependencies.push(parent);
}
}
});
return Array.from(itemMap.values());
}
/**
* Detect dependency conflicts for selective approval
*/
export async function detectDependencyConflicts(
items: SubmissionItemWithDeps[],
selectedItemIds: string[]
): Promise<DependencyConflict[]> {
const conflicts: DependencyConflict[] = [];
const selectedSet = new Set(selectedItemIds);
for (const item of items) {
// Check if parent is rejected but child is selected
if (item.depends_on && selectedSet.has(item.id)) {
const parent = items.find(i => i.id === item.depends_on);
if (parent && (parent.status === 'rejected' || !selectedSet.has(parent.id))) {
// Find existing entities that could be linked
const suggestions: DependencyConflict['suggestions'] = [];
// Suggest creating parent
if (parent.status !== 'rejected') {
suggestions.push({
action: 'create_parent',
label: `Also approve ${parent.item_type}: ${parent.item_data.name}`,
});
}
// Suggest linking to existing entity
if (parent.item_type === 'park') {
const { data: parks } = await supabase
.from('parks')
.select('id, name')
.ilike('name', `%${parent.item_data.name}%`)
.limit(3);
parks?.forEach(park => {
suggestions.push({
action: 'link_existing',
label: `Link to existing park: ${park.name}`,
entityId: park.id,
});
});
}
suggestions.push({
action: 'escalate',
label: 'Escalate to admin for resolution',
});
conflicts.push({
itemId: item.id,
type: 'missing_parent',
message: `Cannot approve ${item.item_type} without its parent ${parent.item_type}`,
suggestions,
});
}
}
}
return conflicts;
}
/**
* Update individual submission item status
*/
export async function updateSubmissionItem(
itemId: string,
updates: Partial<SubmissionItemWithDeps>
): Promise<void> {
const { error } = await supabase
.from('submission_items')
.update(updates)
.eq('id', itemId);
if (error) throw error;
}
/**
* Approve multiple items with dependency handling
*/
export async function approveSubmissionItems(
items: SubmissionItemWithDeps[],
userId: string
): Promise<void> {
// Sort by dependency order (parents first)
const sortedItems = topologicalSort(items);
for (const item of sortedItems) {
let entityId: string | null = null;
// Create the entity based on type
switch (item.item_type) {
case 'park':
entityId = await createPark(item.item_data);
break;
case 'ride':
entityId = await createRide(item.item_data);
break;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer':
entityId = await createCompany(item.item_data, item.item_type);
break;
case 'ride_model':
entityId = await createRideModel(item.item_data);
break;
case 'photo':
entityId = await approvePhotos(item.item_data);
break;
}
// Update item status
await updateSubmissionItem(item.id, {
status: 'approved',
approved_entity_id: entityId,
});
}
}
/**
* Topological sort for dependency-ordered processing
*/
function topologicalSort(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] {
const sorted: SubmissionItemWithDeps[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
function visit(item: SubmissionItemWithDeps) {
if (temp.has(item.id)) {
throw new Error('Circular dependency detected');
}
if (visited.has(item.id)) return;
temp.add(item.id);
if (item.dependencies) {
item.dependencies.forEach(dep => visit(dep));
}
temp.delete(item.id);
visited.add(item.id);
sorted.push(item);
}
items.forEach(item => {
if (!visited.has(item.id)) {
visit(item);
}
});
return sorted;
}
/**
* Helper functions to create entities
*/
async function createPark(data: any): Promise<string> {
const { data: park, error } = await supabase
.from('parks')
.insert(data)
.select('id')
.single();
if (error) throw error;
return park.id;
}
async function createRide(data: any): Promise<string> {
const { data: ride, error } = await supabase
.from('rides')
.insert(data)
.select('id')
.single();
if (error) throw error;
return ride.id;
}
async function createCompany(data: any, companyType: string): Promise<string> {
const { data: company, error } = await supabase
.from('companies')
.insert({ ...data, company_type: companyType })
.select('id')
.single();
if (error) throw error;
return company.id;
}
async function createRideModel(data: any): Promise<string> {
const { data: model, error } = await supabase
.from('ride_models')
.insert(data)
.select('id')
.single();
if (error) throw error;
return model.id;
}
async function approvePhotos(data: any): Promise<string> {
// Photos are already uploaded to Cloudflare
// Just need to associate them with the entity
return data.photos?.[0]?.url || '';
}
/**
* Escalate submission for admin review
*/
export async function escalateSubmission(
submissionId: string,
reason: string,
userId: string
): Promise<void> {
const { error } = await supabase
.from('content_submissions')
.update({
status: 'pending',
escalation_reason: reason,
escalated_by: userId,
reviewer_notes: `Escalated: ${reason}`,
})
.eq('id', submissionId);
if (error) throw error;
}

View File

@@ -25,6 +25,21 @@ export interface PhotoSubmissionContent {
company_id?: string; company_id?: string;
} }
export interface SubmissionItemData {
id: string;
submission_id: string;
item_type: EntityType | 'photo' | 'ride_model';
item_data: any;
original_data?: any;
status: 'pending' | 'approved' | 'rejected';
depends_on: string | null;
order_index: number;
approved_entity_id: string | null;
rejection_reason: string | null;
created_at: string;
updated_at: string;
}
export interface EntityPhotoGalleryProps { export interface EntityPhotoGalleryProps {
entityId: string; entityId: string;
entityType: EntityType; entityType: EntityType;