frontend phase 15
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
import { FunctionComponent, 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 AppIcon from '../common/AppIcon';
|
||||
|
||||
export interface AuditLogRowProps {
|
||||
entry: AuditLogEntry;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @component AuditLogRow
|
||||
*/
|
||||
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-audit-id={entry.id}
|
||||
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }}
|
||||
onClick={fields.length ? () => setOpen((v) => !v) : 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}` : '—'}
|
||||
</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}
|
||||
</Stack>
|
||||
|
||||
<Collapse in={open && fields.length > 0}>
|
||||
<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: 600 }}>{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;
|
||||
Reference in New Issue
Block a user