# Frontend audit — 2026-05-06 Scope: new archive/restore/hard-delete dialogs, bulk archive wizard, client detail header, audit log inspector, webhook delivery log, client list bulk section. Companion to `docs/audit-comprehensive-2026-05-06.md` (does NOT re-flag the Files-tab / reservations / berth-tab "coming soon" stubs already covered there). --- ## Critical ### C1 — `client-detail-header` opens restore dialog from the Archive icon for archived clients **File:** `src/components/clients/client-detail-header.tsx:174-186` **Scenario:** On an archived client the icon button still renders `` when `isArchived` is true (`isArchived ? : ` is correct), BUT both states use the same `setArchiveOpen(true)` handler and the conditional below routes `` vs `` off of `isArchived`. That part is fine. The real problem: the destructive hover colour `hover:text-destructive` is applied via `isArchived ? 'hover:text-foreground' : 'hover:text-destructive'` — but the preceding class string already sets `hover:text-foreground` unconditionally, so the conditional is dead and the restore button hovers red the same as archive. Misleading colour signal on a reversible action; users hesitate to click it. **Fix:** Drop the always-applied `hover:text-foreground` from the base class list and let the conditional own the hover colour, or just colour the restore icon emerald to differentiate. --- ## High ### H1 — `bulk-archive-wizard` lets users skip the reasons step by clicking Continue while preflight is loading then Cancel/reopen **File:** `src/components/clients/bulk-archive-wizard.tsx:253-267, 80-107` **Scenario:** In the `preflight` stage the Continue button is only disabled when `archivable.length === 0 || preflight.isLoading`. But `archivable` is derived from `items = preflight.data ?? []`. While loading, `archivable` is `[]` so Continue is disabled — good. After load with all-blocked selection, `archivable.length === 0` so still disabled — good. However, the `reasonsByClientId: reasons` payload is sent verbatim, so a user who advances to "reasons", types into one client's box, then uses the carousel back arrow and edits another, can submit reasons for clients NOT in `archivable` (e.g. if the preflight is refetched on stale-time). Reasons for blocked or removed client IDs are forwarded to the API. Minor data-quality issue. **Fix:** Filter `reasons` to `archivable` IDs before mutating: `reasonsByClientId: Object.fromEntries(Object.entries(reasons).filter(([id]) => archivable.some(a => a.clientId === id)))`. ### H2 — `client-list` bulk tag mutation uses `alert()` for partial failures and has no `onError` **File:** `src/components/clients/client-list.tsx:88-106` **Scenario:** User bulk-adds a tag to 50 clients; backend returns 200 with `{succeeded: 30, failed: 20}` → user sees a native browser `alert()` blocking the page. If the request itself errors (network drop, 500), there is no `onError` so the dialog closes via `onSettled` and the user sees nothing — silent failure. Inconsistent UX vs. every other mutation in this audit which uses `toast`. **Fix:** Replace `alert(...)` with `toast.warning(...)`, add an `onError: (err) => toast.error(...)` branch matching the pattern used in `bulk-archive-wizard.tsx` and `bulk-hard-delete-dialog.tsx`. ### H3 — `webhook-delivery-log` swallows fetch errors silently **File:** `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74` **Scenario:** Admin opens a webhook detail page while the API is down or the webhook was just deleted. `load()` catches and discards the error (`} catch { /* ignore */ }`). UI shows "Loading deliveries…" forever on the first load, or stays on the last successful page on subsequent loads, with no indication that anything failed. No error state, no toast, no retry. **Fix:** Surface errors via `toast.error` and show an inline error state ("Couldn't load deliveries — Retry") instead of swallowing. ### H4 — `audit-log-list` first-page fetch swallows errors and shows no error state **File:** `src/components/admin/audit/audit-log-list.tsx:150-175` **Scenario:** Filter form is fully interactive, user changes a date — request fires, server 500s. The `try/finally` has no `catch`, so the rejected promise becomes an unhandled rejection. The list shows whatever was previously loaded (or empty state), and the user has no idea their filter didn't apply. Same applies to `loadMore`. **Fix:** Add `catch` blocks that set an error state and render an inline error banner above the table, with a Retry button. ### H5 — `audit-log-card` renders as a link to `href="#"` — clicking jumps the page **File:** `src/components/admin/audit/audit-log-card.tsx:96` **Scenario:** On mobile / card view the audit log entries become clickable cards with `href="#"`. Tapping any card scrolls the page to top and inserts `#` in the URL (back-button trap). There's no detail view to navigate to. **Fix:** Either render a non-link wrapper (button or div) when no detail target exists, or link to a useful destination like `/{portSlug}/{entityType}/{entityId}` when the entity is resolvable. ### H6 — `smart-archive-dialog` `archiveMutation` doesn't invalidate the dossier or single-client query **File:** `src/components/clients/smart-archive-dialog.tsx:197-212` **Scenario:** User archives a client successfully. The dialog invalidates `['clients']`, `['berths']`, `['interests']` but NOT `['client-archive-dossier', clientId]` nor `['clients', clientId]`. If the parent screen (e.g. detail page) keeps the client query mounted, the detail header continues to show the client as un-archived until a hard reload. The Restore icon won't appear. **Fix:** Add `qc.invalidateQueries({queryKey: ['clients', clientId]})` and `qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})` so a re-open re-fetches a fresh dossier (e.g. if user re-archives after restoring in the same session). --- ## Medium ### M1 — `smart-archive-dialog` derives `interestId` from a name match against `primaryBerthMooring` — wrong key **File:** `src/components/clients/smart-archive-dialog.tsx:158-167` **Scenario:** When building per-berth decisions the code does `dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId`. Multiple interests can share the same primary mooring (rare, but possible historically), and worse, when no interest has this berth as primary it falls back to `dossier.interests[0]?.interestId` regardless of which berth is being decided. The wrong interest gets credited with the release, which is then audit-logged. **Fix:** Have the dossier API return `interestId` per berth row (it already joins `interest_berths`), or look up by membership not by primary flag. ### M2 — `hard-delete-dialog` doesn't reset state when switching from intent → confirm if request fails midway **File:** `src/components/clients/hard-delete-dialog.tsx:39-46, 64-79` **Scenario:** User submits hard delete with wrong code → backend returns 400 → toast fires, but the dialog stays on `confirm` stage with the bad code still in the input and no clear cue. If the user then closes (X) and reopens, the `useEffect` resets correctly. But if the email code expired (10 min) and they request a fresh one, there's no "Resend code" button — they must cancel and start over from intent. Minor. **Fix:** Add a "Send a new code" link in the confirm stage that calls `requestCode.mutate()` again and clears `code`. ### M3 — `bulk-hard-delete-dialog` doesn't refetch / invalidate after partial failure shows totals **File:** `src/components/clients/bulk-hard-delete-dialog.tsx:64-85` **Scenario:** Bulk delete returns `{deletedCount: 7}` for 10 selected; toast warns but `qc.invalidateQueries({queryKey: ['clients']})` is invalidated unconditionally — fine. However, the dialog closes immediately (`onOpenChange(false)`), so the user can't see WHICH 3 failed. The toast just says "see audit log". For a destructive bulk op this is too sparse; users will repeat the action thinking it didn't work. **Fix:** Stay open on partial failure and render a list of failed IDs (the API likely already returns per-item results — if not, return them). ### M4 — `audit-log-list` doesn't validate that `dateFrom <= dateTo` **File:** `src/components/admin/audit/audit-log-list.tsx:142-146` **Scenario:** User picks From=2026-06-01, To=2026-05-01. Query fires with an empty result range; user sees "No audit log entries found" and assumes their data isn't there. No client-side validation hint. **Fix:** Show an inline warning "From date must be before To date" and skip the request when invalid. ### M5 — `bulk-archive-wizard` `Cancel` during `archiveMutation.isPending` discards mutation tracking **File:** `src/components/clients/bulk-archive-wizard.tsx:248-251, 293-307` **Scenario:** User clicks "Archive 50" → mutation in flight (10s) → user clicks Cancel. The dialog closes; the mutation continues server-side and its onSuccess fires later, showing a toast for an action the user thought they cancelled. Worse, the dialog is gone so they can't tell which clients got archived. **Fix:** Disable Cancel while `archiveMutation.isPending`, or relabel to "Cancel (won't stop in-progress)" and keep the mutation visible. --- ## Low ### L1 — `audit-log-list` filter row overflows on narrow viewports **File:** `src/components/admin/audit/audit-log-list.tsx:321-467` **Scenario:** 8 filter controls (`Search` 288px, `Entity` 144px, `Action` 176px, `Severity` 128px, `Source` 128px, `User id` 176px, `From` 144px, `To` 144px, total ~1330px) sit in a single `flex-wrap` row. At <1280px viewports they wrap onto multiple lines pushing the table down 200+px; at <640px (mobile) each control wraps onto its own line and the "Clear" button (`ml-auto`) lands on the wrong row. **Fix:** Collapse rarely-used filters (User id / Severity / Source) into a "More filters" Popover for sm: viewports. ### L2 — `audit-log-card` action map missing entries silently fall back to grey "Activity" icon and grey badge **File:** `src/components/admin/audit/audit-log-card.tsx:27-44, 46-52` **Scenario:** New webhook/cron/job actions are in `audit-log-list.tsx` ACTION_COLORS but absent from `audit-log-card.tsx` ACTION_BADGE_COLORS and ACTION_ACCENT. Card view of these entries looks identical to a generic "unknown" entry — visual loss vs. table view. **Fix:** Sync the two maps; consider extracting to a shared module so they can't drift.