mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 18:26:58 -05:00
Compare commits
6 Commits
4c7731410f
...
edit/edt-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8bea4b798 | ||
|
|
250e7c488a | ||
|
|
46c08e10e8 | ||
|
|
b22546e7f2 | ||
|
|
7b0825e772 | ||
|
|
1a57b4f33f |
11
src/App.tsx
11
src/App.tsx
@@ -79,6 +79,7 @@ const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
|
|||||||
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
||||||
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
||||||
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
||||||
|
const ApprovalHistory = lazy(() => import("./pages/admin/ApprovalHistory"));
|
||||||
|
|
||||||
// User routes (lazy-loaded)
|
// User routes (lazy-loaded)
|
||||||
const Profile = lazy(() => import("./pages/Profile"));
|
const Profile = lazy(() => import("./pages/Profile"));
|
||||||
@@ -387,7 +388,15 @@ function AppContent(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/error-lookup"
|
path="/admin/approval-history"
|
||||||
|
element={
|
||||||
|
<AdminErrorBoundary section="Approval History">
|
||||||
|
<ApprovalHistory />
|
||||||
|
</AdminErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/error-lookup"
|
||||||
element={
|
element={
|
||||||
<AdminErrorBoundary section="Error Lookup">
|
<AdminErrorBoundary section="Error Lookup">
|
||||||
<ErrorLookup />
|
<ErrorLookup />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Filter, MessageSquare, FileText, Image } from 'lucide-react';
|
import { Filter, MessageSquare, FileText, Image, Calendar } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { EntityFilter, StatusFilter } from '@/types/moderation';
|
import { format } from 'date-fns';
|
||||||
|
import type { EntityFilter, StatusFilter, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||||
|
|
||||||
interface ActiveFiltersDisplayProps {
|
interface ActiveFiltersDisplayProps {
|
||||||
entityFilter: EntityFilter;
|
entityFilter: EntityFilter;
|
||||||
statusFilter: StatusFilter;
|
statusFilter: StatusFilter;
|
||||||
|
approvalDateRange?: ApprovalDateRangeFilter;
|
||||||
defaultEntityFilter?: EntityFilter;
|
defaultEntityFilter?: EntityFilter;
|
||||||
defaultStatusFilter?: StatusFilter;
|
defaultStatusFilter?: StatusFilter;
|
||||||
}
|
}
|
||||||
@@ -23,12 +25,15 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
|
|||||||
export const ActiveFiltersDisplay = ({
|
export const ActiveFiltersDisplay = ({
|
||||||
entityFilter,
|
entityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
approvalDateRange,
|
||||||
defaultEntityFilter = 'all',
|
defaultEntityFilter = 'all',
|
||||||
defaultStatusFilter = 'pending'
|
defaultStatusFilter = 'pending'
|
||||||
}: ActiveFiltersDisplayProps) => {
|
}: ActiveFiltersDisplayProps) => {
|
||||||
|
const hasDateRange = approvalDateRange && (approvalDateRange.from || approvalDateRange.to);
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
entityFilter !== defaultEntityFilter ||
|
entityFilter !== defaultEntityFilter ||
|
||||||
statusFilter !== defaultStatusFilter;
|
statusFilter !== defaultStatusFilter ||
|
||||||
|
hasDateRange;
|
||||||
|
|
||||||
if (!hasActiveFilters) return null;
|
if (!hasActiveFilters) return null;
|
||||||
|
|
||||||
@@ -46,6 +51,14 @@ export const ActiveFiltersDisplay = ({
|
|||||||
{statusFilter}
|
{statusFilter}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{hasDateRange && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{approvalDateRange.from && format(approvalDateRange.from, 'MMM d')}
|
||||||
|
{approvalDateRange.from && approvalDateRange.to && ' - '}
|
||||||
|
{approvalDateRange.to && format(approvalDateRange.to, 'MMM d')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
54
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
54
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DetailedViewCollapsibleProps {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible wrapper for detailed field-by-field view sections
|
||||||
|
* Provides expand/collapse functionality with visual indicators
|
||||||
|
*/
|
||||||
|
export function DetailedViewCollapsible({
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: DetailedViewCollapsibleProps) {
|
||||||
|
return (
|
||||||
|
<Collapsible open={!isCollapsed} onOpenChange={() => onToggle()}>
|
||||||
|
<div className={cn("mt-6 pt-6 border-t", className)}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full flex items-center justify-between hover:bg-muted/50 p-2 h-auto"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
All Fields (Detailed View)
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground normal-case font-normal">
|
||||||
|
{isCollapsed ? 'Show' : 'Hide'}
|
||||||
|
</span>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent className="mt-3">
|
||||||
|
{children}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Item Approval History Component
|
||||||
|
*
|
||||||
|
* Displays detailed audit trail of approved items with exact timestamps.
|
||||||
|
* Features filtering, sorting, CSV export for compliance reporting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ExternalLink, Download, Clock, User, FileText } from 'lucide-react';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
import type { EntityType } from '@/types/submissions';
|
||||||
|
|
||||||
|
interface ApprovalHistoryItem {
|
||||||
|
item_id: string;
|
||||||
|
submission_id: string;
|
||||||
|
item_type: string;
|
||||||
|
action_type: string;
|
||||||
|
status: string;
|
||||||
|
approved_at: string;
|
||||||
|
approved_entity_id: string;
|
||||||
|
created_at: string;
|
||||||
|
approval_time_seconds: number;
|
||||||
|
submission_type: string;
|
||||||
|
submitter_username: string | null;
|
||||||
|
submitter_display_name: string | null;
|
||||||
|
submitter_avatar_url: string | null;
|
||||||
|
approver_username: string | null;
|
||||||
|
approver_display_name: string | null;
|
||||||
|
approver_avatar_url: string | null;
|
||||||
|
entity_slug: string | null;
|
||||||
|
entity_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemApprovalHistoryProps {
|
||||||
|
submissionId?: string;
|
||||||
|
dateRange?: { from: Date; to: Date };
|
||||||
|
itemType?: EntityType;
|
||||||
|
limit?: number;
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApprovalSpeed = (seconds: number) => {
|
||||||
|
const hours = seconds / 3600;
|
||||||
|
if (hours < 1) return { label: 'Fast', variant: 'default' as const, color: 'text-green-600 dark:text-green-400' };
|
||||||
|
if (hours < 24) return { label: 'Normal', variant: 'secondary' as const, color: 'text-blue-600 dark:text-blue-400' };
|
||||||
|
return { label: 'Slow', variant: 'destructive' as const, color: 'text-orange-600 dark:text-orange-400' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours > 48) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEntityPath = (itemType: string, slug: string | null) => {
|
||||||
|
if (!slug) return null;
|
||||||
|
|
||||||
|
switch (itemType) {
|
||||||
|
case 'park': return `/parks/${slug}/`;
|
||||||
|
case 'ride': return `/rides/${slug}`; // Need park slug ideally
|
||||||
|
case 'manufacturer':
|
||||||
|
case 'designer':
|
||||||
|
case 'operator':
|
||||||
|
return `/companies/${slug}/`;
|
||||||
|
case 'ride_model': return `/models/${slug}/`;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemApprovalHistory = ({
|
||||||
|
submissionId,
|
||||||
|
dateRange,
|
||||||
|
itemType,
|
||||||
|
limit = 100,
|
||||||
|
embedded = false
|
||||||
|
}: ItemApprovalHistoryProps) => {
|
||||||
|
const [sortField, setSortField] = useState<'approved_at' | 'approval_time_seconds'>('approved_at');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
const { data: history, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['approval-history', { submissionId, dateRange, itemType, limit }],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('get_approval_history', {
|
||||||
|
p_item_type: itemType || undefined,
|
||||||
|
p_approver_id: undefined,
|
||||||
|
p_from_date: dateRange?.from?.toISOString() || undefined,
|
||||||
|
p_to_date: dateRange?.to?.toISOString() || undefined,
|
||||||
|
p_limit: limit,
|
||||||
|
p_offset: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Client-side filter by submission_id if provided
|
||||||
|
let filtered = data as ApprovalHistoryItem[];
|
||||||
|
if (submissionId) {
|
||||||
|
filtered = filtered.filter(item => item.submission_id === submissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
handleError(err, { action: 'fetch_approval_history' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedHistory = history ? [...history].sort((a, b) => {
|
||||||
|
const aVal = a[sortField];
|
||||||
|
const bVal = b[sortField];
|
||||||
|
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||||
|
return sortDirection === 'asc' ? comparison : -comparison;
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
const handleSort = (field: typeof sortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToCSV = () => {
|
||||||
|
if (!history || history.length === 0) return;
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Timestamp',
|
||||||
|
'Item Type',
|
||||||
|
'Action',
|
||||||
|
'Entity Name',
|
||||||
|
'Submitter',
|
||||||
|
'Approver',
|
||||||
|
'Time to Approve (hours)',
|
||||||
|
'Submission ID',
|
||||||
|
'Item ID'
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = history.map(item => [
|
||||||
|
format(new Date(item.approved_at), 'yyyy-MM-dd HH:mm:ss'),
|
||||||
|
item.item_type,
|
||||||
|
item.action_type,
|
||||||
|
item.entity_name || 'N/A',
|
||||||
|
item.submitter_display_name || item.submitter_username || 'Unknown',
|
||||||
|
item.approver_display_name || item.approver_username || 'Unknown',
|
||||||
|
(item.approval_time_seconds / 3600).toFixed(2),
|
||||||
|
item.submission_id,
|
||||||
|
item.item_id
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `approval-history-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className={embedded ? '' : 'mt-6'}>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-destructive">Failed to load approval history. Please try again.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{!embedded && (
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Item Approval History</CardTitle>
|
||||||
|
<CardDescription>Detailed audit trail of approved submissions</CardDescription>
|
||||||
|
</div>
|
||||||
|
{sortedHistory.length > 0 && (
|
||||||
|
<Button onClick={exportToCSV} variant="outline" size="sm">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={embedded ? 'p-0' : ''}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sortedHistory.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>No approval history found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleSort('approved_at')}
|
||||||
|
>
|
||||||
|
Approved At {sortField === 'approved_at' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Entity</TableHead>
|
||||||
|
<TableHead>Submitter</TableHead>
|
||||||
|
<TableHead>Approver</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50 text-right"
|
||||||
|
onClick={() => handleSort('approval_time_seconds')}
|
||||||
|
>
|
||||||
|
Time to Approve {sortField === 'approval_time_seconds' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedHistory.map((item) => {
|
||||||
|
const speed = getApprovalSpeed(item.approval_time_seconds);
|
||||||
|
const entityPath = getEntityPath(item.item_type, item.entity_slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={item.item_id}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{format(new Date(item.approved_at), 'MMM d, yyyy')}</span>
|
||||||
|
<span className="text-muted-foreground">{format(new Date(item.approved_at), 'HH:mm:ss')}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{item.item_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.entity_name ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{item.entity_name}</span>
|
||||||
|
{entityPath && (
|
||||||
|
<a
|
||||||
|
href={entityPath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">N/A</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={item.submitter_avatar_url || undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(item.submitter_display_name || item.submitter_username || 'U')[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm">{item.submitter_display_name || item.submitter_username || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={item.approver_avatar_url || undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(item.approver_display_name || item.approver_username || 'M')[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm">{item.approver_display_name || item.approver_username || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Clock className={`w-4 h-4 ${speed.color}`} />
|
||||||
|
<span className="font-mono text-sm">{formatDuration(item.approval_time_seconds)}</span>
|
||||||
|
<Badge variant={speed.variant} className="ml-1">
|
||||||
|
{speed.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return embedded ? content : <Card className="mt-6">{content}</Card>;
|
||||||
|
};
|
||||||
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { CheckCircle2, User } from 'lucide-react';
|
||||||
|
import type { SubmissionItem } from '@/types/moderation';
|
||||||
|
|
||||||
|
interface ItemLevelApprovalHistoryProps {
|
||||||
|
items: SubmissionItem[];
|
||||||
|
reviewerProfile?: {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemLevelApprovalHistory = memo(({
|
||||||
|
items,
|
||||||
|
reviewerProfile,
|
||||||
|
}: ItemLevelApprovalHistoryProps) => {
|
||||||
|
// Filter to only approved items with timestamps
|
||||||
|
const approvedItems = items.filter(
|
||||||
|
item => item.status === 'approved' && (item as any).approved_at
|
||||||
|
);
|
||||||
|
|
||||||
|
if (approvedItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by approval time (newest first)
|
||||||
|
const sortedItems = [...approvedItems].sort((a, b) => {
|
||||||
|
const timeA = new Date((a as any).approved_at).getTime();
|
||||||
|
const timeB = new Date((b as any).approved_at).getTime();
|
||||||
|
return timeB - timeA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to get item display name
|
||||||
|
const getItemName = (item: SubmissionItem): string => {
|
||||||
|
const entityData = item.entity_data || item.item_data;
|
||||||
|
if (entityData && typeof entityData === 'object' && 'name' in entityData) {
|
||||||
|
return String(entityData.name);
|
||||||
|
}
|
||||||
|
return `${item.item_type} #${item.order_index}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get action label
|
||||||
|
const getActionLabel = (actionType: string): string => {
|
||||||
|
switch (actionType) {
|
||||||
|
case 'create': return 'Created';
|
||||||
|
case 'edit': return 'Edited';
|
||||||
|
case 'delete': return 'Deleted';
|
||||||
|
default: return 'Modified';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
Item Approvals
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedItems.map((item) => {
|
||||||
|
const approvedAt = (item as any).approved_at;
|
||||||
|
const itemName = getItemName(item);
|
||||||
|
const actionLabel = getActionLabel(item.action_type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-3 text-sm bg-success/5 border border-success/20 rounded-md p-3"
|
||||||
|
>
|
||||||
|
{/* Approval Icon */}
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item Info */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-foreground truncate">
|
||||||
|
{itemName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{actionLabel}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs font-mono">
|
||||||
|
{item.item_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDistanceToNow(new Date(approvedAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviewer Info */}
|
||||||
|
{reviewerProfile && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarImage src={reviewerProfile.avatar_url ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>
|
||||||
|
Approved by{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{reviewerProfile.display_name || reviewerProfile.username}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ItemLevelApprovalHistory.displayName = 'ItemLevelApprovalHistory';
|
||||||
@@ -501,11 +501,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
activeEntityFilter={queueManager.filters.entityFilter}
|
activeEntityFilter={queueManager.filters.entityFilter}
|
||||||
activeStatusFilter={queueManager.filters.statusFilter}
|
activeStatusFilter={queueManager.filters.statusFilter}
|
||||||
sortConfig={queueManager.filters.sortConfig}
|
sortConfig={queueManager.filters.sortConfig}
|
||||||
|
activeTab={queueManager.filters.activeTab}
|
||||||
|
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||||
isMobile={isMobile ?? false}
|
isMobile={isMobile ?? false}
|
||||||
isLoading={queueManager.loadingState === 'loading'}
|
isLoading={queueManager.loadingState === 'loading'}
|
||||||
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
||||||
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
||||||
onSortChange={queueManager.filters.setSortConfig}
|
onSortChange={queueManager.filters.setSortConfig}
|
||||||
|
onApprovalDateRangeChange={queueManager.filters.setApprovalDateRange}
|
||||||
onClearFilters={queueManager.filters.clearFilters}
|
onClearFilters={queueManager.filters.clearFilters}
|
||||||
showClearButton={queueManager.filters.hasActiveFilters}
|
showClearButton={queueManager.filters.hasActiveFilters}
|
||||||
onRefresh={queueManager.refresh}
|
onRefresh={queueManager.refresh}
|
||||||
@@ -517,6 +520,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
<ActiveFiltersDisplay
|
<ActiveFiltersDisplay
|
||||||
entityFilter={queueManager.filters.entityFilter}
|
entityFilter={queueManager.filters.entityFilter}
|
||||||
statusFilter={queueManager.filters.statusFilter}
|
statusFilter={queueManager.filters.statusFilter}
|
||||||
|
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
|
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar } from 'lucide-react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -7,17 +7,21 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||||||
import { RefreshButton } from '@/components/ui/refresh-button';
|
import { RefreshButton } from '@/components/ui/refresh-button';
|
||||||
import { QueueSortControls } from './QueueSortControls';
|
import { QueueSortControls } from './QueueSortControls';
|
||||||
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||||
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||||
|
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||||
|
|
||||||
interface QueueFiltersProps {
|
interface QueueFiltersProps {
|
||||||
activeEntityFilter: EntityFilter;
|
activeEntityFilter: EntityFilter;
|
||||||
activeStatusFilter: StatusFilter;
|
activeStatusFilter: StatusFilter;
|
||||||
sortConfig: SortConfig;
|
sortConfig: SortConfig;
|
||||||
|
activeTab: QueueTab;
|
||||||
|
approvalDateRange: ApprovalDateRangeFilter;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEntityFilterChange: (filter: EntityFilter) => void;
|
onEntityFilterChange: (filter: EntityFilter) => void;
|
||||||
onStatusFilterChange: (filter: StatusFilter) => void;
|
onStatusFilterChange: (filter: StatusFilter) => void;
|
||||||
onSortChange: (config: SortConfig) => void;
|
onSortChange: (config: SortConfig) => void;
|
||||||
|
onApprovalDateRangeChange: (range: ApprovalDateRangeFilter) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
showClearButton: boolean;
|
showClearButton: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
@@ -37,11 +41,14 @@ export const QueueFilters = ({
|
|||||||
activeEntityFilter,
|
activeEntityFilter,
|
||||||
activeStatusFilter,
|
activeStatusFilter,
|
||||||
sortConfig,
|
sortConfig,
|
||||||
|
activeTab,
|
||||||
|
approvalDateRange,
|
||||||
isMobile,
|
isMobile,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onEntityFilterChange,
|
onEntityFilterChange,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
onApprovalDateRangeChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
showClearButton,
|
showClearButton,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -53,6 +60,7 @@ export const QueueFilters = ({
|
|||||||
const activeFilterCount = [
|
const activeFilterCount = [
|
||||||
activeEntityFilter !== 'all' ? 1 : 0,
|
activeEntityFilter !== 'all' ? 1 : 0,
|
||||||
activeStatusFilter !== 'all' ? 1 : 0,
|
activeStatusFilter !== 'all' ? 1 : 0,
|
||||||
|
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
|
||||||
].reduce((sum, val) => sum + val, 0);
|
].reduce((sum, val) => sum + val, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,6 +172,21 @@ export const QueueFilters = ({
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Approval Date Range Filter - Only show on archive tab */}
|
||||||
|
{activeTab === 'archive' && (
|
||||||
|
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[280px]'}`}>
|
||||||
|
<FilterDateRangePicker
|
||||||
|
label="Approved Between"
|
||||||
|
fromDate={approvalDateRange.from}
|
||||||
|
toDate={approvalDateRange.to}
|
||||||
|
onFromChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, from: date || null })}
|
||||||
|
onToChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, to: date || null })}
|
||||||
|
fromPlaceholder="Start Date"
|
||||||
|
toPlaceholder="End Date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clear Filters & Apply Buttons (mobile only) */}
|
{/* Clear Filters & Apply Buttons (mobile only) */}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
|
|||||||
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
||||||
import { AuditTrailViewer } from './AuditTrailViewer';
|
import { AuditTrailViewer } from './AuditTrailViewer';
|
||||||
import { RawDataViewer } from './RawDataViewer';
|
import { RawDataViewer } from './RawDataViewer';
|
||||||
|
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
|
||||||
|
|
||||||
interface QueueItemProps {
|
interface QueueItemProps {
|
||||||
item: ModerationItem;
|
item: ModerationItem;
|
||||||
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
|
|||||||
{item.type === 'content_submission' && (
|
{item.type === 'content_submission' && (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<SubmissionMetadataPanel item={item} />
|
<SubmissionMetadataPanel item={item} />
|
||||||
|
|
||||||
|
{/* Item-level approval history */}
|
||||||
|
{item.submission_items && item.submission_items.length > 0 && (
|
||||||
|
<ItemLevelApprovalHistory
|
||||||
|
items={item.submission_items}
|
||||||
|
reviewerProfile={item.reviewer_profile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AuditTrailViewer submissionId={item.id} />
|
<AuditTrailViewer submissionId={item.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
|
|||||||
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
||||||
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
||||||
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
||||||
|
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
|
|||||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||||
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
||||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||||
|
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||||
|
|
||||||
interface SubmissionItemsListProps {
|
interface SubmissionItemsListProps {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
@@ -34,6 +36,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { isCollapsed, toggle } = useDetailedViewState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubmissionItems();
|
fetchSubmissionItems();
|
||||||
@@ -188,17 +191,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as ParkSubmissionData}
|
data={entityData as unknown as ParkSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,17 +211,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as RideSubmissionData}
|
data={entityData as unknown as RideSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -234,17 +231,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as CompanySubmissionData}
|
data={entityData as unknown as CompanySubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,17 +251,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as RideModelSubmissionData}
|
data={entityData as unknown as RideModelSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,17 +271,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as TimelineSubmissionData}
|
data={entityData as unknown as TimelineSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useState, useCallback, useEffect } from 'react';
|
|||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
|
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
|
||||||
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField } from '@/types/moderation';
|
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||||
import * as storage from '@/lib/localStorage';
|
import * as storage from '@/lib/localStorage';
|
||||||
|
|
||||||
export interface ModerationFiltersConfig {
|
export interface ModerationFiltersConfig {
|
||||||
@@ -36,6 +36,9 @@ export interface ModerationFiltersConfig {
|
|||||||
|
|
||||||
/** Initial sort configuration */
|
/** Initial sort configuration */
|
||||||
initialSortConfig?: SortConfig;
|
initialSortConfig?: SortConfig;
|
||||||
|
|
||||||
|
/** Initial approval date range filter */
|
||||||
|
initialApprovalDateRange?: ApprovalDateRangeFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModerationFilters {
|
export interface ModerationFilters {
|
||||||
@@ -87,6 +90,15 @@ export interface ModerationFilters {
|
|||||||
/** Reset sort to default */
|
/** Reset sort to default */
|
||||||
resetSort: () => void;
|
resetSort: () => void;
|
||||||
|
|
||||||
|
/** Approval date range filter (immediate) */
|
||||||
|
approvalDateRange: ApprovalDateRangeFilter;
|
||||||
|
|
||||||
|
/** Debounced approval date range (use this for queries) */
|
||||||
|
debouncedApprovalDateRange: ApprovalDateRangeFilter;
|
||||||
|
|
||||||
|
/** Set approval date range */
|
||||||
|
setApprovalDateRange: (range: ApprovalDateRangeFilter) => void;
|
||||||
|
|
||||||
/** Reset pagination to page 1 (callback) */
|
/** Reset pagination to page 1 (callback) */
|
||||||
onFilterChange?: () => void;
|
onFilterChange?: () => void;
|
||||||
}
|
}
|
||||||
@@ -121,6 +133,7 @@ export function useModerationFilters(
|
|||||||
persist = true,
|
persist = true,
|
||||||
storageKey = 'moderationQueue_filters',
|
storageKey = 'moderationQueue_filters',
|
||||||
initialSortConfig = { field: 'created_at', direction: 'asc' },
|
initialSortConfig = { field: 'created_at', direction: 'asc' },
|
||||||
|
initialApprovalDateRange = { from: null, to: null },
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
@@ -174,6 +187,9 @@ export function useModerationFilters(
|
|||||||
|
|
||||||
// Sort state
|
// Sort state
|
||||||
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
|
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
|
||||||
|
|
||||||
|
// Approval date range state
|
||||||
|
const [approvalDateRange, setApprovalDateRangeState] = useState<ApprovalDateRangeFilter>(initialApprovalDateRange);
|
||||||
|
|
||||||
// Debounced filters for API calls
|
// Debounced filters for API calls
|
||||||
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
|
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
|
||||||
@@ -181,6 +197,9 @@ export function useModerationFilters(
|
|||||||
|
|
||||||
// Debounced sort (0ms for immediate feedback)
|
// Debounced sort (0ms for immediate feedback)
|
||||||
const debouncedSortConfig = useDebounce(sortConfig, 0);
|
const debouncedSortConfig = useDebounce(sortConfig, 0);
|
||||||
|
|
||||||
|
// Debounced approval date range
|
||||||
|
const debouncedApprovalDateRange = useDebounce(approvalDateRange, debounceDelay);
|
||||||
|
|
||||||
// Persist filters to localStorage
|
// Persist filters to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -246,6 +265,13 @@ export function useModerationFilters(
|
|||||||
const resetSort = useCallback(() => {
|
const resetSort = useCallback(() => {
|
||||||
setSortConfigState(initialSortConfig);
|
setSortConfigState(initialSortConfig);
|
||||||
}, [initialSortConfig]);
|
}, [initialSortConfig]);
|
||||||
|
|
||||||
|
// Set approval date range with logging and pagination reset
|
||||||
|
const setApprovalDateRange = useCallback((range: ApprovalDateRangeFilter) => {
|
||||||
|
logger.log('🔍 Approval date range changed:', range);
|
||||||
|
setApprovalDateRangeState(range);
|
||||||
|
onFilterChange?.();
|
||||||
|
}, [onFilterChange]);
|
||||||
|
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
@@ -254,7 +280,8 @@ export function useModerationFilters(
|
|||||||
setStatusFilterState(initialStatusFilter);
|
setStatusFilterState(initialStatusFilter);
|
||||||
setActiveTabState(initialTab);
|
setActiveTabState(initialTab);
|
||||||
setSortConfigState(initialSortConfig);
|
setSortConfigState(initialSortConfig);
|
||||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig]);
|
setApprovalDateRangeState(initialApprovalDateRange);
|
||||||
|
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig, initialApprovalDateRange]);
|
||||||
|
|
||||||
// Check if non-default filters are active
|
// Check if non-default filters are active
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
@@ -262,7 +289,9 @@ export function useModerationFilters(
|
|||||||
statusFilter !== initialStatusFilter ||
|
statusFilter !== initialStatusFilter ||
|
||||||
activeTab !== initialTab ||
|
activeTab !== initialTab ||
|
||||||
sortConfig.field !== initialSortConfig.field ||
|
sortConfig.field !== initialSortConfig.field ||
|
||||||
sortConfig.direction !== initialSortConfig.direction;
|
sortConfig.direction !== initialSortConfig.direction ||
|
||||||
|
approvalDateRange.from !== null ||
|
||||||
|
approvalDateRange.to !== null;
|
||||||
|
|
||||||
// Return without useMemo wrapper (OPTIMIZED)
|
// Return without useMemo wrapper (OPTIMIZED)
|
||||||
return {
|
return {
|
||||||
@@ -282,6 +311,9 @@ export function useModerationFilters(
|
|||||||
sortBy,
|
sortBy,
|
||||||
toggleSortDirection,
|
toggleSortDirection,
|
||||||
resetSort,
|
resetSort,
|
||||||
|
approvalDateRange,
|
||||||
|
debouncedApprovalDateRange,
|
||||||
|
setApprovalDateRange,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
currentPage: pagination.currentPage,
|
currentPage: pagination.currentPage,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
sortConfig: filters.debouncedSortConfig,
|
sortConfig: filters.debouncedSortConfig,
|
||||||
|
approvalDateRange: filters.debouncedApprovalDateRange,
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export interface UseQueueQueryConfig {
|
|||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Approval date range filter */
|
||||||
|
approvalDateRange?: {
|
||||||
|
from: Date | null;
|
||||||
|
to: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
/** Whether query is enabled (defaults to true) */
|
/** Whether query is enabled (defaults to true) */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -145,6 +151,7 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
|||||||
currentPage: config.currentPage,
|
currentPage: config.currentPage,
|
||||||
pageSize: config.pageSize,
|
pageSize: config.pageSize,
|
||||||
sortConfig: config.sortConfig,
|
sortConfig: config.sortConfig,
|
||||||
|
approvalDateRange: config.approvalDateRange,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
||||||
@@ -161,6 +168,8 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
|||||||
config.pageSize,
|
config.pageSize,
|
||||||
config.sortConfig.field,
|
config.sortConfig.field,
|
||||||
config.sortConfig.direction,
|
config.sortConfig.direction,
|
||||||
|
config.approvalDateRange?.from?.toISOString(),
|
||||||
|
config.approvalDateRange?.to?.toISOString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Execute query
|
// Execute query
|
||||||
|
|||||||
48
src/hooks/useDetailedViewState.ts
Normal file
48
src/hooks/useDetailedViewState.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'detailed-view-collapsed';
|
||||||
|
|
||||||
|
interface UseDetailedViewStateReturn {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
setCollapsed: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage detailed view collapsed/expanded state
|
||||||
|
* Syncs with localStorage for persistence across sessions
|
||||||
|
* Defaults to collapsed to reduce visual clutter
|
||||||
|
*/
|
||||||
|
export function useDetailedViewState(): UseDetailedViewStateReturn {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||||
|
// Initialize from localStorage on mount
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
// Default to collapsed (true) to reduce visual clutter
|
||||||
|
return stored ? JSON.parse(stored) : true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error reading detailed view state from localStorage', { error });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync to localStorage when state changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(isCollapsed));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error saving detailed view state to localStorage', { error });
|
||||||
|
}
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
const toggle = () => setIsCollapsed(prev => !prev);
|
||||||
|
|
||||||
|
const setCollapsed = (value: boolean) => setIsCollapsed(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCollapsed,
|
||||||
|
toggle,
|
||||||
|
setCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1872,6 +1872,13 @@ export type Database = {
|
|||||||
item_id?: string
|
item_id?: string
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "item_edit_history_item_id_fkey"
|
||||||
|
columns: ["item_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "approval_history_detailed"
|
||||||
|
referencedColumns: ["item_id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
foreignKeyName: "item_edit_history_item_id_fkey"
|
foreignKeyName: "item_edit_history_item_id_fkey"
|
||||||
columns: ["item_id"]
|
columns: ["item_id"]
|
||||||
@@ -5682,6 +5689,13 @@ export type Database = {
|
|||||||
submission_item_id?: string
|
submission_item_id?: string
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
||||||
|
columns: ["submission_item_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "approval_history_detailed"
|
||||||
|
referencedColumns: ["item_id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
||||||
columns: ["submission_item_id"]
|
columns: ["submission_item_id"]
|
||||||
@@ -5694,6 +5708,7 @@ export type Database = {
|
|||||||
submission_items: {
|
submission_items: {
|
||||||
Row: {
|
Row: {
|
||||||
action_type: string | null
|
action_type: string | null
|
||||||
|
approved_at: string | null
|
||||||
approved_entity_id: string | null
|
approved_entity_id: string | null
|
||||||
company_submission_id: string | null
|
company_submission_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -5714,6 +5729,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
action_type?: string | null
|
action_type?: string | null
|
||||||
|
approved_at?: string | null
|
||||||
approved_entity_id?: string | null
|
approved_entity_id?: string | null
|
||||||
company_submission_id?: string | null
|
company_submission_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -5734,6 +5750,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
action_type?: string | null
|
action_type?: string | null
|
||||||
|
approved_at?: string | null
|
||||||
approved_entity_id?: string | null
|
approved_entity_id?: string | null
|
||||||
company_submission_id?: string | null
|
company_submission_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -5760,6 +5777,13 @@ export type Database = {
|
|||||||
referencedRelation: "company_submissions"
|
referencedRelation: "company_submissions"
|
||||||
referencedColumns: ["id"]
|
referencedColumns: ["id"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "submission_items_depends_on_fkey"
|
||||||
|
columns: ["depends_on"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "approval_history_detailed"
|
||||||
|
referencedColumns: ["item_id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
foreignKeyName: "submission_items_depends_on_fkey"
|
foreignKeyName: "submission_items_depends_on_fkey"
|
||||||
columns: ["depends_on"]
|
columns: ["depends_on"]
|
||||||
@@ -5931,6 +5955,13 @@ export type Database = {
|
|||||||
test_session_id?: string | null
|
test_session_id?: string | null
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
||||||
|
columns: ["submission_item_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "approval_history_detailed"
|
||||||
|
referencedColumns: ["item_id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
||||||
columns: ["submission_item_id"]
|
columns: ["submission_item_id"]
|
||||||
@@ -6306,6 +6337,76 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
approval_history_detailed: {
|
||||||
|
Row: {
|
||||||
|
action_type: string | null
|
||||||
|
approval_time_seconds: number | null
|
||||||
|
approved_at: string | null
|
||||||
|
approved_entity_id: string | null
|
||||||
|
approver_avatar_url: string | null
|
||||||
|
approver_display_name: string | null
|
||||||
|
approver_id: string | null
|
||||||
|
approver_username: string | null
|
||||||
|
created_at: string | null
|
||||||
|
entity_name: string | null
|
||||||
|
entity_slug: string | null
|
||||||
|
item_id: string | null
|
||||||
|
item_type: string | null
|
||||||
|
status: string | null
|
||||||
|
submission_id: string | null
|
||||||
|
submission_type: string | null
|
||||||
|
submitted_at: string | null
|
||||||
|
submitter_avatar_url: string | null
|
||||||
|
submitter_display_name: string | null
|
||||||
|
submitter_id: string | null
|
||||||
|
submitter_username: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "content_submissions_reviewer_id_fkey"
|
||||||
|
columns: ["approver_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "filtered_profiles"
|
||||||
|
referencedColumns: ["user_id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "content_submissions_reviewer_id_fkey"
|
||||||
|
columns: ["approver_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "profiles"
|
||||||
|
referencedColumns: ["user_id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "content_submissions_user_id_fkey"
|
||||||
|
columns: ["submitter_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "filtered_profiles"
|
||||||
|
referencedColumns: ["user_id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "content_submissions_user_id_fkey"
|
||||||
|
columns: ["submitter_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "profiles"
|
||||||
|
referencedColumns: ["user_id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "submission_items_submission_id_fkey"
|
||||||
|
columns: ["submission_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "content_submissions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "submission_items_submission_id_fkey"
|
||||||
|
columns: ["submission_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "moderation_queue_with_entities"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
data_retention_stats: {
|
data_retention_stats: {
|
||||||
Row: {
|
Row: {
|
||||||
last_30_days: number | null
|
last_30_days: number | null
|
||||||
@@ -6831,6 +6932,40 @@ export type Database = {
|
|||||||
Returns: string
|
Returns: string
|
||||||
}
|
}
|
||||||
generate_ticket_number: { Args: never; Returns: string }
|
generate_ticket_number: { Args: never; Returns: string }
|
||||||
|
get_approval_history: {
|
||||||
|
Args: {
|
||||||
|
p_approver_id?: string
|
||||||
|
p_from_date?: string
|
||||||
|
p_item_type?: string
|
||||||
|
p_limit?: number
|
||||||
|
p_offset?: number
|
||||||
|
p_to_date?: string
|
||||||
|
}
|
||||||
|
Returns: {
|
||||||
|
action_type: string
|
||||||
|
approval_time_seconds: number
|
||||||
|
approved_at: string
|
||||||
|
approved_entity_id: string
|
||||||
|
approver_avatar_url: string
|
||||||
|
approver_display_name: string
|
||||||
|
approver_id: string
|
||||||
|
approver_username: string
|
||||||
|
created_at: string
|
||||||
|
entity_name: string
|
||||||
|
entity_slug: string
|
||||||
|
item_id: string
|
||||||
|
item_type: string
|
||||||
|
status: string
|
||||||
|
submission_id: string
|
||||||
|
submission_type: string
|
||||||
|
submitted_at: string
|
||||||
|
submitter_avatar_url: string
|
||||||
|
submitter_display_name: string
|
||||||
|
submitter_id: string
|
||||||
|
submitter_username: string
|
||||||
|
updated_at: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
get_auth0_sub_from_jwt: { Args: never; Returns: string }
|
get_auth0_sub_from_jwt: { Args: never; Returns: string }
|
||||||
get_contributor_leaderboard: {
|
get_contributor_leaderboard: {
|
||||||
Args: { limit_count?: number; time_period?: string }
|
Args: { limit_count?: number; time_period?: string }
|
||||||
@@ -7066,13 +7201,13 @@ export type Database = {
|
|||||||
monitor_slow_approvals: { Args: never; Returns: undefined }
|
monitor_slow_approvals: { Args: never; Returns: undefined }
|
||||||
process_approval_transaction: {
|
process_approval_transaction: {
|
||||||
Args: {
|
Args: {
|
||||||
|
p_approval_mode?: string
|
||||||
|
p_idempotency_key?: string
|
||||||
p_item_ids: string[]
|
p_item_ids: string[]
|
||||||
p_moderator_id: string
|
p_moderator_id: string
|
||||||
p_parent_span_id?: string
|
|
||||||
p_request_id?: string
|
p_request_id?: string
|
||||||
p_submission_id: string
|
p_submission_id: string
|
||||||
p_submitter_id: string
|
p_submitter_id: string
|
||||||
p_trace_id?: string
|
|
||||||
}
|
}
|
||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
@@ -7099,6 +7234,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
|
refresh_approval_history: { Args: never; Returns: undefined }
|
||||||
release_expired_locks: { Args: never; Returns: number }
|
release_expired_locks: { Args: never; Returns: number }
|
||||||
release_submission_lock: {
|
release_submission_lock: {
|
||||||
Args: { moderator_id: string; submission_id: string }
|
Args: { moderator_id: string; submission_id: string }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface QueryConfig {
|
|||||||
currentPage: number;
|
currentPage: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
sortConfig?: SortConfig;
|
sortConfig?: SortConfig;
|
||||||
|
approvalDateRange?: { from: Date | null; to: Date | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +54,7 @@ export function buildSubmissionQuery(
|
|||||||
config: QueryConfig,
|
config: QueryConfig,
|
||||||
skipModeratorFilter = false
|
skipModeratorFilter = false
|
||||||
) {
|
) {
|
||||||
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
|
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser, approvalDateRange } = config;
|
||||||
|
|
||||||
// Use optimized view with pre-joined profiles and entity data
|
// Use optimized view with pre-joined profiles and entity data
|
||||||
let query = supabase
|
let query = supabase
|
||||||
@@ -103,6 +104,20 @@ export function buildSubmissionQuery(
|
|||||||
}
|
}
|
||||||
// 'all' and 'reviews' filters don't add any conditions
|
// 'all' and 'reviews' filters don't add any conditions
|
||||||
|
|
||||||
|
// Apply approval date range filter (only works on archive tab with approved items)
|
||||||
|
if (approvalDateRange && tab === 'archive') {
|
||||||
|
if (approvalDateRange.from) {
|
||||||
|
// Filter by checking if submission has at least one item approved on/after this date
|
||||||
|
query = query.gte('first_item_approved_at', approvalDateRange.from.toISOString());
|
||||||
|
}
|
||||||
|
if (approvalDateRange.to) {
|
||||||
|
// Add one day and use < to include the entire "to" day
|
||||||
|
const nextDay = new Date(approvalDateRange.to);
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
query = query.lt('last_item_approved_at', nextDay.toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
|
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
|
||||||
// Admins see all submissions
|
// Admins see all submissions
|
||||||
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions
|
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions
|
||||||
|
|||||||
136
src/pages/admin/ApprovalHistory.tsx
Normal file
136
src/pages/admin/ApprovalHistory.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Approval History Page
|
||||||
|
*
|
||||||
|
* Full-page view for compliance reporting with advanced filters,
|
||||||
|
* date range selection, and export functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ItemApprovalHistory } from '@/components/moderation/ItemApprovalHistory';
|
||||||
|
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { X, FileCheck } from 'lucide-react';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import type { EntityType } from '@/types/submissions';
|
||||||
|
|
||||||
|
export default function ApprovalHistory() {
|
||||||
|
const { isModerator, loading: rolesLoading } = useUserRole();
|
||||||
|
const [fromDate, setFromDate] = useState<Date | null>(null);
|
||||||
|
const [toDate, setToDate] = useState<Date | null>(null);
|
||||||
|
const [itemType, setItemType] = useState<EntityType | 'all'>('all');
|
||||||
|
const [limit, setLimit] = useState<number>(100);
|
||||||
|
|
||||||
|
// Access control: moderators only
|
||||||
|
if (rolesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isModerator()) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = fromDate || toDate || itemType !== 'all' || limit !== 100;
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFromDate(null);
|
||||||
|
setToDate(null);
|
||||||
|
setItemType('all');
|
||||||
|
setLimit(100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<FileCheck className="w-8 h-8 text-primary" />
|
||||||
|
<h1 className="text-3xl font-bold">Approval History</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Complete audit trail of all approved items with exact timestamps for compliance reporting
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Date Range Filter */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<FilterDateRangePicker
|
||||||
|
label="Approval Date Range"
|
||||||
|
fromDate={fromDate}
|
||||||
|
toDate={toDate}
|
||||||
|
onFromChange={(date) => setFromDate(date || null)}
|
||||||
|
onToChange={(date) => setToDate(date || null)}
|
||||||
|
fromPlaceholder="Start Date"
|
||||||
|
toPlaceholder="End Date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item Type Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="item-type">Item Type</Label>
|
||||||
|
<Select value={itemType} onValueChange={(val) => setItemType(val as EntityType | 'all')}>
|
||||||
|
<SelectTrigger id="item-type">
|
||||||
|
<SelectValue placeholder="All Types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
|
<SelectItem value="park">Parks</SelectItem>
|
||||||
|
<SelectItem value="ride">Rides</SelectItem>
|
||||||
|
<SelectItem value="manufacturer">Manufacturers</SelectItem>
|
||||||
|
<SelectItem value="designer">Designers</SelectItem>
|
||||||
|
<SelectItem value="operator">Operators</SelectItem>
|
||||||
|
<SelectItem value="ride_model">Ride Models</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Limit */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="limit">Results Limit</Label>
|
||||||
|
<Select value={limit.toString()} onValueChange={(val) => setLimit(parseInt(val))}>
|
||||||
|
<SelectTrigger id="limit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
<SelectItem value="250">250</SelectItem>
|
||||||
|
<SelectItem value="500">500</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{hasFilters && (
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* History Table */}
|
||||||
|
<ItemApprovalHistory
|
||||||
|
dateRange={fromDate && toDate ? { from: fromDate, to: toDate } : undefined}
|
||||||
|
itemType={itemType === 'all' ? undefined : itemType}
|
||||||
|
limit={limit}
|
||||||
|
embedded={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -313,6 +313,14 @@ export interface SortConfig {
|
|||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval date range filter for moderation queue
|
||||||
|
*/
|
||||||
|
export interface ApprovalDateRangeFilter {
|
||||||
|
from: Date | null;
|
||||||
|
to: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading states for the moderation queue
|
* Loading states for the moderation queue
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
-- Add approved_at column to submission_items table
|
||||||
|
ALTER TABLE submission_items
|
||||||
|
ADD COLUMN approved_at timestamp with time zone;
|
||||||
|
|
||||||
|
-- Add index for analytics queries (filtered index for performance)
|
||||||
|
CREATE INDEX idx_submission_items_approved_at
|
||||||
|
ON submission_items(approved_at)
|
||||||
|
WHERE approved_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN submission_items.approved_at IS
|
||||||
|
'Timestamp when this specific item was approved by a moderator. NULL for pending/rejected items.';
|
||||||
|
|
||||||
|
-- Drop existing function to update parameter signature
|
||||||
|
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||||
|
|
||||||
|
-- Recreate process_approval_transaction function with approved_at support
|
||||||
|
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||||
|
p_submission_id UUID,
|
||||||
|
p_item_ids UUID[],
|
||||||
|
p_moderator_id UUID,
|
||||||
|
p_submitter_id UUID,
|
||||||
|
p_request_id TEXT DEFAULT NULL,
|
||||||
|
p_approval_mode TEXT DEFAULT 'full',
|
||||||
|
p_idempotency_key TEXT DEFAULT NULL
|
||||||
|
) RETURNS JSONB AS $$
|
||||||
|
DECLARE
|
||||||
|
v_item RECORD;
|
||||||
|
v_entity_id UUID;
|
||||||
|
v_entity_type TEXT;
|
||||||
|
v_action_type TEXT;
|
||||||
|
v_item_data JSONB;
|
||||||
|
v_approved_items JSONB := '[]'::JSONB;
|
||||||
|
v_failed_items JSONB := '[]'::JSONB;
|
||||||
|
v_submission_type TEXT;
|
||||||
|
v_result JSONB;
|
||||||
|
v_error_message TEXT;
|
||||||
|
v_error_detail TEXT;
|
||||||
|
v_start_time TIMESTAMP := clock_timestamp();
|
||||||
|
v_duration_ms INTEGER;
|
||||||
|
v_rollback_triggered BOOLEAN := FALSE;
|
||||||
|
v_lock_acquired BOOLEAN := FALSE;
|
||||||
|
BEGIN
|
||||||
|
-- Validate moderator has permission
|
||||||
|
IF NOT is_moderator(p_moderator_id) THEN
|
||||||
|
RAISE EXCEPTION 'User % does not have moderator privileges', p_moderator_id
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get submission type
|
||||||
|
SELECT submission_type INTO v_submission_type
|
||||||
|
FROM content_submissions
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
|
||||||
|
IF v_submission_type IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Submission % not found', p_submission_id
|
||||||
|
USING ERRCODE = 'no_data_found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Acquire advisory lock
|
||||||
|
IF NOT pg_try_advisory_xact_lock(hashtext(p_submission_id::TEXT)) THEN
|
||||||
|
RAISE EXCEPTION 'Could not acquire lock for submission %', p_submission_id
|
||||||
|
USING ERRCODE = '55P03';
|
||||||
|
END IF;
|
||||||
|
v_lock_acquired := TRUE;
|
||||||
|
|
||||||
|
-- Process each item
|
||||||
|
FOR v_item IN
|
||||||
|
SELECT si.*
|
||||||
|
FROM submission_items si
|
||||||
|
WHERE si.submission_id = p_submission_id
|
||||||
|
AND si.id = ANY(p_item_ids)
|
||||||
|
AND si.status = 'pending'
|
||||||
|
ORDER BY si.order_index
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
v_entity_type := v_item.item_type;
|
||||||
|
v_action_type := v_item.action_type;
|
||||||
|
v_item_data := v_item.item_data;
|
||||||
|
|
||||||
|
-- Create/update entity based on type and action
|
||||||
|
IF v_action_type = 'create' THEN
|
||||||
|
IF v_entity_type = 'park' THEN
|
||||||
|
INSERT INTO parks (name, slug, description, location_id, operator_id, property_owner_id)
|
||||||
|
SELECT
|
||||||
|
v_item_data->>'name',
|
||||||
|
v_item_data->>'slug',
|
||||||
|
v_item_data->>'description',
|
||||||
|
(v_item_data->>'location_id')::UUID,
|
||||||
|
(v_item_data->>'operator_id')::UUID,
|
||||||
|
(v_item_data->>'property_owner_id')::UUID
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type = 'ride' THEN
|
||||||
|
INSERT INTO rides (name, slug, park_id, manufacturer_id, designer_id)
|
||||||
|
SELECT
|
||||||
|
v_item_data->>'name',
|
||||||
|
v_item_data->>'slug',
|
||||||
|
(v_item_data->>'park_id')::UUID,
|
||||||
|
(v_item_data->>'manufacturer_id')::UUID,
|
||||||
|
(v_item_data->>'designer_id')::UUID
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||||
|
INSERT INTO companies (name, slug, company_type, description)
|
||||||
|
SELECT
|
||||||
|
v_item_data->>'name',
|
||||||
|
v_item_data->>'slug',
|
||||||
|
v_entity_type,
|
||||||
|
v_item_data->>'description'
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unsupported entity type: %', v_entity_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ELSIF v_action_type = 'edit' THEN
|
||||||
|
v_entity_id := (v_item_data->>'entity_id')::UUID;
|
||||||
|
|
||||||
|
IF v_entity_type = 'park' THEN
|
||||||
|
UPDATE parks SET
|
||||||
|
name = COALESCE(v_item_data->>'name', name),
|
||||||
|
description = COALESCE(v_item_data->>'description', description),
|
||||||
|
location_id = COALESCE((v_item_data->>'location_id')::UUID, location_id),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type = 'ride' THEN
|
||||||
|
UPDATE rides SET
|
||||||
|
name = COALESCE(v_item_data->>'name', name),
|
||||||
|
description = COALESCE(v_item_data->>'description', description),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||||
|
UPDATE companies SET
|
||||||
|
name = COALESCE(v_item_data->>'name', name),
|
||||||
|
description = COALESCE(v_item_data->>'description', description),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_entity_id;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update submission item with approved status and timestamp
|
||||||
|
UPDATE submission_items
|
||||||
|
SET
|
||||||
|
approved_entity_id = v_entity_id,
|
||||||
|
status = 'approved',
|
||||||
|
approved_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.id;
|
||||||
|
|
||||||
|
-- Add to success list
|
||||||
|
v_approved_items := v_approved_items || jsonb_build_object(
|
||||||
|
'item_id', v_item.id,
|
||||||
|
'entity_id', v_entity_id,
|
||||||
|
'entity_type', v_entity_type
|
||||||
|
);
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
GET STACKED DIAGNOSTICS
|
||||||
|
v_error_message = MESSAGE_TEXT,
|
||||||
|
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||||
|
|
||||||
|
-- Add to failed list
|
||||||
|
v_failed_items := v_failed_items || jsonb_build_object(
|
||||||
|
'item_id', v_item.id,
|
||||||
|
'error', v_error_message,
|
||||||
|
'detail', v_error_detail
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Mark item as failed
|
||||||
|
UPDATE submission_items
|
||||||
|
SET
|
||||||
|
status = 'flagged',
|
||||||
|
rejection_reason = v_error_message,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.id;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Update submission status based on approval mode
|
||||||
|
IF p_approval_mode = 'selective' THEN
|
||||||
|
UPDATE content_submissions
|
||||||
|
SET
|
||||||
|
status = 'partially_approved',
|
||||||
|
reviewed_at = now(),
|
||||||
|
reviewer_id = p_moderator_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE content_submissions
|
||||||
|
SET
|
||||||
|
status = 'approved',
|
||||||
|
reviewed_at = now(),
|
||||||
|
reviewer_id = p_moderator_id,
|
||||||
|
resolved_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calculate duration
|
||||||
|
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||||
|
|
||||||
|
-- Log metrics
|
||||||
|
INSERT INTO approval_transaction_metrics (
|
||||||
|
submission_id,
|
||||||
|
moderator_id,
|
||||||
|
submitter_id,
|
||||||
|
items_count,
|
||||||
|
success,
|
||||||
|
duration_ms,
|
||||||
|
request_id,
|
||||||
|
rollback_triggered
|
||||||
|
) VALUES (
|
||||||
|
p_submission_id,
|
||||||
|
p_moderator_id,
|
||||||
|
p_submitter_id,
|
||||||
|
jsonb_array_length(v_approved_items),
|
||||||
|
jsonb_array_length(v_failed_items) = 0,
|
||||||
|
v_duration_ms,
|
||||||
|
p_request_id,
|
||||||
|
v_rollback_triggered
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Build result
|
||||||
|
v_result := jsonb_build_object(
|
||||||
|
'success', TRUE,
|
||||||
|
'approved_items', v_approved_items,
|
||||||
|
'failed_items', v_failed_items,
|
||||||
|
'duration_ms', v_duration_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_result;
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_rollback_triggered := TRUE;
|
||||||
|
GET STACKED DIAGNOSTICS
|
||||||
|
v_error_message = MESSAGE_TEXT,
|
||||||
|
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||||
|
|
||||||
|
-- Log failed transaction
|
||||||
|
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||||
|
|
||||||
|
INSERT INTO approval_transaction_metrics (
|
||||||
|
submission_id,
|
||||||
|
moderator_id,
|
||||||
|
submitter_id,
|
||||||
|
items_count,
|
||||||
|
success,
|
||||||
|
duration_ms,
|
||||||
|
error_message,
|
||||||
|
error_details,
|
||||||
|
request_id,
|
||||||
|
rollback_triggered
|
||||||
|
) VALUES (
|
||||||
|
p_submission_id,
|
||||||
|
p_moderator_id,
|
||||||
|
p_submitter_id,
|
||||||
|
array_length(p_item_ids, 1),
|
||||||
|
FALSE,
|
||||||
|
v_duration_ms,
|
||||||
|
v_error_message,
|
||||||
|
v_error_detail,
|
||||||
|
p_request_id,
|
||||||
|
v_rollback_triggered
|
||||||
|
);
|
||||||
|
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
-- Fix security warning: Add search_path to process_approval_transaction
|
||||||
|
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||||
|
p_submission_id UUID,
|
||||||
|
p_item_ids UUID[],
|
||||||
|
p_moderator_id UUID,
|
||||||
|
p_submitter_id UUID,
|
||||||
|
p_request_id TEXT DEFAULT NULL,
|
||||||
|
p_approval_mode TEXT DEFAULT 'full',
|
||||||
|
p_idempotency_key TEXT DEFAULT NULL
|
||||||
|
) RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_item RECORD;
|
||||||
|
v_entity_id UUID;
|
||||||
|
v_entity_type TEXT;
|
||||||
|
v_action_type TEXT;
|
||||||
|
v_item_data JSONB;
|
||||||
|
v_approved_items JSONB := '[]'::JSONB;
|
||||||
|
v_failed_items JSONB := '[]'::JSONB;
|
||||||
|
v_submission_type TEXT;
|
||||||
|
v_result JSONB;
|
||||||
|
v_error_message TEXT;
|
||||||
|
v_error_detail TEXT;
|
||||||
|
v_start_time TIMESTAMP := clock_timestamp();
|
||||||
|
v_duration_ms INTEGER;
|
||||||
|
v_rollback_triggered BOOLEAN := FALSE;
|
||||||
|
v_lock_acquired BOOLEAN := FALSE;
|
||||||
|
BEGIN
|
||||||
|
-- Validate moderator has permission
|
||||||
|
IF NOT is_moderator(p_moderator_id) THEN
|
||||||
|
RAISE EXCEPTION 'User % does not have moderator privileges', p_moderator_id
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get submission type
|
||||||
|
SELECT submission_type INTO v_submission_type
|
||||||
|
FROM content_submissions
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
|
||||||
|
IF v_submission_type IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Submission % not found', p_submission_id
|
||||||
|
USING ERRCODE = 'no_data_found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Acquire advisory lock
|
||||||
|
IF NOT pg_try_advisory_xact_lock(hashtext(p_submission_id::TEXT)) THEN
|
||||||
|
RAISE EXCEPTION 'Could not acquire lock for submission %', p_submission_id
|
||||||
|
USING ERRCODE = '55P03';
|
||||||
|
END IF;
|
||||||
|
v_lock_acquired := TRUE;
|
||||||
|
|
||||||
|
-- Process each item
|
||||||
|
FOR v_item IN
|
||||||
|
SELECT si.*
|
||||||
|
FROM submission_items si
|
||||||
|
WHERE si.submission_id = p_submission_id
|
||||||
|
AND si.id = ANY(p_item_ids)
|
||||||
|
AND si.status = 'pending'
|
||||||
|
ORDER BY si.order_index
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
v_entity_type := v_item.item_type;
|
||||||
|
v_action_type := v_item.action_type;
|
||||||
|
v_item_data := v_item.item_data;
|
||||||
|
|
||||||
|
-- Create/update entity based on type and action
|
||||||
|
IF v_action_type = 'create' THEN
|
||||||
|
IF v_entity_type = 'park' THEN
|
||||||
|
INSERT INTO parks (name, slug, description, location_id, operator_id, property_owner_id)
|
||||||
|
SELECT
|
||||||
|
v_item_data->>'name',
|
||||||
|
v_item_data->>'slug',
|
||||||
|
v_item_data->>'description',
|
||||||
|
(v_item_data->>'location_id')::UUID,
|
||||||
|
(v_item_data->>'operator_id')::UUID,
|
||||||
|
(v_item_data->>'property_owner_id')::UUID
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type = 'ride' THEN
|
||||||
|
INSERT INTO rides (name, slug, park_id, manufacturer_id, designer_id)
|
||||||
|
SELECT
|
||||||
|
v_item_data->>'name',
|
||||||
|
v_item_data->>'slug',
|
||||||
|
(v_item_data->>'park_id')::UUID,
|
||||||
|
(v_item_data->>'manufacturer_id')::UUID,
|
||||||
|
(v_item_data->>'designer_id')::UUID
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||||
|
INSERT INTO companies (name, slug, company_type, description)
|
||||||
|
SELECT
|
||||||
|
v_item_data->>'name',
|
||||||
|
v_item_data->>'slug',
|
||||||
|
v_entity_type,
|
||||||
|
v_item_data->>'description'
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unsupported entity type: %', v_entity_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ELSIF v_action_type = 'edit' THEN
|
||||||
|
v_entity_id := (v_item_data->>'entity_id')::UUID;
|
||||||
|
|
||||||
|
IF v_entity_type = 'park' THEN
|
||||||
|
UPDATE parks SET
|
||||||
|
name = COALESCE(v_item_data->>'name', name),
|
||||||
|
description = COALESCE(v_item_data->>'description', description),
|
||||||
|
location_id = COALESCE((v_item_data->>'location_id')::UUID, location_id),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type = 'ride' THEN
|
||||||
|
UPDATE rides SET
|
||||||
|
name = COALESCE(v_item_data->>'name', name),
|
||||||
|
description = COALESCE(v_item_data->>'description', description),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||||
|
UPDATE companies SET
|
||||||
|
name = COALESCE(v_item_data->>'name', name),
|
||||||
|
description = COALESCE(v_item_data->>'description', description),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_entity_id;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update submission item with approved status and timestamp
|
||||||
|
UPDATE submission_items
|
||||||
|
SET
|
||||||
|
approved_entity_id = v_entity_id,
|
||||||
|
status = 'approved',
|
||||||
|
approved_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.id;
|
||||||
|
|
||||||
|
-- Add to success list
|
||||||
|
v_approved_items := v_approved_items || jsonb_build_object(
|
||||||
|
'item_id', v_item.id,
|
||||||
|
'entity_id', v_entity_id,
|
||||||
|
'entity_type', v_entity_type
|
||||||
|
);
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
GET STACKED DIAGNOSTICS
|
||||||
|
v_error_message = MESSAGE_TEXT,
|
||||||
|
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||||
|
|
||||||
|
-- Add to failed list
|
||||||
|
v_failed_items := v_failed_items || jsonb_build_object(
|
||||||
|
'item_id', v_item.id,
|
||||||
|
'error', v_error_message,
|
||||||
|
'detail', v_error_detail
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Mark item as failed
|
||||||
|
UPDATE submission_items
|
||||||
|
SET
|
||||||
|
status = 'flagged',
|
||||||
|
rejection_reason = v_error_message,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.id;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Update submission status based on approval mode
|
||||||
|
IF p_approval_mode = 'selective' THEN
|
||||||
|
UPDATE content_submissions
|
||||||
|
SET
|
||||||
|
status = 'partially_approved',
|
||||||
|
reviewed_at = now(),
|
||||||
|
reviewer_id = p_moderator_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE content_submissions
|
||||||
|
SET
|
||||||
|
status = 'approved',
|
||||||
|
reviewed_at = now(),
|
||||||
|
reviewer_id = p_moderator_id,
|
||||||
|
resolved_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calculate duration
|
||||||
|
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||||
|
|
||||||
|
-- Log metrics
|
||||||
|
INSERT INTO approval_transaction_metrics (
|
||||||
|
submission_id,
|
||||||
|
moderator_id,
|
||||||
|
submitter_id,
|
||||||
|
items_count,
|
||||||
|
success,
|
||||||
|
duration_ms,
|
||||||
|
request_id,
|
||||||
|
rollback_triggered
|
||||||
|
) VALUES (
|
||||||
|
p_submission_id,
|
||||||
|
p_moderator_id,
|
||||||
|
p_submitter_id,
|
||||||
|
jsonb_array_length(v_approved_items),
|
||||||
|
jsonb_array_length(v_failed_items) = 0,
|
||||||
|
v_duration_ms,
|
||||||
|
p_request_id,
|
||||||
|
v_rollback_triggered
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Build result
|
||||||
|
v_result := jsonb_build_object(
|
||||||
|
'success', TRUE,
|
||||||
|
'approved_items', v_approved_items,
|
||||||
|
'failed_items', v_failed_items,
|
||||||
|
'duration_ms', v_duration_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_result;
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_rollback_triggered := TRUE;
|
||||||
|
GET STACKED DIAGNOSTICS
|
||||||
|
v_error_message = MESSAGE_TEXT,
|
||||||
|
v_error_detail = PG_EXCEPTION_DETAIL;
|
||||||
|
|
||||||
|
-- Log failed transaction
|
||||||
|
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||||
|
|
||||||
|
INSERT INTO approval_transaction_metrics (
|
||||||
|
submission_id,
|
||||||
|
moderator_id,
|
||||||
|
submitter_id,
|
||||||
|
items_count,
|
||||||
|
success,
|
||||||
|
duration_ms,
|
||||||
|
error_message,
|
||||||
|
error_details,
|
||||||
|
request_id,
|
||||||
|
rollback_triggered
|
||||||
|
) VALUES (
|
||||||
|
p_submission_id,
|
||||||
|
p_moderator_id,
|
||||||
|
p_submitter_id,
|
||||||
|
array_length(p_item_ids, 1),
|
||||||
|
FALSE,
|
||||||
|
v_duration_ms,
|
||||||
|
v_error_message,
|
||||||
|
v_error_detail,
|
||||||
|
p_request_id,
|
||||||
|
v_rollback_triggered
|
||||||
|
);
|
||||||
|
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
-- Create materialized view for approval history with detailed audit trail
|
||||||
|
CREATE MATERIALIZED VIEW approval_history_detailed AS
|
||||||
|
SELECT
|
||||||
|
si.id as item_id,
|
||||||
|
si.submission_id,
|
||||||
|
si.item_type,
|
||||||
|
si.action_type,
|
||||||
|
si.status,
|
||||||
|
si.approved_at,
|
||||||
|
si.approved_entity_id,
|
||||||
|
si.created_at,
|
||||||
|
si.updated_at,
|
||||||
|
-- Calculate approval duration (seconds)
|
||||||
|
EXTRACT(EPOCH FROM (si.approved_at - si.created_at)) as approval_time_seconds,
|
||||||
|
-- Submission context
|
||||||
|
cs.submission_type,
|
||||||
|
cs.user_id as submitter_id,
|
||||||
|
cs.reviewer_id as approver_id,
|
||||||
|
cs.submitted_at,
|
||||||
|
-- Submitter profile
|
||||||
|
p_submitter.username as submitter_username,
|
||||||
|
p_submitter.display_name as submitter_display_name,
|
||||||
|
p_submitter.avatar_url as submitter_avatar_url,
|
||||||
|
-- Approver profile
|
||||||
|
p_approver.username as approver_username,
|
||||||
|
p_approver.display_name as approver_display_name,
|
||||||
|
p_approver.avatar_url as approver_avatar_url,
|
||||||
|
-- Entity slugs for linking (dynamic based on item_type)
|
||||||
|
CASE
|
||||||
|
WHEN si.item_type = 'park' THEN (SELECT slug FROM parks WHERE id = si.approved_entity_id)
|
||||||
|
WHEN si.item_type = 'ride' THEN (SELECT slug FROM rides WHERE id = si.approved_entity_id)
|
||||||
|
WHEN si.item_type = 'manufacturer' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'manufacturer')
|
||||||
|
WHEN si.item_type = 'designer' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'designer')
|
||||||
|
WHEN si.item_type = 'operator' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'operator')
|
||||||
|
WHEN si.item_type = 'ride_model' THEN (SELECT slug FROM ride_models WHERE id = si.approved_entity_id)
|
||||||
|
ELSE NULL
|
||||||
|
END as entity_slug,
|
||||||
|
-- Entity names for display
|
||||||
|
CASE
|
||||||
|
WHEN si.item_type = 'park' THEN (SELECT name FROM parks WHERE id = si.approved_entity_id)
|
||||||
|
WHEN si.item_type = 'ride' THEN (SELECT name FROM rides WHERE id = si.approved_entity_id)
|
||||||
|
WHEN si.item_type = 'manufacturer' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'manufacturer')
|
||||||
|
WHEN si.item_type = 'designer' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'designer')
|
||||||
|
WHEN si.item_type = 'operator' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'operator')
|
||||||
|
WHEN si.item_type = 'ride_model' THEN (SELECT name FROM ride_models WHERE id = si.approved_entity_id)
|
||||||
|
ELSE NULL
|
||||||
|
END as entity_name
|
||||||
|
FROM submission_items si
|
||||||
|
JOIN content_submissions cs ON cs.id = si.submission_id
|
||||||
|
LEFT JOIN profiles p_submitter ON p_submitter.user_id = cs.user_id
|
||||||
|
LEFT JOIN profiles p_approver ON p_approver.user_id = cs.reviewer_id
|
||||||
|
WHERE si.approved_at IS NOT NULL
|
||||||
|
AND si.status = 'approved'
|
||||||
|
ORDER BY si.approved_at DESC;
|
||||||
|
|
||||||
|
-- Create indexes for fast lookups
|
||||||
|
CREATE INDEX idx_approval_history_approved_at ON approval_history_detailed(approved_at DESC);
|
||||||
|
CREATE INDEX idx_approval_history_item_type ON approval_history_detailed(item_type);
|
||||||
|
CREATE INDEX idx_approval_history_approver ON approval_history_detailed(approver_id);
|
||||||
|
CREATE INDEX idx_approval_history_submitter ON approval_history_detailed(submitter_id);
|
||||||
|
|
||||||
|
-- Function to refresh the materialized view
|
||||||
|
CREATE OR REPLACE FUNCTION refresh_approval_history()
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
REFRESH MATERIALIZED VIEW CONCURRENTLY approval_history_detailed;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Security-definer function to query approval history (moderators only)
|
||||||
|
CREATE OR REPLACE FUNCTION get_approval_history(
|
||||||
|
p_item_type text DEFAULT NULL,
|
||||||
|
p_approver_id uuid DEFAULT NULL,
|
||||||
|
p_from_date timestamptz DEFAULT NULL,
|
||||||
|
p_to_date timestamptz DEFAULT NULL,
|
||||||
|
p_limit integer DEFAULT 100,
|
||||||
|
p_offset integer DEFAULT 0
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
item_id uuid,
|
||||||
|
submission_id uuid,
|
||||||
|
item_type text,
|
||||||
|
action_type text,
|
||||||
|
status text,
|
||||||
|
approved_at timestamptz,
|
||||||
|
approved_entity_id uuid,
|
||||||
|
created_at timestamptz,
|
||||||
|
updated_at timestamptz,
|
||||||
|
approval_time_seconds numeric,
|
||||||
|
submission_type text,
|
||||||
|
submitter_id uuid,
|
||||||
|
approver_id uuid,
|
||||||
|
submitted_at timestamptz,
|
||||||
|
submitter_username text,
|
||||||
|
submitter_display_name text,
|
||||||
|
submitter_avatar_url text,
|
||||||
|
approver_username text,
|
||||||
|
approver_display_name text,
|
||||||
|
approver_avatar_url text,
|
||||||
|
entity_slug text,
|
||||||
|
entity_name text
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if user is a moderator
|
||||||
|
IF NOT is_moderator(auth.uid()) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied: Moderator role required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Return filtered results
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
ahd.item_id,
|
||||||
|
ahd.submission_id,
|
||||||
|
ahd.item_type,
|
||||||
|
ahd.action_type,
|
||||||
|
ahd.status,
|
||||||
|
ahd.approved_at,
|
||||||
|
ahd.approved_entity_id,
|
||||||
|
ahd.created_at,
|
||||||
|
ahd.updated_at,
|
||||||
|
ahd.approval_time_seconds,
|
||||||
|
ahd.submission_type,
|
||||||
|
ahd.submitter_id,
|
||||||
|
ahd.approver_id,
|
||||||
|
ahd.submitted_at,
|
||||||
|
ahd.submitter_username,
|
||||||
|
ahd.submitter_display_name,
|
||||||
|
ahd.submitter_avatar_url,
|
||||||
|
ahd.approver_username,
|
||||||
|
ahd.approver_display_name,
|
||||||
|
ahd.approver_avatar_url,
|
||||||
|
ahd.entity_slug,
|
||||||
|
ahd.entity_name
|
||||||
|
FROM approval_history_detailed ahd
|
||||||
|
WHERE (p_item_type IS NULL OR ahd.item_type = p_item_type)
|
||||||
|
AND (p_approver_id IS NULL OR ahd.approver_id = p_approver_id)
|
||||||
|
AND (p_from_date IS NULL OR ahd.approved_at >= p_from_date)
|
||||||
|
AND (p_to_date IS NULL OR ahd.approved_at < p_to_date + interval '1 day')
|
||||||
|
ORDER BY ahd.approved_at DESC
|
||||||
|
LIMIT p_limit
|
||||||
|
OFFSET p_offset;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant execute permission to authenticated users (function checks moderator role internally)
|
||||||
|
GRANT EXECUTE ON FUNCTION get_approval_history TO authenticated;
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW approval_history_detailed IS 'Materialized view storing approval history data - access via get_approval_history() function';
|
||||||
|
COMMENT ON FUNCTION refresh_approval_history() IS 'Refreshes the approval history materialized view - call after bulk approvals';
|
||||||
|
COMMENT ON FUNCTION get_approval_history IS 'Query approval history with filters - moderators only';
|
||||||
Reference in New Issue
Block a user