258 lines
7.3 KiB
TypeScript
258 lines
7.3 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useEffect, useCallback } from 'react';
|
||
|
|
import { type ColumnDef } from '@tanstack/react-table';
|
||
|
|
import { formatDistanceToNow } from 'date-fns';
|
||
|
|
import { Search } from 'lucide-react';
|
||
|
|
|
||
|
|
import { DataTable } from '@/components/shared/data-table';
|
||
|
|
import { PageHeader } from '@/components/shared/page-header';
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select';
|
||
|
|
import { apiFetch } from '@/lib/api/client';
|
||
|
|
|
||
|
|
interface AuditEntry {
|
||
|
|
id: string;
|
||
|
|
userId: string | null;
|
||
|
|
action: string;
|
||
|
|
entityType: string;
|
||
|
|
entityId: string;
|
||
|
|
fieldChanged: string | null;
|
||
|
|
oldValue: Record<string, unknown> | null;
|
||
|
|
newValue: Record<string, unknown> | null;
|
||
|
|
metadata: Record<string, unknown> | null;
|
||
|
|
ipAddress: string | null;
|
||
|
|
createdAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ACTION_COLORS: Record<string, string> = {
|
||
|
|
create: 'bg-green-600',
|
||
|
|
update: 'bg-blue-500',
|
||
|
|
delete: 'bg-red-600',
|
||
|
|
archive: 'bg-orange-500',
|
||
|
|
restore: 'bg-teal-500',
|
||
|
|
login: 'bg-gray-500',
|
||
|
|
permission_denied: 'bg-red-800',
|
||
|
|
};
|
||
|
|
|
||
|
|
const ENTITY_TYPES = [
|
||
|
|
'client',
|
||
|
|
'interest',
|
||
|
|
'berth',
|
||
|
|
'document',
|
||
|
|
'expense',
|
||
|
|
'invoice',
|
||
|
|
'reminder',
|
||
|
|
'user',
|
||
|
|
'role',
|
||
|
|
'port',
|
||
|
|
'setting',
|
||
|
|
'tag',
|
||
|
|
'webhook',
|
||
|
|
];
|
||
|
|
|
||
|
|
export function AuditLogList() {
|
||
|
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [total, setTotal] = useState(0);
|
||
|
|
const [page, setPage] = useState(1);
|
||
|
|
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
||
|
|
const [actionFilter, setActionFilter] = useState<string>('all');
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
|
||
|
|
const fetchLogs = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
page: String(page),
|
||
|
|
limit: '50',
|
||
|
|
});
|
||
|
|
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
|
||
|
|
if (actionFilter !== 'all') params.set('action', actionFilter);
|
||
|
|
if (search) params.set('search', search);
|
||
|
|
|
||
|
|
const res = await apiFetch<{
|
||
|
|
data: AuditEntry[];
|
||
|
|
pagination: { total: number };
|
||
|
|
}>(`/api/v1/admin/audit?${params}`);
|
||
|
|
setEntries(res.data);
|
||
|
|
setTotal(res.pagination.total);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, [page, entityTypeFilter, actionFilter, search]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void fetchLogs();
|
||
|
|
}, [fetchLogs]);
|
||
|
|
|
||
|
|
const columns: ColumnDef<AuditEntry, unknown>[] = [
|
||
|
|
{
|
||
|
|
accessorKey: 'createdAt',
|
||
|
|
header: 'Time',
|
||
|
|
cell: ({ row }) => (
|
||
|
|
<div className="text-sm">
|
||
|
|
<div>{new Date(row.original.createdAt).toLocaleString()}</div>
|
||
|
|
<div className="text-xs text-muted-foreground">
|
||
|
|
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
size: 180,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
accessorKey: 'action',
|
||
|
|
header: 'Action',
|
||
|
|
cell: ({ row }) => (
|
||
|
|
<Badge
|
||
|
|
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
||
|
|
>
|
||
|
|
{row.original.action}
|
||
|
|
</Badge>
|
||
|
|
),
|
||
|
|
size: 100,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
accessorKey: 'entityType',
|
||
|
|
header: 'Entity',
|
||
|
|
cell: ({ row }) => (
|
||
|
|
<div>
|
||
|
|
<span className="font-medium capitalize">{row.original.entityType}</span>
|
||
|
|
<code className="ml-2 text-xs text-muted-foreground">
|
||
|
|
{row.original.entityId.slice(0, 8)}...
|
||
|
|
</code>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'changes',
|
||
|
|
header: 'Changes',
|
||
|
|
cell: ({ row }) => {
|
||
|
|
const { newValue, fieldChanged } = row.original;
|
||
|
|
if (fieldChanged) return <span className="text-sm">{fieldChanged}</span>;
|
||
|
|
if (newValue) {
|
||
|
|
const keys = Object.keys(newValue);
|
||
|
|
return (
|
||
|
|
<span className="text-xs text-muted-foreground">
|
||
|
|
{keys.slice(0, 3).join(', ')}
|
||
|
|
{keys.length > 3 ? ` +${keys.length - 3} more` : ''}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return <span className="text-xs text-muted-foreground">—</span>;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
accessorKey: 'userId',
|
||
|
|
header: 'User',
|
||
|
|
cell: ({ row }) => (
|
||
|
|
<code className="text-xs">
|
||
|
|
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
|
||
|
|
</code>
|
||
|
|
),
|
||
|
|
size: 100,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<PageHeader title="Audit Log" description={`${total} entries`} />
|
||
|
|
|
||
|
|
<div className="flex items-center gap-3 mb-4">
|
||
|
|
<div className="relative flex-1 max-w-sm">
|
||
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
className="pl-9"
|
||
|
|
placeholder="Search..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => {
|
||
|
|
setSearch(e.target.value);
|
||
|
|
setPage(1);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Select
|
||
|
|
value={entityTypeFilter}
|
||
|
|
onValueChange={(v) => {
|
||
|
|
setEntityTypeFilter(v);
|
||
|
|
setPage(1);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="w-36">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="all">All Entities</SelectItem>
|
||
|
|
{ENTITY_TYPES.map((t) => (
|
||
|
|
<SelectItem key={t} value={t}>
|
||
|
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<Select
|
||
|
|
value={actionFilter}
|
||
|
|
onValueChange={(v) => {
|
||
|
|
setActionFilter(v);
|
||
|
|
setPage(1);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="w-36">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="all">All Actions</SelectItem>
|
||
|
|
<SelectItem value="create">Create</SelectItem>
|
||
|
|
<SelectItem value="update">Update</SelectItem>
|
||
|
|
<SelectItem value="delete">Delete</SelectItem>
|
||
|
|
<SelectItem value="archive">Archive</SelectItem>
|
||
|
|
<SelectItem value="restore">Restore</SelectItem>
|
||
|
|
<SelectItem value="permission_denied">Permission Denied</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DataTable
|
||
|
|
columns={columns}
|
||
|
|
data={entries}
|
||
|
|
isLoading={loading}
|
||
|
|
getRowId={(row) => row.id}
|
||
|
|
emptyState={
|
||
|
|
<div className="text-center py-8">
|
||
|
|
<p className="text-muted-foreground">No audit log entries found.</p>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{total > 50 && (
|
||
|
|
<div className="flex items-center justify-center gap-2 mt-4">
|
||
|
|
<button
|
||
|
|
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||
|
|
disabled={page <= 1}
|
||
|
|
onClick={() => setPage((p) => p - 1)}
|
||
|
|
>
|
||
|
|
Previous
|
||
|
|
</button>
|
||
|
|
<span className="text-sm text-muted-foreground">
|
||
|
|
Page {page} of {Math.ceil(total / 50)}
|
||
|
|
</span>
|
||
|
|
<button
|
||
|
|
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||
|
|
disabled={page >= Math.ceil(total / 50)}
|
||
|
|
onClick={() => setPage((p) => p + 1)}
|
||
|
|
>
|
||
|
|
Next
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|