122 lines
5.0 KiB
TypeScript
122 lines
5.0 KiB
TypeScript
'use client';
|
|
import { FunctionComponent, KeyboardEvent, useState } from 'react';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import { Box, Chip, Collapse, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
|
|
import { formatShamsiDateTime } from '@/utils';
|
|
import type { AdminUserSummary, AuditLogEntry } from '@/services/admin/types';
|
|
import AppIcon from '../common/AppIcon';
|
|
|
|
export interface AuditLogRowProps {
|
|
entry: AuditLogEntry;
|
|
/** Resolved actor name (3.2's batch id→label lookup) — falls back to `#id` until the REQ-061 lookup resolves. */
|
|
actorLabel?: string;
|
|
}
|
|
|
|
/** Stringify a diff value (JSON for objects, `—` for null). */
|
|
function displayValue(v: unknown): string {
|
|
if (v == null) return '—';
|
|
if (typeof v === 'object') return JSON.stringify(v);
|
|
return String(v);
|
|
}
|
|
|
|
/** The actor cell's display text — the resolved name when known, else the honest `#id` fallback. */
|
|
export function actorDisplay(actorUserId: number | null, actorLabel?: string): string {
|
|
if (actorUserId == null) return '—';
|
|
return actorLabel ?? `#${actorUserId}`;
|
|
}
|
|
|
|
/** Resolve a batch `lookupUsers` result into the label `AuditLogRow`/`actorDisplay` expects. */
|
|
export function actorLabelFrom(userMap: Map<number, AdminUserSummary> | undefined, userId: number | null): string | undefined {
|
|
if (userId == null) return undefined;
|
|
return userMap?.get(userId)?.displayName;
|
|
}
|
|
|
|
/**
|
|
* One row of the append-only audit viewer, with an expandable `changedFields` diff (old → new per field;
|
|
* PII is server-redacted as `<redacted>`). Read-only by design — there is **no** edit/delete affordance
|
|
* (phase §5). Presentational; the caller passes the paged entries + (once resolved) the actor's name. The
|
|
* expand chevron rotates and the header carries `aria-expanded` + button semantics so open/closed state is
|
|
* visible and keyboard-toggleable (ui-phase-11).
|
|
* @component AuditLogRow
|
|
*/
|
|
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry, actorLabel }) => {
|
|
const t = useTranslations('admin');
|
|
const locale = useLocale();
|
|
const [open, setOpen] = useState(false);
|
|
const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
|
|
const hasDetail = fields.length > 0;
|
|
|
|
const toggle = () => setOpen((v) => !v);
|
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
toggle();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Paper
|
|
elevation={0}
|
|
data-audit-id={entry.id}
|
|
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}
|
|
>
|
|
<Stack
|
|
role={hasDetail ? 'button' : undefined}
|
|
tabIndex={hasDetail ? 0 : undefined}
|
|
aria-expanded={hasDetail ? open : undefined}
|
|
direction="row"
|
|
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: hasDetail ? 'pointer' : 'default', flexWrap: 'wrap' }}
|
|
onClick={hasDetail ? toggle : undefined}
|
|
onKeyDown={hasDetail ? onKeyDown : undefined}
|
|
>
|
|
<Chip size="small" variant="outlined" label={`${entry.entityType} #${entry.entityId}`} />
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
|
{entry.action}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary', flexGrow: 1 }}>
|
|
{actorDisplay(entry.actorUserId, actorLabel)}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{formatShamsiDateTime(entry.occurredAt, locale)}
|
|
</Typography>
|
|
{hasDetail ? (
|
|
<AppIcon
|
|
icon="expand"
|
|
size={18}
|
|
color="var(--bal-text-secondary)"
|
|
style={{ transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)', transform: open ? 'rotate(180deg)' : 'none' }}
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
|
|
<Collapse in={open && hasDetail}>
|
|
<Box sx={{ px: 1.5, pb: 1.5 }}>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
|
{t('audit_diff_title')}
|
|
</Typography>
|
|
<Table size="small" sx={{ mt: 0.5 }}>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_field')}</TableCell>
|
|
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_old')}</TableCell>
|
|
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_new')}</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{fields.map(([field, delta]) => (
|
|
<TableRow key={field}>
|
|
<TableCell sx={{ fontWeight: 500 }}>{field}</TableCell>
|
|
<TableCell sx={{ color: 'text.secondary' }}>{displayValue(delta.old)}</TableCell>
|
|
<TableCell>{displayValue(delta.new)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Box>
|
|
</Collapse>
|
|
</Paper>
|
|
);
|
|
};
|
|
|
|
export default AuditLogRow;
|