Skip to content

[UX] customer-portal — Locations ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 17 Patterns: data-display/table (Data Table)

Summary ​

Locations is a small, well-structured card list built on the shared @aadalen/uiDataTable + PageLayout shells, so it inherits the project-level list defects rather than adding new ones at the list level. The specific problems are all in the write path: the edit modal hardcodes expectedEtag: "*", which turns the backend's mandatory optimistic-concurrency check into If-Match: * and makes a concurrent edit a silent overwrite; a successful save reports success against a list that will not reflect it for up to five minutes; and the machine-assignment autocomplete only ever searches the first 50 of a customer's machines, so beyond that the link-a-machine feature is quietly unusable. The read side is fine — i18n parity is complete (en/nb), the card is a real <button>, and create is reachable from both the desktop header and the mobile sticky bar.

Inherited from PROJECT-LEVEL.md — not re-filed here. All confirmed on this page: MSG-02 (getErrorMessage at LocationsPage.vue:73 prefers the raw backend string); NAV-03 (one static document title); list state is component-local with no URL sync and no KeepAlive, so Back from anywhere resets search and page; and the whole packages/ui DataTable cluster from drafts/customer-portal--catalog.md findings 4–7 — no live region on the results area, unlabelled desktop search, hardcoded-English pagination with unnamed arrows, and empty-state copy that cannot tell "no locations" from "no search matches". LocationsPage.vue:99 is a textbook instance of the confirmed error-copy defect: it interpolates TanStack's status ref, so a failed load reads "Could not load data (status: error).", and forbiddenMessage is never passed so the 403 branch stays dead. A11Y-04 passes here — the page uses PageLayout, which renders a real <h1>.

Findings ​

1. Every location edit is a blind overwrite — optimistic concurrency is switched off at the call site — High · SEC (proposed DATA-02) ​

Where: apps/web/src/pages/LocationsPage.vue:55 → apps/api/src/modules/dataverse-commands/dataverse-commands.service.ts:718 → services/worker/src/worker/dataverse-commands/handlers/location-update.handler.ts:29-31What: The page passes a literal expectedEtag: "*" on every update. The API forwards it verbatim (expectedEtag: body.expectedEtag, no ?? "*" fallback needed because the client always supplies the wildcard), and the worker sends it as If-Match: "*" to Dataverse — which means "match any existing row", i.e. no concurrency check at all. LocationResponse (apps/api/src/modules/locations/locations.dto.ts:9-41) does not expose an ETag or rowVersion, so the page has nothing else it could send; the real ETag exists only on the command record. The repo's own backend rule (apps/api/CLAUDE.md §4) states "Optimistic concurrency (ETags / If-Match) is mandatory", and the same service does enforce it elsewhere (dataverse-commands.service.ts:298, if (tim.dataverseEtag !== body.expectedEtag)). Why it matters: Two people editing the same location — a dispatcher fixing the address while an admin renames the site — silently clobber each other. The loser gets a green "Location updated successfully." and no indication that their change was overwritten or that theirs overwrote someone else's. Fix: Expose the synced ETag on LocationResponse, carry it through useLocationList into EditLocationModal, and send it as expectedEtag. Map the resulting 412 to a "this location changed while you were editing" message that offers reload-and-reapply. Note this is a High, not a Blocker: saving works, and the loss requires a genuine concurrent edit to materialise.

2. Save succeeds, the list does not change, and the user is not told why — High · MSG-06 (proposed), CONTENT-03 ​

Where: apps/web/src/pages/LocationsPage.vue:61-69, apps/web/src/domains/locations/locations.mutations.ts:26-28 (and :52-54), services/worker/.../handlers/location-create.handler.ts (whole file), services/sync-service/src/scheduler/dataverse-sync.scheduler.ts:28What: Create/update go through the command+worker path and write only to Dataverse. Neither handler writes back to the app Postgres tables — the create handler's only local write is stamping targetId/expectedEtag back onto the command row (location-create.handler.ts:41-49); the update handler makes no local write at all. The list, however, reads Postgres (locations.repository.prisma.ts:22-28), which is refreshed by @Cron(CronExpression.EVERY_5_MINUTES). The mutation nevertheless calls queryClient.invalidateQueries({ queryKey: ["locations"] }) on success, which refetches the stale Postgres list, and the page fires notifySuccess(t("locationsContacts.locationCreated")) — "Location created successfully." — before closing the modal. Why it matters: The user is told the location was created, the modal closes, and the list is unchanged for up to five minutes. The obvious reaction is "it didn't work" and to create it again — and because generateRequestId() mints a fresh idempotency key per submit (locations.mutations.ts:14), the second attempt is a genuinely new command, producing a duplicate location in Dataverse. Editing has the milder version: the card still shows the old name. Why High rather than Medium: the failure mode is user-initiated duplicate records in the system of record, not just confusion. Fix: Either have the worker upsert the local Location row on success (making the invalidation meaningful), or change the copy to say what actually happened — "Location saved. It will appear in the list within a few minutes." — and optimistically insert the row locally. The current copy is the one thing that must not stay.

