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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6620650e9e
commit
4c39926483
@@ -0,0 +1,230 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { ErrorsScreen } from './errors-screen';
|
||||
|
||||
export default async function ErroresPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return <ErrorsScreen />;
|
||||
}
|
||||
Reference in New Issue
Block a user