mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 15:11:13 -05:00
Implement rejection workflow
This commit is contained in:
160
src/components/moderation/RejectionDialog.tsx
Normal file
160
src/components/moderation/RejectionDialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export const REJECTION_REASONS = [
|
||||||
|
{ value: 'incomplete', label: 'Incomplete Information', template: 'The submission is missing required information or details.' },
|
||||||
|
{ value: 'inaccurate', label: 'Inaccurate Data', template: 'The information provided appears to be inaccurate or incorrect.' },
|
||||||
|
{ value: 'duplicate', label: 'Duplicate Entry', template: 'This entry already exists in the database.' },
|
||||||
|
{ value: 'inappropriate', label: 'Inappropriate Content', template: 'The submission contains inappropriate or irrelevant content.' },
|
||||||
|
{ value: 'poor_quality', label: 'Poor Quality', template: 'The submission does not meet quality standards (e.g., blurry photos, unclear descriptions).' },
|
||||||
|
{ value: 'spam', label: 'Spam', template: 'This appears to be spam or a test submission.' },
|
||||||
|
{ value: 'custom', label: 'Other (Custom Reason)', template: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface RejectionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
itemCount: number;
|
||||||
|
hasDependents: boolean;
|
||||||
|
onReject: (reason: string, cascade: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RejectionDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
itemCount,
|
||||||
|
hasDependents,
|
||||||
|
onReject,
|
||||||
|
}: RejectionDialogProps) {
|
||||||
|
const [selectedReason, setSelectedReason] = useState<string>('incomplete');
|
||||||
|
const [customReason, setCustomReason] = useState('');
|
||||||
|
const [cascade, setCascade] = useState(true);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const reasonTemplate = REJECTION_REASONS.find(r => r.value === selectedReason);
|
||||||
|
const finalReason = selectedReason === 'custom'
|
||||||
|
? customReason
|
||||||
|
: reasonTemplate?.template || '';
|
||||||
|
|
||||||
|
if (!finalReason.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onReject(finalReason, cascade);
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
setSelectedReason('incomplete');
|
||||||
|
setCustomReason('');
|
||||||
|
setCascade(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentReason = selectedReason === 'custom' ? customReason :
|
||||||
|
REJECTION_REASONS.find(r => r.value === selectedReason)?.template || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reject Submission Items</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You are about to reject {itemCount} item{itemCount !== 1 ? 's' : ''}.
|
||||||
|
Please provide a reason for rejection.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{hasDependents && (
|
||||||
|
<div className="flex items-start gap-2 p-4 bg-warning/10 border border-warning/20 rounded-lg">
|
||||||
|
<AlertCircle className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-warning">Dependency Warning</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Some selected items have dependent items. You can choose to cascade the rejection to all dependents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Rejection Reason</Label>
|
||||||
|
<RadioGroup value={selectedReason} onValueChange={setSelectedReason}>
|
||||||
|
{REJECTION_REASONS.map((reason) => (
|
||||||
|
<div key={reason.value} className="flex items-start space-x-2">
|
||||||
|
<RadioGroupItem value={reason.value} id={reason.value} className="mt-1" />
|
||||||
|
<Label htmlFor={reason.value} className="font-normal cursor-pointer flex-1">
|
||||||
|
<span className="font-medium">{reason.label}</span>
|
||||||
|
{reason.template && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">{reason.template}</p>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedReason === 'custom' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom-reason">Custom Reason *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="custom-reason"
|
||||||
|
placeholder="Provide a detailed reason for rejection..."
|
||||||
|
value={customReason}
|
||||||
|
onChange={(e) => setCustomReason(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasDependents && (
|
||||||
|
<div className="space-y-2 pt-2 border-t">
|
||||||
|
<Label className="text-base">Dependency Handling</Label>
|
||||||
|
<RadioGroup value={cascade ? 'cascade' : 'orphan'} onValueChange={(v) => setCascade(v === 'cascade')}>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<RadioGroupItem value="cascade" id="cascade" className="mt-1" />
|
||||||
|
<Label htmlFor="cascade" className="font-normal cursor-pointer">
|
||||||
|
<span className="font-medium">Cascade Rejection</span>
|
||||||
|
<p className="text-sm text-muted-foreground">Reject all dependent items as well</p>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<RadioGroupItem value="orphan" id="orphan" className="mt-1" />
|
||||||
|
<Label htmlFor="orphan" className="font-normal cursor-pointer">
|
||||||
|
<span className="font-medium">Keep Dependents Pending</span>
|
||||||
|
<p className="text-sm text-muted-foreground">Leave dependent items in pending state</p>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!currentReason.trim()}
|
||||||
|
>
|
||||||
|
Reject {itemCount} Item{itemCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
buildDependencyTree,
|
buildDependencyTree,
|
||||||
detectDependencyConflicts,
|
detectDependencyConflicts,
|
||||||
approveSubmissionItems,
|
approveSubmissionItems,
|
||||||
|
rejectSubmissionItems,
|
||||||
escalateSubmission,
|
escalateSubmission,
|
||||||
type SubmissionItemWithDeps,
|
type SubmissionItemWithDeps,
|
||||||
type DependencyConflict
|
type DependencyConflict
|
||||||
@@ -25,6 +26,7 @@ import { ItemReviewCard } from './ItemReviewCard';
|
|||||||
import { DependencyVisualizer } from './DependencyVisualizer';
|
import { DependencyVisualizer } from './DependencyVisualizer';
|
||||||
import { ConflictResolutionDialog } from './ConflictResolutionDialog';
|
import { ConflictResolutionDialog } from './ConflictResolutionDialog';
|
||||||
import { EscalationDialog } from './EscalationDialog';
|
import { EscalationDialog } from './EscalationDialog';
|
||||||
|
import { RejectionDialog } from './RejectionDialog';
|
||||||
|
|
||||||
interface SubmissionReviewManagerProps {
|
interface SubmissionReviewManagerProps {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
@@ -45,6 +47,7 @@ export function SubmissionReviewManager({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
||||||
const [showEscalationDialog, setShowEscalationDialog] = useState(false);
|
const [showEscalationDialog, setShowEscalationDialog] = useState(false);
|
||||||
|
const [showRejectionDialog, setShowRejectionDialog] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items');
|
const [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items');
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -151,20 +154,53 @@ export function SubmissionReviewManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectSelected = async () => {
|
const handleRejectSelected = async () => {
|
||||||
|
if (selectedItemIds.size === 0) {
|
||||||
|
toast({
|
||||||
|
title: 'No Items Selected',
|
||||||
|
description: 'Please select items to reject',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
toast({
|
||||||
|
title: 'Authentication Required',
|
||||||
|
description: 'You must be logged in to reject items',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any selected items have dependents
|
||||||
|
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
|
||||||
|
const hasDependents = selectedItems.some(item =>
|
||||||
|
item.dependents && item.dependents.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
setShowRejectionDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (reason: string, cascade: boolean) => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// TODO: Implement rejection with reason
|
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
|
||||||
toast({
|
await rejectSubmissionItems(selectedItems, reason, user.id, cascade);
|
||||||
title: 'Success',
|
|
||||||
description: `Rejected ${selectedItemIds.size} item(s)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Items Rejected',
|
||||||
|
description: `Successfully rejected ${selectedItems.length} item${selectedItems.length !== 1 ? 's' : ''}`,
|
||||||
|
});
|
||||||
|
|
||||||
onComplete();
|
onComplete();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error('Error rejecting items:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: error.message || 'Failed to reject items',
|
description: 'Failed to reject items. Please try again.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -246,6 +282,16 @@ export function SubmissionReviewManager({
|
|||||||
onOpenChange={setShowEscalationDialog}
|
onOpenChange={setShowEscalationDialog}
|
||||||
onEscalate={handleEscalate}
|
onEscalate={handleEscalate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RejectionDialog
|
||||||
|
open={showRejectionDialog}
|
||||||
|
onOpenChange={setShowRejectionDialog}
|
||||||
|
itemCount={selectedItemIds.size}
|
||||||
|
hasDependents={items.filter(item => selectedItemIds.has(item.id)).some(item =>
|
||||||
|
item.dependents && item.dependents.length > 0
|
||||||
|
)}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,113 @@ async function approvePhotos(data: any): Promise<string> {
|
|||||||
return data.photos?.[0]?.url || '';
|
return data.photos?.[0]?.url || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject multiple items with optional cascade to dependents
|
||||||
|
*/
|
||||||
|
export async function rejectSubmissionItems(
|
||||||
|
items: SubmissionItemWithDeps[],
|
||||||
|
reason: string,
|
||||||
|
userId: string,
|
||||||
|
cascade: boolean = true
|
||||||
|
): Promise<void> {
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('User authentication required to reject items');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reason || !reason.trim()) {
|
||||||
|
throw new Error('Rejection reason is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToReject = new Set<string>(items.map(i => i.id));
|
||||||
|
|
||||||
|
// If cascading, collect all dependent items
|
||||||
|
if (cascade) {
|
||||||
|
for (const item of items) {
|
||||||
|
await collectDependents(item, itemsToReject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all items to rejected status
|
||||||
|
const updates = Array.from(itemsToReject).map(async (itemId) => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.update({
|
||||||
|
status: 'rejected',
|
||||||
|
rejection_reason: reason,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', itemId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`Error rejecting item ${itemId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(updates);
|
||||||
|
|
||||||
|
// Update parent submission status
|
||||||
|
const submissionId = items[0]?.submission_id;
|
||||||
|
if (submissionId) {
|
||||||
|
await updateSubmissionStatusAfterRejection(submissionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectDependents(
|
||||||
|
item: SubmissionItemWithDeps,
|
||||||
|
rejectedSet: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
if (item.dependents && item.dependents.length > 0) {
|
||||||
|
for (const dependent of item.dependents) {
|
||||||
|
rejectedSet.add(dependent.id);
|
||||||
|
await collectDependents(dependent, rejectedSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSubmissionStatusAfterRejection(submissionId: string): Promise<void> {
|
||||||
|
// Get all items for this submission
|
||||||
|
const { data: allItems, error: fetchError } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.select('status')
|
||||||
|
.eq('submission_id', submissionId);
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error('Error fetching submission items:', fetchError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allItems || allItems.length === 0) return;
|
||||||
|
|
||||||
|
const statuses = allItems.map(i => i.status);
|
||||||
|
const allRejected = statuses.every(s => s === 'rejected');
|
||||||
|
const allApproved = statuses.every(s => s === 'approved');
|
||||||
|
const anyPending = statuses.some(s => s === 'pending');
|
||||||
|
|
||||||
|
let newStatus: string;
|
||||||
|
if (allRejected) {
|
||||||
|
newStatus = 'rejected';
|
||||||
|
} else if (allApproved) {
|
||||||
|
newStatus = 'approved';
|
||||||
|
} else if (anyPending) {
|
||||||
|
newStatus = 'pending';
|
||||||
|
} else {
|
||||||
|
newStatus = 'partially_approved';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.update({
|
||||||
|
status: newStatus,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', submissionId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error('Error updating submission status:', updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escalate submission for admin review
|
* Escalate submission for admin review
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user