fix(audit): frontend HIGHs — surface fetch errors, kill href=#, invalidate queries, toast over alert

R2-H10: webhook-delivery-log and audit-log-list both swallowed fetch
errors silently — failed loads showed spinner forever or stale data.
Both now set a loadError state, show an inline retry banner, and fire
a toast.error. Same applies to audit-log loadMore.

R2-H11: audit-log-card rendered as `<a href="#">` — tapping on mobile
inserted `#` in the URL and scrolled to top (back-button trap).
ListCard now treats `href` as optional and renders a non-link `<div>`
when omitted; audit-log-card no longer passes href.

R2-H12: smart-archive-dialog only invalidated ['clients'] / ['berths']
/ ['interests']. Detail header kept showing Archived=false until hard
reload. Now also invalidates ['clients', clientId] and removes the
['client-archive-dossier', clientId] cache so re-open re-fetches.

R2-H13: client-list bulk mutation used native alert() on partial
failure (blocking the page) and had no onError handler. Replaced with
toast.warning / toast.success / toast.error.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 22:18:14 +02:00
parent a8c6c071e6
commit 5fc68a5f34
6 changed files with 79 additions and 31 deletions

View File

@@ -93,7 +93,6 @@ export function AuditLogCard({ entry }: AuditLogCardProps) {
return ( return (
<ListCard <ListCard
href="#"
ariaLabel={`Audit: ${actionVerb(entry.action)} ${entityTitle}`} ariaLabel={`Audit: ${actionVerb(entry.action)} ${entityTitle}`}
accentClassName={accentClass} accentClassName={accentClass}
> >

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { toast } from 'sonner';
import { DataTable } from '@/components/shared/data-table'; import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
@@ -116,6 +117,7 @@ export function AuditLogList() {
} | null>(null); } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// Filter state - debounce text inputs. // Filter state - debounce text inputs.
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -149,10 +151,15 @@ export function AuditLogList() {
const fetchFirstPage = useCallback(async () => { const fetchFirstPage = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(null);
try { try {
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${queryString}`); const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${queryString}`);
setEntries(res.data); setEntries(res.data);
setNextCursor(res.pagination.nextCursor); setNextCursor(res.pagination.nextCursor);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load audit log';
setLoadError(msg);
toast.error(msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -168,6 +175,8 @@ export function AuditLogList() {
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${params}`); const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${params}`);
setEntries((prev) => [...prev, ...res.data]); setEntries((prev) => [...prev, ...res.data]);
setNextCursor(res.pagination.nextCursor); setNextCursor(res.pagination.nextCursor);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load more audit entries');
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
@@ -466,6 +475,14 @@ export function AuditLogList() {
) : null} ) : null}
</div> </div>
{loadError && !loading && entries.length === 0 ? (
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm space-y-2">
<p className="text-destructive">Couldn&rsquo;t load audit log: {loadError}</p>
<Button size="sm" variant="outline" onClick={() => void fetchFirstPage()}>
Retry
</Button>
</div>
) : (
<div className="mt-4"> <div className="mt-4">
<DataTable <DataTable
columns={columns} columns={columns}
@@ -480,6 +497,7 @@ export function AuditLogList() {
} }
/> />
</div> </div>
)}
{nextCursor ? ( {nextCursor ? (
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">

View File

@@ -42,6 +42,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState<string | null>(null); const [retrying, setRetrying] = useState<string | null>(null);
const { can } = usePermissions(); const { can } = usePermissions();
const canReplay = can('admin', 'manage_webhooks'); const canReplay = can('admin', 'manage_webhooks');
@@ -63,14 +64,17 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
async function load(p: number) { async function load(p: number) {
setLoading(true); setLoading(true);
setLoadError(null);
try { try {
const result = await apiFetch<{ data: Delivery[]; total: number }>( const result = await apiFetch<{ data: Delivery[]; total: number }>(
`/api/v1/admin/webhooks/${webhookId}/deliveries?page=${p}&limit=25`, `/api/v1/admin/webhooks/${webhookId}/deliveries?page=${p}&limit=25`,
); );
setDeliveries(result.data); setDeliveries(result.data);
setTotal(result.total); setTotal(result.total);
} catch { } catch (err) {
// ignore const msg = err instanceof Error ? err.message : 'Failed to load deliveries';
setLoadError(msg);
toast.error(msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -85,6 +89,17 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
return <p className="text-sm text-muted-foreground">Loading deliveries...</p>; return <p className="text-sm text-muted-foreground">Loading deliveries...</p>;
} }
if (loadError && deliveries.length === 0) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm space-y-2">
<p className="text-destructive">Couldn&rsquo;t load deliveries: {loadError}</p>
<Button size="sm" variant="outline" onClick={() => void load(page)}>
Retry
</Button>
</div>
);
}
if (!loading && deliveries.length === 0) { if (!loading && deliveries.length === 0) {
return <p className="text-sm text-muted-foreground">No deliveries yet.</p>; return <p className="text-sm text-muted-foreground">No deliveries yet.</p>;
} }

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react'; import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table'; import { DataTable } from '@/components/shared/data-table';
@@ -102,9 +103,14 @@ export function ClientList() {
queryClient.invalidateQueries({ queryKey: ['clients'] }); queryClient.invalidateQueries({ queryKey: ['clients'] });
const s = res.data.summary; const s = res.data.summary;
if (s.failed > 0) { if (s.failed > 0) {
alert(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`); toast.warning(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
} else if (s.succeeded > 0) {
toast.success(`${s.succeeded} client${s.succeeded === 1 ? '' : 's'} updated.`);
} }
}, },
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Bulk action failed');
},
}); });
const columns = getClientColumns({ const columns = getClientColumns({

View File

@@ -209,6 +209,11 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
: `${clientName} archived.`, : `${clientName} archived.`,
); );
qc.invalidateQueries({ queryKey: ['clients'] }); qc.invalidateQueries({ queryKey: ['clients'] });
// Invalidate the single-client query AND the dossier so detail
// pages re-fetch (header now shows Archived badge) and a re-open
// of the dialog re-fetches a fresh dossier.
qc.invalidateQueries({ queryKey: ['clients', clientId] });
qc.removeQueries({ queryKey: ['client-archive-dossier', clientId] });
qc.invalidateQueries({ queryKey: ['berths'] }); qc.invalidateQueries({ queryKey: ['berths'] });
qc.invalidateQueries({ queryKey: ['interests'] }); qc.invalidateQueries({ queryKey: ['interests'] });
onOpenChange(false); onOpenChange(false);

View File

@@ -7,8 +7,9 @@ import { type ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ListCardProps { interface ListCardProps {
/** Detail-page URL the card navigates to when tapped. */ /** Detail-page URL the card navigates to when tapped. Omit to render a
href: string; * non-navigating card (audit log entries, read-only rows). */
href?: string;
/** /**
* Optional Tailwind background class painted on a 3px vertical strip on the * Optional Tailwind background class painted on a 3px vertical strip on the
* left edge - used to encode pipeline stage / status / category at a glance. * left edge - used to encode pipeline stage / status / category at a glance.
@@ -41,6 +42,12 @@ export function ListCard({
className, className,
children, children,
}: ListCardProps) { }: ListCardProps) {
const innerClassName = cn(
'block p-3',
accentClassName && 'pl-4',
'rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
);
return ( return (
<article <article
className={cn( className={cn(
@@ -52,17 +59,15 @@ export function ListCard({
{accentClassName ? ( {accentClassName ? (
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-1', accentClassName)} /> <span aria-hidden className={cn('absolute inset-y-0 left-0 w-1', accentClassName)} />
) : null} ) : null}
<Link {href ? (
href={href as Route} <Link href={href as Route} aria-label={ariaLabel} className={innerClassName}>
aria-label={ariaLabel}
className={cn(
'block p-3',
accentClassName && 'pl-4',
'rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
)}
>
{children} {children}
</Link> </Link>
) : (
<div aria-label={ariaLabel} className={innerClassName}>
{children}
</div>
)}
{actions ? <div className="absolute right-1.5 top-1.5">{actions}</div> : null} {actions ? <div className="absolute right-1.5 top-1.5">{actions}</div> : null}
</article> </article>
); );