3. Linking a machine only searches the customer's first 50 machines — High · MSG-06 (proposed), FORM-12 (proposed: inert/partial controls) ​

Where: apps/web/src/components/EditLocationModal.vue:49 and :66-80, against packages/domain-helpers/src/create-domain-helpers.ts:86What: The modal calls useCustomerAssetList() with no options. createDomainHelpers defaults to pageSize: 50, page: 1, and the modal never touches that list's search ref — searchAssets() filters allAssets.valueclient-side (:72-79). So the autocomplete is searching one page of 50 assets, not the customer's fleet. There is no "showing 50 of N" hint anywhere, and the failure surfaces as t('locationsContacts.noMachinesFound') — "No machines match your search." Why it matters: Any customer with more than 50 machines — the whole point of a portal that groups machines by location — can never link machine #51. They type a serial number they are reading off the machine, are told it does not exist, and have no way forward. It also looks like a data problem, not a UI limit, so it generates support tickets rather than bug reports. Why High rather than Blocker: the feature works correctly for accounts under 50 assets, so it is conditional on data volume rather than broken for everyone. Fix: Drive the existing server-side search — bind the AutoComplete's @complete query into a useCustomerAssetList() search ref (it already debounces at 300 ms and refetches) instead of filtering a fixed first page client-side.

4. Assigning or removing a machine fails silently — Medium · MSG-01, MSG-03 ​

Where: apps/web/src/components/EditLocationModal.vue:84-90 and :92-100What: assignAsset and confirmRemove call assignMutation.mutateAsync(...) with no try/catch, and the underlying client uses throwOnError: true (apps/web/src/domains/customer-assets/customer-assets.mutations.ts:13). Neither useAssignAssetLocationMutation nor either call site has an onError or a notifyError. On failure the rejection is unhandled, refetchLocationAssets() never runs, and nothing is rendered. The remove path is worse: pendingRemoveId is cleared at :97 before the await, so a failed removal leaves the row in place with the confirm chip gone — indistinguishable from a click that missed. Contrast the location save at LocationsPage.vue:72-74, which does catch and notifyError. Why it matters: The user cannot tell whether the machine was unlinked. The repo's own frontend rule (apps/web/CLAUDE.md §7) is "No silent failures". Fix: Wrap both in try/catch and notifyError(getErrorMessage(error, …)), matching the location-save path, and restore pendingRemoveId on failure so the confirm state is not lost.

5. The four location form fields have no programmatically associated label — Medium · FORM-01 ​

Where: apps/web/src/components/EditLocationModal.vue:150-158, :162-166, :171-175, :177-182What: Each field is a bare <label> followed by a sibling InputText — no for, no id, no aria-label, and the input is not nested inside the label. The same shape repeats for the machine autocomplete at :291-302. This is in-repo application code, not a PrimeVue internal, so it is asserted rather than unverified. Why it matters: A screen-reader user hears "edit text, blank" four times and must infer field order from the surrounding reading. Clicking the visible label does not focus the field either, which on a mobile bottom sheet costs a precise tap on a small target. Fix: Give each InputText an id and each <label> the matching for. The required marker at :152 should also be conveyed non-visually — the * span carries no accessible text.

6. Save is disabled instead of explaining, and the required-name rule is never stated — Medium · FORM-05, FORM-04 ​

