Appearance
[UX] tt-time-tracker — Vehicles
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 26 Patterns:forms/form-validation
Summary
Vehicles is a small, tidy admin CRUD screen — it has the ListErrorState + retry surface the worker screens lack, a real archive view, and a shared confirm dialog. The problems are on the edges: the vehicle cards are click-only <div>s so the entire edit/archive path is mouse-only; the vehicle picker that every other feature consumes (FieldSelectVehicle) takes a one-off snapshot of the collection instead of a reactive source, unlike its sibling FieldSelectUser; and archiving is treated as free even though license plates are globally unique and archived rows keep reserving them, so a vehicle can be archived and then never re-created.
Findings
1. Vehicle cards are click-only <div>s — the whole feature is mouse-only — High · A11Y-03
Where: services/client/src/views/Admin/Vehicles.vue:53-58What: Each card is <div class="… cursor-pointer" @click="showDetailsModal = true; selectedVehicle = vehicle"> with no tabindex, no role="button", no @keydown.enter/space, and no focus style. The card is the only way to reach a vehicle: there is no row menu, no detail route, and vehicles has no :id child route (router/routes.ts:62). Why it matters: A keyboard or screen-reader user can add a vehicle and can read the plate/name text, but can never open one — so editing, archiving and restoring are unreachable for them. Every existing vehicle is permanently read-only. Fix: Make the card a <button type="button" class="text-left w-full …"> (or add role="button", tabindex="0", Enter/Space handlers and a focus-visible: ring). The grid should keep DOM order so tab order matches visual order. Severity: High rather than Blocker because the screen still works for pointer users; it is a total barrier for keyboard users, not a broken feature.
2. The vehicle picker is built from a non-reactive snapshot, so it can be permanently stale or empty — High · proposed DATA-REACTIVE-SOURCE
Where: services/client/src/components/Forms/Fields/FieldSelectVehicle.vue:19What:
ts
const orderedVehicles = useSorted(vehicles.list.value ?? [], (a,b) => …);vehicles.list is a live-query ref; .value is read once at setup and the plain array is handed to useSorted, whose non-dirty form is computed(() => [...toValue(source)].sort(fn)) — toValue on a plain array returns it unchanged, so nothing re-tracks a ref reassignment. The sibling picker does it correctly: FieldSelectUser.vue:61 wraps the source in computed(() => users.list.value ?? []). Why it matters: Wherever the picker mounts before the vehicles collection has settled — a cold load, or right after a vehicle is created/archived — the dropdown keeps whatever the list held at that instant. On ModalInvoiceDetails.vue:38-44 the vehicle field is marked required with a red asterisk for gasoil invoices, so an empty picker means the invoice cannot be completed at all; on ModalAddUser.vue:88-94 and UserDetails.vue:174-187 a just-created vehicle cannot be assigned as a driver's default. Fix: One-line change to match FieldSelectUser: useSorted(computed(() => vehicles.list.value ?? []), …). Severity: High, not Blocker, because the exact first-paint symptom depends on whether @tanstack/vue-db's useLiveQuery replaces its data ref or mutates the array in place — node_modules is not installed, so that could not be settled from source (see Unverified). The divergence from the sibling component is asserted from source either way.
3. Archiving a vehicle silently blanks it everywhere it is referenced, and the confirmation says nothing about that — High · MSG-05
Where: services/client/src/components/Modals/ModalVehicle.vue:51-59,148 · services/client/src/composables/useArchiveConfirm.ts:20-30 · services/client/src/components/Forms/Fields/FieldSelect.vue:67-72What: The confirm dialog is « Confirmer l'archivage / Archiver ce véhicule ? » — the message never mentions what still points at the vehicle. Vehicle is referenced by MemberProfile.vehicleId and Invoice.vehicleId (packages/database/prisma/models/vehicle.prisma:11-12), and FieldSelectVehicle builds its options from vehicles.list (non-archived only). FieldSelect's formattedModel.get resolves the current value by looking it up in the options list, so once the vehicle is archived the lookup returns null and the control renders its placeholder « Sélectionner un véhicule… ». Why it matters: A gasoil invoice that does have a vehicle, and a driver who does have a default vehicle, both render as if nothing were assigned. An admin who "corrects" that empty-looking field overwrites the historical link — the archive action quietly rewrites past records' apparent attribution, with no warning at the point of archiving. Fix: (a) Say what is affected in the confirm copy, ideally with counts ("N factures et N utilisateurs y font référence ; elles resteront liées mais le véhicule n'apparaîtra plus dans les listes"). (b) Have FieldSelect fall back to rendering the bound value when it is not in the options (e.g. append the archived option labelled « (archivé) ») so a referenced vehicle is never displayed as absent. Severity: High rather than Medium because the consequence is silent misattribution of financial records, not just weak copy. The copy half alone matches the Medium MSG-05 finding already drafted for Users (#7).
4. License plates are globally unique, so an archived plate can never be re-used — and the failure is unexplained — High · MSG-03 (also MSG-04)
Where: packages/database/prisma/models/vehicle.prisma:5 · services/api/src/common/prisma-exception.filter.ts:41-44 · services/client/src/components/Modals/ModalVehicle.vue:124-130What: licensePlate String? @unique is a bare field-level unique constraint — it is not scoped to organizationId (contrast @@index([organizationId]) on the next line), and VehicleService.remove is a soft delete (vehicle.service.ts:29-31, sets archived: true) so archived rows keep holding their plate. Creating a vehicle whose plate exists anywhere in the database returns 409 and the user gets a toast from extractErrorMessage(err, "Erreur lors de la sauvegarde"). Why it matters: The realistic path is: archive a van, the van comes back months later, admin types the same plate → the save fails every time with a message that does not say the plate is taken, does not say it is taken by an archived vehicle of theirs, and does not point at « Voir les archives » where restoring would fix it. The second path is cross-tenant: organization B cannot register a plate organization A already holds, which is both an unfixable dead end and a weak existence oracle over another tenant's fleet. Fix: Scope the constraint — @@unique([organizationId, licensePlate]) — and, for the same-org case, detect the conflict client-side against vehicles.all before saving, offering « Ce véhicule est archivé — le réactiver ? » as the recovery action. Severity: High rather than Blocker because it needs a colliding plate to trigger; when it does trigger, the save can never succeed.
5. The first load renders as "you have no vehicles" — Medium · MSG-06 (project-level family)
Where: services/client/src/views/Admin/Vehicles.vue:44-102What: vehicles.loading is consulted only for the retry button's spinner (:45). The template branches error → grid → ListEmptyState, with no loading branch, so while the collection is still fetching filteredVehicles.length === 0 and the user reads « Aucun véhicule — Ajoutez votre premier véhicule pour commencer. » before the grid replaces it. AdminDashboard.vue:298 shows the intended idiom (dataLoaded = !users.loading.value && !projects.loading.value). Why it matters: An admin on a slow connection is told their fleet is empty and invited to re-create vehicles that already exist. Also CONTENT-04 — nothing names what is being waited on. Fix: Add v-if="vehicles.loading.value && !filteredVehicles.length" with a skeleton grid ahead of the empty branch. Note for the queue: this screen does have ListErrorState (:43-48), so the admin/worker split recorded in PROJECT-LEVEL.md holds — the gap here is the loading state, not the error state.
6. The vehicle form's inputs have no programmatically associated labels — Medium · FORM-01
Where: services/client/src/components/Modals/ModalVehicle.vue:12-16,24-28What: Both labels are bare <label class="…">Plaque d'immatriculation</label> / …>Nom</label> with no for, and the InputTexts have no id. The error <small> elements (:18-21, :30-33) are likewise not connected via aria-describedby. The repo already has the correct idiom: UserDetails.vue:411-412 builds const uid = useId() / const ids = {…} and wires :for / :id / :input-id. Why it matters: A screen-reader user tabbing into either field hears no label, and on failed validation hears no reason — the message is visually adjacent but programmatically detached. This is the corpus's "Separating labels, hints, and errors" anti-pattern verbatim. Fix: Copy the useId() pattern from UserDetails.vue; add :aria-describedby="errors.x ? ids.xError : undefined" and id on the <small>.
7. Both fields are required but nothing says so, and the plate requirement contradicts the data model — Medium · FORM-04
Where: services/client/src/components/Modals/ModalVehicle.vue:103-106 · services/api/src/modules/vehicle/dto/create-vehicle.dto.ts:10-11What: The zod schema requires licensePlate and name (min(1)), but neither label carries a required marker — the constraint only appears after a failed save. Meanwhile the plate is optional in the API (@NullableOptionalString()), nullable in Prisma, and the list explicitly renders vehicle.licensePlate ?? 'Sans plaque' (Vehicles.vue:65). Also min(1) is not trimmed, so a single space passes. Why it matters: A plateless vehicle can legitimately exist (created via the API, or by a sync), but opening it in this modal makes every edit impossible — renaming it is blocked until a plate is invented — and there is no way to clear a plate once set. It is the mirror image of the Users finding #9 (name required on create, clearable on edit). Fix: Mark required fields in the labels (the invoice modal already does this: ModalInvoiceDetails.vue:40-42 uses a red *), .trim() before min(1), and decide one contract — either make the plate genuinely optional in the form or make it required in the DTO.
8. A duplicate plate produces an English, column-named message in a French UI — Medium · MSG-02 (also MSG-03)
Where: services/api/src/common/prisma-exception.filter.ts:42-43 · services/client/src/utils/index.ts:44-59,83-86What: The filter builds A record with this ${target} already exists from Prisma's meta.target. ERROR_CODE_MESSAGES has no entry for a conflict, so extractErrorMessage falls through to fromValue(e.message) and shows the backend string verbatim in layout.showError(...). Why it matters: French users get English prose naming a database column, and no instruction — it does not say which field collides or what to do. Fix: Add a CONFLICT (or P2002) entry to ERROR_CODE_MESSAGES (« Cette plaque d'immatriculation est déjà enregistrée. »), and have the API return a stable code rather than relying on prose.
9. On mobile the « Ajouter » button has no accessible name — Medium · A11Y-05
Where: services/client/src/components/Layout/LayoutMain.vue:109-111 · services/client/src/components/AddButton.vue:1-7What: .mobile-header-buttons :deep(.p-button-label) { display: none; } hides the label below lg. AddButton supplies its name only via label="Ajouter" and no aria-label, so on mobile the control is an unnamed icon button. (The overflow control is fine — Vehicles.vue:23 sets aria-label="Plus d'actions".) Why it matters: The primary action of the screen is announced as "button" on the viewport where it is icon-only. Fix: Add aria-label="Ajouter" to AddButton, or swap display: none for a visually-hidden class. Same defect and same fix as the Users draft (#3) and Projects draft (#5) — this is one shared-component issue, worth promoting rather than filing three times.
10. Neither list state nor field errors are announced — Low · MSG-01
Where: services/client/src/components/ListErrorState.vue:6-11 · services/client/src/components/ModalVehicle.vue:18-21,30-33What: ListErrorState is plain <p> text with no role="alert"/aria-live, and the inline validation <small> elements have neither. The toasts from layout.showSuccess/showError are the only announced channel (PrimeVue Toast — see Unverified). Why it matters: A screen-reader user who triggers a failed load or a failed validation gets a silent DOM swap (WCAG 4.1.3). Fix: role="alert" on ListErrorState's wrapper and on the error <small>s.
11. The vehicle form is not a <form>, so Enter does not submit and nothing gets focus — Low · FORM-11 + proposed FORM-SUBMIT-ON-ENTER
Where: services/client/src/components/Modals/ModalVehicle.vue:7-35What: The two fields sit in <div>s; the primary button is a footer Button wired to @click, and there is no <form> element and no autofocus on open. The auth views do this properly (LoginLocal.vue, ResetPassword.vue, AcceptInvite.vue all use real <form>s), so this is an in-project inconsistency, not a house style. Why it matters: Typing a plate and pressing Enter does nothing; the user must reach for the mouse in a two-field dialog. And on open, focus stays on <body>/the dialog container rather than the first field. Fix: Wrap the fields in <form @submit.prevent="handleSave">, give the save button type="submit", and autofocus the plate field when the sheet opens.
Covered elsewhere — not re-filed
CONTENT-01— not-applicable, see the project-level i18n finding.NAV-03— fails project-wide (« Tim » on every route), see PROJECT-LEVEL.md.A11Y-04—LayoutMain.vue:6,74renders the page title as<h2>; there is no<h1>on this or any admin screen. Already filed as Users #4.- Unsaved edits overwritten by a background refetch —
useChangeTracker.ts:19-24deep-watches the source withimmediate: true, so a refetch of the live-query row resetscopymid-edit. Identical to Users #2 (FORM-DISCARD-GUARD); same composable, same fix. - actifs/archivés view is component state, not URL state (
Vehicles.vue:125) — same as Users #13 / Projects #13, pendingNAV-06(e). - Silent redirect on
/admin/*for non-admins — inventory-level observation, filed under Users #5. buildApiFilters— checked as instructed: not used by this feature. Vehicles fetches the whole collection (vehicle.controller.ts:17-24takes no query params) and filters client-side (Vehicles.vue:140-148), so the dropped-filter-key defect found in the invoices/entries stores does not apply here.- Currency precision — no money surface on this screen; nothing to report toward the four-project table.
Unverified
A11Y-01contrast. Needs rendered colours. Candidates:text-surface-400attext-xsfor the vehicle name (Vehicles.vue:67) and for empty/error descriptions, and the amber-on-amber archive banner (ArchiveViewBanner.vue:2-6).A11Y-06responsive / short viewport. The sheet ish-[80dvh]on mobile with amin-height: 32remdialog on desktop (SheetOrDialog.vue:85,173); behaviour with a keyboard open needs a real viewport.@tanstack/vue-dbuseLiveQuerysemantics (finding 2) — whetherdatais reassigned or mutated in place decides whether the stale picker is empty-forever or merely stale.node_modulesis not installed in this checkout.- PrimeVue internals —
Button :loadingmapping to nativedisabled(would makeModalVehicle.vue:46,52,69aFORM-06focus-drop),InputText :invalidemittingaria-invalid,Menupopup keyboard/focus behaviour (Vehicles.vue:30-34),AutoCompletecombobox semantics inFieldSelect, and whetherToastcarries a live region. All unreadable from source here. - The exact 409 string in finding 8 depends on Prisma's
meta.target(field name vs. Postgres constraint name); either form is English and database-flavoured.
Baseline additions
DATA-REACTIVE-SOURCE— A derived list passed to a composable must be a reactive source (ref/getter/computed), never.valueread once at setup; otherwise the UI silently freezes at first paint. Second instance of the shape in this project (see finding 2 vs.FieldSelectUser).FORM-SUBMIT-ON-ENTER— A single-purpose form submits on Enter: fields live inside a real<form>with atype="submit"primary action. Applies to all the admin modals in this repo, not just this one.DATA-SCOPE-UNIQUE— Uniqueness constraints on tenant-owned data are scoped to the tenant, and a soft-deleted record must not keep reserving a unique value that the UI still offers to re-enter. Covers finding 4; likely worth checking every@uniqueinpackages/database/prisma/models/.- Finding 3 is arguably a general rule too — archiving must disclose what references the record, and a reference that resolves to an archived row must render as archived, not as absent — but it is close enough to
MSG-05that I have citedMSG-05rather than proposing a fifth colliding ID.
Cross-project note
A11Y-03click-only rows/cards (finding 1) — confirmed in all four projects; tt's Projects draft has the same defect on<tr>s. This is now strong enough for the alignment pass.- Loading rendered as an empty state (finding 5) — the
MSG-06family, already confirmed in playout, members and tt. - Archive/soft-delete blanking live references (finding 3) — worth checking customer-portal (archived catalog machines referenced by bids) and members (archived events referenced by registrations); the
FieldSelect-resolves-against-options mechanism is generic and likely repeats wherever a picker filters out inactive rows. - Tenant-unscoped
@unique(finding 4) — worth one grep per project over their schema/Firestore rules; playout and customer-portal are both multi-tenant.