Skip to content

[UX] customer-portal — Commands (admin) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 6 Patterns: user-feedback/notification (consulted), data-display/table (baseline only)

Summary ​

A read-only admin table of Dataverse commands plus a detail modal whose only action is "Retry" for terminally-failed commands. The retry path works but is completely silent — success closes the modal with no confirmation and failure shows the admin nothing — and the retry control is unreachable for a whole class of retryable commands. Status colouring is also driven by a stale map that doesn't match the backend enum, so lifecycle states are visually indistinguishable. Nothing is internationalised.

Findings ​

1. Retry gives no success or failure feedback — silent outcome — High · MSG-01 ​

Where: apps/web/src/components/admin/CommandDetailModal.vue:36-44, apps/web/src/domains/admin/commands/commands.mutations.ts:13-28What: onRetry fires retryCommand.mutate(id). On success the mutation only invalidates the list and emits retried, which closes the modal (CommandsPage.vue:97). There is no toast, no role="status"/aria-live region, and no visible confirmation anywhere. On failure it is worse: the call uses throwOnError: true, so the promise rejects, isPending flips back to false and the spinner stops — but there is no onError, no isError branch in the template, and no toast provider is wired for this feature (grep: no useToast in pages/admin/CommandsPage.vue, components/admin/*, or domains/admin/commands/*). The error is swallowed. Why it matters: An admin requeues a failed Dataverse write and cannot tell whether it was accepted, rejected, or errored. The UX Patterns notification pattern calls out exactly this ("skipping announcement strategy"; "verify the default, loading, error and success states"; "reconcile async state after failure"). It also violates the repo's own apps/web/CLAUDE.md §7 "No silent failures." This is the feature's single user-facing action and it has no outcome signal. Rated High rather than Blocker because the write itself succeeds/fails server-side regardless; the harm is the admin acting blind, not a broken write. Fix: Add a PrimeVue useToast success ("Command requeued") and error notification in the mutation's onSuccess/onError (or in onRetry), carrying role="status"/role="alert" respectively, and keep the modal open on error so the admin can retry again.

2. Retry is unreachable for CANCELED (and error-message-less FAILED) commands — Medium · MSG-04 ​

Where: apps/web/src/components/admin/CommandDetailModal.vue:124-146What: canRetry correctly mirrors the backend (status === "FAILED" || "CANCELED", matching dataverse-commands.service.ts:266,1306,1331 and the Prisma enum QUEUED,RUNNING,RETRYING,SUCCEEDED,FAILED,CANCELED). But the Retry <Button v-if="canRetry"> is nested inside v-if="command.lastErrorMessage" (line 125). A CANCELED command is terminal-but-not-errored and typically carries no lastErrorMessage, so the entire error block — and with it the only retry control — never renders. The same happens to any FAILED command whose lastErrorMessage is empty. Why it matters: The backend explicitly permits requeueing CANCELED commands, but the UI offers no way to do it — a dead end for a supported recovery action, with no alternative route out of the modal except closing it. Fix: Hoist the Retry button out of the lastErrorMessage block into its own region gated on canRetry alone (e.g. a modal footer), so it appears whenever the command is retryable regardless of whether an error message exists.

3. Status severity map is stale — a dead branch, and real states collapse to one colour — Medium · data-display/table (proposed DATA-STATUS-FIDELITY) ​

Where: apps/web/src/utils/admin/severity.ts (commandStatusSeverity, commandStatusBadgeClass) consumed at CommandsPage.vue:43 and CommandDetailModal.vue:28What: Both helpers branch on SUCCEEDED/FAILED/PROCESSING, else warn. The backend enum has no PROCESSING (dead branch) and four values that all fall through to the amber warn default: QUEUED, RUNNING, RETRYING, CANCELED. So a pending QUEUED, an in-flight RUNNING, and a terminal CANCELED command all render as the same amber tag/badge. Why it matters: On a monitoring table whose whole purpose is triage, an admin cannot distinguish "not started yet" from "currently running" from "cancelled/dead" at a glance — the status column, the most load-bearing signal, under-communicates. (Colour is also the sole differentiator; see A11Y note.) Fix: Map every enum value explicitly: RUNNING/RETRYING → info, CANCELED → a distinct secondary/danger tone, QUEUED → neutral; drop the PROCESSING branch. Prefer deriving the map from the generated enum type so it fails the build when the enum changes.

4. Feature is entirely un-internationalised — Medium · CONTENT-01 ​

Where: apps/web/src/pages/admin/CommandsPage.vue (column headers Type/Entity/Status/Actor/Attempts/Created at :32-58, title="Commands" :71, empty/error messages :75-76), apps/web/src/components/admin/CommandDetailModal.vue (header="Command Details" :53, every field label :62-121, label="Retry" :140, attempt table headers :168-182) What: No useI18n/t() anywhere in the feature (grep confirms zero i18n calls in either file); all copy is hardcoded English literals. customer-portal ships vue-i18n with en/nb and is held to CONTENT-01 per feature (BASELINE.md). Why it matters: A Norwegian admin sees English throughout this surface. Fix: Route all copy through vue-i18n with nb parity. Likely shared across the whole admin section — worth confirming and fixing section-wide rather than per page.

5. Page never passes :status, so the 403 branch and status detail are lost — Low · MSG-03 ​

Where: apps/web/src/pages/admin/CommandsPage.vue:73-90What: <DataTable> supports a :status prop that drives its 403 → forbiddenMessage branch and the "Status N" error description (packages/ui/.../DataTable.vue:247-276). CommandsPage passes :is-error but not :status, so statusCode stays null: a permission failure renders the generic "Failed to load commands" with no description, indistinguishable from a 500. Why it matters: An admin who has lost the manage all ability gets a misleading generic error instead of a permission message. Minor because the route is already ability-gated (manage/all) so this is an edge case. Fix: Destructure and pass the query's error/status through to :status/:forbidden-message.

6. NAV-03 — one static document title app-wide ​

Fails project-wide; see PROJECT-LEVEL.md ("one static document title for the whole app", three agents). Not re-filed here.

Unverified ​

  • A11Y-01 (contrast): status tags/badges (Tag severities and the bg-status-* badge classes at severity.ts) and the text-[10px]/text-text-3 field labels in the modal need a contrast tool. Cannot settle from code.
  • A11Y-06 (responsive): the detail modal is class="w-full max-w-3xl" on a PrimeVue <Dialog> with a dense grid-cols-2 of 15 fields, break-all mono IDs, and a nested 5-column attempts table — apps/web/CLAUDE.md requires :breakpoints="{ '640px': '95vw' }" on fixed-width Dialogs and this one omits it. Likely cramped at 320–390px but needs a rendered viewport to confirm.
  • A11Y-02 / A11Y-05 (DataTable chrome): pagination is icon-only PrimeVue buttons with no aria-label and the search input is placeholder-labelled (DataTable.vue:417-420, :671-695). These live in the shared @aadalen/ui DataTable — see the DataTable cluster in PROJECT-LEVEL.md rather than filing per feature. Row keyboard activation is Enter-only (no Space) — same shared component.
  • MSG-02: the modal shows raw lastErrorCategory, formatJson(lastErrorDetails) and per-attempt message verbatim. Deliberate and correct for an admin debugging surface — recorded as a non-defect, not a MSG-02 failure.

Baseline additions ​

  • DATA-STATUS-FIDELITY (finding 3): a status indicator must map every value the backend can emit to a distinct visual treatment; a catch-all default that collapses several lifecycle states into one colour (or branches on values the enum no longer contains) misinforms triage. Candidate to fold into a data-display/table rule family.

Cross-project note ​

  • Silent action outcome (finding 1) is a strong cross-project risk on every admin retry/requeue surface — check tt-time-tracker and members admin action buttons, and customer-portal's sibling admin pages (#31 Sync monitoring retry, #33 Emails). The shared severity.ts map (finding 3) already covers sync/email status in the same file, so the "stale-enum, everything-warn" shape likely repeats on admin.sync-* and admin.emails.
  • Un-i18n'd admin section (finding 4) almost certainly spans all customer-portal admin pages; confirm and raise once for the section.