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,173 @@
|
||||
'use client';
|
||||
|
||||
import type { AdminAuditDto, AdminAuditQuery } from '@impuestos/contracts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 EMPTY: AdminAuditQuery = {};
|
||||
|
||||
/**
|
||||
* Flow H: read only, filterable, exportable. The action is shown as the code it is stored
|
||||
* as rather than a translated phrase: it is the same token the filter takes, and an
|
||||
* operator searching a log wants to match what they see.
|
||||
*/
|
||||
export function AuditScreen() {
|
||||
const t = useT();
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<AdminAuditQuery>(EMPTY);
|
||||
const [pages, setPages] = useState<AdminAuditDto[]>([]);
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
|
||||
const audit = useQuery({
|
||||
queryKey: ['admin', 'audit', filters, cursor],
|
||||
queryFn: ({ signal }) => api.listAudit({ ...filters, ...(cursor ? { cursor } : {}) }, signal),
|
||||
});
|
||||
|
||||
// Pages accumulate, so "show more" appends rather than replacing what is on screen.
|
||||
const rows = cursor ? [...pages, ...(audit.data?.items ?? [])] : (audit.data?.items ?? []);
|
||||
|
||||
function apply(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setPages([]);
|
||||
setCursor(undefined);
|
||||
setFilters(clean(draft));
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setDraft({});
|
||||
setPages([]);
|
||||
setCursor(undefined);
|
||||
setFilters(EMPTY);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t('admin.audit.title')}</h1>
|
||||
<a
|
||||
href={api.auditCsvUrl(filters)}
|
||||
className="text-sm text-accent-700 hover:underline"
|
||||
download
|
||||
>
|
||||
{t('admin.audit.export')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form onSubmit={apply} className="flex flex-wrap items-end gap-3">
|
||||
{(
|
||||
[
|
||||
['actor', 'admin.audit.filterActor', 'text'],
|
||||
['action', 'admin.audit.filterAction', 'text'],
|
||||
['subject', 'admin.audit.filterSubject', 'text'],
|
||||
['from', 'admin.audit.filterFrom', 'date'],
|
||||
['to', 'admin.audit.filterTo', 'date'],
|
||||
] as const
|
||||
).map(([key, label, type]) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<label htmlFor={`audit-${key}`} className="block text-xs font-medium">
|
||||
{t(label)}
|
||||
</label>
|
||||
<Input
|
||||
id={`audit-${key}`}
|
||||
type={type}
|
||||
value={draft[key] ?? ''}
|
||||
onChange={(event) => setDraft({ ...draft, [key]: event.target.value })}
|
||||
className="h-9 w-44 rounded-lg text-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Grouped, so the two buttons wrap together rather than one at a time. */}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="h-9 rounded-lg">
|
||||
{t('admin.audit.apply')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className="h-9 rounded-lg" onClick={clear}>
|
||||
{t('admin.audit.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{audit.data ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{t('admin.audit.total', { count: audit.data.total })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{audit.isPending && rows.length === 0 ? <Skeleton name="admin-user-row" count={6} /> : null}
|
||||
|
||||
{audit.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 audit.refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{audit.data && rows.length === 0 ? <EmptyState title={t('admin.audit.empty')} /> : null}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>{t('admin.audit.col.when')}</Th>
|
||||
<Th>{t('admin.audit.col.actor')}</Th>
|
||||
<Th>{t('admin.audit.col.action')}</Th>
|
||||
<Th>{t('admin.audit.col.subject')}</Th>
|
||||
<Th>{t('admin.audit.col.resource')}</Th>
|
||||
<Th>{t('admin.audit.col.ip')}</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<Tr key={row.id}>
|
||||
<Td className="tnum whitespace-nowrap">
|
||||
{row.createdAt.slice(0, 16).replace('T', ' ')}
|
||||
</Td>
|
||||
<Td className="whitespace-nowrap">{row.actorEmail ?? row.actorUserId}</Td>
|
||||
<Td className="whitespace-nowrap font-medium">{row.action}</Td>
|
||||
<Td className="whitespace-nowrap">{row.subjectEmail ?? t('common.none')}</Td>
|
||||
<Td className="max-w-64 truncate" title={row.resource}>
|
||||
{row.resource}
|
||||
</Td>
|
||||
<Td className="tnum whitespace-nowrap">{row.ip ?? t('common.none')}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
) : null}
|
||||
|
||||
{audit.data?.cursor ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-9 rounded-lg"
|
||||
onClick={() => {
|
||||
setPages(rows);
|
||||
setCursor(audit.data.cursor);
|
||||
}}
|
||||
>
|
||||
{t('common.loadMore')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clean(draft: Record<string, string>): AdminAuditQuery {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(draft)) {
|
||||
if (value.trim().length > 0) out[key] = value.trim();
|
||||
}
|
||||
return out as AdminAuditQuery;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { AuditScreen } from './audit-screen';
|
||||
|
||||
export default async function AuditoriaPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return <AuditScreen />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { isApiError, type SessionDto } from '@impuestos/contracts';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { Link, redirect } from '@/i18n/navigation';
|
||||
import { getT } from '@/i18n/t';
|
||||
import { serverApi } from '@/lib/api-server';
|
||||
|
||||
const ADMIN_ROLES = new Set(['staff', 'superadmin']);
|
||||
|
||||
const TABS = [
|
||||
{ href: '/usuarios', key: 'admin.users.title' },
|
||||
{ href: '/errores', key: 'admin.errors.title' },
|
||||
{ href: '/auditoria', key: 'admin.audit.title' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* FLOWS.md Flow H. No tab bar, no floating button, no playfulness: a wide page with a
|
||||
* plain nav. The role is checked here and again on every API call, because a layout is a
|
||||
* convenience and the server is the rule.
|
||||
*/
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getT(locale);
|
||||
|
||||
const api = await serverApi();
|
||||
const session: SessionDto | null = await api.getSession().catch((error: unknown) => {
|
||||
if (isApiError(error) && error.code === 'unauthorized') return null;
|
||||
throw error;
|
||||
});
|
||||
if (!session) redirect({ href: '/login', locale });
|
||||
|
||||
if (!session || !ADMIN_ROLES.has(session.role)) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-md px-5 py-16">
|
||||
<Card>
|
||||
<EmptyState title={t('admin.forbidden')} />
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-6xl flex-col">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-5 py-3">
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-sm font-semibold tracking-tight">{t('admin.nav.console')}</span>
|
||||
<nav aria-label={t('admin.nav.console')}>
|
||||
<ul className="flex items-center gap-4">
|
||||
{TABS.map((tab) => (
|
||||
<li key={tab.href}>
|
||||
<Link href={tab.href} className="text-sm hover:underline">
|
||||
{t(tab.key)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-[var(--text-muted)]">{session.email}</span>
|
||||
<LanguageSwitcher />
|
||||
<Link href="/inicio" className="text-sm text-accent-700 hover:underline">
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* The reminder sits above every screen in the console, not only the user search. */}
|
||||
<p className="border-b bg-attention-soft px-5 py-2 text-xs text-attention">
|
||||
{t('admin.users.auditBanner')}
|
||||
</p>
|
||||
|
||||
<main className="flex-1 px-5 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isApiError } from '@impuestos/contracts';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { serverApi } from '@/lib/api-server';
|
||||
import { UserOverview } from './user-overview';
|
||||
|
||||
export default async function UsuarioPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
// Role management is superadmin only, so the viewer's role decides what renders.
|
||||
const api = await serverApi();
|
||||
const session = await api.getSession().catch((error: unknown) => {
|
||||
if (isApiError(error)) return null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return <UserOverview id={id} viewerRole={session?.role ?? 'user'} />;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import type { UserRole } from '@impuestos/contracts';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const ROLES: UserRole[] = ['user', 'accountant', 'staff', 'superadmin'];
|
||||
|
||||
/** Flow H: one account at a glance, plus the one destructive thing a superadmin can do. */
|
||||
export function UserOverview({ id, viewerRole }: { id: string; viewerRole: string }) {
|
||||
const t = useT();
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: ['admin', 'user', id],
|
||||
queryFn: ({ signal }) => api.getUserOverview(id, signal),
|
||||
});
|
||||
|
||||
if (overview.isPending) {
|
||||
return (
|
||||
<Card>
|
||||
<Skeleton name="profile-section" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (overview.isError || !overview.data) {
|
||||
return (
|
||||
<Card 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 overview.refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { user, profile, counts, recentErrors, lastActivityAt } = overview.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{user.fullName}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<Link href="/usuarios" className="text-sm text-accent-700 hover:underline">
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
['documents', counts.documents],
|
||||
['needsReview', counts.needsReview],
|
||||
['declarations', counts.declarations],
|
||||
] as const
|
||||
).map(([key, value]) => (
|
||||
<Card key={key} className="p-4">
|
||||
<p className="text-xs text-[var(--text-muted)]">{t(`admin.users.counts.${key}`)}</p>
|
||||
<p className="tnum text-2xl font-semibold">{value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="space-y-2 p-4 text-sm">
|
||||
{profile ? (
|
||||
<dl className="grid gap-4 sm:grid-cols-4">
|
||||
<Row label={t('admin.users.col.doc')} value={docOf(profile.ruc, profile.rucDv, profile.ci)} />
|
||||
<Row label={t('admin.role.title')} value={t(`admin.role.${user.role}` as const)} />
|
||||
<Row
|
||||
label={t('admin.users.obligations')}
|
||||
value={profile.obligations
|
||||
.filter((obligation) => obligation.active)
|
||||
.map((obligation) => t(`obligation.${obligation.code}` as const))
|
||||
.join(', ')}
|
||||
/>
|
||||
<Row label={t('admin.users.col.created')} value={user.createdAt.slice(0, 10)} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-[var(--text-muted)]">{t('admin.users.noProfile')}</p>
|
||||
)}
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{lastActivityAt
|
||||
? // A full date, not "15 ene": in a console the year is part of the fact.
|
||||
t('admin.users.lastActivity', { date: lastActivityAt.slice(0, 10) })
|
||||
: t('admin.users.noActivity')}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-medium">{t('admin.users.openErrors')}</h2>
|
||||
{recentErrors.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-muted)]">{t('admin.users.noErrors')}</p>
|
||||
) : (
|
||||
<Card className="p-0">
|
||||
<ul className="divide-y">
|
||||
{recentErrors.map((error) => (
|
||||
<li key={error.id} className="flex flex-wrap gap-x-3 px-4 py-2 text-sm">
|
||||
<span className="text-[var(--text-muted)]">
|
||||
{t(`admin.errors.stage.${error.stage}` as const)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">{error.message}</span>
|
||||
<span className="tnum text-xs text-[var(--text-muted)]">
|
||||
{error.createdAt.slice(0, 10)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
{/* The queue itself, for the rows this summary does not show. */}
|
||||
<Link href="/errores" className="inline-block text-sm text-accent-700 hover:underline">
|
||||
{t('admin.errors.goToQueue')}
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{viewerRole === 'superadmin' ? <RoleManager user={user} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleManager({ user }: { user: { id: string; email: string; role: UserRole } }) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [role, setRole] = useState<UserRole>(user.role);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: () => api.setUserRole(user.id, { role }),
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'user', user.id] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="text-sm font-medium">{t('admin.role.title')}</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
aria-label={t('admin.role.title')}
|
||||
value={role}
|
||||
onChange={(event) => {
|
||||
setRole(event.target.value as UserRole);
|
||||
setConfirming(false);
|
||||
}}
|
||||
>
|
||||
{ROLES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.role.${value}` as const)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-9 rounded-lg"
|
||||
disabled={role === user.role || change.isPending}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t('admin.role.change')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{confirming ? (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<p className="text-sm">
|
||||
{t('admin.role.confirm', { email: user.email, role: t(`admin.role.${role}` as const) })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-9 rounded-lg"
|
||||
disabled={change.isPending}
|
||||
onClick={() => change.mutate()}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-9 rounded-lg"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{change.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
{change.isSuccess ? <p className="text-sm text-positive">{t('admin.role.saved')}</p> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label above value. Side by side across a wide card, a right aligned value ends up next
|
||||
* to the following pair's label and the two read as one field.
|
||||
*/
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs text-[var(--text-muted)]">{label}</dt>
|
||||
<dd className="tnum truncate">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function docOf(ruc: string | null, rucDv: string | null, ci: string | null): string {
|
||||
if (ruc) return rucDv ? `${ruc}-${rucDv}` : ruc;
|
||||
return ci ?? '';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { UsersScreen } from './users-screen';
|
||||
|
||||
export default async function UsuariosPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return <UsersScreen />;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Table, Td, Th, Tr } from '@/components/ui/table';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
/** Two characters is the shortest thing the server treats as a search. */
|
||||
const MIN_LENGTH = 2;
|
||||
|
||||
/** Flow H: search by email, RUC or name, then open one account. */
|
||||
export function UsersScreen() {
|
||||
const t = useT();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [term, setTerm] = useState('');
|
||||
|
||||
const results = useQuery({
|
||||
queryKey: ['admin', 'users', term],
|
||||
queryFn: ({ signal }) => api.searchUsers(term, signal),
|
||||
enabled: term.length >= MIN_LENGTH,
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setTerm(draft.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t('admin.users.title')}</h1>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-64 flex-1 space-y-1">
|
||||
<label htmlFor="admin-user-search" className="text-xs font-medium">
|
||||
{t('admin.users.searchLabel')}
|
||||
</label>
|
||||
<Input
|
||||
id="admin-user-search"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
className="h-10 rounded-lg text-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)]">{t('admin.users.searchHint')}</p>
|
||||
</div>
|
||||
<Button type="submit" className="h-10 rounded-lg">
|
||||
{t('common.search')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{term.length < MIN_LENGTH ? <EmptyState title={t('admin.users.start')} /> : null}
|
||||
|
||||
{results.isPending && term.length >= MIN_LENGTH ? (
|
||||
<Skeleton name="admin-user-row" count={4} />
|
||||
) : null}
|
||||
|
||||
{results.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 results.refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.data && results.data.items.length === 0 ? (
|
||||
<EmptyState title={t('admin.users.empty')} />
|
||||
) : null}
|
||||
|
||||
{results.data && results.data.items.length > 0 ? (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>{t('admin.users.col.email')}</Th>
|
||||
<Th>{t('admin.users.col.name')}</Th>
|
||||
<Th>{t('admin.users.col.doc')}</Th>
|
||||
<Th>{t('admin.users.col.role')}</Th>
|
||||
<Th>{t('admin.users.col.created')}</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.data.items.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/usuarios/${user.id}`}
|
||||
className="font-medium text-accent-700 hover:underline"
|
||||
>
|
||||
{user.email}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>{user.fullName}</Td>
|
||||
<Td className="tnum">{user.doc ?? t('common.none')}</Td>
|
||||
<Td>{t(`admin.role.${user.role}` as const)}</Td>
|
||||
<Td className="tnum whitespace-nowrap">{user.createdAt.slice(0, 10)}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** A native select: the admin filters are short lists and this is the fastest thing to use. */
|
||||
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'h-9 rounded-lg border bg-[var(--surface-raised)] px-2 text-sm',
|
||||
'focus-visible:border-accent-500 focus-visible:outline-2 focus-visible:outline-offset-0',
|
||||
'focus-visible:outline-accent-500/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The admin console is the one place in the product that is a table (FLOWS.md Flow H:
|
||||
* plain, dense, desktop first). Everything scrolls inside its own box so a long resource
|
||||
* string never pushes the page sideways.
|
||||
*/
|
||||
export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto rounded-xl border bg-[var(--surface-raised)]">
|
||||
<table className={cn('w-full border-collapse text-sm', className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={cn(
|
||||
'border-b px-3 py-2 text-left text-xs font-medium whitespace-nowrap text-[var(--text-muted)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <td className={cn('border-b px-3 py-2 align-top', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Tr({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return <tr className={cn('hover:bg-accent-50/50', className)} {...props} />;
|
||||
}
|
||||
Reference in New Issue
Block a user