mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
1011 lines
38 KiB
TypeScript
1011 lines
38 KiB
TypeScript
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 { Input } from '@/components/ui/input';
|
|
import {
|
|
FileEdit,
|
|
Plus,
|
|
History,
|
|
Shield,
|
|
UserCog,
|
|
FileCheck,
|
|
FileX,
|
|
Flag,
|
|
AlertCircle,
|
|
Star,
|
|
AlertTriangle,
|
|
Image as ImageIcon,
|
|
CheckCircle,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Trash2,
|
|
UserPlus,
|
|
UserX,
|
|
Ban,
|
|
UserCheck,
|
|
MessageSquare,
|
|
MessageSquareX,
|
|
Search,
|
|
RefreshCw,
|
|
Filter,
|
|
X
|
|
} from 'lucide-react';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import {
|
|
fetchSystemActivities,
|
|
SystemActivity,
|
|
ActivityType,
|
|
EntityChangeDetails,
|
|
AdminActionDetails,
|
|
SubmissionReviewDetails,
|
|
ReportResolutionDetails,
|
|
ReviewModerationDetails,
|
|
PhotoApprovalDetails,
|
|
AccountLifecycleDetails,
|
|
ReviewLifecycleDetails,
|
|
SubmissionWorkflowDetails
|
|
} from '@/lib/systemActivityService';
|
|
import { getErrorMessage } from '@/lib/errorHandler';
|
|
|
|
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',
|
|
},
|
|
account_created: {
|
|
icon: UserPlus,
|
|
color: 'text-green-600',
|
|
bgColor: 'bg-green-600/10',
|
|
label: 'Account Created',
|
|
},
|
|
account_deletion_requested: {
|
|
icon: UserX,
|
|
color: 'text-orange-600',
|
|
bgColor: 'bg-orange-600/10',
|
|
label: 'Deletion Requested',
|
|
},
|
|
account_deletion_confirmed: {
|
|
icon: UserX,
|
|
color: 'text-red-600',
|
|
bgColor: 'bg-red-600/10',
|
|
label: 'Deletion Confirmed',
|
|
},
|
|
account_deletion_cancelled: {
|
|
icon: UserCheck,
|
|
color: 'text-blue-600',
|
|
bgColor: 'bg-blue-600/10',
|
|
label: 'Deletion Cancelled',
|
|
},
|
|
user_banned: {
|
|
icon: Ban,
|
|
color: 'text-red-700',
|
|
bgColor: 'bg-red-700/10',
|
|
label: 'User Banned',
|
|
},
|
|
user_unbanned: {
|
|
icon: UserCheck,
|
|
color: 'text-green-700',
|
|
bgColor: 'bg-green-700/10',
|
|
label: 'User Unbanned',
|
|
},
|
|
review_created: {
|
|
icon: MessageSquare,
|
|
color: 'text-blue-600',
|
|
bgColor: 'bg-blue-600/10',
|
|
label: 'Review Created',
|
|
},
|
|
review_deleted: {
|
|
icon: MessageSquareX,
|
|
color: 'text-red-600',
|
|
bgColor: 'bg-red-600/10',
|
|
label: 'Review Deleted',
|
|
},
|
|
submission_created: {
|
|
icon: Plus,
|
|
color: 'text-blue-500',
|
|
bgColor: 'bg-blue-500/10',
|
|
label: 'Submission Created',
|
|
},
|
|
submission_claimed: {
|
|
icon: UserCog,
|
|
color: 'text-indigo-500',
|
|
bgColor: 'bg-indigo-500/10',
|
|
label: 'Submission Claimed',
|
|
},
|
|
submission_escalated: {
|
|
icon: AlertTriangle,
|
|
color: 'text-orange-600',
|
|
bgColor: 'bg-orange-600/10',
|
|
label: 'Submission Escalated',
|
|
},
|
|
submission_reassigned: {
|
|
icon: History,
|
|
color: 'text-purple-600',
|
|
bgColor: 'bg-purple-600/10',
|
|
label: 'Submission Reassigned',
|
|
},
|
|
};
|
|
|
|
export const SystemActivityLog = forwardRef<SystemActivityLogRef, SystemActivityLogProps>(
|
|
({ limit = 50, showFilters = true }, ref): React.JSX.Element => {
|
|
const [activities, setActivities] = useState<SystemActivity[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [filterType, setFilterType] = useState<ActivityType | 'all'>('all');
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [showFiltersPanel, setShowFiltersPanel] = useState(false);
|
|
|
|
const loadActivities = async (showLoader = true): Promise<void> => {
|
|
if (showLoader) {
|
|
setIsLoading(true);
|
|
} else {
|
|
setIsRefreshing(true);
|
|
}
|
|
try {
|
|
const data = await fetchSystemActivities(limit, {
|
|
type: filterType === 'all' ? undefined : filterType,
|
|
});
|
|
setActivities(data);
|
|
} catch (error: unknown) {
|
|
// Activity load failed - display empty list
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const handleRefresh = (): void => {
|
|
void loadActivities(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadActivities();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [limit, filterType]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
refresh: loadActivities,
|
|
}));
|
|
|
|
const toggleExpanded = (id: string): void => {
|
|
setExpandedIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const clearFilters = (): void => {
|
|
setFilterType('all');
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const hasActiveFilters = filterType !== 'all' || searchQuery.length > 0;
|
|
|
|
// Filter activities based on search query
|
|
const filteredActivities = activities.filter(activity => {
|
|
if (!searchQuery) return true;
|
|
|
|
const query = searchQuery.toLowerCase();
|
|
|
|
// Search in actor username/display name
|
|
if (activity.actor?.username?.toLowerCase().includes(query)) return true;
|
|
if (activity.actor?.display_name?.toLowerCase().includes(query)) return true;
|
|
|
|
// Search in action
|
|
if (activity.action.toLowerCase().includes(query)) return true;
|
|
|
|
// Search in type
|
|
if (activity.type.toLowerCase().includes(query)) return true;
|
|
|
|
// Search in details based on activity type
|
|
const details = activity.details;
|
|
if ('entity_name' in details && details.entity_name?.toLowerCase().includes(query)) return true;
|
|
if ('target_username' in details && details.target_username?.toLowerCase().includes(query)) return true;
|
|
if ('username' in details && details.username?.toLowerCase().includes(query)) return true;
|
|
if ('submission_type' in details && details.submission_type?.toLowerCase().includes(query)) return true;
|
|
|
|
return false;
|
|
});
|
|
|
|
const renderActivityDetails = (activity: SystemActivity): React.JSX.Element => {
|
|
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.admin_audit_details && details.admin_audit_details.length > 0 && (
|
|
<div className="space-y-1 text-xs bg-muted p-2 rounded">
|
|
{details.admin_audit_details.map((detail: any) => (
|
|
<div key={detail.id} className="flex gap-2">
|
|
<strong className="text-muted-foreground min-w-[100px]">{detail.detail_key}:</strong>
|
|
<span>{detail.detail_value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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';
|
|
|
|
// Special handling for photo deletion submissions
|
|
if (details.submission_type === 'photo_delete' && details.photo_url) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className={statusColor}>
|
|
{details.status}
|
|
</Badge>
|
|
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">Photo deletion</span>
|
|
{details.entity_type && (
|
|
<span className="text-muted-foreground">
|
|
from {details.entity_type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3 items-start">
|
|
<img
|
|
src={details.photo_url}
|
|
alt={details.photo_title || details.photo_caption || 'Deleted photo'}
|
|
className="w-20 h-20 object-cover rounded border"
|
|
/>
|
|
<div className="flex-1 space-y-1">
|
|
{details.photo_title && (
|
|
<p className="text-sm font-medium">{details.photo_title}</p>
|
|
)}
|
|
{details.photo_caption && (
|
|
<p className="text-sm text-muted-foreground">{details.photo_caption}</p>
|
|
)}
|
|
{details.deletion_reason && (
|
|
<div className="flex items-start gap-2 mt-2 p-2 bg-muted rounded text-sm">
|
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
<span className="text-muted-foreground">
|
|
<strong>Reason:</strong> {details.deletion_reason}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Special handling for photo additions
|
|
if (details.submission_type === 'photo' && details.photo_url) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className={statusColor}>
|
|
{details.status}
|
|
</Badge>
|
|
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">Photo submission</span>
|
|
{details.entity_type && (
|
|
<span className="text-muted-foreground">
|
|
to {details.entity_type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="flex gap-3 items-start">
|
|
<img
|
|
src={details.photo_url}
|
|
alt={details.photo_title || details.photo_caption || 'Photo'}
|
|
className="w-20 h-20 object-cover rounded border"
|
|
/>
|
|
<div className="flex-1 space-y-1">
|
|
{details.photo_title && (
|
|
<p className="text-sm font-medium">{details.photo_title}</p>
|
|
)}
|
|
{details.photo_caption && (
|
|
<p className="text-sm text-muted-foreground">{details.photo_caption}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Generic submission display
|
|
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>
|
|
);
|
|
}
|
|
|
|
case 'account_created': {
|
|
const details = activity.details as AccountLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Badge className="bg-green-600/10 text-green-600">New Account</Badge>
|
|
{details.username && (
|
|
<span className="font-medium">@{details.username}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'account_deletion_requested': {
|
|
const details = activity.details as AccountLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className="bg-orange-600/10 text-orange-600">Deletion Requested</Badge>
|
|
{details.scheduled_date && (
|
|
<span className="text-muted-foreground text-xs">
|
|
Scheduled: {new Date(details.scheduled_date).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{isExpanded && details.request_id && (
|
|
<p className="text-xs text-muted-foreground font-mono">
|
|
Request ID: {details.request_id}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'account_deletion_confirmed': {
|
|
const details = activity.details as AccountLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className="bg-red-600/10 text-red-600">Deletion Confirmed</Badge>
|
|
{details.scheduled_date && (
|
|
<span className="text-muted-foreground text-xs">
|
|
Will be deleted: {new Date(details.scheduled_date).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 p-2 bg-red-500/5 rounded text-sm">
|
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
|
<span className="text-muted-foreground">
|
|
Account will be permanently deleted after 7 days
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'account_deletion_cancelled': {
|
|
const details = activity.details as AccountLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Badge className="bg-blue-600/10 text-blue-600">Deletion Cancelled</Badge>
|
|
</div>
|
|
{isExpanded && details.reason && (
|
|
<div className="flex items-start gap-2 p-2 bg-muted rounded text-sm">
|
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
<span className="text-muted-foreground">
|
|
<strong>Reason:</strong> {details.reason}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'user_banned': {
|
|
const details = activity.details as AccountLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Badge variant="destructive">Banned</Badge>
|
|
{details.username && (
|
|
<span className="font-medium">@{details.username}</span>
|
|
)}
|
|
</div>
|
|
{isExpanded && details.reason && (
|
|
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded text-sm">
|
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0 text-destructive" />
|
|
<span className="text-muted-foreground">
|
|
<strong>Reason:</strong> {details.reason}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'user_unbanned': {
|
|
const details = activity.details as AccountLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Badge className="bg-green-700/10 text-green-700">Unbanned</Badge>
|
|
{details.username && (
|
|
<span className="font-medium">@{details.username}</span>
|
|
)}
|
|
</div>
|
|
{isExpanded && details.reason && (
|
|
<p className="text-sm text-muted-foreground pl-4 border-l-2">
|
|
Reason: {details.reason}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'review_created': {
|
|
const details = activity.details as ReviewLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className="bg-blue-600/10 text-blue-600">New Review</Badge>
|
|
{details.rating && (
|
|
<div className="flex items-center gap-1">
|
|
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
|
<span className="font-medium">{details.rating}/5</span>
|
|
</div>
|
|
)}
|
|
{details.entity_name && (
|
|
<>
|
|
<span className="text-muted-foreground">for</span>
|
|
<span className="font-medium">{details.entity_name}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{isExpanded && details.content && (
|
|
<div className="p-3 bg-muted rounded text-sm">
|
|
<p className="text-muted-foreground line-clamp-3">
|
|
"{details.content}"
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'review_deleted': {
|
|
const details = activity.details as ReviewLifecycleDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge variant="destructive">Review Deleted</Badge>
|
|
{details.was_moderated && (
|
|
<Badge variant="outline" className="text-orange-600 border-orange-600">
|
|
Moderated
|
|
</Badge>
|
|
)}
|
|
{details.rating && (
|
|
<div className="flex items-center gap-1">
|
|
<Star className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">{details.rating}/5</span>
|
|
</div>
|
|
)}
|
|
{details.entity_name && (
|
|
<span className="text-muted-foreground">from {details.entity_name}</span>
|
|
)}
|
|
</div>
|
|
{isExpanded && details.content && (
|
|
<div className="p-3 bg-muted rounded text-sm">
|
|
<p className="text-muted-foreground line-clamp-3 opacity-60">
|
|
"{details.content}"
|
|
</p>
|
|
</div>
|
|
)}
|
|
{isExpanded && details.deletion_reason && (
|
|
<div className="flex items-start gap-2 p-2 bg-red-500/5 rounded text-sm">
|
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0 text-red-600" />
|
|
<span className="text-muted-foreground">
|
|
<strong>Reason:</strong> {details.deletion_reason}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'submission_created': {
|
|
const details = activity.details as SubmissionWorkflowDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className="bg-blue-500/10 text-blue-500">New Submission</Badge>
|
|
<span className="text-muted-foreground capitalize">{details.submission_type.replace(/_/g, ' ')}</span>
|
|
{details.username && (
|
|
<span className="font-medium">by @{details.username}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'submission_claimed': {
|
|
const details = activity.details as SubmissionWorkflowDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className="bg-indigo-500/10 text-indigo-500">Claimed</Badge>
|
|
<span className="text-muted-foreground capitalize">{details.submission_type.replace(/_/g, ' ')}</span>
|
|
{details.username && (
|
|
<span className="text-muted-foreground">by @{details.username}</span>
|
|
)}
|
|
</div>
|
|
{isExpanded && details.assigned_username && (
|
|
<div className="flex items-center gap-2 p-2 bg-muted rounded text-sm">
|
|
<UserCog className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">
|
|
Assigned to <strong>@{details.assigned_username}</strong>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'submission_escalated': {
|
|
const details = activity.details as SubmissionWorkflowDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className="bg-orange-600/10 text-orange-600">Escalated</Badge>
|
|
<span className="text-muted-foreground capitalize">{details.submission_type.replace(/_/g, ' ')}</span>
|
|
</div>
|
|
{isExpanded && details.escalation_reason && (
|
|
<div className="flex items-start gap-2 p-2 bg-orange-500/5 rounded text-sm">
|
|
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0 text-orange-600" />
|
|
<span className="text-muted-foreground">
|
|
<strong>Reason:</strong> {details.escalation_reason}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'submission_reassigned': {
|
|
const details = activity.details as SubmissionWorkflowDetails;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Badge className="bg-purple-600/10 text-purple-600">Reassigned</Badge>
|
|
<span className="text-muted-foreground capitalize">{details.submission_type.replace(/_/g, ' ')}</span>
|
|
</div>
|
|
{isExpanded && (details.from_moderator_username || details.to_moderator_username) && (
|
|
<div className="flex items-center gap-2 p-2 bg-muted rounded text-sm">
|
|
<History className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">
|
|
{details.from_moderator_username && details.to_moderator_username ? (
|
|
<>
|
|
From <strong>@{details.from_moderator_username}</strong> to <strong>@{details.to_moderator_username}</strong>
|
|
</>
|
|
) : details.to_moderator_username ? (
|
|
<>
|
|
To <strong>@{details.to_moderator_username}</strong>
|
|
</>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return <span>Unknown activity type</span>;
|
|
}
|
|
};
|
|
|
|
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 flex-col gap-4">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<CardTitle>System Activity Log</CardTitle>
|
|
<CardDescription>
|
|
Complete audit trail of all system changes and actions
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
loading={isRefreshing}
|
|
loadingText="Refreshing..."
|
|
>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Refresh
|
|
</Button>
|
|
{showFilters && (
|
|
<Button
|
|
variant={showFiltersPanel ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setShowFiltersPanel(!showFiltersPanel)}
|
|
>
|
|
<Filter className="h-4 w-4 mr-2" />
|
|
Filters
|
|
{hasActiveFilters && (
|
|
<Badge variant="secondary" className="ml-2 px-1.5 py-0.5 text-xs">
|
|
{(filterType !== 'all' ? 1 : 0) + (searchQuery ? 1 : 0)}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showFilters && showFiltersPanel && (
|
|
<div className="flex flex-col gap-3 p-4 bg-muted/50 rounded-lg border">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-medium">Filter Activities</h4>
|
|
{hasActiveFilters && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearFilters}
|
|
className="h-7 text-xs"
|
|
>
|
|
<X className="h-3 w-3 mr-1" />
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Activity Type
|
|
</label>
|
|
<Select value={filterType} onValueChange={(value) => setFilterType(value as ActivityType | 'all')}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="All types" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">
|
|
<div className="flex items-center gap-2">
|
|
All Activities
|
|
</div>
|
|
</SelectItem>
|
|
{Object.entries(activityTypeConfig).map(([key, config]) => {
|
|
const Icon = config.icon;
|
|
return (
|
|
<SelectItem key={key} value={key}>
|
|
<div className="flex items-center gap-2">
|
|
<Icon className={`h-4 w-4 ${config.color}`} />
|
|
{config.label}
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Search
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by user, entity, or action..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{hasActiveFilters && (
|
|
<div className="flex items-center gap-2 pt-2 border-t">
|
|
<span className="text-xs text-muted-foreground">Active filters:</span>
|
|
{filterType !== 'all' && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{activityTypeConfig[filterType as keyof typeof activityTypeConfig]?.label || filterType}
|
|
</Badge>
|
|
)}
|
|
{searchQuery && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Search: "{searchQuery}"
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{filteredActivities.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
{hasActiveFilters ? (
|
|
<div className="space-y-3">
|
|
<div className="flex justify-center">
|
|
<div className="p-4 bg-muted rounded-full">
|
|
<Search className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-lg mb-1">No activities found</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
Try adjusting your filters or search query
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={clearFilters}>
|
|
Clear Filters
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex justify-center">
|
|
<div className="p-4 bg-muted rounded-full">
|
|
<History className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-lg mb-1">No activities yet</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
System activities will appear here as they occur
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between mb-3 pb-3 border-b">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">
|
|
Showing {filteredActivities.length} {filteredActivities.length === 1 ? 'activity' : 'activities'}
|
|
</span>
|
|
{hasActiveFilters && (
|
|
<span className="text-xs text-muted-foreground">
|
|
(filtered from {activities.length})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setExpandedIds(new Set())}
|
|
className="h-7 text-xs"
|
|
disabled={expandedIds.size === 0}
|
|
>
|
|
Collapse All
|
|
</Button>
|
|
</div>
|
|
{filteredActivities.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';
|