Six audit documents capture the 2026-05-06 review pass (comprehensive, frontend, missing-features, permissions, reliability) along with the Documenso integration audit + locked build plan that drove the bulk of subsequent feature work. Adds `docs/admin-ux-backlog.md` as a living tracker for the autonomous push — every item marked DONE or REMAINING with file pointers and scope estimates so future sessions can pick up where this one stopped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
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.