Skip to content

[UX] customer-portal — Shipping orders (internal) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 10 Patterns: none consulted (baseline sufficient; the queue's forms/signature-pad pattern does not apply — this feature records reception via photo + per-line received/not-received decisions, no signature capture exists)

Summary ​

A well-built internal goods-reception flow: a card-only list with pending/received tabs, and a detail page whose per-line decisions, partial-quantity steppers, optimistic mutations and server-polled write-back progress are unusually careful, with persistent (non-toast) error surfaces that correctly distinguish forbidden/pending/retryable command outcomes. en/nb locale parity is complete (CONTENT-01 passes). Defects are localized: a focus-dropping Confirm button (FORM-06), a sub-24px photo-delete control (A11Y-02), and an ARIA tab widget with no keyboard model or panel relationship (A11Y-03). Nothing here is a blocker.

Findings ​

1. Confirm button is disabled during submission — Medium · FORM-06 ​

Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:720-728What: The primary action is bound :disabled="!allDecided || isConfirming" and :loading="isConfirming". The moment the user activates Confirm, isConfirming becomes true and the button disables itself. A disabled element cannot hold focus, so focus is dropped to <body>. Why it matters: Keyboard and screen-reader staff lose their place mid-action on the one control that commits a reception; the subsequent success/error surface is not focus-managed either, so nothing announces the outcome to where focus lands. This is the explicit || isConfirming disable, independent of PrimeVue's internal :loading→disabled behaviour, so it is assertable from source. Fix: Keep the button enabled and guard re-entry inside onConfirm (early-return while isConfirming); convey busy state with aria-busy + the spinner rather than disabled. Move focus to the resulting ReceptionProgress/error region.

2. Photo-delete control is below the 24px minimum target — Medium · A11Y-02 ​

Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:572-580What: The remove-photo button is size-5 (20×20 CSS px), positioned overlapping the thumbnail corner (-right-1.5 -top-1.5). Why it matters: WCAG 2.5.8 requires ≥24×24px. On a warehouse phone this is a hard-to-hit control that also sits partly off the thumbnail edge, so accidental misses (and accidental hits) are both likely on the evidence photos a reception depends on. Fix: Give the control a ≥24px hit area (e.g. size-6 or a larger padded tap target while keeping the visual glyph small).

3. Tab strip declares ARIA tab roles without the tab keyboard model or a panel — Medium · A11Y-03 ​

Where: apps/web/src/pages/internal/ShippingOrdersPage.vue:72-90What: The pending/received switch uses role="tablist" + role="tab" + aria-selected, but the tabs are plain buttons with no roving tabindex/arrow-key navigation, no aria-controls, and the DataTable they drive has no role="tabpanel" or matching id. Why it matters: A screen reader announces "tab, 1 of 2" and sets the expectation of arrow-key movement and an associated panel; neither exists, so the widget behaves unlike what it claims to be. This mislabels the control rather than merely under-describing it. Fix: Either complete the pattern (roving tabindex + Left/Right handling, aria-controls pointing at a role="tabpanel" wrapper on the list) or drop the tab roles and expose the two views as a labelled button group / segmented control.

4. Uploaded photos are deleted with no confirmation — Low · MSG-05 ​

Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:293-300, 572-580What: Tapping the × on a thumbnail calls onRemovePhoto immediately; there is no confirm step and no undo. Why it matters: The photo is evidence attached to a reception line; an accidental tap (made more likely by finding #2) silently discards it. Fix: Confirm before deleting, or offer a brief undo. Mitigated by the receipt still being a draft (v-if="isDraft"), so the photo can be re-taken — hence Low.

5. Confirm stays disabled while the reception is incomplete — Low · FORM-05 ​

Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:707-728What: Confirm is disabled until every line has a decision (allDecided). Why it matters: Baseline prefers submits that stay enabled with a message explaining the block, so the user can trigger the explanation. Rated Low, not Medium, because the block is already explained live and specifically: the SelectionActionBar #info slot renders "{count} product(s) still need to be checked" that updates as lines are decided, so the user is never left without a next action — this is a soft FORM-05. Fix: Optionally keep Confirm enabled and surface the same remaining-count message on activation; current mitigation is close to compliant in spirit.

Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:590-597What: <input type="file" accept="image/*,.heic,.heif" capture="environment">. capture forces the rear camera on mobile and removes the "choose existing photo" path. Why it matters: Staff cannot attach a photo already taken (e.g. one snapped before opening the order, or a supplier's damage photo). Likely intentional for live reception, so Low. Fix: Drop capture (keep accept) to let the OS offer both camera and gallery, or offer both affordances.

Unverified ​

  • A11Y-01 (contrast): the pale status pills — text-status-*-fg on bg-status-*-bg, and text-text-3 muted metadata (product counts, internId) — need a contrast tool against the ÅDALEN tokens; not determinable from class names.
  • A11Y-06 (short viewport / mobile keyboard): the detail page's fixed SelectionActionBar plus sticky mobile title and the card grid need a rendered ≈700px-height viewport to confirm the last line card and the action bar do not collide.
  • MSG-01 on PrimeVue Message: the load-failure Message (:429) and the fully-received/already-confirmed Messages rely on PrimeVue's internal ARIA role; node_modules is not installed, so whether they carry role="alert"/ status is unverified. (The in-repo CommandErrorNotice and ReceptionProgress both set correct role/aria-live from source.)

Baseline additions ​

  • FORM-12 (proposed, ID will collide — orchestrator to renumber): A file input must not set capture unless capturing live media is the only valid source; otherwise it suppresses the gallery and blocks attaching an existing file. Already proposed as FORM-12(a) elsewhere in this batch (see PROJECT-LEVEL.md proposed-rule collisions) — cite, do not re-mint.

Cross-project note ​

  • FORM-06 (disable-on-submit focus drop) is the customer-portal recurrence of a theme already confirmed in playout (VButton disabled-loses-focus, repo-wide). Likely present anywhere a PrimeVue/FormKit submit is bound :disabled to its own in-flight flag — worth grepping :disabled=".*isConfirming|isPending|isSubmitting" across customer-portal, tt-time-tracker (also PrimeVue) and members.
  • A11Y-03 mislabelled/incomplete ARIA widgets aligns with the cross-project "click-only rows/chips" A11Y-03 row in PROJECT-LEVEL.md (all four projects).

Project-level items affecting this feature (referenced, not re-filed) ​

  • NAV-03 — one static document title app-wide. Fails here too; see PROJECT-LEVEL.md.
  • List state is component-local (no URL sync / no KeepAlive) — the tab, search and page on the list reset on Back from a detail; see PROJECT-LEVEL.md.
  • Shared @aadalen/ui DataTable cluster — see drafts/customer-portal--catalog.md. Note: the specific "error copy interpolates TanStack status string / 403 branch is dead code" defect appears fixed in this checkout's DataTable.vue:247-276 (numeric statusCode, live 403 branch, Status {n} description only when a code exists). This list passes :is-error without :status, so its 403s show the generic message rather than the forbidden-specific one — minor, not re-filed.
  • MSG-02 (getErrorMessage prefers raw backend string) — not exercised here: this feature maps errors via useCommandError to commandError.<category> i18n keys and static t() copy; no raw SDK prose reaches the user.