Appearance
[UX] customer-portal — Orders logistics (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 — the queue-mapped slugs (data-display/kanban-board, data-display/table, content-management/drag-and-drop) are not what this feature implements. As built it is a single-page card list plus a signature-capture confirmation dialog; there is no kanban board, no data table and no drag-and-drop anywhere inOrdersLogisticsPage.vue. The baseline alone covers it.
Summary
Internal "Ready for logistics" queue: a card list of work orders, each confirmed as picked-up by drawing a signature in a dialog that fires a durable Dataverse command. The durable-command error handling (CommandErrorNotice, persistent, role="alert", category-aware retry vs. "ask an admin") is genuinely good and above baseline. The two real problems are structural: the list silently shows only the first 50 orders with no pagination and a search that only filters those 50, so in a busy queue orders become invisible and unconfirmable; and the signature — the sole gate on the primary action — is pointer-only, so a keyboard/switch user cannot complete a delivery at all. orders.logistics and orders.logistics.sandboxed render the same component; findings apply to both.
Findings
1. Only the first 50 orders load; search filters just those 50 — High · DATA-TRUTHFUL-AGGREGATE (proposed)
Where: apps/web/src/pages/internal/OrdersLogisticsPage.vue:17,53,76,266; apps/web/src/domains/internal/orders/orders.collection.ts:18-22; packages/domain-helpers/src/create-domain-helpers.ts:86,129-131What: The page destructures only { rows, isLoading, isError } from useLogisticsOrderList() and ignores total, totalPages, page, setPage and the hook's own debounced search. rows is a single server page — defaultPageSize is 50. No pagination control is rendered, and setPage is never called, so orders 51+ never load. The visible search box is a separate local ref (OrdersLogisticsPage.vue:19) whose matchesSearch (:58-74) filters only the loaded 50 rows; the server-side search param is never wired. The heading count (${readyOrders.length}) (:266) is the length of the filtered loaded page, not total. Why it matters: Logistics is described in orders.collection.ts:16 as a global, all-customers queue, so exceeding 50 ready orders is an ordinary daily state. When it happens, orders past the first page are invisible and a worker who searches for a known order on "page 2" is told "No orders match your search" — the confirm control for a real, pending delivery is unreachable and the UI actively denies the order exists. The heading count also understates the true backlog. Below 50 orders everything works, which is exactly why this degrades silently. Placed High rather than Blocker only because the normal low-volume case functions; in high-volume operation it is effectively a Blocker. Fix: Drive the queue off the hook's real state — bind the search box to the hook's search (server-side, debounced) and render pagination via page/ totalPages/setPage, or switch this queue to a load-all/infinite-scroll fetch if the total is bounded. Show total in the heading, not the loaded-page length.
2. Signature capture is pointer-only — keyboard users cannot confirm a delivery — High · A11Y-03
Where: apps/web/src/pages/internal/OrdersLogisticsPage.vue:439-448 (canvas), :477 (confirm :disabled="!hasSignature") What: The signature <canvas> is driven entirely by pointerdown/ pointermove/pointerup/pointerleave. hasSignature — the only thing that enables the confirm button — is set exclusively inside startDrawing (pointer-only). There is no keyboard path to produce a signature and no typed-name or checkbox-attestation alternative. Why it matters: Confirming pickup is the feature's entire purpose, and it is gated on a control no keyboard-only or switch-access employee can operate. That is a hard WCAG 2.2 AA (2.1.1) barrier on the primary action, not a cosmetic gap. Fix: Offer a keyboard-operable alternative to satisfy the signature requirement (typed full name + explicit "I confirm pickup" affirmation, or an "upload signature" path), or, if a drawn signature is legally required, at least make that requirement and its inaccessibility explicit and provide a non-drawing fallback route to complete the task.
3. Transport-status labels are Norwegian-only in the English locale — Medium · CONTENT-01
Where: apps/web/src/i18n/messages/en.yml:1670-1674 (identical strings in nb.yml:1670-1674) What: ordersLogistics.transportStatus.* (the Dynamics option-set labels rendered by getTransportStatusLabel, OrdersLogisticsPage.vue:97-102) are Norwegian with emoji in both locales, e.g. the en.yml value for 100000000 is "Ikke behandlet 😴", 100000001 is "Ådalen Logistikk 🐌". Why it matters: An English-locale user sees Norwegian transport-type labels in the card, the search haystack and the dialog "quick info" — the one field-specific piece of copy on the card is untranslated. customer-portal (en/nb) is held to CONTENT-01 per feature per the baseline. Fix: Provide real English strings for each option-set code in en.yml; keep the emoji if desired but translate the words.
4. Search field is labelled by placeholder only — Medium · FORM-01
Where: apps/web/src/pages/internal/OrdersLogisticsPage.vue:279-283What: The <InputText v-model="search"> has only a :placeholder; no associated <label> and no aria-label. Why it matters: Placeholder text is not an accessible name and vanishes on input, so a screen-reader user gets an unnamed textbox and a returning user has no persistent cue to the field's purpose. FORM-01 forbids placeholder-as-label. Fix: Add a visually-hidden <label>/aria-label (e.g. "Search orders").
5. Confirm button disabled while invalid, with nothing explaining the block — Medium · FORM-05
Where: apps/web/src/pages/internal/OrdersLogisticsPage.vue:476-481What: The dialog's confirm button is :disabled="!hasSignature". Before a signature is drawn it is a dead control with no adjacent message stating that a signature is required to proceed. Why it matters: A disabled submit gives the user nothing to act on; on a warehouse floor the reason the button "does nothing" is not obvious. The baseline prefers keeping submit actionable and surfacing a validation message that names the block. Fix: Keep the button enabled and, on activation without a signature, show a "Draw a signature to confirm" message (or add persistent helper text under the canvas); if it stays disabled, pair it with visible required-state text.
6. Signature <label> not associated; dead confirmFailed copy — Low · FORM-01
Where: apps/web/src/pages/internal/OrdersLogisticsPage.vue:438-448; en.yml:1686/nb.yml:1686What: The "Signature" <label> (:438) has no for and the <canvas> has no id/aria-label, so the two are not programmatically associated. Separately, ordersLogistics.errors.confirmFailed exists in both locales but is unused — the component surfaces commandError copy instead — so the translated string is dead weight that will drift. Why it matters: Minor: an unassociated label is not announced with the control; the dead key is maintenance noise. Fix: Give the canvas an id and point the label's for at it (or wrap it), and remove the unused confirmFailed key (or wire it as the generic fallback).
Unverified
- A11Y-01 (contrast): the emoji-bearing status labels,
text-text-3muted product metadata (:359-363) and the#0f172asignature stroke onbg-paperall need a contrast tool against the ÅDALEN tokens — not decidable from class names. - A11Y-06 (short viewport / mobile keyboard): the fixed-size
820×220canvas (:439-448) inside amin(900px,95vw)dialog / mobileBottomSheetneeds a rendered ~700px-height viewport to confirm the canvas and footer buttons stay reachable with a keyboard open. - A11Y-02 (target size): the
size="small" text"Clear signature" and thesize="small"dismiss/retry buttons inCommandErrorNoticemay fall under 24×24 px — needs a render. - MSG-01 (PrimeVue
<Message>role): the load-failed / empty / submit-error<Message>blocks (:303-325,457-463) rely on PrimeVue's internal ARIA;node_modulesis not installed, so whether they carryrole="alert"/statusis unverifiable here. (CommandErrorNoticesetsrole="alert"explicitly and is fine.) - FORM-06 (focus drop on
:loading): the confirm button uses PrimeVue:loading="isSubmitting", which may setdisabledand drop focus — PrimeVue internal, unverifiable without the package.
Baseline additions
- DATA-TRUTHFUL-AGGREGATE (proposed; reconciles project-level DATA-TRUTHFUL-COUNT / FILTER-SCOPE / DATA-PAGE-AGGREGATE): a list must not silently present one server page as the whole set — pagination must be reachable, a free-text search must query the full dataset (not just the loaded page), and any displayed count must cover the whole set or say what it covers. Finding 1 is a strong instance (truncation plus page-scoped search plus a misleading count in one screen).
- A11Y-SIGNATURE / input-modality (proposed): a required signature or other drawn input must offer a non-pointer path to satisfy it, or the task it gates is unreachable by keyboard/switch users. Covered by A11Y-03 in spirit; worth a named rule as signature pads recur (queue #27 Shipping orders also uses one).
Cross-project note
- Finding 1 (page-scoped list/search/count): squarely the project-level "aggregates computed from one page of data" theme (PROJECT-LEVEL.md) already confirmed on customer-portal dashboard and trade-in machines; here it is a bespoke non-DataTable list, so the shared-
DataTablecluster fix does not reach it — flag separately. tt-time-tracker shows the same shape (projects "Coût HT" over one invoice page), so likely present there too. - Finding 2 (pointer-only signature): customer-portal queue #27 Shipping orders (
forms/signature-pad) almost certainly shares this exact canvas approach; check it. tt-time-tracker has no signature surface noted. - NAV-03 (per-route document title) — fails project-wide, see PROJECT-LEVEL.md ("one static document title for the whole app"); not re-filed.
- CONTENT-01 status-label leak (finding 3): worth grepping other Dynamics option-set label maps in en.yml for Norwegian literals — the pattern (borrowing nb copy for an untranslated option set) may recur on other internal screens.