Files
impuestospy/apps/web/app/[locale]/(admin)/errores/errors-screen.tsx
T
MichilisandClaude Opus 5 4c39926483 phase-6: the staff console, and a log that says who did what
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>
2026-09-04 21:55:59 +00:00

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>
);
}