Files
pn-new-crm/docs/audit-frontend-2026-05-06.md
Matt a0e68eb060 docs: comprehensive audits + Documenso build plan + admin UX backlog
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>
2026-05-07 20:57:53 +02:00

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.

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.