Flow H, three screens behind a role check: find an account, work the ingestion error queue, read and export the audit log. Superadmins can change a role, never their own. The error queue merges ingest errors and dead jobs into one table with a cursor that pages both sources; only a job can be retried and only an ingest row resolved, with a note that migration 003 gives it somewhere to live. writeAudit no longer defaults a missing subject to the actor, which had been recording a user search as staff looking themselves up. Omitting the subject still means acting on yourself; null now means the action has no subject, which is what a search, a retry and an export are. Reading the log is not audited. Exporting it is: a copy leaving the building is a different act from looking. e2e/global-setup.ts asks for every screen once before the suite starts, so a dev server's first-request compile is paid before the first test rather than by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
7.4 KiB
TypeScript
231 lines
7.4 KiB
TypeScript
'use client';
|
|
|
|
import type { AdminErrorDto } from '@impuestos/contracts';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Select } from '@/components/ui/select';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Table, Td, Th, Tr } from '@/components/ui/table';
|
|
import { useT } from '@/i18n/t';
|
|
import { api } from '@/lib/api';
|
|
|
|
const STAGES = ['qr_parse', 'ocr', 'dedupe', 'verify', 'job', 'other'] as const;
|
|
const STATUSES = ['open', 'resolved'] as const;
|
|
|
|
type Stage = (typeof STAGES)[number];
|
|
type Status = (typeof STATUSES)[number];
|
|
|
|
/** Flow H: everything that failed on the way in, with the two things staff can do about it. */
|
|
export function ErrorsScreen() {
|
|
const t = useT();
|
|
const [stage, setStage] = useState<Stage | ''>('');
|
|
const [status, setStatus] = useState<Status | ''>('open');
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
|
|
const errors = useQuery({
|
|
queryKey: ['admin', 'errors', stage, status],
|
|
queryFn: ({ signal }) =>
|
|
api.listAdminErrors({ ...(stage ? { stage } : {}), ...(status ? { status } : {}) }, signal),
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<h1 className="text-xl font-semibold tracking-tight">{t('admin.errors.title')}</h1>
|
|
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<label className="space-y-1 text-xs font-medium">
|
|
<span className="block">{t('admin.errors.filterStage')}</span>
|
|
<Select value={stage} onChange={(event) => setStage(event.target.value as Stage | '')}>
|
|
<option value="">{t('admin.errors.all')}</option>
|
|
{STAGES.map((value) => (
|
|
<option key={value} value={value}>
|
|
{t(`admin.errors.stage.${value}` as const)}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</label>
|
|
<label className="space-y-1 text-xs font-medium">
|
|
<span className="block">{t('admin.errors.filterStatus')}</span>
|
|
<Select value={status} onChange={(event) => setStatus(event.target.value as Status | '')}>
|
|
<option value="">{t('admin.errors.allStatus')}</option>
|
|
{STATUSES.map((value) => (
|
|
<option key={value} value={value}>
|
|
{t(`admin.errors.status.${value}` as const)}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</label>
|
|
</div>
|
|
|
|
{errors.isPending ? <Skeleton name="admin-user-row" count={5} /> : null}
|
|
|
|
{errors.isError ? (
|
|
<div className="space-y-3">
|
|
<p role="alert" className="text-sm">
|
|
{t('common.error.generic')}
|
|
</p>
|
|
<Button type="button" className="h-9 rounded-lg" onClick={() => void errors.refetch()}>
|
|
{t('common.retry')}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{errors.data && errors.data.items.length === 0 ? (
|
|
<EmptyState title={t('admin.errors.empty')} />
|
|
) : null}
|
|
|
|
{errors.data && errors.data.items.length > 0 ? (
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Th>{t('admin.errors.col.when')}</Th>
|
|
<Th>{t('admin.errors.col.stage')}</Th>
|
|
<Th>{t('admin.errors.col.user')}</Th>
|
|
<Th>{t('admin.errors.col.message')}</Th>
|
|
<Th>{t('admin.errors.col.status')}</Th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{errors.data.items.map((row) => (
|
|
<ErrorRow
|
|
key={row.id}
|
|
row={row}
|
|
expanded={expanded === row.id}
|
|
onToggle={() => setExpanded(expanded === row.id ? null : row.id)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorRow({
|
|
row,
|
|
expanded,
|
|
onToggle,
|
|
}: {
|
|
row: AdminErrorDto;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const t = useT();
|
|
|
|
return (
|
|
<>
|
|
<Tr>
|
|
{/* A timestamp is machine data here, so it stays sortable rather than prose. */}
|
|
<Td className="tnum whitespace-nowrap">{row.createdAt.slice(0, 16).replace('T', ' ')}</Td>
|
|
<Td className="whitespace-nowrap">{t(`admin.errors.stage.${row.stage}` as const)}</Td>
|
|
<Td className="whitespace-nowrap">{row.userEmail ?? t('common.none')}</Td>
|
|
<Td>
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
aria-expanded={expanded}
|
|
className="text-left hover:underline"
|
|
>
|
|
{row.message}
|
|
</button>
|
|
</Td>
|
|
<Td className="whitespace-nowrap">{t(`admin.errors.status.${row.status}` as const)}</Td>
|
|
</Tr>
|
|
{expanded ? (
|
|
<tr>
|
|
<Td colSpan={5} className="bg-[var(--surface)]">
|
|
<ExpandedRow row={row} />
|
|
</Td>
|
|
</tr>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ExpandedRow({ row }: { row: AdminErrorDto }) {
|
|
const t = useT();
|
|
const queryClient = useQueryClient();
|
|
const [note, setNote] = useState('');
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'errors'] });
|
|
|
|
const resolve = useMutation({
|
|
mutationFn: () => api.resolveAdminError(row.id, { note: note.trim() }),
|
|
onSuccess: invalidate,
|
|
});
|
|
const retry = useMutation({
|
|
mutationFn: () => api.retryJob(row.id),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{row.attempts !== null ? (
|
|
<p className="text-xs text-[var(--text-muted)]">
|
|
{t('admin.errors.attempts', { count: row.attempts })}
|
|
</p>
|
|
) : null}
|
|
|
|
<pre className="max-h-64 overflow-auto rounded-lg border bg-[var(--surface-raised)] p-3 text-xs">
|
|
{JSON.stringify(row.payload ?? {}, null, 2)}
|
|
</pre>
|
|
|
|
{row.source === 'job' ? (
|
|
<div className="space-y-2">
|
|
<Button
|
|
type="button"
|
|
className="h-9 rounded-lg"
|
|
disabled={retry.isPending}
|
|
onClick={() => retry.mutate()}
|
|
>
|
|
{t('admin.errors.retry')}
|
|
</Button>
|
|
{retry.isError ? (
|
|
<p role="alert" className="text-sm text-overdue">
|
|
{t('common.error.generic')}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{row.source === 'ingest' && row.status === 'open' ? (
|
|
<form
|
|
className="flex flex-wrap items-end gap-2"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
resolve.mutate();
|
|
}}
|
|
>
|
|
<div className="min-w-64 flex-1 space-y-1">
|
|
<label htmlFor={`note-${row.id}`} className="text-xs font-medium">
|
|
{t('admin.errors.note')}
|
|
</label>
|
|
<Input
|
|
id={`note-${row.id}`}
|
|
value={note}
|
|
onChange={(event) => setNote(event.target.value)}
|
|
placeholder={t('admin.errors.notePlaceholder')}
|
|
className="h-9 rounded-lg text-sm"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
className="h-9 rounded-lg"
|
|
disabled={note.trim().length === 0 || resolve.isPending}
|
|
>
|
|
{t('admin.errors.resolve')}
|
|
</Button>
|
|
{resolve.isError ? (
|
|
<p role="alert" className="text-sm text-overdue">
|
|
{t('common.error.generic')}
|
|
</p>
|
|
) : null}
|
|
</form>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|