ifixkart-admin/app/(admin)/activity-logs/page.tsx

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>
);
}