Where: apps/web/src/components/EditLocationModal.vue:337 and :130-133What: :disabled="!form.name.trim() || (!isDirty && isEditing)" gives the user a dead button with no message. handleSubmit() also silently returns on an empty name (:131), and although the input carries required (:157), the Save button lives in the dialog's #footer — outside the <form> element at :143 — so clicking it calls handleSubmit() directly and native constraint validation never runs on the primary path. Nothing anywhere states that name is the only mandatory field until the user notices the asterisk. Why it matters: Two different reasons produce the identical dead button — "you haven't typed a name" and "you haven't changed anything" — and neither is explained. On the mobile sheet the footer is stacked below the fold of a long form, so the user can be looking at a greyed button with the offending empty field scrolled out of view. Fix: Keep Save enabled and validate on submit with an inline message under the name field (role="alert"); if the no-op-edit guard is worth keeping, label it ("No changes to save") rather than expressing it as a disabled control.

7. Every location card is presented as editable regardless of the user's ability — Medium · FORM-05 (proposed PERM-01) ​

Where: apps/web/src/pages/LocationsPage.vue:126 (can-edit with no binding), against apps/web/src/router/index.ts:284-289What: The route gates on { action: "read", subject: "Location" }, but the page hardcodes can-edit on every LocationCard. LocationCard.vue:34-40 therefore always renders the card as a <button> with is-interactive styling, and the "New location" buttons (LocationsPage.vue:86-92, :116-122) are likewise ungated. The API enforces separately — dataverse-commands.service.ts:698 calls assertCan(ability, "update", "Location") — so a read-only user is offered the affordance and rejected only on save. The project has the right pattern elsewhere: PartnerServiceOrderDetailPage.vue:31 does const canEdit = computed(() => can("update", "PartnerBooking")). Why it matters: A read-only user opens the editor, retypes an address, assigns machines, hits Save, and gets a 403 rendered through getErrorMessage — after the work, not before. It also mislabels the whole list as interactive for keyboard and screen-reader users who have no write access. Fix: :can-edit='can("update", "Location")', and v-if the create buttons on can("create", "Location"). Identical defect in Contacts — ContactsPage.vue:128 hardcodes can-edit the same way (queue row #21).

8. No ordering the user can reason about, and no way to change it — Medium · data-display/table best practice (proposed DATA-03) ​

Where: apps/api/src/modules/locations/locations.repository.prisma.ts:27 and apps/web/src/pages/LocationsPage.vue:45-48, 96What: The list is ordered createdAt: "desc" — and for a Dataverse-synced entity createdAt is the local sync insertion timestamp, so the order is effectively "whatever order the sync ingested them in". Because the page passes card-only, DataTable never renders the table branch (packages/ui/src/components/DataTable.vue:565), so there are no column headers and therefore no sort affordance at all — on desktop or mobile. The locationColumns array at :45-48 is consequently dead configuration; its one column is also headed with t("locationsContacts.tabs.locations"), a tab label left over from the pre-split combined page. Why it matters: With pageSize: 50 the user scans up to 50 unordered cards per page looking for "Bergen depot". The Data Table pattern treats sortability as a core affordance precisely for this case, and locations are a naturally alphabetical set. Fix: Order by name ascending by default. If sorting is wanted, either drop card-only on desktop so the table's headers appear, or add a small sort control alongside the search field. Either way, delete or correctly label locationColumns.

9. Card copy breaks on single machines and on locations without an address — Low · CONTENT-01, CONTENT-04 ​

Where: apps/web/src/components/LocationCard.vue:53 and :60-63; apps/web/src/i18n/messages/en.yml + nb.yml, locationsContacts.locations.machineCountWhat: Two copy defects. (a) machineCount: "{count} machines" / "{count} maskiner" uses interpolation, not vue-i18n pluralisation (|), so a location with one machine reads "1 machines" / "1 maskiner". (b) The address line is built by string concatenation — `${location.address ?? ''}, ${location.postalCode ?? ''} ${location.city ?? ''}`. Only name is required by the form (EditLocationModal.vue:131) and all four address fields are optional in the API (locations.dto.ts:11-23), so a name-only location renders a map-pin icon followed by a bare ", ". Why it matters: Small, but it is on every card of a list page in both locales, and the orphan comma reads as a rendering bug. Fix: Change the key to "{count} machine | {count} machines" (and the nb equivalent) and use t(..., count); build the address from the non-empty parts with .filter(Boolean).join(", "), hiding the row entirely when nothing remains. Minor related note: server search covers name, address, city (locations.repository.prisma.ts:9) but not postalCode, which the card displays — searching a postcode returns nothing.

10. Closing the editor discards typed changes with no warning — Low · FORM-12 (proposed: warn before discarding entered form data) ​

Where: apps/web/src/components/EditLocationModal.vue:126-128, :106-124, :331-335What: The modal already computes isDirty (:37-42) but uses it only to enable Save. Cancel, the PrimeVue dialog's ✕/overlay dismiss, and Escape all go straight to emit("update:visible", false), and the watch at :106 re-snapshots the form on next open, so the edits are gone. Why it matters: On the mobile bottom sheet a swipe-down or an accidental backdrop tap silently throws away a re-typed address. Low rather than Medium because a location record is four short fields, not a long form. Fix: Guard handleClose (and the dialog's own dismiss) on isDirty with a confirm.

