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:
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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’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">
|
||||||
|
|||||||
@@ -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’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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user