Appearance
[UX] customer-portal — Work orders
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 22 Patterns:data-display/timeline
Summary
Work orders is the customer's "where is my order" screen, and its two distinguishing pieces — the status/type filter bar and the pipeline stepper — are both wrong in ways the shared DataTable defects do not explain. The filters run in the page over the current server page only, while the pagination strip below them reports the unfiltered server totals, so on any account with more than 50 orders the filter returns a wrong subset and can show "Ingen ordrer funnet" over "Page 1 of 4 (180 total)". Separately, a cancelled order is mishandled end to end: the list card renders the literal string orders.detail.steps.cancelled because that key exists in neither locale, and the detail page draws the stepper with step 1 highlighted, so a cancelled order reads as freshly ordered and progressing.
Two checks from the assignment, answered explicitly:
- List state lost on Back? Yes, and worse here than on catalog. Confirmed project-level:
AppLayout.vue:445-463wraps<RouterView>in a keyed animation<div :key="route.path">with no<KeepAlive>, and nothing inOrdersPage.vueorcreate-domain-helpers.tstouchesroute.query. Work orders loses three things on Back from an order — server search, page number, and the two client-side filter values (OrdersPage.vue:19, a plainref). See PROJECT-LEVEL.md; not re-filed, but note the filter reset is specific to this page's local-filter design. - Does a service-report PDF download surface failure? Not applicable to this feature. There is no PDF/report download anywhere on the work-orders path — grep for
downloadReportreturns onlyBookingDetailPage.vue:359, which is queue item #15 Service overview, not #18. The only new-window action here is the tracking link, and it is not subject to the members popup-blocker bug:openLinkis called synchronously inside the click handler with no precedingawait(OrdersPage.vue:92,OrderDetailPage.vue:83). It has a different problem — see finding 3.
Project-level, already filed, one line each and not re-derived:
packages/uiDataTabledefects — unlabelled desktop search box and filterSelects (FORM-01), no live region on the results area (MSG-01), noisFetchingbusy state (CONTENT-04), hardcoded-English pagination strip with unnamed arrow buttons (CONTENT-01, A11Y-05), and no clear-search affordance on desktop (MSG-04). All present on this page. Seedrafts/customer-portal--catalog.mdfindings 4–7.- NAV-03 — no per-route document title mechanism;
ordersandorders.detailset none. - DataTable 403 branch is dead code here too —
OrdersPage.vue:135-155never passes:status, sostatusCodestaysnull(DataTable.vue:247-259) andforbiddenMessagecan never render. Unlike catalog, the error copy has no{status}placeholder (en.yml:1102), so this page does not show "(status: error)". - A11Y-04 passes. Both routes use
PageLayoutfrom@aadalen/ui, which renders one real<h1>(PageLayout.vue:103); the detail page's section headings are real<h2>s (OrderDetailPage.vue:219, 310, 376, 402).
Findings
1. Status and type filters run over one page of results while the pagination strip reports the server's totals — High · proposed FILTER-CLIENT-SIDE (merges with the existing FORM-12(c) "inert filters" proposal)
Where: apps/web/src/pages/OrdersPage.vue:19-90 (all filtering and counting) and :135-155 (what is handed to DataTable); backend params that go unused at apps/api/src/modules/orders/orders.controller.ts:186-206What: useOrderList() is called with no filters option (OrdersPage.vue:15), so createDomainHelpers never puts a filter into the query key or the request (create-domain-helpers.ts:101-125). Every filter is applied afterwards in the page, over rows.value — which is one server page, default pageSize 50 (create-domain-helpers.ts:86). Three consequences follow mechanically:
- The counts in the filter labels (
:74-89, e.g.Åpne (5)) count only the loaded page, not the account's orders. - With a filter active,
:rows="sortedOrders"is the filtered subset of page 1, but:totaland:total-pagesare still the server's unfiltered numbers (:152-153).DataTablerenders the pagination strip from those (DataTable.vue:661-696) and — because that strip is a sibling of the empty-state branch, not part of itsv-ifchain — it renders even when the filtered list is empty. Filter to "Sendt" on an account whose shipped orders start on page 2 and the screen reads "Ingen ordrer funnet." above "Page 1 of 4 (180 total)". - Paging with a filter active re-filters the new page, so page counts jump arbitrarily (page 1: 7 results, page 2: 0, page 3: 12).
The backend already supports this properly: GET /orders accepts phase, slugs, transportStatus, excludeTransportStatus and excludeSystemStatus (orders.controller.ts:186-206 → orders.service.ts:31-41 → orders.repository.prisma.ts:49-70), and the page's type filter (machine / parts / quick) maps one-to-one onto slugs. The params exist and the frontend never sends them. Why it matters: The whole point of this screen is finding one order. A customer with more than 50 work orders — the fleet customers this portal is built for — filters to "Åpne", sees a count that is wrong, a list that is missing orders, and pagination that contradicts both. There is no signal that the filter is partial. Severity call: High rather than Blocker because an account whose orders fit on one page (probably the common case) gets correct results, and the user can always clear the filter and search instead — so the feature is not unusable, but it is confidently wrong past the first page. Fix: Pass the filter map into the composable — useOrderList({ filters: () => ({ slugs: typeSlugs.value, ... }) }) — and let normalizeFilters + fetchPage forward it to the existing query params (add filters to the ordersList({ query }) call in orders.collection.ts:5-16). Delete filteredOrders, sortedOrders (the repository already orders by orderDate desc, orders.repository.prisma.ts:79) and the two count computeds; have the API return the per-filter counts, or drop the counts from the labels rather than showing wrong ones. Status (open / shipped) needs a server-side equivalent of deriveOrderStep — until that exists, the honest interim fix is to remove the status filter rather than ship a partial one.
2. A cancelled order is drawn as an order at step 1 of its pipeline — High · MSG-03, proposed DATA-DERIVED-FALLBACK
Where: apps/web/src/pages/OrderDetailPage.vue:42-49 and :169-214; derivation at apps/web/src/domains/orders/useOrderPipeline.ts:56-60, 76-86What: deriveOrderStep returns "cancelled" when systemStatus === 690970005 or the phase contains "kansellert" (useOrderPipeline.ts:77-81). But "cancelled" is not a member of any pipeline — MACHINE_STEPS, SALES_STEPS and QUICK_PAYMENT_BEFORE_DELIVERY_STEPS (:18-36) contain no such step. So currentStepIndex's findIndex returns -1, and the guard at OrderDetailPage.vue:48 converts that to 0. The stepper then renders the first step ("Bestilt") in the current-step colour, every later step as pending, and nothing anywhere on the detail page says the order is cancelled: there is no banner, no tag, and the order-info card (:308-372) has no status field at all. The tracking button, the totals and the delivery address all render as normal. Why it matters: A customer opening a cancelled order is told it has been placed and is moving through production. They wait for a machine that is not coming, or call to chase an order that Ådalen already cancelled. The list card they arrived from did mark it (OrderCard.vue:29, danger tag) — so the two screens contradict each other, and the more detailed one is the wrong one. Severity call: close to Blocker. High because the page still renders, loads and navigates; but the single most decision-relevant fact about the order is not merely missing, it is displayed as its opposite. Fix: Branch before the stepper: if currentStepKey === "cancelled", replace the pipeline card with a Message severity="error" / danger banner using the existing orders.status.cancelled key (already translated in both locales, en.yml:1115 / nb.yml:1115, and currently unused), and suppress the tracking action. Independently, make currentStepIndex fail loudly rather than defaulting to 0 — return -1 and render no step as current when the derived step is not in the pipeline, so a future unmapped step cannot silently claim step 1.
3. Tracking links from Dataverse are opened with window.open unvalidated and without noopener — High · SEC (proposed SEC-EXTERNAL-LINK)
Where: apps/web/src/pages/OrdersPage.vue:92 and apps/web/src/pages/OrderDetailPage.vue:83 — both const openLink = (link: string) => window.open(link, "_blank") — reached with row.trackingLink (OrderCard.vue:144) and order.trackingLink (OrderDetailPage.vue:126) What: trackingLink is a free-text Dataverse field (cr06b_sporingslink, services/worker/src/mapping/profiles/work-order.profile.ts:38) synced verbatim into work_order.tracking_link (packages/database/prisma/app/work_order.prisma:15) and returned to the browser untouched (orders.dto.ts:185). The frontend passes it straight to window.open() with no scheme allow-list and no "noopener" window feature. Two distinct problems: (a) unlike an <a target="_blank">, which browsers implicitly give rel=noopener, window.open does hand the opened page a live window.opener, so a hostile destination can navigate the portal tab to a look-alike login page while the user is away (reverse tabnabbing); (b) a javascript: value in that Dataverse field executes in the portal's own origin when the user clicks "Spor ordre", with the session cookie in scope. Why it matters: The portal's stated security model is "the backend is the security boundary" (apps/web/CLAUDE.md §3), but this is a client-side sink for server-supplied text. The tracking button is the most-clicked control on the screen. Severity call: High, not Blocker, per the severity normalisation — exploitation requires a second condition (someone able to write into the Dataverse tracking field, i.e. an internal actor or a compromised upstream), so it is a hardening gap rather than something an outsider can act on directly. Fix: One shared helper: validate the parsed URL's protocol against ["http:", "https:", "mailto:"], refuse anything else, and call window.open(url, "_blank", "noopener,noreferrer"). Better still, render tracking as a real <a :href target="_blank" rel="noopener noreferrer"> so it is middle-clickable and keyboard-native, with the same scheme check applied when the link is built.
4. Cancelled orders show the raw translation key as their status — Medium · CONTENT-01
Where: apps/web/src/components/OrderCard.vue:21-23 (t(\orders.detail.steps.${status}`), rendered as the Tagvalue at:65-69) **What:** orders.detail.stepsdefinesordered, awaitingPayment, productionShipping, preparation, delivered, processing, shipped— and **notcancelled** — in both en.yml:1187-1194andnb.yml:1187-1194. deriveOrderStepcan and does return"cancelled" (useOrderPipeline.ts:77-81). fallbackLocaleis"en" (apps/web/src/i18n/index.ts:38-43) and the key is missing there too, so vue-i18n returns the key path. The card's status tag on a cancelled order therefore reads, literally, **orders.detail.steps.cancelled**. A translated orders.status.cancelled("Kansellert" / "Cancelled") exists two blocks away and is referenced by nothing. **Why it matters:** Machine-readable junk in a status badge, in the one row the customer most needs to understand. Same root cause as finding 2 — the cancelled state was added to the derivation and never to the presentation layer. **Fix:** Addcancelledtoorders.detail.stepsin both locale files (or pointgetStatusLabelat the existingorders.status.*block), and add a CI check that everyPipelineStepunion member has a key — the union is exhaustive anduseOrderPipeline.ts:3` already lists them.
5. The order pipeline is a row of divs: progress is conveyed by colour and icon only, with no list semantics and no current-step announcement — Medium · A11Y-03, proposed A11Y-07(e)
Where: apps/web/src/pages/OrderDetailPage.vue:175-212What: The stepper is <div v-for> inside a flex <div>. There is no <ol>, no aria-current="step", no role="list", no aria-label on the region, and no text that distinguishes done / current / pending — the only differences are the circle's background colour (:185-191), the pi-check vs step icon (:193-201), the connector colour (:180) and a text-colour change (:205). The icons are decorative <i> elements with no aria-hidden. A screen-reader user hears "Bestilt Produksjon & Shipping Klargjøring Sendt" — the same four words for an order that has just been placed and one that is out for delivery. The data-display/timeline pattern's reference implementation is a plain <ol> of <li>s, and its accessibility guidance is explicit: "Do not rely on color alone to convey severity, completion, or selection state" and "Use semantic elements first."Why it matters: The stepper is the answer to the question the customer came to ask. For a screen-reader user it currently carries no answer at all. It is also the only place on the page where order progress is shown. Fix: Render <ol>/<li>, put aria-current="step" on the active <li>, add aria-hidden="true" to the icons, and give each step a visually-hidden state suffix ("Bestilt — fullført", "Klargjøring — pågår", "Sendt — gjenstår"). Wrap the card in <nav>/<section aria-label="…"> fed by an i18n key.
6. Every failure on the detail page is one generic sentence with no retry, and the "not found" copy is unreachable — Medium · MSG-03, MSG-04
Where: apps/web/src/pages/OrderDetailPage.vue:24-36 (fetch) and :154-167 (states) What: The page bypasses the domain helper entirely: an onMounted async block calls ordersGetById({ throwOnError: true }) into three local refs. Every failure mode — 404 from OrdersService.getById, 403 from assertCanOnRecord, a 500, an offline fetch — lands in the same catch and renders t("orders.detail.error"), "Kunne ikke laste ordre." There is no retry control, no status code, and no link into the orders list other than PageLayout's back affordance. The v-else-if="!order" branch carrying orders.detail.notFound ("Ordre ikke funnet.") is dead code: with throwOnError a missing order throws, so order is only ever null alongside isError, and the earlier branch wins. Being hand-rolled also means no TanStack cache — every Back-then-forward refetches from scratch — and useOrderDetail, exported from orders.collection.ts:25, is non-functional because that file configures no fetchDetail (create-domain-helpers.ts:164-172 would throw). Why it matters: A customer on a truck yard with poor signal is told the order could not be loaded and given nothing to press; the only recovery is a browser reload, which on the Capacitor wrapper is not an obvious gesture. A customer who followed a stale link is told the same thing as a customer whose network blipped. Fix: Switch to useDetail — add fetchDetail to orders.collection.ts — and split the branch: 404 keeps orders.detail.notFound plus a "Tilbake til ordrer" link; everything else shows the load error with a Retry button bound to the query's refetch. Use EmptyState so it matches the list page's treatment.
7. Line-item description toggles are unnamed, stateless disclosures — Medium · A11Y-05, proposed A11Y-07(d)
Where: apps/web/src/pages/OrderDetailPage.vue:272-283What: Each order line with a description gets an icon-only chevron button whose accessible name is t('orders.detail.description') — "Beskrivelse" — identical for every line. It has no aria-expanded, no aria-controls, and the revealed panel (:286-296) has no id. The chevron direction (:281) is the only expanded/ collapsed signal. Why it matters: An order with eight lines presents eight buttons all called "Beskrivelse", none of which reports whether it is open, and nothing ties a button to the text it reveals. A screen-reader user cannot tell which product they are expanding or whether the action did anything. Fix: :aria-label="t('orders.detail.descriptionFor', { name: product.name })", :aria-expanded="expandedLines.has(product.id ?? '')", :aria-controls="\line-desc-${product.id}`", and the matching idon the panel. (The 24×24 target size passes —h-6 w-6` is exactly 24 px.)
8. The order card is a click-only Card — Medium · A11Y-03
Where: apps/web/src/components/OrderCard.vue:51-55What: The card root is <Card class="cursor-pointer" @click="emit('open')"> with no tabindex, no role="button"/<button> wrapper, no keyboard handler and no focus style. The whole tile is the primary affordance on this list — it is card-only at every breakpoint (OrdersPage.vue:137-138), so there is no table row alternative. A keyboard user can still reach the "Se detaljer" link button inside (:132-137), so the destination is reachable; the outer target is not. This is the pattern catalog and marketplace get right — their cards wrap the content in a real button with an openDetailsA11y name. Why it matters: Inconsistent activation between mouse and keyboard on the app's own list-card convention, and no focus indicator on the element that visibly behaves like a button. Severity call: Medium, not High — an equivalent keyboard-reachable control exists inside the card, so this is inconsistency and friction rather than a barrier. Fix: Follow the catalog card: wrap the content in a <button type="button"> (or add role="button" tabindex="0" @keydown.enter/@keydown.space) with an accessible name naming the order, and keep @click.stop on the nested buttons.
9. Amounts are rounded to whole kroner by two separate local formatters, so shown line items need not sum to the shown total — Medium · CONTENT-05(a) (project-level; work-orders instance)
Where: apps/web/src/components/OrderCard.vue:44-47 (a local formatNok) and apps/web/src/utils/formatters.ts:36-41 (the shared one), consumed at OrderDetailPage.vue:259, 271, 302What: Both use Intl.NumberFormat("nb-NO", { maximumFractionDigits: 0 }), and they disagree on the suffix — the card appends t("orders.labels.nok"), the util appends a hardcoded " kr". On the detail page each product's unit price, its discount amount and its line total are rounded independently, then the order total is rounded separately from a different source (order.totalAmount || order.estimateSubtotal || totalAmount || 0, OrderDetailPage.vue:302). A parts order of five lines at 249,80 kr shows five "250 kr" lines over a "1 249 kr" total. Why it matters: Work orders is where a customer reconciles what they were charged. Numbers that visibly do not add up read as a billing error and generate support calls. PROJECT-LEVEL.md already records nine hand-rolled formatters in this repo; OrderCard.vue:44 is a tenth, duplicating the shared one it sits next to. Fix: Delete the local copy in OrderCard, and make the shared formatNok use style: "currency", currency: "NOK" with the locale's normal 2-digit behaviour. Fix once in utils/formatters.ts and the whole repo follows.
10. An unrecognised Dataverse phase silently reads as "Bestilt" — Medium · proposed DATA-DERIVED-FALLBACK
Where: apps/web/src/domains/orders/useOrderPipeline.ts:62-74What: Status is derived by substring-matching the raw phase string against hardcoded Norwegian and English fragments — "levert", "klargjøring", "pdi", "klar for logistikk", "plukking", and so on. Any phase that matches none of them falls through to return "ordered" (:66, :73). There is no "unknown" outcome, even though orders.status.unknown exists in both locales (and is referenced nowhere). Because the fragments are content, not codes, renaming a phase in Dynamics — or adding one — silently reclassifies every affected order as newly ordered, on the card badge, in the "Åpne/Sendt" filters and on the detail stepper, with no error anywhere. Why it matters: A machine sitting in preparation displays as "Bestilt" for its entire lifecycle after an upstream phase rename, and neither the customer nor the portal team gets any signal. It is the same failure shape as finding 2: an unmapped value is presented as a confident, specific, wrong answer. Fix: Map on a stable code (workOrderSubstatusId / a phase code) rather than free text; where free text is unavoidable, return "unknown" for a non-match, render the existing orders.status.unknown copy, and log it so unmapped phases surface in monitoring instead of masquerading as step 1.
11. Cancelled and "misc" orders are invisible to the filters, and the counts do not add up — Low · MSG-03
Where: apps/web/src/pages/OrdersPage.vue:37-49 and :69-90What: The status filter offers only All / Åpne / Sendt, backed by ACTIVE_STEPS and SHIPPED_STEPS (useOrderPipeline.ts:38-39); "cancelled" is in neither, so cancelled orders can only be found under "Alle" and the label arithmetic visibly fails — "Alle (12), Åpne (5), Sendt (4)". The type filter covers machine/second-hand, parts and quick, but the API's slug list also includes misc (orders.repository.prisma.ts:52), which likewise appears only under "Alle". An orders.filters.delivered key exists in both locales and is wired to nothing. Why it matters: Users read tab counts as a partition. Three unaccounted orders look like a bug, and a customer who wants to check a cancellation has no filter for it. Fix: Add a "Kansellert" status option (and either a "Annet" type option or fold misc into an existing bucket) so the buckets are exhaustive; remove the orphan delivered key. Best done as part of the finding-1 move to server-side filtering, where the buckets become explicit query values.
12. Small consistency and copy issues — Low
Where: three spots. What:
apps/web/src/pages/OrdersPage.vue:107-109—columnsis built once at setup with a non-reactivet("orders.heading"), so it would not follow a locale switch. Currently unobservable (the page iscard-only, soDataTablenever renders the table branch,DataTable.vue:565), but it is a live trap for whoever removescard-onlyand the single column is otherwise pointless.apps/web/src/pages/OrderDetailPage.vue:338-345—resolvePaymentTermsrenders code163140009, whose value in both locales is the literal"-", as a populated "Betalingsbetingelser" row. The siblingresolveWarrantyguards against exactly this (:346,!== '-'); payment terms does not.apps/web/src/components/OrderCard.vue:76andapps/web/src/pages/OrderDetailPage.vue:302—totalAmount || estimateSubtotaluses||, so a legitimately zero-value order (fully covered by warranty, say) falls through and displays the estimate instead of 0. Use??. Fix: As described; all three are one-line changes.
Unverified
- A11Y-01 (contrast). Not assessable from code. Highest-risk spots for a contrast tool: the stepper's pending step labels,
text-[10px] sm:text-xsontext-text-2(OrderDetailPage.vue:203-205); the card'stext-[10px] uppercase tracking-[0.16em] text-text-3"Totalt" eyebrow (OrderCard.vue:72-74); and the white-on-status-warn-fgcurrent-step circle (OrderDetailPage.vue:187) — all muted-ramp or coloured-background text below the large-text threshold. - A11Y-06 (short viewport / responsive). Two specific things need a rendered page. (a) The stepper is a single non-wrapping flex row of up to four steps (
OrderDetailPage.vue:175-181) withtruncatelabels; the Norwegian strings are long ("Produksjon & Shipping", "Venter på betaling") and at 320 px each step gets roughly 70 px, so the labels very likely truncate to a few characters. The only fallback is atitleattribute (:206), which does not open on touch — and this app is touch-primary perapps/web/CLAUDE.md§12. Needs measuring at 320/360/390 px in nb, as that file requires. (b) Whether the sticky mobile header plus the filter-chip row leave usable list height at ~700 px. - PrimeVue internals.
node_modulesis not installed, so I could not confirm whetherMessage(OrderDetailPage.vue:154-167) carriesrole="alert"(bears on MSG-01 for the detail error state), what elementCard's wrapper renders, or whetherTagexposes its icon accessibly (OrderCard.vue:65-69). @aadalen/uiPageLayout/EmptyStateare in-repo and were read; the<h1>claim above is asserted, not inferred.
Baseline additions
Descriptive IDs with one-line definitions; the orchestrator renumbers. Two of these should merge into collisions already logged in PROJECT-LEVEL.md.
FILTER-CLIENT-SIDE— When a list is paginated by the server, its filters, sorting and result counts are computed by the server too; a control must never filter one page while the pagination strip reports the whole set. (Finding 1. Merge with the existingFORM-12(c)proposal "inert controls (filters that never reach the API)" — same rule, and this is the sharper instance because the API params already exist.)DATA-DERIVED-FALLBACK— A derived status has an explicit unknown outcome: a value that matches no known case is shown as unknown, never defaulted to the first or most benign case. (Findings 2 and 10. Close sibling of theDATA-RAW-CODEproposal indrafts/customer-portal--catalog.md— that one is "a code reaches the user undecoded", this one is "an undecodable code is silently decoded to the wrong label". Same family, opposite failure.)SEC-EXTERNAL-LINK— A URL that originates in data is scheme-validated before use and opened withnoopener,noreferrer;window.opennever receives unvalidated server-supplied text. (Finding 3. No existing SEC- rule covers client-side link sinks — SEC-01…05 are all auth-flow rules.)*
Findings 4–9 and 11–12 are covered by CONTENT-01, CONTENT-05, MSG-03, MSG-04, A11Y-03 and A11Y-05 as written.
Cross-project note
- Finding 1 (client-side filters over server pagination) is the customer-portal twin of the tt-time-tracker "inert filter" defect already logged as
FORM-12(c), and worth a coordinated sweep: any list whose page component filtersrowslocally while passing:total/:total-pagesfrom the server has it. In this repo,PartnerServiceOrdersPage.vue(#19) is the first place to look — it is the sibling screen and shares theDataTable+createDomainHelpersshape. - Finding 3 (
window.openon data-supplied URLs) — grep forwindow.openin all four repos. customer-portal has two more instances outside this feature (ServicePlansPage.vue:209, an internal route). playout renders share links and members' widgets render host-page links, so both are plausible. - Finding 5 (progress/status conveyed by colour and icon only) matches the playout
LIVE-02proposal almost word for word ("live state is never conveyed by colour alone") and the members selection-state instance. Strong candidate for the singleA11Y-07(e)rule the orchestrator is already reconciling. - Finding 9 (whole-krone rounding) confirms customer-portal's currency-precision entry for a third surface (after bids and the shared util). tt-time-tracker's invoice screens still need the same grep for
maximumFractionDigitsper PROJECT-LEVEL.md. - Finding 2/4 (a state exists in the derivation but not in the presentation) — a cheap cross-project check: for every discriminated-union status type, assert that each member has a translation key and a rendering branch. All four projects derive statuses this way.