mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-25 09:51:12 -05:00
Implements audit trail view for item approvals, adds approval date range filtering to moderation queue, and wires up UI and backend components (Approval History page, ItemApprovalHistory component, materialized view-based history, and query/filters integration) to support compliant reporting and time-based moderation filtering.
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
/**
|
|
* 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>;
|
|
}; |