# 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 `<Archive>`
when `isArchived` is true (`isArchived ? <RotateCcw /> : <Archive />` is
correct), BUT both states use the same `setArchiveOpen(true)` handler and
the conditional below routes `<SmartRestoreDialog>` vs `<SmartArchiveDialog>`
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.
