mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 17:31:14 -05:00
feat: Implement system activity log (Phases 1-3)
This commit is contained in:
368
src/components/admin/SystemActivityLog.tsx
Normal file
368
src/components/admin/SystemActivityLog.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
FileEdit,
|
||||
Plus,
|
||||
History,
|
||||
Shield,
|
||||
UserCog,
|
||||
FileCheck,
|
||||
FileX,
|
||||
Flag,
|
||||
AlertCircle,
|
||||
Star,
|
||||
AlertTriangle,
|
||||
Image as ImageIcon,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
fetchSystemActivities,
|
||||
SystemActivity,
|
||||
ActivityType,
|
||||
EntityChangeDetails,
|
||||
AdminActionDetails,
|
||||
SubmissionReviewDetails,
|
||||
ReportResolutionDetails,
|
||||
ReviewModerationDetails,
|
||||
PhotoApprovalDetails
|
||||
} from '@/lib/systemActivityService';
|
||||
|
||||
export interface SystemActivityLogRef {
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface SystemActivityLogProps {
|
||||
limit?: number;
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
const activityTypeConfig = {
|
||||
entity_change: {
|
||||
icon: FileEdit,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
label: 'Entity Change',
|
||||
},
|
||||
admin_action: {
|
||||
icon: Shield,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-500/10',
|
||||
label: 'Admin Action',
|
||||
},
|
||||
submission_review: {
|
||||
icon: FileCheck,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10',
|
||||
label: 'Submission Review',
|
||||
},
|
||||
report_resolution: {
|
||||
icon: Flag,
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-500/10',
|
||||
label: 'Report Resolution',
|
||||
},
|
||||
review_moderation: {
|
||||
icon: Star,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
label: 'Review Moderation',
|
||||
},
|
||||
photo_approval: {
|
||||
icon: ImageIcon,
|
||||
color: 'text-teal-500',
|
||||
bgColor: 'bg-teal-500/10',
|
||||
label: 'Photo Approval',
|
||||
},
|
||||
};
|
||||
|
||||
export const SystemActivityLog = forwardRef<SystemActivityLogRef, SystemActivityLogProps>(
|
||||
({ limit = 50, showFilters = true }, ref) => {
|
||||
const [activities, setActivities] = useState<SystemActivity[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filterType, setFilterType] = useState<ActivityType | 'all'>('all');
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadActivities = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchSystemActivities(limit, {
|
||||
type: filterType === 'all' ? undefined : filterType,
|
||||
});
|
||||
setActivities(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading system activities:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadActivities();
|
||||
}, [limit, filterType]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: loadActivities,
|
||||
}));
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderActivityDetails = (activity: SystemActivity) => {
|
||||
const isExpanded = expandedIds.has(activity.id);
|
||||
|
||||
switch (activity.type) {
|
||||
case 'entity_change': {
|
||||
const details = activity.details as EntityChangeDetails;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{details.change_type}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{details.entity_type}</span>
|
||||
{details.entity_name && (
|
||||
<span className="font-medium">{details.entity_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && details.change_reason && (
|
||||
<p className="text-sm text-muted-foreground pl-4 border-l-2">
|
||||
Reason: {details.change_reason}
|
||||
</p>
|
||||
)}
|
||||
{isExpanded && details.version_number && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Version #{details.version_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'admin_action': {
|
||||
const details = activity.details as AdminActionDetails;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="destructive" className="capitalize">
|
||||
{details.action.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
{details.target_username && (
|
||||
<span className="text-muted-foreground">
|
||||
→ @{details.target_username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && details.details && (
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-auto">
|
||||
{JSON.stringify(details.details, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'submission_review': {
|
||||
const details = activity.details as SubmissionReviewDetails;
|
||||
const statusColor = details.status === 'approved' ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500';
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge className={statusColor}>
|
||||
{details.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{details.submission_type}</span>
|
||||
{details.entity_name && (
|
||||
<span className="font-medium">{details.entity_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'report_resolution': {
|
||||
const details = activity.details as ReportResolutionDetails;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline">{details.status}</Badge>
|
||||
<span className="text-muted-foreground">{details.reported_entity_type}</span>
|
||||
</div>
|
||||
{isExpanded && details.resolution_notes && (
|
||||
<p className="text-sm text-muted-foreground pl-4 border-l-2">
|
||||
{details.resolution_notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'review_moderation': {
|
||||
const details = activity.details as ReviewModerationDetails;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline">{details.moderation_status}</Badge>
|
||||
<span className="text-muted-foreground">{details.entity_type} review</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'photo_approval': {
|
||||
const details = activity.details as PhotoApprovalDetails;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge className="bg-teal-500/10 text-teal-500">Approved</Badge>
|
||||
<span className="text-muted-foreground">{details.entity_type} photo</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Activity Log</CardTitle>
|
||||
<CardDescription>Loading recent system activities...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>System Activity Log</CardTitle>
|
||||
<CardDescription>
|
||||
Complete audit trail of all system changes and actions
|
||||
</CardDescription>
|
||||
</div>
|
||||
{showFilters && (
|
||||
<Select value={filterType} onValueChange={(value) => setFilterType(value as ActivityType | 'all')}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Activities</SelectItem>
|
||||
{Object.entries(activityTypeConfig).map(([key, config]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{config.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activities.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No activities found
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => {
|
||||
const config = activityTypeConfig[activity.type];
|
||||
const Icon = config.icon;
|
||||
const isExpanded = expandedIds.has(activity.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className={`p-2 rounded-full ${config.bgColor} h-fit`}>
|
||||
<Icon className={`h-5 w-5 ${config.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{activity.actor ? (
|
||||
<>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={activity.actor.avatar_url} />
|
||||
<AvatarFallback>
|
||||
{activity.actor.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">
|
||||
{activity.actor.display_name || activity.actor.username}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-muted-foreground">System</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{activity.action}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => toggleExpanded(activity.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{renderActivityDetails(activity)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SystemActivityLog.displayName = 'SystemActivityLog';
|
||||
Reference in New Issue
Block a user