Implement planned features

This commit is contained in:
gpt-engineer-app[bot]
2025-11-03 00:38:16 +00:00
parent ecca11a475
commit 061c06be29
10 changed files with 771 additions and 123 deletions

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchEditHistory } from '@/lib/submissionItemsService';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { EditHistoryEntry } from './EditHistoryEntry';
import { History, Loader2, AlertCircle } from 'lucide-react';
interface EditHistoryAccordionProps {
submissionId: string;
}
const INITIAL_LOAD = 20;
const LOAD_MORE_INCREMENT = 10;
export function EditHistoryAccordion({ submissionId }: EditHistoryAccordionProps) {
const [limit, setLimit] = useState(INITIAL_LOAD);
const { data: editHistory, isLoading, error } = useQuery({
queryKey: ['edit-history', submissionId, limit],
queryFn: async () => {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch edit history with user profiles
const { data, error } = await supabase
.from('item_edit_history')
.select(`
id,
item_id,
edited_at,
edited_by,
previous_data,
new_data,
edit_reason,
changed_fields,
profiles:edited_by (
username,
avatar_url
)
`)
.eq('item_id', submissionId)
.order('edited_at', { ascending: false })
.limit(limit);
if (error) throw error;
return data || [];
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const loadMore = () => {
setLimit(prev => prev + LOAD_MORE_INCREMENT);
};
const hasMore = editHistory && editHistory.length === limit;
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="edit-history">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<History className="h-4 w-4" />
<span>Edit History</span>
{editHistory && editHistory.length > 0 && (
<span className="text-xs text-muted-foreground">
({editHistory.length} edit{editHistory.length !== 1 ? 's' : ''})
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load edit history: {error instanceof Error ? error.message : 'Unknown error'}
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length === 0 && (
<Alert>
<AlertDescription>
No edit history found for this submission.
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length > 0 && (
<div className="space-y-4">
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-3">
{editHistory.map((entry: any) => (
<EditHistoryEntry
key={entry.id}
editId={entry.id}
editorName={entry.profiles?.username || 'Unknown User'}
editorAvatar={entry.profiles?.avatar_url}
timestamp={entry.edited_at}
changedFields={entry.changed_fields || []}
editReason={entry.edit_reason}
beforeData={entry.previous_data}
afterData={entry.new_data}
/>
))}
</div>
</ScrollArea>
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={loadMore}
>
Load More
</Button>
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,131 @@
import { formatDistanceToNow } from 'date-fns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, Edit, User } from 'lucide-react';
import { useState } from 'react';
interface EditHistoryEntryProps {
editId: string;
editorName: string;
editorAvatar?: string;
timestamp: string;
changedFields: string[];
editReason?: string;
beforeData?: Record<string, any>;
afterData?: Record<string, any>;
}
export function EditHistoryEntry({
editId,
editorName,
editorAvatar,
timestamp,
changedFields,
editReason,
beforeData,
afterData,
}: EditHistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(false);
const getFieldValue = (data: Record<string, any> | undefined, field: string): string => {
if (!data || !(field in data)) return '—';
const value = data[field];
if (value === null || value === undefined) return '—';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};
return (
<Card className="p-4">
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div className="flex items-start gap-3">
{/* Editor Avatar */}
<Avatar className="h-8 w-8">
<AvatarImage src={editorAvatar} alt={editorName} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
{/* Edit Info */}
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{editorName}</span>
<Badge variant="secondary" className="text-xs">
<Edit className="h-3 w-3 mr-1" />
{changedFields.length} field{changedFields.length !== 1 ? 's' : ''}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(timestamp), { addSuffix: true })}
</span>
</div>
{/* Changed Fields Summary */}
<div className="flex flex-wrap gap-1">
{changedFields.slice(0, 3).map((field) => (
<Badge key={field} variant="outline" className="text-xs">
{field}
</Badge>
))}
{changedFields.length > 3 && (
<Badge variant="outline" className="text-xs">
+{changedFields.length - 3} more
</Badge>
)}
</div>
{/* Edit Reason */}
{editReason && (
<p className="text-sm text-muted-foreground italic">
"{editReason}"
</p>
)}
{/* Expand/Collapse Button */}
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2">
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
<span className="ml-1">{isExpanded ? 'Hide' : 'Show'} Changes</span>
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* Detailed Changes */}
<CollapsibleContent className="mt-3 space-y-3">
{changedFields.map((field) => {
const beforeValue = getFieldValue(beforeData, field);
const afterValue = getFieldValue(afterData, field);
return (
<div key={field} className="border-l-2 border-muted pl-3 space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{field}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Before</div>
<div className="bg-destructive/10 text-destructive rounded p-2 font-mono text-xs break-all">
{beforeValue}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">After</div>
<div className="bg-success/10 text-success rounded p-2 font-mono text-xs break-all">
{afterValue}
</div>
</div>
</div>
</div>
);
})}
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@@ -1,8 +1,11 @@
import { Filter, MessageSquare, FileText, Image, X } from 'lucide-react';
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
interface QueueFiltersProps {
@@ -39,107 +42,161 @@ export const QueueFilters = ({
onClearFilters,
showClearButton
}: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState();
// Count active filters
const activeFilterCount = [
activeEntityFilter !== 'all' ? 1 : 0,
activeStatusFilter !== 'all' ? 1 : 0,
].reduce((sum, val) => sum + val, 0);
return (
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
</div>
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
{/* Entity Type Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Select
value={activeEntityFilter}
onValueChange={onEntityFilterChange}
>
<SelectTrigger
id="entity-filter"
className={isMobile ? "h-10" : ""}
aria-label="Filter by entity type"
>
<SelectValue>
<div className="flex items-center gap-2">
{getEntityFilterIcon(activeEntityFilter)}
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
All Items
</div>
</SelectItem>
<SelectItem value="reviews">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Reviews
</div>
</SelectItem>
<SelectItem value="submissions">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Submissions
</div>
</SelectItem>
<SelectItem value="photos">
<div className="flex items-center gap-2">
<Image className="w-4 h-4" />
Photos
</div>
</SelectItem>
</SelectContent>
</Select>
<div className={`bg-muted/50 rounded-lg transition-all duration-250 ${isMobile ? 'p-3' : 'p-4'}`}>
<Collapsible open={!isCollapsed} onOpenChange={() => toggle()}>
{/* Header with collapse trigger on mobile */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
{isCollapsed && activeFilterCount > 0 && (
<Badge variant="secondary" className="text-xs">
{activeFilterCount} active
</Badge>
)}
</div>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
</Button>
</CollapsibleTrigger>
)}
</div>
{/* Status Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Select
value={activeStatusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger
id="status-filter"
className={isMobile ? "h-10" : ""}
aria-label="Filter by submission status"
>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="partially_approved">Partially Approved</SelectItem>
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
<SelectItem value="flagged">Flagged</SelectItem>
<CollapsibleContent className="space-y-4">
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
{/* Entity Type Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Select
value={activeEntityFilter}
onValueChange={onEntityFilterChange}
>
<SelectTrigger
id="entity-filter"
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by entity type"
>
<SelectValue>
<div className="flex items-center gap-2">
{getEntityFilterIcon(activeEntityFilter)}
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
All Items
</div>
</SelectItem>
<SelectItem value="reviews">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Reviews
</div>
</SelectItem>
<SelectItem value="submissions">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Submissions
</div>
</SelectItem>
<SelectItem value="photos">
<div className="flex items-center gap-2">
<Image className="w-4 h-4" />
Photos
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Select
value={activeStatusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger
id="status-filter"
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by submission status"
>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="partially_approved">Partially Approved</SelectItem>
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
<SelectItem value="flagged">Flagged</SelectItem>
)}
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
{/* Sort Controls */}
<QueueSortControls
sortConfig={sortConfig}
onSortChange={onSortChange}
isMobile={isMobile}
isLoading={isLoading}
/>
</div>
{/* Clear Filters & Apply Buttons (mobile only) */}
{isMobile && (
<div className="flex gap-2 pt-2 border-t border-border">
{showClearButton && (
<Button
variant="outline"
size="default"
onClick={onClearFilters}
className="flex-1 h-11 min-h-[44px]"
aria-label="Clear all filters"
>
<X className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="default"
size="default"
onClick={() => toggle()}
className="flex-1 h-11 min-h-[44px]"
>
Apply
</Button>
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* Sort Controls */}
<QueueSortControls
sortConfig={sortConfig}
onSortChange={onSortChange}
isMobile={isMobile}
isLoading={isLoading}
/>
</div>
{/* Clear Filters Button */}
{showClearButton && (
<div className={isMobile ? "" : "flex items-end"}>
{/* Clear Filters Button (desktop only) */}
{!isMobile && showClearButton && (
<div className="flex items-end pt-2">
<Button
variant="outline"
size={isMobile ? "default" : "sm"}
size="sm"
onClick={onClearFilters}
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
className="flex items-center gap-2"
aria-label="Clear all filters"
>
<X className="w-4 h-4" />

View File

@@ -13,8 +13,10 @@ import {
approveSubmissionItems,
rejectSubmissionItems,
escalateSubmission,
checkSubmissionConflict,
type SubmissionItemWithDeps,
type DependencyConflict
type DependencyConflict,
type ConflictCheckResult
} from '@/lib/submissionItemsService';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
@@ -22,7 +24,7 @@ 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 { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp, History } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useIsMobile } from '@/hooks/use-mobile';
@@ -34,6 +36,8 @@ import { RejectionDialog } from './RejectionDialog';
import { ItemEditDialog } from './ItemEditDialog';
import { ValidationBlockerDialog } from './ValidationBlockerDialog';
import { WarningConfirmDialog } from './WarningConfirmDialog';
import { ConflictResolutionModal } from './ConflictResolutionModal';
import { EditHistoryAccordion } from './EditHistoryAccordion';
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
import { logger } from '@/lib/logger';
@@ -70,6 +74,9 @@ export function SubmissionReviewManager({
const [userConfirmedWarnings, setUserConfirmedWarnings] = useState(false);
const [hasBlockingErrors, setHasBlockingErrors] = useState(false);
const [globalValidationKey, setGlobalValidationKey] = useState(0);
const [conflictData, setConflictData] = useState<ConflictCheckResult | null>(null);
const [showConflictResolutionModal, setShowConflictResolutionModal] = useState(false);
const [lastModifiedTimestamp, setLastModifiedTimestamp] = useState<string | null>(null);
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
@@ -113,15 +120,16 @@ export function SubmissionReviewManager({
try {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch submission type
// Fetch submission type and last_modified_at
const { data: submission } = await supabase
.from('content_submissions')
.select('submission_type')
.select('submission_type, last_modified_at')
.eq('id', submissionId)
.single();
if (submission) {
setSubmissionType(submission.submission_type || 'submission');
setLastModifiedTimestamp(submission.last_modified_at);
}
const fetchedItems = await fetchSubmissionItems(submissionId);
@@ -211,6 +219,18 @@ export function SubmissionReviewManager({
dispatch({ type: 'START_APPROVAL' });
try {
// Check for conflicts first (optimistic locking)
if (lastModifiedTimestamp) {
const conflictCheck = await checkSubmissionConflict(submissionId, lastModifiedTimestamp);
if (conflictCheck.hasConflict) {
setConflictData(conflictCheck);
setShowConflictResolutionModal(true);
dispatch({ type: 'RESET' }); // Return to reviewing state
return; // Block approval until conflict resolved
}
}
// Run validation on all selected items
const validationResultsMap = await validateMultipleItems(
selectedItems.map(item => ({
@@ -603,6 +623,43 @@ export function SubmissionReviewManager({
i.item_data?.name || i.item_type.replace('_', ' ')
)}
/>
<ConflictResolutionModal
open={showConflictResolutionModal}
onOpenChange={setShowConflictResolutionModal}
conflictData={conflictData}
onResolve={async (strategy) => {
if (strategy === 'keep-mine') {
// Log conflict resolution
const { supabase } = await import('@/integrations/supabase/client');
await supabase.from('conflict_resolutions').insert([{
submission_id: submissionId,
resolved_by: user?.id || null,
resolution_strategy: strategy,
conflict_details: conflictData as any,
}]);
// Force override and proceed with approval
await handleApprove();
} else if (strategy === 'keep-theirs') {
// Reload data and discard local changes
await loadSubmissionItems();
toast({
title: 'Changes Discarded',
description: 'Loaded the latest version from the server',
});
} else if (strategy === 'reload') {
// Just reload without approving
await loadSubmissionItems();
toast({
title: 'Reloaded',
description: 'Viewing the latest version',
});
}
setShowConflictResolutionModal(false);
setConflictData(null);
}}
/>
</>
);
@@ -724,13 +781,13 @@ export function SubmissionReviewManager({
<Tabs
value={activeTab}
onValueChange={(v) => {
if (v === 'items' || v === 'dependencies') {
setActiveTab(v);
if (v === 'items' || v === 'dependencies' || v === 'history') {
setActiveTab(v as 'items' | 'dependencies');
}
}}
className="flex-1 flex flex-col"
>
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="items">
<CheckCircle2 className="w-4 h-4 mr-2" />
Items ({items.length})
@@ -739,6 +796,10 @@ export function SubmissionReviewManager({
<Network className="w-4 h-4 mr-2" />
Dependencies
</TabsTrigger>
<TabsTrigger value="history">
<History className="w-4 h-4 mr-2" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="items" className="flex-1 overflow-hidden">
@@ -778,6 +839,12 @@ export function SubmissionReviewManager({
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
</TabsContent>
<TabsContent value="history" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<EditHistoryAccordion submissionId={submissionId} />
</ScrollArea>
</TabsContent>
</Tabs>
{/* Blocking error alert */}