mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-26 10:47:00 -05:00
Add audit trail and filters
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.
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
|
||||
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>;
|
||||
};
|
||||
@@ -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,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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -7,17 +7,21 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
||||
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 { 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,11 +41,14 @@ export const QueueFilters = ({
|
||||
activeEntityFilter,
|
||||
activeStatusFilter,
|
||||
sortConfig,
|
||||
activeTab,
|
||||
approvalDateRange,
|
||||
isMobile,
|
||||
isLoading = false,
|
||||
onEntityFilterChange,
|
||||
onStatusFilterChange,
|
||||
onSortChange,
|
||||
onApprovalDateRangeChange,
|
||||
onClearFilters,
|
||||
showClearButton,
|
||||
onRefresh,
|
||||
@@ -53,6 +60,7 @@ export const QueueFilters = ({
|
||||
const activeFilterCount = [
|
||||
activeEntityFilter !== 'all' ? 1 : 0,
|
||||
activeStatusFilter !== 'all' ? 1 : 0,
|
||||
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
|
||||
].reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
@@ -164,6 +172,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) */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user