323 lines
14 KiB
TypeScript
323 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Search, Filter, RefreshCw, Loader2, ChevronDown, Download, FileSpreadsheet } from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import { toast } from 'sonner';
|
|
import { adminService } from '@/services/api/adminService';
|
|
import { CustomSelect } from '@/components/ui/CustomSelect';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
|
|
type ActivityAction = 'Create' | 'Update' | 'Delete' | 'Export' | 'Failed Login';
|
|
|
|
interface MergedActivityLog {
|
|
logId: string;
|
|
auditId: string;
|
|
user: {
|
|
name: string;
|
|
avatarInitials: string;
|
|
};
|
|
action: ActivityAction;
|
|
module: string;
|
|
recordId: string;
|
|
actionDate: string;
|
|
ipAddress: string;
|
|
}
|
|
|
|
function formatAction(action: string): ActivityAction {
|
|
if (action === 'failed_login') return 'Failed Login';
|
|
const label = action.charAt(0).toUpperCase() + action.slice(1).toLowerCase();
|
|
if (label === 'Create' || label === 'Update' || label === 'Delete' || label === 'Export') return label;
|
|
return 'Update';
|
|
}
|
|
|
|
function actionBadgeClass(action: string) {
|
|
const act = action.toLowerCase();
|
|
if (act === 'create') return 'bg-success text-white';
|
|
if (act === 'update') return 'bg-primary text-white';
|
|
if (act === 'delete') return 'bg-destructive text-white';
|
|
if (act === 'failed login') return 'bg-warning text-white';
|
|
return 'bg-muted text-muted-foreground';
|
|
}
|
|
|
|
export default function ActivityLogsPage() {
|
|
const [logs, setLogs] = useState<MergedActivityLog[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [actionFilter, setActionFilter] = useState('');
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [draftAction, setDraftAction] = useState('');
|
|
const filterRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchLogs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await adminService.getAuditLogs(100);
|
|
const backendLogs: MergedActivityLog[] = res.map((item) => ({
|
|
logId: item.audit_id.substring(0, 8).toUpperCase(),
|
|
auditId: item.audit_id,
|
|
user: {
|
|
name: item.user?.name || 'System / Guest',
|
|
avatarInitials: item.user?.avatarInitials || 'SYS',
|
|
},
|
|
action: formatAction(item.action),
|
|
module: item.entity_type,
|
|
recordId: item.entity_id,
|
|
actionDate: item.created_at,
|
|
ipAddress: item.ip_address,
|
|
}));
|
|
setLogs(backendLogs);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load activity logs';
|
|
toast.error(message);
|
|
setLogs([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchLogs();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!showFilters) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!filterRef.current?.contains(event.target as Node)) setShowFilters(false);
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
}, [showFilters]);
|
|
|
|
const filteredLogs = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return logs.filter((log) => {
|
|
const matchesSearch =
|
|
!q ||
|
|
log.user.name.toLowerCase().includes(q) ||
|
|
log.module.toLowerCase().includes(q) ||
|
|
log.logId.toLowerCase().includes(q) ||
|
|
log.recordId.toLowerCase().includes(q);
|
|
const matchesAction = actionFilter ? log.action === actionFilter : true;
|
|
return matchesSearch && matchesAction;
|
|
});
|
|
}, [logs, search, actionFilter]);
|
|
|
|
const pager = useClientPagination(filteredLogs);
|
|
|
|
const filtersActive = Boolean(actionFilter);
|
|
|
|
const handleExportCSV = () => {
|
|
const headers = ['Log ID', 'User', 'Action', 'Module', 'Record ID', 'Action Date', 'IP Address'];
|
|
const rows = filteredLogs.map((log) => [
|
|
log.logId,
|
|
log.user.name,
|
|
log.action,
|
|
log.module,
|
|
log.recordId,
|
|
format(new Date(log.actionDate), 'dd MMM yyyy, hh:mm a'),
|
|
log.ipAddress,
|
|
]);
|
|
const csv = [headers, ...rows]
|
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
|
.join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = 'user-activity-logs.csv';
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-visible min-w-0';
|
|
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
|
|
const primaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
|
|
const secondaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">User Activity Logs</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
|
|
{logs.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="relative group">
|
|
<button type="button" className={secondaryButton}>
|
|
<Download className="w-3.5 h-3.5" />
|
|
Export
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
<div className="absolute right-0 top-full mt-1 z-50 w-48 crm-radius-section border border-border bg-card shadow-[0_8px_24px_rgba(16,24,40,0.12)] hidden group-hover:block p-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={handleExportCSV}
|
|
className="w-full text-left px-3 py-2 crm-radius-section text-[13px] text-[#6b7280] hover:bg-[#eef0f4] hover:text-[#3d4654] cursor-pointer flex items-center gap-1.5"
|
|
>
|
|
<FileSpreadsheet className="w-4 h-4" /> Excel (.csv)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={fetchLogs}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={dataCardShell}>
|
|
<div className="p-4">
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 min-w-0">
|
|
<div className="relative" ref={filterRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftAction(actionFilter);
|
|
setShowFilters((open) => !open);
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control border bg-card text-[13px] font-medium cursor-pointer transition-colors ${
|
|
filtersActive || showFilters
|
|
? 'border-primary text-primary bg-primary/5'
|
|
: 'border-border text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<Filter className="w-3.5 h-3.5" />
|
|
Filter
|
|
{filtersActive && <span className="w-1.5 h-1.5 rounded-full bg-primary" />}
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
{showFilters && (
|
|
<div className="absolute left-0 top-full mt-1.5 z-50 w-[240px] crm-radius-section border border-border bg-card shadow-lg p-3 space-y-3">
|
|
<div>
|
|
<label className={labelClass}>Action</label>
|
|
<CustomSelect
|
|
value={draftAction}
|
|
onChange={setDraftAction}
|
|
aria-label="Action"
|
|
options={[
|
|
{ value: '', label: 'All actions' },
|
|
{ value: 'Create', label: 'Create' },
|
|
{ value: 'Update', label: 'Update' },
|
|
{ value: 'Delete', label: 'Delete' },
|
|
{ value: 'Export', label: 'Export' },
|
|
{ value: 'Failed Login', label: 'Failed login' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftAction('');
|
|
setActionFilter('');
|
|
setShowFilters(false);
|
|
}}
|
|
className={secondaryButton}
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActionFilter(draftAction);
|
|
setShowFilters(false);
|
|
}}
|
|
className={primaryButton}
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative w-full sm:w-[280px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search logs"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full h-9 pl-8 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto border-t border-border">
|
|
<table className="crm-data-table min-w-[820px]">
|
|
<thead>
|
|
<tr className="border-b border-border bg-gray-50">
|
|
<th className="px-4 py-3 text-gray-700">Log ID</th>
|
|
<th className="px-4 py-3 text-gray-700">User</th>
|
|
<th className="px-4 py-3 text-gray-700">Action</th>
|
|
<th className="px-4 py-3 text-gray-700">Module</th>
|
|
<th className="px-4 py-3 text-gray-700">Record ID</th>
|
|
<th className="px-4 py-3 text-gray-700">Action date</th>
|
|
<th className="px-4 py-3 text-gray-700">IP address</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-12 text-center whitespace-normal">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary mx-auto" />
|
|
<p className="text-[13px] text-muted-foreground mt-2">Loading activity logs...</p>
|
|
</td>
|
|
</tr>
|
|
) : filteredLogs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-12 text-center text-[13px] text-muted-foreground whitespace-normal">
|
|
{search || actionFilter ? 'No logs match your search' : 'No activity logs recorded'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pager.items.map((log) => (
|
|
<tr key={log.auditId} className="border-b border-border hover:bg-muted/10 transition-colors">
|
|
<td className="px-4 py-3 font-mono font-semibold text-foreground">{log.logId}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-7 h-7 rounded-full bg-primary/10 text-primary text-[10px] font-bold flex items-center justify-center shrink-0">
|
|
{log.user.avatarInitials}
|
|
</div>
|
|
<span className="font-semibold text-foreground">{log.user.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-flex items-center px-2 py-0.5 crm-radius-badge text-[11px] font-semibold ${actionBadgeClass(log.action)}`}>
|
|
{log.action}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
<span className="crm-cell-clip max-w-[140px]" title={log.module}>{log.module}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground font-mono">
|
|
<span className="crm-cell-clip" title={log.recordId}>{log.recordId}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{format(new Date(log.actionDate), 'dd MMM yyyy, hh:mm a')}
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground font-mono">{log.ipAddress}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|