Files
baya-monorepo/client/src/components/admin/AuditLogRow.tsx
T
2026-07-27 22:27:04 +03:30

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;