Appearance
[UX] customer-portal — Service plans & checklist builder
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 16 Patterns:content-management/drag-and-drop
Summary
The checklist builder can only be operated with an HTML5 mouse drag: there is no click-to-add, no move-up/down, no keyboard path and no touch fallback, so a keyboard user cannot create a single field and — because HTML5 drag events do not fire on touch and no polyfill is installed — neither can any user of the Capacitor / PWA mobile app that apps/web/CLAUDE.md names as the primary platform. Behind that, the builder loses work on any dismissal (no dirty guard on back, cancel, Escape, scrim tap or swipe-down), swallows both its load and its save errors in empty catch { // handle } blocks, and ServicePlansPage freezes the selected customer account at mount so plans created after an account switch are written to the previous customer. The reorder logic itself is sound — items carry their own options array, so there is no parallel-array desync of the kind found in playout.
Findings
1. Checklist fields can only be added and reordered by mouse drag — Blocker · A11Y-03
Where: apps/web/src/components/ChecklistBuilderForm.vue:233-242 (toolbox), :158 (only call to newItem()), :128/:163 (reorder), :294-297 (item drag source) What: The six toolbox tiles are <div draggable="true"> with no tabindex, no role, no @click and no @keydown. newItem() is reachable from exactly one place — onGapDrop() at :158. Reordering is likewise only reachable through onItemDragStart + onGapDrop. There are no move-up/move-down buttons, no "add field" menu, no position <Select>, and grep over apps/web/package.json, package.json and packages/ui/package.json finds no drag library or touch-events polyfill (sortable, dnd-kit, vuedraggable, dragula — zero hits); ChecklistBuilderForm.vue is the only file in apps/web/src and packages/ui/src using draggable=, so this is hand-rolled native HTML5 DnD. Why it matters: A keyboard-only or screen-reader user cannot add one field to a checklist — the create flow has no reachable control at all, and Save (:400) stays permanently disabled on a new checklist because the title is the only thing they can fill in. Native HTML5 dragstart/drop do not fire from touch input, so the same is true for every user on the mobile wrapper and on any tablet — the platform the frontend guide says to design for first. The UX Patterns drag-and-drop entry lists "Fallback controls" as a required part of the component anatomy and its accessibility checklist opens with "Verify that drag and drop can be completed using keyboard alone."Severity call: Blocker rather than High — this is not a degraded path, it is the absence of any path: the feature's only content-creating control is unreachable and a save cannot produce a non-empty checklist. Fix: Make the toolbox tiles real <button type="button">s that append the field on click/Enter/Space (drag stays as an enhancement). Give each item card a move-up / move-down pair (or a "Position" number input) next to the delete button, with aria-live="polite" announcing "Moved Oil level to position 3 of 7". Give the pi-bars handle (:301-303) an accessible name and make it the actual drag source rather than decoration.
2. The selected customer account is frozen at mount — plans are saved to the wrong customer — Blocker · proposed DATA-SCOPE-01 (see PROJECT-LEVEL "persisted scope never revalidated")
Where: apps/web/src/pages/ServicePlansPage.vue:22, :89, :108, :142; apps/web/src/components/ChecklistBuilderForm.vue:29, :91What: const { accountId } = useAdminAccountStore() destructures a Pinia setup store. The store object is reactive(), so accountId (a computed at stores/admin-account.ts:62) is unwrapped to a plain string | undefined at destructure time. watch(() => accountId, loadData) at :108 therefore watches a constant getter and never fires, and the query built at :89 plus customerId: accountId at :142 and ChecklistBuilderForm.vue:91 keep the value captured when the page mounted. Every other consumer of this store in the repo uses the reactive form () => useAdminAccountStore().accountId (domains/*/**.collection.ts, domains/skills/skills.composables.ts), so these two files are the outlier. Switching account does not remount the page — AccountSwitcher.vue:31, AccountChip.vue:44 and mobile/AccountPickerSheet.vue:36 only call store.select(), with no navigation. Why it matters: An internal/admin user switches from Customer A to Customer B in the account switcher; the Service plans page silently keeps showing A's plans and checklists as if they were B's, and the watch the author wrote to prevent exactly this never runs. If they then create a plan or a checklist, it is POSTed with customerId: <Customer A>. The record lands on the wrong customer with no error and no visible clue — the list it appears in is the stale one, so it looks correct. Severity call: close between High and Blocker. Placed at Blocker because a normal, routine action (switch account → create plan) produces a materially different result from the one requested, with no signal. It is not a security failure — the backend still authorises the admin for both accounts — so it is not filed under SEC. Fix: const store = useAdminAccountStore() and read store.accountId at each use site, or const { accountId } = storeToRefs(useAdminAccountStore()) with watch(accountId, loadData). Same change in ChecklistBuilderForm.vue:29.
3. A half-built checklist is discarded by every exit route, with no warning — High · MSG-05
Where: apps/web/src/pages/ChecklistBuilderPage.vue:19, :35-36, :41; apps/web/src/components/ChecklistBuilderForm.vue:398; apps/web/src/pages/ServicePlansPage.vue:694-705What: No dirty tracking exists anywhere in the feature. The standalone page's back affordance goes straight to useBackNavigation → router.back(); @cancel emits and navigates immediately; there is no onBeforeRouteLeave and no beforeunload listener in either file. The inline builder is mounted in a ResponsiveDialog, which on desktop is a PrimeVue Dialog (mask click + Escape both close) and on mobile is packages/ui/src/components/BottomSheet.vue, which dismisses on scrim click (:204), Escape (:126) and a swipe-down gesture (:159-178) — none of which is intercepted. Why it matters: Building a service checklist is long-form work: a dozen fields, each with a typed label, dropdown options and a required flag, none of it persisted until Save. On mobile a single mis-swipe on the sheet destroys all of it with no confirm and no recovery, and the same is true of an accidental Escape on desktop. This is precisely the case MSG-05 exists for. Fix: Track a isDirty flag (title non-empty or items.length > 0 and changed against the loaded snapshot). Guard @cancel, @back, onBeforeRouteLeave and ResponsiveDialog's update:visible with a "Discard checklist? N fields will be lost" confirm, and pass :dismissible="false" to the sheet while dirty so swipe-down and scrim tap route through the same confirm.
4. The builder swallows both its load error and its save error — a failed load can silently erase a checklist — High · MSG-01, MSG-02/MSG-06 (see PROJECT-LEVEL)
Where: apps/web/src/components/ChecklistBuilderForm.vue:61-62 and :108-109What: Both catch blocks are empty with a // handle placeholder comment. No toast, no Message, no error ref, no role="alert" surface anywhere in the component's 410 lines. Why it matters: Two distinct harms. (a) Save: the user clicks Save, isSaving flips back to false, the dialog stays open and absolutely nothing else happens. There is no way to tell a failed save from a slow one, so the user clicks again — and note the route is gated on abilityCheck: { action: "read", subject: "Booking" } (router/index.ts:239, :247), a read ability, so a user who can open the builder but lacks the write policy hits this dead-silent failure on every attempt. (b) Load, in edit mode: serviceChecklistsGetById fails, isLoading goes false and the builder renders as an empty new checklist — blank title, empty drop zone, no error. The user reasonably retypes the title and saves; serviceChecklistsUpdate is then called with items: [] (:90) and the real checklist's fields are overwritten with nothing. That is unrecoverable data loss caused by an unreported fetch failure. This is the customer-portal instance of the MSG-06 theme already logged in PROJECT-LEVEL.md ("failed fetch must not render as an empty state"). Severity call: High rather than Blocker because (b) needs a failed request to trigger; (a) alone would be High. Fix: In catch on load, set an isLoadError ref and render an error state with a Retry in place of the builder — never the empty form. In catch on save, add toast.add({ severity: "error", summary: t("common.saveError") }) (the key already exists, en.yml:287) inside a role="alert" region.
5. A failed delete is rendered as a successful one — High · MSG-06 (see PROJECT-LEVEL)
Where: apps/web/src/pages/ServicePlansPage.vue:171-177What: servicePlansRemove is called without throwOnError, unlike every other call in the file. The API (apps/api/src/modules/service-plans/service-plans.controller.ts:54-65) answers 200 + body when it deactivates and 204 when it hard-deletes, and the frontend branches on status === 200 && response.data. Any failure — 403 from the delete policy, 404, 500 — is not thrown, misses that branch and falls into the else, which filters the plan out of the list and closes the dialog. The catch at :178 can never fire for an HTTP error because nothing throws. Why it matters: The user sees the plan disappear and the confirm dialog close — an unambiguous "deleted". The plan is still there; it reappears on the next load, possibly days later, still generating scheduled services the user believes they cancelled. Fix: Pass throwOnError: true and branch on response.status === 204 for the hard-delete case, letting the existing catch show common.deleteError.
6. "Delete service plan" often deactivates instead, and a deactivated plan can never be edited, reactivated or removed — High · MSG-05, MSG-04
Where: apps/web/src/pages/ServicePlansPage.vue:325-341, :673-676; apps/web/src/i18n/messages/en.yml:1303-1305; apps/api/src/modules/service-plans/service-plans.repository.prisma.ts:40-53What: The backend chooses between a hard delete and a soft deactivatedAt stamp based on hasCompletedHistory(id) — a condition the user cannot see. The confirm copy is a single unconditional sentence: "Are you sure you want to delete this service plan? This action cannot be undone." / "…Denne handlingen kan ikke angres." It never says the plan may survive as a deactivated row, nor what happens to the service history attached to it. Worse, once deactivatedAt is set, both the edit and the delete buttons are hidden behind v-if="!(row).deactivatedAt" (:325, :334) and no reactivate control exists anywhere in the page — the row is permanently stuck at 50% opacity with no action available on it. Why it matters: MSG-05 requires a destructive confirm to say what will be lost; this one describes the wrong outcome half the time. And a mis-click produces a state the user can never get out of from the UI: a dead row that cannot be edited, restored or cleared. That is the unrecoverable dead end MSG-04 exists to prevent. Fix: Split the copy by outcome (the API already knows — surface hasCompletedHistory on the plan response, or reword to "…plans with completed service history are deactivated rather than removed, and their history is kept"). Add a Reactivate action on deactivated rows, and keep Edit available on them.
7. Form labels are not programmatically associated; question and option inputs have no label at all — Medium · FORM-01
Where: apps/web/src/pages/ServicePlansPage.vue:507-514, :552-568, :573-584, :588-590; apps/web/src/components/ChecklistBuilderForm.vue:214-221, :306-310, :335-339What: Every one of these <label> elements is a bare styled block with no for and no id on the paired InputText / InputNumber / Select — e.g. the plan title label at :507 and its input at :510. The two radio buttons at :523/:536 are the only correctly associated controls in the feature (input-id + for), as is the per-item Required checkbox (ChecklistBuilderForm.vue:360-370). The builder's per-item question input (:306) and each dropdown option input (:335) have no label element at all — only placeholder="Enter question or label…" / "Write option…", which disappears on first keystroke. Why it matters: A screen reader announces the plan title field as "edit, blank" with no name, and the interval Select as unlabelled. In a checklist with ten option inputs, a screen-reader user has no way to tell which item any given box belongs to. Placeholder-as-label also fails at 200% zoom and for anyone who pauses mid-entry. Fix: for / input-id pairs on all six labelled fields. For the repeated inputs, add a visually-hidden label bound to the item, e.g. :aria-label="t('checklistBuilder.zone.questionAria', { n: index + 1 })" and option n of item.label.
8. Save buttons are disabled while invalid and while saving; item content is never validated — Medium · FORM-05, FORM-06, FORM-04
Where: apps/web/src/components/ChecklistBuilderForm.vue:401 and :404; apps/web/src/pages/ServicePlansPage.vue:650 and :652What: Both Save buttons carry :disabled="!<title>.trim() || isSaving" plus :loading. Nothing on either surface states that a title is required — no asterisk, no hint, no message — so the only signal is a button that looks broken. The isSaving half disables a just-clicked button, and the guards at ChecklistBuilderForm.vue:85 and ServicePlansPage.vue:132 already prevent a double submit, making the disable redundant. Conversely, nothing validates the item content: a checklist saves cleanly with ten fields whose label is "" and a dropdown whose options are [""] (:69 seeds exactly that empty string). Why it matters: A user who has dragged in eight fields and not noticed the empty title sees a dead Save button and no explanation (FORM-05). Disabling on click drops focus to <body>, so a keyboard user loses their place (FORM-06 — PrimeVue Button's :loading → disabled mapping is Unverified, see below, but the explicit :disabled binding is not). And the checklist that does save can reach a technician in the field as a list of blank questions — the constraint that matters most is the one not enforced. Fix: Keep both buttons enabled, use aria-busy for the in-flight state, and on click show an inline role="alert" naming what is missing. Validate that every item has a non-empty label and every dropdown at least one non-empty option, and mark the title field required before the user types (FORM-04).
9. Heading structure skips a level, and the Checklists tab has no <h1> on mobile — Medium · A11Y-04
Where: apps/web/src/pages/ServicePlansPage.vue:240, :272-284, :308, :379What: PageLayout renders the only <h1> (packages/ui/src/components/PageLayout.vue:103 — in-repo, asserted from source). The Plans tab then jumps straight to <h3> per plan card (:308) with no <h2> in between. Separately, :mobile-header="false" at :240 hides that whole header block below 760px (PageLayout.vue:78), on the documented assumption that DataTable supplies the sticky mobile header instead — but the DataTable is inside v-if="activeTab === 'plans'" (:272). On the Checklists tab on mobile there is therefore no page header at all: no <h1>, no "Service Plans" title, and the #actions create button is gone too — which is why a duplicate mobile-only create button had to be hand-rolled at :384-391. Why it matters: A screen-reader user landing on the Checklists tab on a phone gets a document whose first heading is <h2>Checklists</h2> with no page title, and sighted mobile users lose the page title and the scroll-away header when they switch tabs. Fix: Wrap each tab panel in an <h2> (the Checklists tab already has one at :379; add the matching one for Plans) so the card <h3>s nest correctly, and drive :mobile-header off the active tab — :mobile-header="activeTab !== 'plans'" — so the header returns when the DataTable is not mounted.
10. The chip remove button is 16×16 CSS px — Medium · A11Y-02
Where: apps/web/src/pages/ServicePlansPage.vue:603-612What: class="ml-1 !h-4 !w-4" with ! importance overrides PrimeVue's button sizing to a 1rem × 1rem hit area — the control that unlinks a checklist from a service plan. WCAG 2.5.8 requires 24×24. Why it matters: On a phone this is a ~4mm target sitting immediately beside the chip text; mis-taps either miss entirely or unlink the wrong checklist, and there is no undo for the unlink. Fix: Drop the size overrides and keep the icon visually small via font-size while letting the button keep a ≥24px box (!h-6 !w-6 at minimum, plus spacing between adjacent chips).
11. Checklist delete button has no accessible name — Medium · A11Y-05
Where: apps/web/src/pages/ServicePlansPage.vue:453-460What: Icon-only <Button icon="pi pi-trash" rounded text> with no :aria-label and no :label. Every comparable button in the feature does have one (:287, :326, :335, :385, :604, ChecklistBuilderForm.vue:313, :341), so this is an omission rather than a convention. Why it matters: It is announced as "button" with no name, next to an "Edit" button — a screen-reader user is one guess away from destroying a checklist. Fix: :aria-label="t('servicePlans.checklists.delete')" — the key already exists at en.yml:1278 / nb.yml:1278 and is currently unused.
12. Editing a checklist opens a new browser tab from a hardcoded URL — Medium · proposed NAV-TAB-01
Where: apps/web/src/pages/ServicePlansPage.vue:207-210What: window.open(\/service-plans/checklists/${id}`, "_blank"). This is the only navigation to ChecklistBuilderPage.vuein the entire app —grepforchecklist-builderacrossapps/web/src returns only the two route definitions themselves (router/index.ts:237, :245), so the named routes are never used and checklist-builder.newis dead code with no entry point at all. **Why it matters:** Three consequences. (a) In the Capacitor wrapper,_blank hands the URL to the system browser / in-app browser, outside the authenticated SPA session — the user lands on a sign-in screen or a blank page instead of the builder. (b) The path is a duplicated string literal, so it drifts silently if the route ever changes — the router's named routes exist precisely to prevent that. (c) Even on desktop, a new tab breaks the back affordance the builder page carefully implements (ChecklistBuilderPage.vue:19useBackNavigation): window.history.state.backis null in a fresh tab, so back always falls through to the list rather than returning where the user came from. **Fix:**router.push({ name: "checklist-builder.edit", params: { id } }). If the intent was genuinely to preserve the open plan modal, reuse the existing inline ResponsiveDialog in edit mode (ChecklistBuilderFormalready acceptseditId) rather than leaving the app. **Proposed rule:** *NAV-TAB-01— in-app destinations are reached through the router by name, neverwindow.open/_blankwith a hand-written path; a Capacitor or PWA shell treats_blank` as leaving the app.*
13. Every plan card reports "0 assigned machines" — Medium · proposed CONTENT-STUB-01
Where: apps/web/src/pages/ServicePlansPage.vue:361What: t("servicePlans.card.machines", { count: 0 }) — the count is a hardcoded literal, not read from the plan. The neighbouring checklist count at :357 is real (.checklists.length), which makes the stub indistinguishable from live data. Why it matters: A plan applied to forty machines displays "0 assigned machines" in Norwegian and English alike. The user reasonably concludes the plan is not attached to anything and either re-creates it or stops trusting the page. Fix: Bind the real count from the plan response, or remove the row until the field exists — an unimplemented metric should be absent, not wrong. Proposed rule: CONTENT-STUB-01 — placeholder values must never be rendered in the same shape as live data; an unimplemented figure is omitted, not shown as zero.
14. The drag handle is decorative and the drag source is the whole card — Low · A11Y-05
Where: apps/web/src/components/ChecklistBuilderForm.vue:294-297, :301-303What: draggable="true" and @dragstart sit on the Card, while the pi-bars glyph that signals "drag here" is a plain <span class="cursor-move"> with no name, no role and no handler. So the affordance points at something that does nothing, and the real drag target is everything including the label input and the delete button. Why it matters: The handle is announced as nothing at all, and the mismatch means a drag started anywhere in the card — including a mis-press on the trash button — is a reorder. Fix: Move draggable and @dragstart onto the handle, give it role="button", tabindex="0" and an accessible name, and wire it to the keyboard reorder from finding 1.
15. The edit dialog is titled just "Edit" — Low · CONTENT-02
Where: apps/web/src/i18n/messages/en.yml:1284 / nb.yml:1284, used at apps/web/src/pages/ServicePlansPage.vue:501What: servicePlans.modal.editTitle: "Edit" / "Rediger", against createTitle: "Create service plan" / "Opprett serviceplan" on the same dialog. Why it matters: The control that opens it is an unlabelled pencil icon on a card, so the dialog heading is the user's only confirmation of what they are editing — and it says nothing. On the mobile bottom sheet, which fills the screen and hides the card behind it, this is the only context available. Fix: "Edit service plan" / "Rediger serviceplan", ideally with the plan title.
16. The checklist title field is not focused on open — Low · FORM-11
Where: apps/web/src/components/ChecklistBuilderForm.vue:217-221What: No autofocus, no ref + onMounted().focus(). The builder is a single-purpose form and its title is both the first field and the one gating Save. Fix: Focus the title input on mount when editId is absent (and after the load resolves when it is present).
Not applicable / passing
- CONTENT-01 — passes.
servicePlans.*andchecklistBuilder.*are fully populated in bothen.ymlandnb.yml(lines 1253-1329 in each), with no key drift and no untranslated English values. Worth noting explicitly since this is one of the two projects held to CONTENT-01 per feature. - NAV-03 — fails project-wide (one static document title), see PROJECT-LEVEL.md. Not re-filed here.
- Parallel-array desync — checked specifically.
items[]is a single array and each item owns its ownoptions[](ChecklistBuilderForm.vue:11-17), so a reorder cannot desynchronise a sibling array the way playout'sskipped[]/structuredid. The reorder maths at:163-168is also correct: theinsertAt = from < to ? to-1 : toadjustment compensates for the splice-out, and a drop into an adjacent gap is a no-op rather than an off-by-one. - NAV-02 — no timers, intervals or subscriptions in the feature.
- SEC-* — no credential, token or session surface here; not applicable.
- MSG-02 — fails at the
getErrorMessagehelper level project-wide (PROJECT-LEVEL.md). This feature is a different shape: it does not surface raw provider strings, it surfaces nothing (finding 4).
Unverified
- A11Y-01 (contrast) — needs computed colour values. Several surfaces are candidates worth measuring on a rendered page:
text-text-3onbg-sunkenin the empty drop zone (ChecklistBuilderForm.vue:259), thetext-xs text-status-warn-fgcreated-at line (ServicePlansPage.vue:365), and the drop indicatorbg-status-warn-fgatopacity-50(:282,:383) which is the only signal of where a dragged field will land. - A11Y-06 (responsive / short viewport) — needs a rendered viewport. Two specific things to check: the builder's
lg:grid-cols-[240px_minmax(0,1fr)](ChecklistBuilderForm.vue:224) collapses to a single column belowlg, which stacks the toolbox above the drop zone — a drag would have to cross a scroll boundary even if DnD worked on touch; and the inline builder opens a secondResponsiveDialogon top of the plan modal (ServicePlansPage.vue:694), i.e. two stackedBottomSheets on mobile, whose focus trapping and scroll locking interact in ways only a rendered page will settle. - PrimeVue internals —
node_modulesis not installed in this checkout, so I could not confirm from source thatButton's:loadingsets the nativedisabledattribute (FORM-06, finding 8) or thatDialog's mask click and Escape are enabled by default (finding 3). Both are the documented defaults; thepackages/uiclaims (PageLayout,BottomSheet,DataTable) are asserted from source, since@aadalen/uiis an in-repo workspace package. - The drag-select interaction — a
draggable="true"ancestor is widely reported to intercept press-and-drag text selection inside descendant inputs in Chromium and WebKit, which would affect the question label input atChecklistBuilderForm.vue:306. I could not confirm the current browser behaviour by reading code, so it is listed here rather than as a finding.
Baseline additions
NAV-TAB-01— In-app destinations are reached through the router by name, neverwindow.open/target="_blank"with a hand-written path. A Capacitor or PWA shell treats_blankas leaving the application. (Finding 12.)CONTENT-STUB-01— Placeholder values must never be rendered in the same shape as live data. An unimplemented figure is omitted, not displayed as zero. (Finding 13.)DATA-SCOPE-01— The active scope (tenant / customer account / organization) is read reactively at every use site and re-read before any write. A scope captured once at component setup is a stale-write bug. This is the write-side complement to the "persisted scope never revalidated" theme already tracked in PROJECT-LEVEL.md for all four projects — the orchestrator should merge them. (Finding 2.)A11Y-03clarification (no new ID) — the existing rule already covers finding 1, but it is worth adding the explicit sentence "an interaction available only via pointer drag (native HTML5 DnD or otherwise) requires an equivalent click/keyboard control; drag is an enhancement, never the only path", since three projects in this programme now have drag surfaces.
Cross-project note
- playout —
orders.logisticsin this same repo is a Kanban board tagged with the identicalcontent-management/drag-and-droppattern (queue row #26) and should be checked for the same drag-only defect; playout's audits have already found click-onlydivrows under A11Y-03, so a drag surface there is likely to repeat it. - All four — the empty
catch { // handle }(finding 4) and the failure-rendered-as-success delete (finding 5) are both the MSG-06 theme already confirmed in all four projects. The specific edit-form-renders-empty-then-saves-empty variant in finding 4 is the sharpest instance found so far and is worth calling out when MSG-06 is finally written: it is not just a misleading empty state, it is a path to data loss. - members / tt-time-tracker — both destructure Pinia stores in places; the
DATA-SCOPE-01shape (finding 2) is worth grepping for asconst { \w+ } = use\w+Store()in each.