roadmap #23
200
client/src/components/AuditLog.jsx
Normal file
200
client/src/components/AuditLog.jsx
Normal file
@@ -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 (
|
||||
<div style={s.overlay} onClick={handleOverlay}>
|
||||
<div style={s.panel} onClick={e => e.stopPropagation()}>
|
||||
<div style={s.header}>
|
||||
<div style={s.headerRow}>
|
||||
<div>
|
||||
<div style={s.title}>Audit Log</div>
|
||||
<div style={s.subtitle}>All system write actions — append-only</div>
|
||||
</div>
|
||||
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={s.filters}>
|
||||
<select style={s.select} value={filterType} onChange={e => { setFilterType(e.target.value); setOffset(0); }}>
|
||||
<option value="">All entity types</option>
|
||||
{Object.entries(ENTITY_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
<select style={s.select} value={filterAction} onChange={e => { setFilterAction(e.target.value); setOffset(0); }}>
|
||||
<option value="">All actions</option>
|
||||
{Object.entries(ACTION_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={s.body}>
|
||||
{loading && entries.length === 0 ? (
|
||||
<div style={s.empty}>Loading…</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div style={s.empty}>No audit entries found.</div>
|
||||
) : (
|
||||
entries.map(e => (
|
||||
<div key={e.id} style={s.entry}>
|
||||
<div style={s.dot(e.action)} />
|
||||
<div style={s.entryMain}>
|
||||
<div>
|
||||
<span style={s.actionBadge(e.action)}>
|
||||
{ACTION_LABELS[e.action] || e.action}
|
||||
</span>
|
||||
<span style={s.entityRef}>
|
||||
{ENTITY_LABELS[e.entity_type] || e.entity_type}
|
||||
{e.entity_id ? ` #${e.entity_id}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{e.details && (
|
||||
<div style={s.details}>{renderDetails(e.details)}</div>
|
||||
)}
|
||||
<div style={s.meta}>
|
||||
{e.performed_by ? `by ${e.performed_by} · ` : ''}{fmtDt(e.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<button style={s.loadMore} onClick={() => load(false)}>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user