Skip to content

[UX] customer-portal — Charging (internal) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 7 Patterns: none consulted (feature plainly covered by BASELINE + PROJECT-LEVEL; MCP budget conserved)

Summary ​

A well-considered, mobile-first select-then-confirm screen for warehouse staff to batch-confirm lifts put on charge. Error handling for the write path is genuinely good (persistent role="alert" notice, category-mapped copy, retry that preserves selection, forbidden distinguished from transient). The gaps are all on the read side and in a few hand-rolled controls: the search field has no accessible label, the list-load error is a dead end with no retry, and the lift-type chip filter presents a partial (one-page) result as if it were the complete set.

Findings ​

1. Search input has no accessible label — Medium · FORM-01 ​

Where: apps/web/src/pages/internal/ChargingPage.vue:183-187What: The primary control is <InputText v-model="search" :placeholder="t('charging.search')" /> inside an IconField. There is no <label>, no aria-label, no aria-labelledby. The pi pi-search InputIcon is decorative. The placeholder is the only naming. Why it matters: This is a search-first flow — the search box is the first thing staff use and the whole task hinges on it. A screen-reader user focusing the field hears only "edit text" (the placeholder is not a reliable accessible name and vanishes the moment they type), so the one control the page is built around is unnamed. Fix: Add a visually-hidden <label> bound to the input, or aria-label="t('charging.search')" on the InputText. Keep the placeholder as supplementary hint text.

2. List-load failure is a dead end with no retry — Medium · MSG-03 / MSG-04 ​

Where: apps/web/src/pages/internal/ChargingPage.vue:216-221; composable exposes an unused refetch at apps/web/src/domains/internal/charging/charging.collection.ts:50What: On isError, the page renders a static Message: "Could not load chargeable lifts." with no action. useChargingProducts already returns refetch, but the template never wires it, and there is no reload/try-again control. Why it matters: A transient fetch failure (network blip, token refresh) strands the employee on a screen that only states the problem. The contrast with the write path — which offers a retry that preserves the selection — is stark. The user's only recovery is a full page reload, which also discards any pending selection. MSG-03 (say what to do next) and MSG-04 (a dead end offers a route out) both fail. Fix: Add a "Try again" button to the error Message calling the exposed refetch(), mirroring CommandErrorNotice's retry affordance.

3. Lift-type filter presents a one-page subset as the complete set — Medium · proposed DATA-TRUTHFUL-AGGREGATE ​

Where: apps/web/src/pages/internal/ChargingPage.vue:282-288 (count), :374-375 (load-more gate), :39-57 (chips + filter) What: Type chips are derived from, and typeFilter is applied to, only the loaded products (visibleProducts = products.filter(...)). When a chip is active: the count branch !typeFilter && total > products.length is false, so the plain liftCount ("{n} lifts") is shown with no "showing X of Y" caveat; and "Load more" is hidden (hasMore && !typeFilter). Page size is 200, so with 200+ total, lifts of the chosen type sitting on page 2+ are invisible and uncounted. Why it matters: The non-filtered path is scrupulously honest ("Showing 12 of 47"), which makes the filtered path more misleading by contrast: "8 lifts" reads as all 8 of this type, when there may be more unloaded. Staff scanning by type could conclude a lift isn't in the system. (The code comment at :369-373 acknowledges the load-more suppression but not that the count then over-claims completeness.) Why Medium not High: search (server-side, authoritative) is the primary path and is correct; the chip filter is a secondary convenience, and there is no data-loss, only a potentially misleading count. This is the customer-portal instance of the "aggregates computed from one page of data" theme in PROJECT-LEVEL.md. Fix: When a type chip is active and total > products.length, either keep the "Showing X of Y (load more to filter the rest)" caveat visible, or push the type filter server-side so the count and chips reflect the whole result set.

4. Confirm button is :disabled during submit — focus drop — Medium · FORM-06 ​

