'use client'; import { useEffect, useState, useCallback, useMemo } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { formatDistanceToNow } from 'date-fns'; import { Search, X } 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 { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; 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 | null; fieldChanged: string | null; oldValue: Record | null; newValue: Record | null; metadata: Record | null; ipAddress: string | null; createdAt: string; actor: { id: string; email: string; name: string } | null; } interface AuditResponse { data: AuditEntry[]; pagination: { nextCursor: { createdAt: string; id: string } | null }; } const ACTION_COLORS: Record = { 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', merge: 'bg-purple-500', revert: 'bg-amber-500', }; const ENTITY_TYPES = [ 'client', 'interest', 'berth', 'document', 'expense', 'invoice', 'reminder', 'user', 'role', 'port', 'setting', 'tag', 'webhook', ]; function useDebounced(value: T, ms = 300): T { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; } export function AuditLogList() { const [entries, setEntries] = useState([]); const [nextCursor, setNextCursor] = useState<{ createdAt: string; id: string; } | null>(null); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); // Filter state — debounce text inputs. const [search, setSearch] = useState(''); const [entityType, setEntityType] = useState('all'); const [action, setAction] = useState('all'); const [userId, setUserId] = useState(''); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const debouncedSearch = useDebounced(search); const debouncedUserId = useDebounced(userId); const queryString = useMemo(() => { const params = new URLSearchParams({ limit: '50' }); if (entityType !== 'all') params.set('entityType', entityType); if (action !== 'all') params.set('action', action); if (debouncedSearch) params.set('search', debouncedSearch); if (debouncedUserId) params.set('userId', debouncedUserId); if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString()); if (dateTo) { const end = new Date(dateTo); end.setHours(23, 59, 59, 999); params.set('dateTo', end.toISOString()); } return params.toString(); }, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]); const fetchFirstPage = useCallback(async () => { setLoading(true); try { const res = await apiFetch(`/api/v1/admin/audit?${queryString}`); setEntries(res.data); setNextCursor(res.pagination.nextCursor); } finally { setLoading(false); } }, [queryString]); const loadMore = useCallback(async () => { if (!nextCursor) return; setLoadingMore(true); try { const params = new URLSearchParams(queryString); params.set('cursorAt', nextCursor.createdAt); params.set('cursorId', nextCursor.id); const res = await apiFetch(`/api/v1/admin/audit?${params}`); setEntries((prev) => [...prev, ...res.data]); setNextCursor(res.pagination.nextCursor); } finally { setLoadingMore(false); } }, [queryString, nextCursor]); useEffect(() => { void fetchFirstPage(); }, [fetchFirstPage]); function clearFilters() { setSearch(''); setEntityType('all'); setAction('all'); setUserId(''); setDateFrom(''); setDateTo(''); } const hasActiveFilter = Boolean(search) || entityType !== 'all' || action !== 'all' || Boolean(userId) || Boolean(dateFrom) || Boolean(dateTo); const columns: ColumnDef[] = [ { accessorKey: 'createdAt', header: 'Time', cell: ({ row }) => (
{new Date(row.original.createdAt).toLocaleString()}
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
), size: 180, }, { accessorKey: 'action', header: 'Action', cell: ({ row }) => ( {row.original.action} ), size: 110, }, { accessorKey: 'entityType', header: 'Entity', cell: ({ row }) => (
{row.original.entityType} {row.original.entityId ? ( {row.original.entityId.slice(0, 8)}… ) : null}
), }, { id: 'changes', header: 'Changes', cell: ({ row }) => { const { newValue, fieldChanged } = row.original; if (fieldChanged) return {fieldChanged}; if (newValue) { const keys = Object.keys(newValue); return ( {keys.slice(0, 3).join(', ')} {keys.length > 3 ? ` +${keys.length - 3} more` : ''} ); } return ; }, }, { id: 'actor', header: 'Actor', cell: ({ row }) => { const { actor, userId: rawId } = row.original; if (actor) { return (
{actor.name}
{actor.email}
); } if (rawId) { return {rawId.slice(0, 8)}…; } return system; }, size: 200, }, ]; return (
setSearch(e.target.value)} data-testid="audit-search" />
setUserId(e.target.value)} />
setDateFrom(e.target.value)} />
setDateTo(e.target.value)} />
{hasActiveFilter ? ( ) : null}
row.id} emptyState={

No audit log entries found.

} />
{nextCursor ? (
) : null}
); }