From 2525cce03ec5e98555a83c2b6a624a55dd134f12 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:24:49 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20AuditLog=20panel=20component=20?= =?UTF-8?q?=E2=80=94=20filterable,=20paginated=20audit=20trail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/AuditLog.jsx | 200 +++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 client/src/components/AuditLog.jsx diff --git a/client/src/components/AuditLog.jsx b/client/src/components/AuditLog.jsx new file mode 100644 index 0000000..2e09af3 --- /dev/null +++ b/client/src/components/AuditLog.jsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; + +const ACTION_COLORS = { + employee_created: '#667eea', + employee_edited: '#9b8af8', + employee_merged: '#f0a500', + violation_created: '#28a745', + violation_amended: '#4db6ac', + violation_negated: '#ffc107', + violation_restored:'#17a2b8', + violation_deleted: '#dc3545', +}; + +const ACTION_LABELS = { + employee_created: 'Employee Created', + employee_edited: 'Employee Edited', + employee_merged: 'Employee Merged', + violation_created: 'Violation Logged', + violation_amended: 'Violation Amended', + violation_negated: 'Violation Negated', + violation_restored:'Violation Restored', + violation_deleted: 'Violation Deleted', +}; + +const ENTITY_LABELS = { + employee: 'Employee', + violation: 'Violation', +}; + +const s = { + overlay: { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', + zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end', + }, + panel: { + background: '#111217', color: '#f8f9fa', width: '680px', maxWidth: '95vw', + height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.7)', + display: 'flex', flexDirection: 'column', + }, + header: { + background: 'linear-gradient(135deg, #000000, #151622)', color: 'white', + padding: '22px 26px', position: 'sticky', top: 0, zIndex: 10, + borderBottom: '1px solid #222', + }, + headerRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, + title: { fontSize: '17px', fontWeight: 700 }, + subtitle: { fontSize: '12px', color: '#9ca0b8', marginTop: '3px' }, + closeBtn: { + background: 'none', border: 'none', color: 'white', fontSize: '22px', + cursor: 'pointer', lineHeight: 1, + }, + filters: { + padding: '14px 26px', borderBottom: '1px solid #1c1d29', + display: 'flex', gap: '10px', flexWrap: 'wrap', + }, + select: { + background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px', + color: '#f8f9fa', padding: '7px 12px', fontSize: '12px', outline: 'none', + }, + body: { padding: '16px 26px', flex: 1 }, + entry: { + borderBottom: '1px solid #1c1d29', padding: '12px 0', + display: 'flex', gap: '12px', alignItems: 'flex-start', + }, + dot: (action) => ({ + width: '8px', height: '8px', borderRadius: '50%', marginTop: '5px', flexShrink: 0, + background: ACTION_COLORS[action] || '#555', + }), + entryMain: { flex: 1, minWidth: 0 }, + actionBadge: (action) => ({ + display: 'inline-block', padding: '2px 8px', borderRadius: '10px', + fontSize: '10px', fontWeight: 700, letterSpacing: '0.3px', marginRight: '6px', + background: (ACTION_COLORS[action] || '#555') + '22', + color: ACTION_COLORS[action] || '#aaa', + border: `1px solid ${(ACTION_COLORS[action] || '#555')}44`, + }), + entityRef: { fontSize: '11px', color: '#9ca0b8' }, + details: { fontSize: '11px', color: '#667', marginTop: '4px', fontFamily: 'monospace', wordBreak: 'break-all' }, + meta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' }, + empty: { textAlign: 'center', color: '#555a7a', padding: '60px 0', fontSize: '13px' }, + loadMore: { + width: '100%', background: 'none', border: '1px solid #2a2b3a', borderRadius: '6px', + color: '#9ca0b8', padding: '10px', cursor: 'pointer', fontSize: '12px', marginTop: '16px', + }, +}; + +function fmtDt(iso) { + if (!iso) return '—'; + return new Date(iso).toLocaleString('en-US', { + timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short', + }); +} + +function renderDetails(detailsStr) { + if (!detailsStr) return null; + try { + const obj = JSON.parse(detailsStr); + return JSON.stringify(obj, null, 0) + .replace(/^\{/, '').replace(/\}$/, '').replace(/","/g, ' '); + } catch { + return detailsStr; + } +} + +export default function AuditLog({ onClose }) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [filterType, setFilterType] = useState(''); + const [filterAction, setFilterAction] = useState(''); + const LIMIT = 50; + + const load = useCallback((reset = false) => { + setLoading(true); + const o = reset ? 0 : offset; + const params = { limit: LIMIT, offset: o }; + if (filterType) params.entity_type = filterType; + if (filterAction) params.action = filterAction; // future: server-side action filter + axios.get('/api/audit', { params }) + .then(r => { + const data = r.data; + // Client-side action filter (cheap enough at this scale) + const filtered = filterAction ? data.filter(e => e.action === filterAction) : data; + setEntries(prev => reset ? filtered : [...prev, ...filtered]); + setHasMore(data.length === LIMIT); + setOffset(o + LIMIT); + }) + .finally(() => setLoading(false)); + }, [offset, filterType, filterAction]); + + useEffect(() => { load(true); }, [filterType, filterAction]); // eslint-disable-line + + const handleOverlay = e => { if (e.target === e.currentTarget) onClose(); }; + + return ( +
+
e.stopPropagation()}> +
+
+
+
Audit Log
+
All system write actions — append-only
+
+ +
+
+ +
+ + +
+ +
+ {loading && entries.length === 0 ? ( +
Loading…
+ ) : entries.length === 0 ? ( +
No audit entries found.
+ ) : ( + entries.map(e => ( +
+
+
+
+ + {ACTION_LABELS[e.action] || e.action} + + + {ENTITY_LABELS[e.entity_type] || e.entity_type} + {e.entity_id ? ` #${e.entity_id}` : ''} + +
+ {e.details && ( +
{renderDetails(e.details)}
+ )} +
+ {e.performed_by ? `by ${e.performed_by} · ` : ''}{fmtDt(e.created_at)} +
+
+
+ )) + )} + + {hasMore && ( + + )} +
+
+
+ ); +}