342 lines
17 KiB
TypeScript
342 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { db, Item } from '@/lib/db';
|
|
import { inventoryApi } from '@/lib/api';
|
|
import PageShell from '@/components/PageShell';
|
|
import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User, RefreshCw } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { fetchAndCacheItems } from '@/lib/sync';
|
|
import StatCard from '@/components/StatCard';
|
|
|
|
export default function LogsPage() {
|
|
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
|
const [inventory, setInventory] = useState<Item[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterAction, setFilterAction] = useState('ALL');
|
|
const [selectedLog, setSelectedLog] = useState<any | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
// 1. First, try to get fresh items to resolve names
|
|
let freshItems: Item[] = [];
|
|
try {
|
|
freshItems = await fetchAndCacheItems();
|
|
} catch (itemErr) {
|
|
console.warn("Item sync failed, using local cache for names", itemErr);
|
|
freshItems = await db.items.toArray();
|
|
}
|
|
setInventory(freshItems);
|
|
|
|
// 2. Then, fetch fresh logs
|
|
const logs = await inventoryApi.getAuditLogs(100);
|
|
|
|
// 3. Pre-resolve names to avoid UI flickering/mismatches
|
|
const enrichedLogs = (logs || []).map((log: any) => {
|
|
// [AUDIT HARDENING] Prioritize the historical snapshot from the backend
|
|
if (log.target_item_name) {
|
|
return { ...log, resolved_name: log.target_item_name };
|
|
}
|
|
|
|
// Fallback for legacy logs or system operations
|
|
const hasTarget = log.target_item_id && String(log.target_item_id) !== 'null';
|
|
const item = hasTarget ? freshItems.find(i => String(i.id) === String(log.target_item_id)) : null;
|
|
|
|
return {
|
|
...log,
|
|
resolved_name: item ? item.name : (hasTarget ? `Item #${log.target_item_id}` : "System Operation")
|
|
};
|
|
});
|
|
|
|
setAuditLogs(enrichedLogs);
|
|
} catch (err: any) {
|
|
console.error("Critical log load failure:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredLogs = auditLogs.filter(log => {
|
|
const matchesSearch = (log.resolved_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
log.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(log.username || '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
|
|
const matchesAction = filterAction === 'ALL' || log.action.includes(filterAction);
|
|
|
|
return matchesSearch && matchesAction;
|
|
});
|
|
|
|
// Calculate stats
|
|
const totalCount = auditLogs.length;
|
|
const inCount = auditLogs.filter(l => l.action.includes('IN') || l.action.includes('ADD')).length;
|
|
const outCount = auditLogs.filter(l => l.action.includes('OUT') || l.action.includes('TRASH') || l.action.includes('DELETE')).length;
|
|
const criticalLogsCount = auditLogs.filter(l => l.action.includes('DELETE') || l.action.includes('TRASH')).length;
|
|
|
|
const mostActiveUser = auditLogs.length > 0 ?
|
|
Object.entries(auditLogs.reduce((acc: any, curr) => {
|
|
const user = curr.username || 'System';
|
|
acc[user] = (acc[user] || 0) + 1;
|
|
return acc;
|
|
}, {})).sort((a: any, b: any) => b[1] - a[1])[0]?.[0] : 'N/A';
|
|
|
|
return (
|
|
<PageShell>
|
|
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-6 md:space-y-10 mb-20">
|
|
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 md:p-4 bg-primary/10 rounded-2xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
|
<History size={28} className="md:w-8 md:h-8" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Operations Audit</h1>
|
|
<p className="text-[10px] md:text-xs text-slate-500 font-bold uppercase tracking-widest mt-0.5">Real-time Intervention Tracking</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 ml-auto sm:ml-0">
|
|
<button
|
|
onClick={loadData}
|
|
disabled={loading}
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-[10px] font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl"
|
|
>
|
|
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
|
|
Sync Logs
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Stats Grid */}
|
|
<section className="grid grid-cols-2 lg:grid-cols-4 gap-2.5 md:gap-4">
|
|
<StatCard
|
|
label="Total Events"
|
|
value={totalCount}
|
|
icon={Activity}
|
|
/>
|
|
<StatCard
|
|
label="Inbound"
|
|
value={inCount}
|
|
icon={ArrowDownCircle}
|
|
/>
|
|
<StatCard
|
|
label="Outbound"
|
|
value={outCount}
|
|
icon={ArrowUpCircle}
|
|
/>
|
|
<StatCard
|
|
label="Integrity"
|
|
value={totalCount - criticalLogsCount}
|
|
icon={History}
|
|
/>
|
|
</section>
|
|
|
|
<section className="space-y-6">
|
|
<div className="relative group">
|
|
<input
|
|
type="text"
|
|
placeholder="Filter by user, action or asset..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full bg-slate-900/40 backdrop-blur-xl border border-slate-800 rounded-2xl py-3.5 pr-4 pl-12 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600 shadow-2xl"
|
|
/>
|
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600 transition-colors group-focus-within:text-primary">
|
|
<Search size={18} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-1 px-1 -mx-1">
|
|
<button
|
|
onClick={() => setFilterAction('ALL')}
|
|
className={cn(
|
|
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
|
filterAction === 'ALL'
|
|
? "bg-white text-slate-950 border-white shadow-xl shadow-white/10"
|
|
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
|
|
)}
|
|
>
|
|
All Streams
|
|
</button>
|
|
{['ADD', 'REMOVE', 'ADJUST', 'DELETE', 'LOGIN'].map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilterAction(f)}
|
|
className={cn(
|
|
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
|
filterAction === f
|
|
? "bg-primary text-white border-primary shadow-xl shadow-primary/10"
|
|
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
|
|
)}
|
|
>
|
|
{f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center py-32 text-slate-600 gap-4 animate-pulse">
|
|
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
|
<p className="text-[10px] font-black tracking-widest uppercase italic">Securing Audit Stream...</p>
|
|
</div>
|
|
) : filteredLogs.length === 0 ? (
|
|
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[2.5rem] py-20 flex flex-col items-center justify-center text-center gap-6">
|
|
<div className="w-16 h-16 bg-slate-900 rounded-2xl flex items-center justify-center text-slate-700 border border-slate-800">
|
|
<Search size={32} />
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-black text-slate-300 tracking-tight">No events found</p>
|
|
<p className="text-xs text-slate-600 font-bold mt-1">Refine your strategic filters</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-2.5">
|
|
{filteredLogs.map((log) => (
|
|
<button
|
|
key={log.id}
|
|
onClick={() => setSelectedLog(log)}
|
|
className="w-full text-left bg-slate-900/40 backdrop-blur-md border border-slate-800/30 p-3 px-4 rounded-2xl flex items-center justify-between gap-4 hover:bg-slate-800/40 hover:border-primary/30 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
|
|
>
|
|
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
|
{/* Compact Action Badge */}
|
|
<div className={cn(
|
|
"text-[8px] font-black px-2 py-1 rounded-lg border min-w-[75px] text-center uppercase tracking-tighter",
|
|
log.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/20" :
|
|
(log.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/20" :
|
|
(log.action.includes('DB') ? "bg-sky-500/10 text-sky-400 border-sky-500/20" :
|
|
(log.action.includes('DELETE') ? "bg-red-500/10 text-red-500 border-red-500/30" :
|
|
(log.action.includes('CREATE') ? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" : "bg-primary/10 text-primary border-primary/20"))))
|
|
)}>
|
|
{log.action.replace('_', ' ')}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-sm font-bold text-slate-100 group-hover:text-primary transition-colors truncate">
|
|
{log.resolved_name}
|
|
</h3>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className="text-[9px] font-black text-slate-500 uppercase tracking-tighter shrink-0">{log.username || 'System'}</span>
|
|
<span className="w-0.5 h-0.5 rounded-full bg-slate-800 shrink-0" />
|
|
<span className="text-[9px] text-slate-600 font-bold tabular-nums truncate">
|
|
{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · {new Date(log.timestamp).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 flex items-center gap-3 z-10">
|
|
<div className={cn(
|
|
"text-lg font-black tabular-nums min-w-[35px] text-right",
|
|
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-primary/50")
|
|
)}>
|
|
{log.quantity_change ? (log.quantity_change > 0 ? `+${log.quantity_change}` : log.quantity_change) : (log.action.includes('DB') ? 'SYS' : '±')}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Selected Log Modal */}
|
|
{selectedLog && (
|
|
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
|
|
<div className="bg-slate-900 border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[3rem] p-6 sm:p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in slide-in-from-bottom-10 duration-300 overflow-hidden">
|
|
<div className="flex justify-between items-start">
|
|
<div className="space-y-1 pr-4">
|
|
<div className={cn(
|
|
"text-[9px] font-black px-3 py-1 rounded-full border inline-block uppercase tracking-widest",
|
|
selectedLog.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/30" :
|
|
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-primary/10 text-primary border-primary/30")
|
|
)}>
|
|
{selectedLog.action}
|
|
</div>
|
|
<h2 className="text-2xl font-black text-white tracking-tight pt-2 leading-tight">
|
|
{selectedLog.resolved_name}
|
|
</h2>
|
|
</div>
|
|
<button onClick={() => setSelectedLog(null)} className="p-3 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800 shrink-0 shadow-lg">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
|
|
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Protocol Operator</p>
|
|
<p className="text-sm font-black text-slate-200">{selectedLog.username || 'Automated Process'}</p>
|
|
</div>
|
|
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
|
|
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Quantity Delta</p>
|
|
<p className={cn(
|
|
"text-xl font-black tabular-nums",
|
|
(selectedLog.quantity_change || 0) > 0 ? "text-green-500" : "text-rose-500"
|
|
)}>
|
|
{selectedLog.quantity_change
|
|
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change}`
|
|
: (selectedLog.action.includes('DB') ? 'SYS' : '±')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-5">
|
|
<div className="space-y-1">
|
|
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Universal Timestamp</p>
|
|
<p className="text-xs font-bold text-slate-400 bg-slate-950/30 p-4 rounded-2xl border border-slate-800/30 tabular-nums">
|
|
{new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })}
|
|
</p>
|
|
</div>
|
|
|
|
{selectedLog.target_snapshot && (() => {
|
|
try {
|
|
const snap = JSON.parse(selectedLog.target_snapshot) as Record<string, any>;
|
|
return (
|
|
<div className="space-y-4 pt-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-px flex-1 bg-slate-800/50" />
|
|
<p className="text-[8px] font-black text-slate-700 uppercase tracking-[0.2em]">Snapshot Record</p>
|
|
<div className="h-px flex-1 bg-slate-800/50" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
{Object.entries(snap).map(([key, val]) => (
|
|
(val && key !== 'image_url' && key !== 'id') ? (
|
|
<div key={key} className="bg-slate-950/20 p-3 rounded-xl border border-slate-800/20">
|
|
<p className="text-[7px] font-black text-slate-600 uppercase mb-1 tracking-tighter opacity-70">{key.replace('_', ' ')}</p>
|
|
<p className="text-[10px] font-bold text-slate-400 truncate" title={String(val)}>{String(val)}</p>
|
|
</div>
|
|
) : null
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
} catch (e) { return null; }
|
|
})()}
|
|
|
|
{selectedLog.details && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Intervention Details</p>
|
|
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-xs font-bold leading-relaxed italic shadow-inner">
|
|
"{selectedLog.details}"
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setSelectedLog(null)}
|
|
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-4.5 rounded-2xl transition-all active:scale-95 border border-slate-700 shadow-xl"
|
|
>
|
|
Close Audit Insight
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</PageShell>
|
|
);
|
|
}
|