|
|
import { useState } from 'react'
|
|
|
import { Badge, DataTable, EmptyState, ErrorState, PageHeader, Skeleton, Toolbar } from '@sims/ui'
|
|
|
import { getAudit, getEmployees, type AuditPage, type Employee } from '../api'
|
|
|
import { useData } from './Clients'
|
|
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
|
|
/** Compact before→after diff of the changed keys in an audit row. */
|
|
|
function diff(before: string | null, after: string | null): string {
|
|
|
const b = before !== null ? JSON.parse(before) as Record<string, unknown> : {}
|
|
|
const a = after !== null ? JSON.parse(after) as Record<string, unknown> : {}
|
|
|
const keys = [...new Set([...Object.keys(b), ...Object.keys(a)])]
|
|
|
const changed = keys.filter((k) => JSON.stringify(b[k]) !== JSON.stringify(a[k]))
|
|
|
if (changed.length === 0) return before === null && after !== null ? 'created' : after === null ? '' : '—'
|
|
|
return changed.slice(0, 6).map((k) => `${k}: ${JSON.stringify(a[k] ?? b[k])}`).join(', ')
|
|
|
}
|
|
|
|
|
|
/** Audit log viewer (D27, owner-only): who did what, filterable + paginated. */
|
|
|
export function Audit() {
|
|
|
const [entity, setEntity] = useState('')
|
|
|
const [action, setAction] = useState('')
|
|
|
const [page, setPage] = useState(1)
|
|
|
const list = useData<AuditPage | undefined>(
|
|
|
() => getAudit({ entity, action, page, pageSize: PAGE_SIZE }), [entity, action, page])
|
|
|
const emps = useData<{ employees: Employee[]; total: number } | undefined>(() => getEmployees(), [])
|
|
|
const nameOf = (id: string) => id === 'system' ? 'system'
|
|
|
: (emps.data?.employees.find((e) => e.id === id)?.displayName ?? id.slice(0, 8))
|
|
|
|
|
|
const data = list.data
|
|
|
const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
|
|
|
|
|
|
return (
|
|
|
<div className="wf-page">
|
|
|
<PageHeader title="Audit log" desc="Every change, append-only — who did what, when. Owner-only." />
|
|
|
<Toolbar>
|
|
|
<select className="wf" style={{ maxWidth: 200 }} value={entity} aria-label="Entity"
|
|
|
onChange={(e) => { setEntity(e.target.value); setPage(1) }}>
|
|
|
<option value="">Entity: any</option>
|
|
|
{(data?.facets.entities ?? []).map((x) => <option key={x} value={x}>{x}</option>)}
|
|
|
</select>
|
|
|
<select className="wf" style={{ maxWidth: 200 }} value={action} aria-label="Action"
|
|
|
onChange={(e) => { setAction(e.target.value); setPage(1) }}>
|
|
|
<option value="">Action: any</option>
|
|
|
{(data?.facets.actions ?? []).map((x) => <option key={x} value={x}>{x}</option>)}
|
|
|
</select>
|
|
|
<span style={{ flex: 1 }} />
|
|
|
{data !== undefined && <Badge>{data.total} entries</Badge>}
|
|
|
</Toolbar>
|
|
|
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
|
|
|
: data === undefined ? <Skeleton rows={8} />
|
|
|
: data.rows.length === 0 ? <EmptyState>No audit entries match.</EmptyState> : (
|
|
|
<>
|
|
|
<DataTable
|
|
|
columns={[
|
|
|
{ key: 'when', label: 'When' }, { key: 'who', label: 'Who' },
|
|
|
{ key: 'action', label: 'Action' }, { key: 'entity', label: 'Entity' },
|
|
|
{ key: 'change', label: 'Change' },
|
|
|
]}
|
|
|
rows={data.rows.map((r) => ({
|
|
|
when: r.at_wall.replace('T', ' ').slice(0, 19),
|
|
|
who: nameOf(r.user_id),
|
|
|
action: <Badge>{r.action}</Badge>,
|
|
|
entity: <span className="mono" style={{ fontSize: 12 }}>{r.entity}</span>,
|
|
|
change: <span style={{ fontSize: 12.5, opacity: 0.85 }}>{diff(r.before_json, r.after_json)}</span>,
|
|
|
}))}
|
|
|
/>
|
|
|
<Toolbar>
|
|
|
<span style={{ flex: 1 }} />
|
|
|
<button type="button" className="wf" disabled={data.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>‹ Prev</button>
|
|
|
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data.page} / {totalPages}</span>
|
|
|
<button type="button" className="wf" disabled={data.page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next ›</button>
|
|
|
</Toolbar>
|
|
|
</>
|
|
|
)}
|
|
|
</div>
|
|
|
)
|
|
|
}
|