mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 12:11:11 -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 { 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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user