Appearance
[UX] customer-portal — Service overview
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 14 Patterns:data-display/filter-panel
Summary
The Service overview merges bookings and service-history rows into one paginated list with two server-side filters, and its detail route (bookings.detail) is a well-built read-only page. Two things are actually broken rather than merely rough: the merge/pagination arithmetic on the API side invents a row total and can serve empty pages while hiding rows the total claims exist, and the service-history dialog swallows every write error and never refreshes the list it was opened from, so a technician can complete the same service twice and never learn that a save failed. Beyond that, the page renders a search field that filters nothing, and it is the only one of 18 DataTable pages that does not pass error-message, so its failure state is untranslated English with no way back.
Checked and not defects (recorded so they are not re-derived): Card/Button/ Skeleton resolve via unplugin-vue-components + PrimeVueResolver (apps/web/vite.config.ts:28-30), so the unimported components in both templates render; the non-reactive bookingId in bookingsGetByIdOptions (BookingDetailPage.vue:27) and the destructured accountId (service-overview.composable.ts:12) are both saved by AppLayout's keyed RouterView (AppLayout.vue:446, :459), which remounts the page on both route-path and account change; exactly one <h1> renders on each page in both breakpoints; and the status/service-type filters do reach the API (service-overview.collection.ts:6-16 → service-overview.controller.ts:38-63).
Project-wide items that also apply here, not re-filed: NAV-03 (one static document title) and CONTENT-05 currency precision — formatNok (apps/web/src/utils/formatters.ts:35-41) is one of the nine hand-rolled maximumFractionDigits: 0 formatters, and it renders every money value on both routes (cost column, unit price, discounts, grand total). See PROJECT-LEVEL.md.
Findings
1. Filtered pagination invents the row total, then serves empty pages and hides rows — Blocker · proposed DATA-PAGINATION-TRUTH (see Baseline additions), cross-ref MSG-06
Where: apps/api/src/modules/service-overview/service-overview.service.ts:45, :60, :85, :90, :104-108
What: The list is a client-side merge of two sources. It fetches page * pageSize rows from bookings and from service history, merges, sorts by date desc and slices. That window is only correct while nothing is discarded after the fetch — but the booking status filter is applied after fetching (:60, filterBookingsByStatus), and the total is then extrapolated from the survival ratio:
ts
// :104-108
private approximateFilteredBookingTotal (underlyingTotal, fetched, kept) {
if (fetched === 0) return 0;
if (kept === fetched) return underlyingTotal;
return Math.round((kept / fetched) * underlyingTotal);
}So with Status = Planned selected (the page's most obvious use), if the 25 most recent bookings happen to be completed, zero pending bookings enter the window, while total still claims a proportional share of them exists. Worked example with 25 rows/page, 300 bookings of which ~8% are pending and 30 pending history entries: total ≈ 24 + 30 = 54 → totalPages = 3, but page 3 slices merged[50..75) out of a merged array that only ever reaches ~37 rows, so it returns [].
Why it matters: Three user-visible consequences on the same screen. (a) The paginator offers pages that render DataTable's empty state — "Ingen serviceoppføringer funnet" — mid-list, which reads as "you have no planned services" rather than "this page is broken". (b) Pending bookings older than the fetched window are unreachable through the UI, so a customer checking upcoming service on their fleet can simply not be shown a scheduled visit. (c) The "(N total)" figure in the footer (DataTable.vue:664-668) is a fabricated number presented as a count.
Severity call: rated Blocker rather than High because the wrong result appears in the feature's primary task (filter to planned services and page through them), and because the failure mode is a silent omission the user cannot detect. The unfiltered default view is correct, which is the only argument for High.
Fix: Push the status filter into the bookings query (bookings.list already takes filters at the repository level — bookings.repository.prisma.ts:99, :134) so total comes from the database, and page the merge with a proper keyset/limit-offset over both sources rather than page * pageSize per source. If an exact merged total is genuinely unavailable, stop rendering it as a count and cap pagination at the rows actually retrievable.
2. Completing, skipping or deleting a service entry fails silently — High · MSG-01, MSG-03
Where: apps/web/src/components/ServiceHistoryEntryDialog.vue:213-215, :220-222, :238-240, :256-268
What: Every write path in the dialog opened from a service-history row swallows its error. submitProcessComplete ends } catch { /* handle */ } (:220-222) — the comment is the whole handler. submitProcessSkip is identical (:238-240). confirmDeleteEntry (:256-268) handles only HTTP 403 with a toast and discards every other status. Attachment uploads are looped with } catch { // non-fatal } (:213-215), after the completion has already been written.
Why it matters: On a failed complete/skip the spinner stops, the dialog stays open, and nothing is said — the technician cannot tell whether the service was recorded, and the obvious response (press Complete again) issues a second write. On attachment failure the entry is marked complete while the inspection photos the user attached are gone, with no indication either at the time or later. The repo's own frontend guide requires the opposite (apps/web/CLAUDE.md §7 "No silent failures", "Errors are surfaced clearly to users"), and the file already imports useToast, so the mechanism is present and unused on these paths.
Fix: Route all four catches through the same toast the 403 branch uses, with distinct copy for "not saved" vs "saved, but N attachments failed" (and offer re-upload for the latter). Do not close the dialog on failure.
3. A completed or deleted entry stays on the table until a full reload — High · proposed DATA-STALE-LIST (see Baseline additions), cross-ref MSG-06
Where: apps/web/src/domains/service-overview/service-overview.composable.ts:101-103; wired at ServiceOverviewPage.vue:409
What: The dialog's @updated handler is:
ts
async function refetch (): Promise<void> {
await countsQuery.refetch();
}It refetches the counts query only. The list query (["service-overview","list",{…}], created in create-domain-helpers.ts:107-127) is never invalidated, and nothing else invalidates it either — the account store's DOMAIN_QUERY_KEYS (stores/admin-account.ts:8-24) lists bookings but not service-overview.
Why it matters: After completing a service the dialog closes and the row is still there, still tagged "Planlagt", while the filter chips beside it visibly update their numbers — so the screen contradicts itself. Clicking the stale row re-opens the dialog in process mode (mode is derived from the stale row.status, :38-41) and lets the user complete an already-completed entry. Deleting has the same shape: the deleted row remains clickable.
Fix: queryClient.invalidateQueries({ queryKey: ["service-overview"] }) alongside the counts refetch, and add service-overview to DOMAIN_QUERY_KEYS.
4. The page renders a search field that cannot filter anything — High · proposed FORM-INERT-CONTROL (collides with FORM-12(c), see Baseline additions), FORM-01
Where: packages/ui/src/components/DataTable.vue:199-203, :414-422, :329-338; page usage ServiceOverviewPage.vue:266-280
What: DataTable always renders a search input — desktop toolbar (:414-422) and, via MobileListHeader :searchable="true", the mobile sticky header (:329-338). But filteredData short-circuits when the table is server-paginated:
ts
// :199-203
const filteredData = computed(() => {
if (isServerPaginated.value || props.serverSearch) return tableData.value;
…ServiceOverviewPage passes :page/:total-pages (so isServerPaginated is true) and does not bind search, @update:search or serverSearch. The API has no search parameter for this endpoint either (service-overview.controller.ts:38-63 accepts only page/pageSize/accountId/ status/serviceType), so there is nothing to bind it to.
Why it matters: Typing a machine name or work-order number into the most prominent control on the page changes nothing at all — not the rows, not a count, not a "no matches" message. With 25 rows per page over a fleet's whole service history, search is the natural way to find one visit, and its silence reads as "no results exist" or "the app is frozen". The input also has no label — placeholder-only (FORM-01), and the placeholder is the hardcoded English 'Search' (see finding 11).
Severity call: High rather than Medium because the control is not merely inconsistent, it is a primary affordance that silently does nothing, and the user has no way to discover that.
Fix: Either add search to /service-overview and wire :search/@update:search/serverSearch, or have DataTable hide the search input when it is server-paginated and no search binding was supplied.
5. The table's failure state is untranslated English, unannounced, with no way out — Medium · CONTENT-01, MSG-03, MSG-04, MSG-01
Where: ServiceOverviewPage.vue:266-280 (no error-message); packages/ui/src/components/DataTable.vue:269-276, :526-531; packages/ui/src/components/EmptyState.vue (no live region)
What: errorStateMessage falls back to the literal "Failed to load data." and errorStateDescription to `Status ${statusCode}` (:270-276). ServiceOverviewPage is the only one of 18 DataTable pages that does not pass error-message (17 others do — LocationsPage, OrdersPage, CustomerAssetsPage, …), so on a Norwegian UI a failed load says "Failed to load data." The error branch (:526-531) renders EmptyState with tone="error", which has no role="alert"/aria-live, and — unlike the empty branch at :532-541 — passes no #action slot, so there is no retry control. The user's only recovery is a browser reload.
Cross-reference: a sibling audit reported that DataTable pages interpolate TanStack's status string so users read "(status: error)", and that the 403 branch is dead. Here it presents differently — this page passes neither status nor useLiveQuery, so statusValue is null (:181-183), the 403 branch at :271-273 is unreachable and errorStateDescription is undefined. Same root cause (status is an untyped free-for-all prop), different symptom: a permission failure shows the generic message instead of the forbidden copy. Fixing it centrally in DataTable fixes both pages.
Fix: Add localised error-message / forbidden-message on this page; give EmptyState role="alert" for tone="error"; render the #empty-action (or a new #error-action) slot in the error branch and put a retry button in it; and type status as the HTTP status rather than accepting whatever the caller has.
6. Filter counts are extrapolated from a 200-row sample and can contradict the rows — Medium · CONTENT-04-adjacent, proposed DATA-COUNT-ACCURACY
Where: apps/api/src/modules/service-overview/service-overview.service.ts:128-141; rendered at ServiceOverviewPage.vue:215-230, :84-93
What: Booking contributions to the status counts are estimated:
ts
// :130-135
const sample = await this.bookings.list(req, { page: 1, pageSize: 200 }, accountId);
bookingTotal = sample.total;
bookingCompleted = sample.data.filter(b => b.bookingStatus === "completed").length;
const ratio = sample.data.length > 0 ? bookingCompleted / sample.data.length : 0;
bookingCompleted = Math.round(ratio * bookingTotal);
bookingPending = bookingTotal - bookingCompleted;The frontend renders those figures as exact parenthesised counts on every filter option — Fullførte (143), Planlagte (12) — on the desktop selects and the mobile chips alike.
Why it matters: The filter panel's result-count feedback is the user's cue for whether a filter is worth applying (the data-display/filter-panel pattern lists "result count feedback" as part of the panel's anatomy). Here the number can differ from the number of rows the same filter actually produces, and it disagrees with the footer total from finding 1. A count that is wrong is worse than no count.
Fix: Count with a GROUP BY on the bookings table instead of sampling; if that is genuinely impossible, drop the parenthesised numbers rather than showing an estimate as a fact.
7. Choosing a service type silently removes every booking from the list — Medium · MSG-03, CONTENT-04
Where: apps/api/src/modules/service-overview/service-overview.service.ts:37, :40; UI at ServiceOverviewPage.vue:227-230
What: const excludeBookings = Boolean(serviceType) — whenever the Service type filter is anything but "All", bookings are dropped from the result entirely, because bookings carry workOrderType rather than serviceType. The same happens for Status = Skipped (:40), which has no booking equivalent. The UI presents both as ordinary filters over one homogeneous list; nothing says the two row kinds obey different vocabularies.
Why it matters: A customer who filters "Service" to review servicing sees only planned/checklist rows and none of their actual work orders, and reasonably concludes no service was carried out. The two filters interact invisibly too: type + status can produce an empty list where each filter alone has rows.
Fix: Map booking workOrderType onto the same vocabulary where a mapping exists (service, warranty), or make the exclusion explicit in the UI — e.g. a note under the filter row ("Service type applies to planned services only; work orders are hidden"), which is the honest version of what the API already does.
8. Keyboard users cannot reach the other visits on a work order — Medium · A11Y-03
Where: apps/web/src/pages/BookingDetailPage.vue:618-626; also packages/ui/src/components/DataTable.vue:608-617
What: In the "Service visits" section the desktop table rows are bare <tr … @click="goToSiblingBooking(visit.id)"> with no tabindex, no role and no key handler. The mobile stack directly above (:580-599) uses real <button> elements, so the accessible implementation already exists in the same file, three lines up. Separately, DataTable's clickable rows do set role="button" + tabindex="0" but bind @keydown.enter only — an element exposed as a button must also activate on Space (WCAG 4.1.2 / native button behaviour), and Space will instead scroll the page.
Why it matters: Sibling visits are the only in-page route between the visits of one work order; a keyboard or switch user on desktop has to go back to the list and hunt. On the overview table itself, a screen-reader user told "button" presses Space and the page scrolls away from the row.
Fix: Make the sibling rows <tr> containing a full-width <button> (or give the row tabindex/role/@keydown.enter/@keydown.space.prevent), and add @keydown.space.prevent to DataTable's row handler.
9. The booking error banner interpolates the raw exception message — Medium · MSG-02, MSG-03
Where: apps/web/src/pages/BookingDetailPage.vue:237, :286; copy at i18n/messages/en.yml:1022, nb.yml:1022
What:
ts
const errorStatus = computed(() => error.value instanceof Error ? error.value.message : t("common.unknown"));is interpolated into bookings.detail.error — "Could not load booking (status: {status})." / "Kunne ikke laste booking (status: {status})." The slot is written for a status code; what arrives is whatever the generated hey-api client threw — "Failed to fetch", a serialised response body, a CORS message.
Why it matters: Users read sentences like "Kunne ikke laste booking (status: Failed to fetch)." — SDK prose, in English, inside Norwegian copy, and it tells them nothing actionable. This is the same rule as the project-level getErrorMessage finding, but a separate hand-rolled code path, so fixing the helper will not fix this page. The page does at least offer a retry button (:288-294), which is more than finding 5's table state manages.
Fix: Map to error.response?.status (or drop the placeholder) and add a next-step sentence. Keep the retry button.
10. Icon-only pagination and view-toggle controls have no accessible name — Low · A11Y-05, A11Y-02
Where: packages/ui/src/components/DataTable.vue:671-678, :688-695, :428-441
What: The previous/next pagination buttons are icon="pi pi-angle-left" / pi-angle-right with no aria-label; the card/table view toggle is two <button>s whose only content is a decorative <span class="pi pi-th-large">, with no name and no aria-pressed to expose which view is active (selection is conveyed by background colour alone). The page's own mobile action buttons do this correctly (ServiceOverviewPage.vue:282-296 both set aria-label), so the gap is in the shared component.
Why it matters: A screen-reader user hears "button, button" for pagination and cannot tell which view mode is selected. The toggle buttons are 36×36 CSS px (h-9 w-9), which passes 24×24 but leaves no spacing between the two.
Fix: Add i18n-able aria-labels to both pagination buttons and both toggle buttons, and :aria-pressed="viewMode === 'card'" on the toggle.
11. Hardcoded English in the shared table chrome — Low · CONTENT-01
Where: packages/ui/src/components/DataTable.vue:664-668 ("Page {{ page }} of {{ totalPages }}", "({{ total }} total)"), :420 ('Search'), :269 ("No data available."), :274 ("Failed to load data."), :276 (Status N), :279 ("Create"), :352 (aria-label="Create")
What: @aadalen/ui takes localised strings for some slots (emptyMessage, applyLabel, clearLabel) but hardcodes the rest in English. On this page the pagination line and the search placeholder are always visible, so an nb user sees "Page 2 of 7 (154 total)" and "Search" under a fully Norwegian heading.
Why it matters: customer-portal is held to en/nb parity per feature. This is one component, but it is on 18 pages.
Fix: Add optional paginationLabel/searchPlaceholder/errorMessage defaults as required props, or inject vue-i18n into @aadalen/ui and key them there.
Unverified
- A11Y-01 (contrast) — not determinable from source. Candidates worth a contrast pass:
text-text-3onbg-paperfor the pagination/meta lines, the status Tag severities against their pale-bgfills, and the white-on-accent active view-toggle button. - A11Y-06 (short viewport / mobile keyboard) — the mobile sticky header (
MobileListHeader) plus theBottomTabBarplus an open keyboard leaves very little for the card list at ~700px height; needs a rendered check at 320/360/390px. - PrimeVue internals (
node_modulesnot installed in this checkout, per PROJECT-LEVEL.md): whetherSelectexposes an accessible name fromplaceholderalone (finding 4'sFORM-01claim for the filter selects, as opposed to the plainInputTextsearch which certainly has none); whetherButton :loadingsetsdisabledand drops focus (FORM-06) in the dialog's Complete/Skip buttons; whetherGalleria :full-screentraps focus and restores it to the trigger thumbnail; whetherToastannounces via a live region. - Report download inside the Capacitor wrapper —
BookingDetailPage.vue:233-235builds${VITE_API_BASE_URL}/api/v1/bookings/{id}/reportand opens it with a plain<a target="_blank">. The endpoint is session-cookie authenticated (bookings.controller.ts:14,:33-36), which is fine in the browser (SameSite=Lax sends cookies on top-level GET navigation) but likely 401s when the native wrapper hands the URL to the system browser. Needs a device test; every other download on the page uses a presigneddownloadUrl, so this is the only authenticated link. - Whether the merged sort is stable for rows whose date is null — both sources sort
desc(bookings.repository.prisma.ts:99,service-history.repository.prisma.ts:30) and null dates getsortKey: 0, so they sink to the bottom; the ordering among them is undefined and may shuffle between pages. Not asserted.
Baseline additions
Descriptive IDs plus definitions; the orchestrator should renumber and merge.
DATA-PAGINATION-TRUTH— A paginated list's reported total and page count must be derived from the same query that produces the rows. Never extrapolate a total from a sample, and never offer a page the query cannot fill. (Cited by finding 1. Related to but distinct from the MSG-06 family: the symptom here is a correct-looking page that is empty, not a failure rendered as emptiness.)DATA-STALE-LIST— A mutation performed from a list must invalidate the query that produced the list, not only its adjacent aggregates. A row that still shows a pre-mutation state invites the user to repeat the action. (Finding 3. This is the write-side twin of the MSG-06 theme and should probably merge into it.)FORM-INERT-CONTROL— A control that cannot affect the result must not be rendered. Search boxes, filters and sort controls that the data layer ignores are worse than absent, because their silence reads as "no matches". (Finding 4. Collides with the existing FORM-12(c) proposal — "inert controls (filters that never reach the API)". Same rule; keep one.)DATA-COUNT-ACCURACY— A number presented to the user as a count must be a count. If only an estimate is available, either omit it or label it as approximate. (Finding 6.)
Cross-project note
- Findings 4, 5, 10 and 11 are
@aadalen/uiDataTabledefects, not page defects — 18 customer-portal pages mount this component (queue rows #9, #10, #14, #18, #19, #20, #21, #22, #27, #31–#35 all listdata-display/table). The inert search box in particular applies to every server-paginated list in the app. These are strong candidates for PROJECT-LEVEL.md so the remaining table audits can cite instead of re-deriving.packages/uiis in-repo, so unlike PrimeVue these are asserted from source. - tt-time-tracker uses PrimeVue 4 + TanStack Table over a similar admin-table stack and already has a recorded MSG-06 split (admin tables have
ListErrorState, worker screens have none); findings 5 and 8 are the same shape and worth checking there. - members already has two confirmed instances of finding 3's shape (
DonsProgress.vue,objectifs.store.ts) and of finding 2's (silent catches inwidgets/src/api.ts). Findings 2 and 3 look like a fourth confirmation of the cross-project MSG-06 theme. - playout — worth grepping for the finding-1 shape (in-memory merge of two paginated sources) in the event list, and for empty-catch write handlers, which the batch has now found in three of four projects.