ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
+45 -9
View File
@@ -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')}