mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 18:26:58 -05:00
Compare commits
9 Commits
7b0825e772
...
edit/edt-6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e89483b604 | ||
|
|
44a713af62 | ||
|
|
46275e0f1e | ||
|
|
6bd7d24a1b | ||
|
|
72e76e86af | ||
|
|
a35486fb11 | ||
|
|
3d3ae57ee3 | ||
|
|
46c08e10e8 | ||
|
|
b22546e7f2 |
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 RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
||||
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
||||
const ApprovalHistory = lazy(() => import("./pages/admin/ApprovalHistory"));
|
||||
|
||||
// User routes (lazy-loaded)
|
||||
const Profile = lazy(() => import("./pages/Profile"));
|
||||
@@ -387,7 +388,15 @@ function AppContent(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/error-lookup"
|
||||
path="/admin/approval-history"
|
||||
element={
|
||||
<AdminErrorBoundary section="Approval History">
|
||||
<ApprovalHistory />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/error-lookup"
|
||||
element={
|
||||
<AdminErrorBoundary section="Error Lookup">
|
||||
<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 type { EntityFilter, StatusFilter } from '@/types/moderation';
|
||||
import { format } from 'date-fns';
|
||||
import type { EntityFilter, StatusFilter, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
|
||||
interface ActiveFiltersDisplayProps {
|
||||
entityFilter: EntityFilter;
|
||||
statusFilter: StatusFilter;
|
||||
approvalDateRange?: ApprovalDateRangeFilter;
|
||||
defaultEntityFilter?: EntityFilter;
|
||||
defaultStatusFilter?: StatusFilter;
|
||||
}
|
||||
@@ -23,12 +25,15 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
|
||||
export const ActiveFiltersDisplay = ({
|
||||
entityFilter,
|
||||
statusFilter,
|
||||
approvalDateRange,
|
||||
defaultEntityFilter = 'all',
|
||||
defaultStatusFilter = 'pending'
|
||||
}: ActiveFiltersDisplayProps) => {
|
||||
const hasDateRange = approvalDateRange && (approvalDateRange.from || approvalDateRange.to);
|
||||
const hasActiveFilters =
|
||||
entityFilter !== defaultEntityFilter ||
|
||||
statusFilter !== defaultStatusFilter;
|
||||
statusFilter !== defaultStatusFilter ||
|
||||
hasDateRange;
|
||||
|
||||
if (!hasActiveFilters) return null;
|
||||
|
||||
@@ -46,6 +51,14 @@ export const ActiveFiltersDisplay = ({
|
||||
{statusFilter}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
67
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
67
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DetailedViewCollapsibleProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
fieldCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible wrapper for detailed field-by-field view sections
|
||||
* Provides expand/collapse functionality with visual indicators
|
||||
*/
|
||||
export function DetailedViewCollapsible({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
children,
|
||||
fieldCount,
|
||||
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 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
All Fields (Detailed View)
|
||||
</span>
|
||||
{fieldCount !== undefined && fieldCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs font-normal transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{fieldCount}
|
||||
</Badge>
|
||||
)}
|
||||
</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 transition-transform duration-200" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground transition-transform duration-200" />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="mt-3 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
||||
{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}
|
||||
activeStatusFilter={queueManager.filters.statusFilter}
|
||||
sortConfig={queueManager.filters.sortConfig}
|
||||
activeTab={queueManager.filters.activeTab}
|
||||
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||
isMobile={isMobile ?? false}
|
||||
isLoading={queueManager.loadingState === 'loading'}
|
||||
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
||||
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
||||
onSortChange={queueManager.filters.setSortConfig}
|
||||
onApprovalDateRangeChange={queueManager.filters.setApprovalDateRange}
|
||||
onClearFilters={queueManager.filters.clearFilters}
|
||||
showClearButton={queueManager.filters.hasActiveFilters}
|
||||
onRefresh={queueManager.refresh}
|
||||
@@ -517,6 +520,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
<ActiveFiltersDisplay
|
||||
entityFilter={queueManager.filters.entityFilter}
|
||||
statusFilter={queueManager.filters.statusFilter}
|
||||
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RefreshButton } from '@/components/ui/refresh-button';
|
||||
import { QueueSortControls } from './QueueSortControls';
|
||||
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
||||
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
|
||||
interface QueueFiltersProps {
|
||||
activeEntityFilter: EntityFilter;
|
||||
activeStatusFilter: StatusFilter;
|
||||
sortConfig: SortConfig;
|
||||
activeTab: QueueTab;
|
||||
approvalDateRange: ApprovalDateRangeFilter;
|
||||
isMobile: boolean;
|
||||
isLoading?: boolean;
|
||||
onEntityFilterChange: (filter: EntityFilter) => void;
|
||||
onStatusFilterChange: (filter: StatusFilter) => void;
|
||||
onSortChange: (config: SortConfig) => void;
|
||||
onApprovalDateRangeChange: (range: ApprovalDateRangeFilter) => void;
|
||||
onClearFilters: () => void;
|
||||
showClearButton: boolean;
|
||||
onRefresh?: () => void;
|
||||
@@ -37,22 +43,27 @@ export const QueueFilters = ({
|
||||
activeEntityFilter,
|
||||
activeStatusFilter,
|
||||
sortConfig,
|
||||
activeTab,
|
||||
approvalDateRange,
|
||||
isMobile,
|
||||
isLoading = false,
|
||||
onEntityFilterChange,
|
||||
onStatusFilterChange,
|
||||
onSortChange,
|
||||
onApprovalDateRangeChange,
|
||||
onClearFilters,
|
||||
showClearButton,
|
||||
onRefresh,
|
||||
isRefreshing = false
|
||||
}: QueueFiltersProps) => {
|
||||
const { isCollapsed, toggle } = useFilterPanelState();
|
||||
const { isCollapsed: detailsCollapsed, toggle: toggleDetails } = useDetailedViewState();
|
||||
|
||||
// Count active filters
|
||||
const activeFilterCount = [
|
||||
activeEntityFilter !== 'all' ? 1 : 0,
|
||||
activeStatusFilter !== 'all' ? 1 : 0,
|
||||
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
|
||||
].reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
@@ -68,14 +79,51 @@ export const QueueFilters = ({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Global toggle for detailed views */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleDetails}
|
||||
className="h-8 gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{detailsCollapsed ? (
|
||||
<>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
{!isMobile && <span>Expand All</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
{!isMobile && <span>Collapse All</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
{detailsCollapsed
|
||||
? "Show detailed field-by-field view for all items in the queue"
|
||||
: "Hide detailed field-by-field view for all items in the queue"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This preference is saved to your account
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isMobile && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="space-y-4">
|
||||
@@ -164,6 +212,21 @@ export const QueueFilters = ({
|
||||
isMobile={isMobile}
|
||||
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>
|
||||
|
||||
{/* Clear Filters & Apply Buttons (mobile only) */}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
|
||||
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
||||
import { AuditTrailViewer } from './AuditTrailViewer';
|
||||
import { RawDataViewer } from './RawDataViewer';
|
||||
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
|
||||
|
||||
interface QueueItemProps {
|
||||
item: ModerationItem;
|
||||
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
|
||||
{item.type === 'content_submission' && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
|
||||
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
||||
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
||||
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
||||
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
|
||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||
|
||||
interface SubmissionItemsListProps {
|
||||
submissionId: string;
|
||||
@@ -34,11 +36,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isCollapsed, toggle } = useDetailedViewState();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubmissionItems();
|
||||
}, [submissionId]);
|
||||
|
||||
// Helper function to count non-null fields in entity data
|
||||
const countFields = (data: any): number => {
|
||||
if (!data || typeof data !== 'object') return 0;
|
||||
return Object.values(data).filter(value => value !== null && value !== undefined).length;
|
||||
};
|
||||
|
||||
const fetchSubmissionItems = async () => {
|
||||
try {
|
||||
// Only show skeleton on initial load, show refreshing indicator on refresh
|
||||
@@ -188,17 +197,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as ParkSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -211,17 +221,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as RideSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -234,17 +245,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as CompanySubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -257,17 +269,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as RideModelSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -280,17 +293,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as TimelineSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { logger } from '@/lib/logger';
|
||||
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';
|
||||
|
||||
export interface ModerationFiltersConfig {
|
||||
@@ -36,6 +36,9 @@ export interface ModerationFiltersConfig {
|
||||
|
||||
/** Initial sort configuration */
|
||||
initialSortConfig?: SortConfig;
|
||||
|
||||
/** Initial approval date range filter */
|
||||
initialApprovalDateRange?: ApprovalDateRangeFilter;
|
||||
}
|
||||
|
||||
export interface ModerationFilters {
|
||||
@@ -87,6 +90,15 @@ export interface ModerationFilters {
|
||||
/** Reset sort to default */
|
||||
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) */
|
||||
onFilterChange?: () => void;
|
||||
}
|
||||
@@ -121,6 +133,7 @@ export function useModerationFilters(
|
||||
persist = true,
|
||||
storageKey = 'moderationQueue_filters',
|
||||
initialSortConfig = { field: 'created_at', direction: 'asc' },
|
||||
initialApprovalDateRange = { from: null, to: null },
|
||||
onFilterChange,
|
||||
} = config;
|
||||
|
||||
@@ -174,6 +187,9 @@ export function useModerationFilters(
|
||||
|
||||
// Sort state
|
||||
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
|
||||
|
||||
// Approval date range state
|
||||
const [approvalDateRange, setApprovalDateRangeState] = useState<ApprovalDateRangeFilter>(initialApprovalDateRange);
|
||||
|
||||
// Debounced filters for API calls
|
||||
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
|
||||
@@ -181,6 +197,9 @@ export function useModerationFilters(
|
||||
|
||||
// Debounced sort (0ms for immediate feedback)
|
||||
const debouncedSortConfig = useDebounce(sortConfig, 0);
|
||||
|
||||
// Debounced approval date range
|
||||
const debouncedApprovalDateRange = useDebounce(approvalDateRange, debounceDelay);
|
||||
|
||||
// Persist filters to localStorage
|
||||
useEffect(() => {
|
||||
@@ -246,6 +265,13 @@ export function useModerationFilters(
|
||||
const resetSort = useCallback(() => {
|
||||
setSortConfigState(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
|
||||
const clearFilters = useCallback(() => {
|
||||
@@ -254,7 +280,8 @@ export function useModerationFilters(
|
||||
setStatusFilterState(initialStatusFilter);
|
||||
setActiveTabState(initialTab);
|
||||
setSortConfigState(initialSortConfig);
|
||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig]);
|
||||
setApprovalDateRangeState(initialApprovalDateRange);
|
||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig, initialApprovalDateRange]);
|
||||
|
||||
// Check if non-default filters are active
|
||||
const hasActiveFilters =
|
||||
@@ -262,7 +289,9 @@ export function useModerationFilters(
|
||||
statusFilter !== initialStatusFilter ||
|
||||
activeTab !== initialTab ||
|
||||
sortConfig.field !== initialSortConfig.field ||
|
||||
sortConfig.direction !== initialSortConfig.direction;
|
||||
sortConfig.direction !== initialSortConfig.direction ||
|
||||
approvalDateRange.from !== null ||
|
||||
approvalDateRange.to !== null;
|
||||
|
||||
// Return without useMemo wrapper (OPTIMIZED)
|
||||
return {
|
||||
@@ -282,6 +311,9 @@ export function useModerationFilters(
|
||||
sortBy,
|
||||
toggleSortDirection,
|
||||
resetSort,
|
||||
approvalDateRange,
|
||||
debouncedApprovalDateRange,
|
||||
setApprovalDateRange,
|
||||
onFilterChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
currentPage: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
sortConfig: filters.debouncedSortConfig,
|
||||
approvalDateRange: filters.debouncedApprovalDateRange,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface UseQueueQueryConfig {
|
||||
direction: SortDirection;
|
||||
};
|
||||
|
||||
/** Approval date range filter */
|
||||
approvalDateRange?: {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
};
|
||||
|
||||
/** Whether query is enabled (defaults to true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -145,6 +151,7 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
||||
currentPage: config.currentPage,
|
||||
pageSize: config.pageSize,
|
||||
sortConfig: config.sortConfig,
|
||||
approvalDateRange: config.approvalDateRange,
|
||||
};
|
||||
|
||||
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
||||
@@ -161,6 +168,8 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
||||
config.pageSize,
|
||||
config.sortConfig.field,
|
||||
config.sortConfig.direction,
|
||||
config.approvalDateRange?.from?.toISOString(),
|
||||
config.approvalDateRange?.to?.toISOString(),
|
||||
];
|
||||
|
||||
// Execute query
|
||||
|
||||
127
src/hooks/useDetailedViewState.ts
Normal file
127
src/hooks/useDetailedViewState.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleNonCriticalError } from '@/lib/errorHandler';
|
||||
import type { Json } from '@/integrations/supabase/types';
|
||||
|
||||
const STORAGE_KEY = 'detailed-view-collapsed';
|
||||
|
||||
interface ModerationPreferences {
|
||||
detailed_view_collapsed: boolean;
|
||||
}
|
||||
|
||||
interface UseDetailedViewStateReturn {
|
||||
isCollapsed: boolean;
|
||||
toggle: () => void;
|
||||
setCollapsed: (value: boolean) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage detailed view collapsed/expanded state
|
||||
* Persists to database for authenticated users, localStorage for guests
|
||||
* Defaults to collapsed to reduce visual clutter
|
||||
*/
|
||||
export function useDetailedViewState(): UseDetailedViewStateReturn {
|
||||
const { user } = useAuth();
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load preferences on mount
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, [user]);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
if (user) {
|
||||
// Load from database for authenticated users
|
||||
const { data, error } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('moderation_preferences')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Load moderation preferences',
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (data?.moderation_preferences) {
|
||||
const prefs = data.moderation_preferences as ModerationPreferences;
|
||||
setIsCollapsed(prefs.detailed_view_collapsed ?? true);
|
||||
}
|
||||
} else {
|
||||
// Load from localStorage for guests
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
setIsCollapsed(stored ? JSON.parse(stored) : true);
|
||||
} catch (error) {
|
||||
logger.warn('Error reading detailed view state from localStorage', { error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error loading detailed view preferences', { error });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePreferences = async (collapsed: boolean) => {
|
||||
try {
|
||||
if (user) {
|
||||
// Save to database for authenticated users
|
||||
const moderationPrefs: ModerationPreferences = {
|
||||
detailed_view_collapsed: collapsed,
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
moderation_preferences: moderationPrefs as unknown as Json,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, {
|
||||
onConflict: 'user_id',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Save moderation preferences',
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Save to localStorage for guests
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
|
||||
} catch (error) {
|
||||
logger.warn('Error saving detailed view state to localStorage', { error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error saving detailed view preferences', { error });
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !isCollapsed;
|
||||
setIsCollapsed(newValue);
|
||||
savePreferences(newValue);
|
||||
};
|
||||
|
||||
const setCollapsed = (value: boolean) => {
|
||||
setIsCollapsed(value);
|
||||
savePreferences(value);
|
||||
};
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
toggle,
|
||||
setCollapsed,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -1872,6 +1872,13 @@ export type Database = {
|
||||
item_id?: string
|
||||
}
|
||||
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"
|
||||
columns: ["item_id"]
|
||||
@@ -5682,6 +5689,13 @@ export type Database = {
|
||||
submission_item_id?: string
|
||||
}
|
||||
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"
|
||||
columns: ["submission_item_id"]
|
||||
@@ -5763,6 +5777,13 @@ export type Database = {
|
||||
referencedRelation: "company_submissions"
|
||||
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"
|
||||
columns: ["depends_on"]
|
||||
@@ -5934,6 +5955,13 @@ export type Database = {
|
||||
test_session_id?: string | null
|
||||
}
|
||||
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"
|
||||
columns: ["submission_item_id"]
|
||||
@@ -6309,6 +6337,76 @@ export type Database = {
|
||||
}
|
||||
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: {
|
||||
Row: {
|
||||
last_30_days: number | null
|
||||
@@ -6834,6 +6932,40 @@ export type Database = {
|
||||
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_contributor_leaderboard: {
|
||||
Args: { limit_count?: number; time_period?: string }
|
||||
@@ -7102,6 +7234,7 @@ export type Database = {
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
refresh_approval_history: { Args: never; Returns: undefined }
|
||||
release_expired_locks: { Args: never; Returns: number }
|
||||
release_submission_lock: {
|
||||
Args: { moderator_id: string; submission_id: string }
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface QueryConfig {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
sortConfig?: SortConfig;
|
||||
approvalDateRange?: { from: Date | null; to: Date | null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ export function buildSubmissionQuery(
|
||||
config: QueryConfig,
|
||||
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
|
||||
let query = supabase
|
||||
@@ -103,6 +104,20 @@ export function buildSubmissionQuery(
|
||||
}
|
||||
// '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
|
||||
// Admins see all submissions
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval date range filter for moderation queue
|
||||
*/
|
||||
export interface ApprovalDateRangeFilter {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading states for the moderation queue
|
||||
*/
|
||||
|
||||
@@ -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