Where: apps/web/src/pages/internal/ChargingPage.vue:405-412What: The confirm button sets both :loading="isSubmitting" and :disabled="isSubmitting". A native disabled applied to the just-activated button removes it from the focus order, dropping focus to <body>. Why it matters: A keyboard or screen-reader user who presses Confirm loses their place mid-action; on success the SelectionActionBar then hides entirely (selection is cleared), so focus is never restored to anything meaningful. FORM-06 calls for aria-busy + a handler guard (already present via confirmSelected's isSubmitting check at :143) rather than disabled. Fix: Drop :disabled="isSubmitting"; rely on the existing :loading (aria-busy) and the in-handler guard. On success, move focus to a stable element before the bar unmounts.

5. Text-only "select all" / "clear" controls are small targets — Low · A11Y-02 ​

Where: apps/web/src/pages/internal/ChargingPage.vue:272-278 (select-all), :396-402 (clear in action bar) What: These are bare text-sm <button>s with no padding, so their hit area is roughly the 20px text line-height — under the 24×24 CSS-px floor (WCAG 2.5.8). Why it matters: On the phone this page targets, a 20px text link is an easy miss, especially "Clear" which discards the whole selection. Rated Low because they sit inline beside adjacent text (which softens the 2.5.8 requirement) and each has a larger alternative nearby. Fix: Add vertical padding (e.g. py-1.5 giving ≥24px) or render them as small outlined buttons.

6. Search field is not focused on mount — Low · FORM-11 ​

Where: apps/web/src/pages/internal/ChargingPage.vue:25, :180-188What: This is a single-purpose, search-first task, but the search input is not autofocused; the user must tap it before typing the serials they arrived holding. Why it matters: Minor friction on the exact control the flow is designed around. Fix: Focus the search InputText on mount (desktop; consider not auto-opening the mobile keyboard if that is undesirable on the warehouse floor — a deliberate call, not an omission).

Unverified ​

  • A11Y-01 (contrast) — needs a rendered page. Watch: white text on bg-accent-strong chips/cards (:194, :357), text-text-3 on the empty-state icon (:229), text-text-2/text-xs metadata, and the PrimeVue Tag severity foregrounds.
  • A11Y-06 (short viewport / mobile keyboard) — needs a rendered viewport. The sticky SelectionActionBar plus the mobile tab bar plus an open keyboard is the case to check.
  • A11Y-03 (visible focus) — the hand-rolled <button>s (type chips :191-212, lift cards :304-365, select-all, clear) carry no explicit focus-visible: ring. Whether a visible indicator survives depends on global CSS (packages/ui theme, not read here); cannot assert from this file alone.
  • MSG-01 for the list-load error (:216) — relies on PrimeVue Message emitting an alert role; PrimeVue internals are not readable (node_modules not installed). The write-path notice (CommandErrorNotice.vue) sets role="alert" explicitly and passes.

Baseline additions ​

  • DATA-TRUTHFUL-AGGREGATE (finding 3, reconciling the separately-proposed DATA-TRUTHFUL-COUNT / FILTER-SCOPE / DATA-PAGE-AGGREGATE in PROJECT-LEVEL.md): a displayed count, total or aggregate must either cover the whole data set or state what it covers; a filter applied to one loaded page must not present its result as complete.
  • No other new rules; the rest map to existing IDs.

Project-level (referenced, not re-filed) ​

  • NAV-03 — one static document title app-wide; see PROJECT-LEVEL.md ("three agents"). This route sets no distinct title. Not re-filed.
  • CONTENT-01 — PASSES here: charging.* keys have full en/nb parity (en.yml:1816-1849, nb.yml:1816-1849), Norwegian fully translated.
  • MSG-02 — the project-level getErrorMessage raw-string defect does NOT reach this page: the write path uses useCommandError, which maps errorCategory to i18n keys (useCommandError.ts:80-96); the load error uses a static localised string. No finding.

Cross-project note ​

  • FORM-01 (placeholder-only search): search-first list screens are common across all four apps; likely recurs in tt-time-tracker and members list/search surfaces.
  • MSG-03/04 (load error with no retry): matches the PROJECT-LEVEL "failed load renders with no recovery" theme confirmed in members (TableData.vue) and tt-time-tracker (ProjectDetails, Dashboard entry list). Same shape, different repo.
  • DATA-TRUTHFUL-AGGREGATE: the "aggregate from one page" theme is already confirmed in customer-portal (dashboard, trade-in) and tt-time-tracker (projects); this is a fresh instance in a fourth customer-portal surface.
  • FORM-06 (disable on submit): the PrimeVue :loading+:disabled pattern is a house habit; tt-time-tracker (also PrimeVue) is the likely twin.