ui phase 11
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
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 { AuditLogEntry } from '@/services/admin/types';
|
||||
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). */
|
||||
@@ -17,17 +19,40 @@ function displayValue(v: unknown): string {
|
||||
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.
|
||||
* (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 }) => {
|
||||
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
|
||||
@@ -36,24 +61,35 @@ const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
|
||||
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, 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: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }}
|
||||
onClick={fields.length ? () => setOpen((v) => !v) : undefined}
|
||||
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 }}>
|
||||
{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}
|
||||
{actorDisplay(entry.actorUserId, actorLabel)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDateTime(entry.occurredAt, locale)}
|
||||
</Typography>
|
||||
{fields.length ? <AppIcon icon="expand" size={18} color="var(--bal-text-secondary)" /> : null}
|
||||
{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 && fields.length > 0}>
|
||||
<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')}
|
||||
|
||||
Reference in New Issue
Block a user