Dealer_Onboard_Frontend/src/features/admin/pages/SystemLogsPage.tsx

613 lines
28 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Activity,
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
Eye,
User as UserIcon,
Filter
} from 'lucide-react';
import { toast } from 'sonner';
import { API } from '@/api/API';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
type SystemAuditLog = {
id: string;
module: string;
moduleLabel: string;
action: string;
actionLabel: string;
entityType: string;
entityId: string | null;
entityLabel: string | null;
description: string;
actor: {
id: string | null;
name: string;
role: string | null;
email: string | null;
};
oldData: any;
newData: any;
metadata: any;
ipAddress: string | null;
userAgent: string | null;
timestamp: string;
};
type Summary = {
totalEntries: number;
byModule: { module: string; moduleLabel: string; total: number }[];
lastActivity: SystemAuditLog | null;
};
const MODULE_OPTIONS = [
{ value: '__all__', label: 'All Modules' },
{ value: 'QUESTIONNAIRE', label: 'Questionnaire' },
{ value: 'INTERVIEW_CONFIG', label: 'Interview Configuration' },
{ value: 'SYSTEM_CONFIG', label: 'System Configuration' },
{ value: 'SLA_CONFIG', label: 'SLA Configuration' },
{ value: 'EMAIL_TEMPLATE', label: 'Email Template' },
{ value: 'MASTER_HIERARCHY', label: 'Master Hierarchy' },
{ value: 'ROLE_ASSIGNMENT', label: 'Role Assignment' },
{ value: 'USER_ADMIN', label: 'User Administration' },
{ value: 'DEALER_MAPPING', label: 'Dealer Mapping' }
];
const ACTION_OPTIONS = [
{ value: '__all__', label: 'All Actions' },
{ value: 'CREATED', label: 'Created' },
{ value: 'UPDATED', label: 'Updated' },
{ value: 'DELETED', label: 'Deleted' },
{ value: 'ACTIVATED', label: 'Activated' },
{ value: 'DEACTIVATED', label: 'Deactivated' },
{ value: 'INITIALIZED', label: 'Initialized' },
{ value: 'SUBMITTED', label: 'Submitted' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'UNASSIGNED', label: 'Unassigned' },
{ value: 'REORDERED', label: 'Reordered' }
];
const ACTION_BADGE_CLASS: Record<string, string> = {
CREATED: 'bg-emerald-100 text-emerald-700 border-emerald-200',
UPDATED: 'bg-blue-100 text-blue-700 border-blue-200',
DELETED: 'bg-rose-100 text-rose-700 border-rose-200',
ACTIVATED: 'bg-emerald-100 text-emerald-700 border-emerald-200',
DEACTIVATED: 'bg-slate-200 text-slate-700 border-slate-300',
INITIALIZED: 'bg-amber-100 text-amber-700 border-amber-200',
SUBMITTED: 'bg-indigo-100 text-indigo-700 border-indigo-200',
ASSIGNED: 'bg-violet-100 text-violet-700 border-violet-200',
UNASSIGNED: 'bg-slate-200 text-slate-700 border-slate-300',
REORDERED: 'bg-sky-100 text-sky-700 border-sky-200'
};
const formatTimestamp = (ts: string) => {
try {
const d = new Date(ts);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch {
return ts;
}
};
const JsonBlock = ({ value }: { value: any }) => {
if (value === null || value === undefined) {
return <p className="text-xs italic text-slate-400"></p>;
}
return (
<pre className="text-xs bg-slate-900 text-slate-100 rounded-lg p-3 overflow-auto max-h-72 whitespace-pre-wrap break-words">
{JSON.stringify(value, null, 2)}
</pre>
);
};
export const SystemLogsPage: React.FC = () => {
const [logs, setLogs] = useState<SystemAuditLog[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [limit] = useState(25);
const [totalPages, setTotalPages] = useState(1);
const [totalEntries, setTotalEntries] = useState(0);
const [moduleFilter, setModuleFilter] = useState<string>('__all__');
const [actionFilter, setActionFilter] = useState<string>('__all__');
const [search, setSearch] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [appliedSearch, setAppliedSearch] = useState('');
const [selected, setSelected] = useState<SystemAuditLog | null>(null);
const queryParams = useMemo(
() => ({
module: moduleFilter !== '__all__' ? moduleFilter : undefined,
action: actionFilter !== '__all__' ? actionFilter : undefined,
search: appliedSearch || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
page,
limit
}),
[moduleFilter, actionFilter, appliedSearch, dateFrom, dateTo, page, limit]
);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const res = (await API.getSystemAuditLogs(queryParams)) as any;
const body = res?.data;
if (res?.ok && body?.success) {
setLogs(body.data || []);
setTotalPages(body.pagination?.totalPages || 1);
setTotalEntries(body.pagination?.total || 0);
} else {
toast.error(body?.message || 'Unable to load system logs');
setLogs([]);
}
} catch (err) {
console.error('[SystemLogsPage] fetchLogs error:', err);
toast.error('Failed to load system logs');
setLogs([]);
} finally {
setLoading(false);
}
}, [queryParams]);
const fetchSummary = useCallback(async () => {
try {
const res = (await API.getSystemAuditSummary()) as any;
const body = res?.data;
if (res?.ok && body?.success) {
setSummary(body.data || null);
}
} catch (err) {
console.error('[SystemLogsPage] fetchSummary error:', err);
}
}, []);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
fetchSummary();
}, [fetchSummary]);
const applySearch = () => {
setPage(1);
setAppliedSearch(search.trim());
};
const clearFilters = () => {
setModuleFilter('__all__');
setActionFilter('__all__');
setSearch('');
setAppliedSearch('');
setDateFrom('');
setDateTo('');
setPage(1);
};
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Activity className="w-6 h-6 text-re-red" />
System Activity Logs
</h1>
<p className="text-slate-500">
Configuration-level changes across questionnaires, interview configs, master hierarchy, system / SLA configs, and role assignments.
</p>
</div>
<Button
variant="outline"
onClick={() => {
fetchLogs();
fetchSummary();
}}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-bold text-slate-500 uppercase tracking-wider">
Total Events
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-slate-900">{summary?.totalEntries ?? '—'}</p>
<p className="text-[11px] text-slate-400 mt-1">Lifetime</p>
</CardContent>
</Card>
{(summary?.byModule || []).slice(0, 4).map((row) => (
<Card key={row.module} className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-bold text-slate-500 uppercase tracking-wider">
{row.moduleLabel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-slate-900">{row.total}</p>
<button
onClick={() => {
setModuleFilter(row.module);
setPage(1);
}}
className="text-[11px] text-re-red hover:underline mt-1"
>
View module
</button>
</CardContent>
</Card>
))}
</div>
{/* Filters */}
<Card className="border-slate-200">
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 items-end">
<div className="lg:col-span-2">
<label className="text-xs font-semibold text-slate-600 mb-1 block flex items-center gap-1">
<Search className="w-3 h-3" /> Search
</label>
<div className="flex gap-2">
<Input
placeholder="Entity name, description, or actor…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applySearch();
}}
/>
<Button variant="outline" onClick={applySearch}>
Go
</Button>
</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">Module</label>
<Select
value={moduleFilter}
onValueChange={(v) => {
setModuleFilter(v);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="All Modules" />
</SelectTrigger>
<SelectContent>
{MODULE_OPTIONS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">Action</label>
<Select
value={actionFilter}
onValueChange={(v) => {
setActionFilter(v);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="All Actions" />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">From</label>
<Input
type="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setPage(1);
}}
/>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">To</label>
<Input
type="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setPage(1);
}}
/>
</div>
</div>
<div className="flex items-center justify-between mt-4">
<div className="text-xs text-slate-500 flex items-center gap-2">
<Filter className="w-3 h-3" />
Showing {logs.length} of {totalEntries} matching event(s)
</div>
<Button variant="ghost" size="sm" onClick={clearFilters}>
Reset filters
</Button>
</div>
</CardContent>
</Card>
{/* Logs table */}
<Card className="border-slate-200 overflow-hidden">
<div className="overflow-x-auto custom-scrollbar-x-slim">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr className="text-left">
<th className="px-4 py-3 font-semibold">When</th>
<th className="px-4 py-3 font-semibold">Module</th>
<th className="px-4 py-3 font-semibold">Action</th>
<th className="px-4 py-3 font-semibold">Entity</th>
<th className="px-4 py-3 font-semibold">Actor</th>
<th className="px-4 py-3 font-semibold">Description</th>
<th className="px-4 py-3 font-semibold w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-400">
<RefreshCw className="w-5 h-5 inline-block mr-2 animate-spin" />
Loading system logs
</td>
</tr>
)}
{!loading && logs.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-400">
No events match the current filters.
</td>
</tr>
)}
{!loading &&
logs.map((log) => (
<tr
key={log.id}
className="hover:bg-slate-50/70 cursor-pointer"
onClick={() => setSelected(log)}
>
<td className="px-4 py-3 whitespace-nowrap text-slate-700">
{formatTimestamp(log.timestamp)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge variant="outline" className="border-slate-300 text-slate-700">
{log.moduleLabel}
</Badge>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge
variant="outline"
className={
ACTION_BADGE_CLASS[log.action] ||
'bg-slate-100 text-slate-700 border-slate-200'
}
>
{log.actionLabel}
</Badge>
</td>
<td className="px-4 py-3 text-slate-800">
<div className="font-medium truncate max-w-[260px]" title={log.entityLabel || ''}>
{log.entityLabel || `${log.entityType}`}
</div>
{log.entityId && (
<div className="text-[10px] font-mono text-slate-400 truncate max-w-[260px]">
{log.entityId}
</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-slate-100 text-slate-600 flex items-center justify-center text-xs font-semibold">
{(log.actor?.name || 'S').charAt(0).toUpperCase()}
</div>
<div>
<div className="text-slate-800 leading-tight">{log.actor?.name || 'System'}</div>
{log.actor?.role && (
<div className="text-[10px] text-slate-500 uppercase tracking-wider">
{log.actor.role}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-slate-600">
<div className="truncate max-w-[360px]" title={log.description}>
{log.description}
</div>
</td>
<td className="px-4 py-3">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
setSelected(log);
}}
title="View details"
>
<Eye className="w-4 h-4 text-slate-500" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100 bg-slate-50/50">
<div className="text-xs text-slate-500">
Page {page} of {totalPages}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={loading || page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Prev
</Button>
<Button
size="sm"
variant="outline"
disabled={loading || page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
</Card>
{/* Detail dialog */}
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
{selected && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-re-red" />
{selected.moduleLabel} · {selected.actionLabel}
</DialogTitle>
<DialogDescription className="text-slate-600">
{selected.description}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Entity
</p>
<p className="text-slate-800">{selected.entityLabel || selected.entityType}</p>
{selected.entityId && (
<p className="text-[11px] font-mono text-slate-400 mt-0.5">
{selected.entityId}
</p>
)}
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Actor
</p>
<p className="text-slate-800 flex items-center gap-2">
<UserIcon className="w-4 h-4 text-slate-400" />
{selected.actor?.name || 'System'}
</p>
{selected.actor?.role && (
<p className="text-[11px] text-slate-500 uppercase tracking-wider mt-0.5">
{selected.actor.role}
</p>
)}
{selected.actor?.email && (
<p className="text-[11px] text-slate-500 mt-0.5">
{selected.actor.email}
</p>
)}
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
When
</p>
<p className="text-slate-800">{formatTimestamp(selected.timestamp)}</p>
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Source
</p>
<p className="text-slate-800">{selected.ipAddress || '—'}</p>
<p className="text-[11px] text-slate-500 truncate" title={selected.userAgent || ''}>
{selected.userAgent || ''}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Previous values
</p>
<JsonBlock value={selected.oldData} />
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
New values
</p>
<JsonBlock value={selected.newData} />
</div>
</div>
{selected.metadata && (
<div className="mt-4">
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Metadata
</p>
<JsonBlock value={selected.metadata} />
</div>
)}
</>
)}
</DialogContent>
</Dialog>
</div>
);
};
export default SystemLogsPage;