mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 14:11:12 -05:00
feat: Implement enhanced moderation system
This commit is contained in:
96
src/components/moderation/ConflictResolutionDialog.tsx
Normal file
96
src/components/moderation/ConflictResolutionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/moderation/DependencyVisualizer.tsx
Normal file
118
src/components/moderation/DependencyVisualizer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/moderation/EscalationDialog.tsx
Normal file
93
src/components/moderation/EscalationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/moderation/ItemReviewCard.tsx
Normal file
171
src/components/moderation/ItemReviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
@@ -13,6 +13,7 @@ import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { format } from 'date-fns';
|
||||
import { PhotoModal } from './PhotoModal';
|
||||
import { SubmissionReviewManager } from './SubmissionReviewManager';
|
||||
|
||||
interface ModerationItem {
|
||||
id: string;
|
||||
@@ -56,6 +57,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const [photoModalOpen, setPhotoModalOpen] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<any[]>([]);
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
|
||||
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
|
||||
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const { isAdmin, isSuperuser } = useUserRole();
|
||||
const { user } = useAuth();
|
||||
@@ -1204,7 +1207,23 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
/>
|
||||
</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
|
||||
onClick={() => handleModerationAction(item, 'approved', notes[item.id])}
|
||||
disabled={actionLoading === item.id}
|
||||
@@ -1437,6 +1456,18 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
isOpen={photoModalOpen}
|
||||
onClose={() => setPhotoModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Submission Review Manager for multi-entity submissions */}
|
||||
{selectedSubmissionId && (
|
||||
<SubmissionReviewManager
|
||||
submissionId={selectedSubmissionId}
|
||||
open={reviewManagerOpen}
|
||||
onOpenChange={setReviewManagerOpen}
|
||||
onComplete={() => {
|
||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
315
src/components/moderation/SubmissionReviewManager.tsx
Normal file
315
src/components/moderation/SubmissionReviewManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
299
src/lib/submissionItemsService.ts
Normal file
299
src/lib/submissionItemsService.ts
Normal 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;
|
||||
}
|
||||
@@ -25,6 +25,21 @@ export interface PhotoSubmissionContent {
|
||||
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 {
|
||||
entityId: string;
|
||||
entityType: EntityType;
|
||||
|
||||
Reference in New Issue
Block a user