Unverified ​

  • A11Y-01 (contrast) — the card uses text-text-2/text-text-3 on surface-quiet, and LocationCard.vue:52 sets text-xs on text-text-2 for the machine count. Needs computed colour values against packages/ui/src/styles/theme.css; not assertable from class names.
  • A11Y-06 (short viewport) — EditLocationModal is a genuinely tall form (four fields + a max-h-64 scrolling machine list + an autocomplete) inside a ResponsiveDialog whose mobile sheet is capped at 86% (ResponsiveDialog.vue:26). The desktop branch is passed dialog-class="w-full max-w-lg" and no :breakpoints, which apps/web/CLAUDE.md requires for pixel-width dialogs — but max-w-lg is a max, not a literal width, so this may be fine. Needs a rendered 700px-height viewport with a mobile keyboard open.
  • A11Y-02 (target size) — the inline remove/confirm cluster (EditLocationModal.vue:238-271) packs a text label and two size="small" PrimeVue buttons into one row; whether they clear 24×24 depends on PrimeVue's small-button padding, which is not readable here (node_modules absent).
  • MSG-01 on PrimeVue toasts — whether notifySuccess/notifyError render with role="alert" depends on PrimeVue Toast internals.
  • FORM-06 — :loading on the Save button (:339) and the machine buttons (:252, :263) maps to PrimeVue's internal disabled; the focus-drop claim needs the library source.

Baseline additions ​

  • DATA-02 — a write that the backend gates on optimistic concurrency must send the real version token, never a wildcard. A client that hardcodes If-Match: * / expectedEtag: "*" has disabled the check while appearing to honour it. (Finding 1. Likely a family with the members DATA-01 undeclared-prop proposal — the orchestrator should decide whether they are one rule or two.)
  • DATA-03 — a list of user-named records has a stable, explicable default order, and long lists offer a way to change it. Insertion order is not an order the user can reason about. (Finding 8.)
  • PERM-01 — an affordance for an action the user's abilities forbid is not rendered. Authorisation may be enforced server-side, but the UI must not invite work that will be rejected after it is done. (Finding 7.)
  • Finding 4 is the customer-portal instance of the already-proposed MSG-06 family (a non-throwing or unhandled failure that renders as nothing); findings 2 and 3 belong to the same family. No new ID needed.
  • Finding 10 matches the already-proposed FORM-12(d) (warn before discarding entered form data). Finding 9(a) is a pluralisation rule that CONTENT-01 arguably already covers; if the orchestrator wants it explicit, it is "interpolated counts use the locale's plural forms".

Cross-project note ​

  • Findings 1 and 2 are structurally specific to customer-portal's Dataverse command+worker architecture; the other three projects write to their own store directly. But the shape of finding 2 — success reported before the read model reflects the write — is the confirmed playout "success feedback fires before the write resolves" defect, and is already in the cross-project MSG-06 row.
  • Finding 7 (ungated edit affordance) is worth checking in all three others. tt-time-tracker and members both do role-based rendering, and the confirmed in-repo instance here is a hardcoded prop rather than a missing check, which is the easiest kind to miss in review. Immediate same-repo instance: ContactsPage.vue:128.
  • Finding 5 (label with no for) is the most portable of these — it is a hand-rolled label above a component-library input, a pattern present in all four codebases.
  • Finding 3 (a client-side filter over one server page presented as a search) is a general risk anywhere an autocomplete is fed by a paginated list helper. Worth a grep for useQuery-backed lists feeding AutoComplete/combobox components in tt-time-tracker and playout.