Appearance
[UX] tt-time-tracker — Invoices (admin)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 28 Patterns:forms/currency-input,data-display/table
Summary
The invoice review drawer is the most carefully built surface audited so far — deep-linkable, OCR-aware, with a real audit timeline, a proper archive confirmation and correctly sanitised search highlights. Two structural defects undercut it: two of the six filters are wired to nothing (the store never sends createdById/projectId, which the API supports), and there is no way to save an edit unless the invoice is already marked paid — the "Enregistrer" button is rendered only in the v-else branch of the workflow button, so correcting a vendor name or an OCR amount forces the admin to advance the invoice's status. Around those, the money path lacks the reversibility the backend already implements: validating amounts locks them permanently from this UI, and archived invoices become unreachable despite the confirmation promising they are restorable.
Findings
1. "Créé par" and "Projet" filters change nothing — Blocker · FORM-12 (proposed)
Where: services/client/src/stores/organization/allInvoices.store.ts:76-93 (and the watcher at :129) What: buildApiFilters() maps status, type, archived, amountHtMin/Max, dateRange and sort — but never created_by or project. Both are in the watcher list, so picking one does trigger a refetch, and both count toward advancedFilterCount (InvoiceList.vue:347-357) and toward hasFilters, so the "Réinitialiser" button and the mobile "Filtres · 2" badge both appear. The request goes out with the filter stripped. The API supports both parameters (services/api/src/modules/invoice/invoice.service.ts:135 where.createdById, :158 where.projects = { some: { projectId } }), and they are in the generated client types (src/api/types.gen.ts:1575-1576). Why it matters: An admin filters to one project or one submitter, the list visibly reloads, the filter chip says it is active — and the result set is every invoice in the organisation. On a money path this produces confident wrong conclusions ("this project only has these three invoices"). The empty state even offers "Réinitialiser les filtres" for a filter that was never applied. It also desyncs the export: useInvoiceExport.ts:15 does filter by project client-side, so the exported file and the screen disagree. Fix: In buildApiFilters(), add if (filters.created_by) apiFilters.createdById = filters.created_by.id; and if (filters.project) apiFilters.projectId = filters.project.id;. Delete the unused filters.amount field (:31) while there.
2. Edits cannot be saved unless the invoice is already paid — Blocker · FORM-05
Where: services/client/src/components/Modals/ModalInvoiceReview.vue:499-532, with primaryAction at :903-908What: The footer renders the workflow button when primaryAction is truthy and the "Enregistrer" button only in the v-else. primaryAction is null only when invoice.marked_as_paid is true. So for every invoice in nouveau, validee or approuvee state there is no save control at all — the sole persisting action is "Valider les montants" / "Approuver" / "Marquer comme payée", each of which also advances the status. handleUpdate (:725) is unreachable for those states. The changed flag is computed and, on this branch, never used: there is no dirty indicator either. Why it matters: The drawer's whole purpose is reviewing and correcting AI-extracted data. Fixing a mis-read vendor, a wrong date, a typo in the comment, or re-splitting the project allocation on an unpaid invoice is impossible without also validating/approving/paying it. Conversely, once paid, "Enregistrer" finally appears — but the three amount fields are disabled by then (:269, :278, :288). The only accidental escape hatch is "Demander l'approbation" (:535-551), which calls invoices.update with the full copy — so admins will learn to send a spurious approval email to save a typo. Fix: Always render "Enregistrer" alongside the workflow button (the footer already has room — it is a two-button row). Keep it enabled whenever changed, and show the dirty state so the user knows there is something to save.
3. Unsaved edits are discarded silently, by at least three routes — High · MSG-05
Where: ModalInvoiceReview.vue:664 (useChangeTracker), :716 (goToInvoice), SheetOrDialog.vue:16 + :140-162 (dismissable defaults true); composables/useChangeTracker.ts:19-24; collections/queryClient.ts:10What: The drawer edits a copy seeded by a watch(getter, …, { deep: true }) on the TanStack query result. Three things overwrite that copy with no prompt: (a) any refetch of invoiceControllerFindOne — staleTime is 30 s and refetchOnWindowFocus is left at its default, so alt-tabbing away and back after 30 s re-seeds the copy and drops everything typed; (b) closing the drawer with Escape or a click on the mask, since SheetOrDialog is dismissable by default and nothing guards it; (c) the prev/next arrows (:32-52), which just reassign selectedInvoiceId. None of these warns, and there is no autosave. Why it matters: These are hand-corrected monetary amounts on a screen whose primary flow (finding 2) already makes saving hard. The user retypes an HT/TVA/TTC triple, glances at another window, comes back to the original OCR values, and has no way to tell that anything was lost. Fix: Set refetchOnWindowFocus: false for this query (or skip re-seeding while changed is true); pass :dismissable="!changed" and confirm on Escape/mask-close; on prev/next, confirm when changed. A "modifications non enregistrées" marker in the header would make all three legible.
4. Every workflow step is one click, unconfirmed, and irreversible in the UI — High · MSG-05, MSG-04
Where: ModalInvoiceReview.vue:499-515 (primary button), :827-854 (handleSet), :269/:278/:288 (amount fields disabled by invoice.checked) What: "Valider les montants" fires handleSet('checked') on a single click with no confirmation, and from that moment the Montant HT / TVA / TTC inputs are permanently disabled on this screen. "Approuver" behaves the same. There is no un-validate, un-approve or un-pay control anywhere in the drawer — yet the backend implements all three (services/api/src/modules/invoice/invoice.service.ts:369-383 logs unchecked, approval_reversed, payment_reversed), and this very component ships timeline styling for those events (:865, :867, :869) that no UI action can ever produce. Only "Marquer comme payée" has an interstitial, and that dialog asks for a date ("Veuillez entrer la date", :556-583) without saying the step is not undoable. Why it matters: A mis-click on the large accent-coloured primary button locks the amounts of a financial record, and the admin's only recovery is a developer or a database edit. Contrast with the archive action three pixels to the left, which does confirm and does explain the consequence. Fix: Confirm before checked and approved, naming the consequence ("les montants ne seront plus modifiables"). Expose the reversals the API already supports — an "Annuler la validation" action on a validated invoice, matching the unchecked/approval_reversed events the timeline can already render.
5. Archived/refused invoices disappear with no way back — High · MSG-04
Where: ModalInvoiceReview.vue:787-796 (confirm copy), InvoiceList.vue:46-122 (filter row), stores/organization/allInvoices.store.ts:59 (archived: false) What: The confirmation promises "Les données seront archivées et pourront être restaurées par un administrateur", and the backend honours that (invoice.service.ts:406-412 sets archived: true). But the admin list hard-codes archived: false and offers no control to flip it. The drawer's "Restaurer" button (:490-498) only renders for an invoice that is already archived — which you can only open by pasting its /admin/invoices/:invoiceId URL from memory. Every sibling list in this repo has the archive view: Projects/ProjectList.vue:181, Users.vue, Vehicles.vue, TaskLists.vue all use ArchiveViewBanner with a "Voir les archives" toggle. Why it matters: "Refuser" is the destructive half of the approval decision on a money path. Refusing an invoice by mistake — or needing to review what was refused — leaves the admin with no route out, and the confirmation dialog's promise is one the UI cannot keep. Fix: Add the same "Voir les archives" toggle used by the four sibling lists, driven by the archived filter the store and API already implement.
6. Invoice rows cannot be opened from the keyboard — High · A11Y-03
Where: components/InfiniteTable.vue:98-102, components/Table.vue:92-97What: Rows are <tr class="cursor-pointer" @click="emit('select', item)"> with no tabindex, no role, and no key handler. Clicking a row is the only way to open the review drawer (InvoiceList.vue:129, :229) — there is no per-row action button. Related, in the same header: <th :role="column.sortable ? 'button' : undefined"> (InfiniteTable.vue:29, Table.vue:29) replaces the implicit columnheader role, which makes the aria-sort attribute set right above it inert, and no scope="col" is set on any header. Why it matters: A keyboard or screen-reader user can reach the filters, the search box and the export button, but cannot open a single invoice. The whole feature is mouse-only past the list. This is the same component pair used by most admin lists in the app, so the barrier is not local to invoices. Fix: Keep <th> semantics and put a <button> inside the header cell (the data-display/table reference markup does exactly this, with an aria-label naming the current sort), add scope="col", and make rows operable — either a focusable first-cell button/link to invoice-details (which also gives real deep-link semantics), or tabindex="0" + role="button" + Enter/Space on the row.
7. The export can silently write wrong amounts and partial data — High · FORM-12 (proposed)
Where: composables/useInvoiceExport.ts:9-12 and :52; InvoiceList.vue:453-471; components/Modals/ModalAddInvoice.vue:265What: Two separate problems in one file. (a) When an invoice has allocations, each exported row's "Montant HT" is the allocation amount (value: (i) => i.amount || 0), and ModalAddInvoice creates every project-tagged invoice with [{ project, amount: null }] — so those invoices export as 0 rather than their HT value. Only invoices with no allocations at all fall back to i.amount_ht. (b) handleExport pages the whole result set in a loop and if (!ok) break; — on a failed page it stops and exports what it has, with no warning; the same happens silently at the guard < 1000 ceiling. Why it matters: The export is the artefact that leaves the product and goes to accounting. An understated total or a truncated row set is indistinguishable from a correct file once it is opened in Excel. Fix: Fall back to a.amount ?? i.amount_ht for a single allocation, or export the invoice HT in its own column alongside the per-project split. On a paging failure, surface an error and refuse the download rather than exporting a partial file.
8. Money fields and filters have no programmatic labels — Medium · FORM-01
Where: ModalInvoiceReview.vue:207-294; InvoiceList.vue:48-110; components/Forms/Fields/FieldSearch.vue:1-12What: In the details grid every field is labelled by a sibling <span> (<span class="text-xs …">Montant HT</span> next to an InputNumber) — no <label for>, no id, no aria-label, no aria-labelledby. The filter row is worse: the status Select has no label of any kind (it just reads "Tous"), and period/type/min/max are placeholder-only (placeholder="Période", "Montant min (HT)"), so the labels vanish the moment a value is entered. The page search input is placeholder-only too. The forms/currency-input reference markup additionally expects the currency in the accessible name ("Amount in euros") with the symbol aria-hidden. Why it matters: A screen-reader user tabbing the drawer hears "edit text, blank" nine times in a row on a form where three of those fields are HT, TVA and TTC. In the mobile filter sheet, six unlabelled controls appear in a bare column. Fix: Give each control an id and turn the <span> into a <label for> (the grid layout is unaffected); add aria-label to the filter controls and the search field, keeping the placeholder as a hint rather than the label.
9. Search results drop the Fournisseur column and their headers sort nothing — Medium · FORM-12 (proposed)
Where: InvoiceList.vue:123-214 with tableColumns at :393-401; components/Table.vue:123, :226-242; composables/useTableSelection.ts:44-47What: In search mode the same tableColumns are fed rows of shape { document, highlight } from Typesense, and every column is rendered through an explicit #item.* template that unwraps item.document.* — except vendor, which has no template. It therefore falls through to resolve(item, "vendor") → item.vendor → undefined, so Fournisseur is blank for every search hit. For the same reason, Table.vue's client-side sort resolves undefined for every hit on every column, so clicking any sortable header in search mode reorders nothing while still flipping the sort arrow. Why it matters: Search is how an admin finds a specific invoice. The column that identifies who issued it is empty, and the sort controls lie about being applied. Fix: Add a #item.vendor template mirroring the others, and either normalise hits to invoice shape before handing them to Table (which fixes the sort at the same time) or mark the columns non-sortable in search mode.
10. A failed search renders as "Aucun résultat" — Medium · MSG-06 (proposed)
Where: InvoiceList.vue:123-130 (no :error binding); components/Table.vue:60-76, :192-194What: useQuery for the search endpoint destructures only data and isLoading. searchResults returns undefined on error and the template passes searchResults?.hits ?? [], so a 500 or an offline search shows the empty state: "Aucune facture ne correspond à votre recherche. Essayez d'autres mots-clés." Table already accepts error/errorTitle and renders a retry state — its own prop comment says it exists "so a failed fetch never masquerades as 'no data'" — and the non-search InfiniteTable on the very next line does bind it (:219-220). Why it matters: The user is told their invoice does not exist when the search backend is simply down, and is nudged to keep retyping search terms. Fix: Destructure isError from the query and pass :error="isSearchError" plus a retry handler, exactly as the InfiniteTable branch does.
11. Money is formatted by two different rulesets — Medium · FORM-12 (proposed)
Where: composables/useFormat.ts:27 vs ModalInvoiceReview.vue:267-293 and InvoiceList.vue:80-97What: All money output is pinned: new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }). All money input is a bare PrimeVue InputNumber with suffix=" €" and no mode="currency", no currency, no locale, and no minFractionDigits/maxFractionDigits. PrimeVue falls back to the browser's locale and to Intl's default fraction digits for mode="decimal", so grouping/decimal separators and the number of decimals shown in the editor are determined by the viewer's machine, not by the app. The forms/currency-input pattern lists this exact anti-pattern ("Hardcoding US number formatting" / relying on the browser default: always pass an explicit locale and currency). The drawer's amount fields also set no :min="0", while the filter inputs do — so a negative HT is accepted in the editor. Why it matters: The same invoice reads one way in the table and another in the drawer, and a French user on an en-US browser profile gets . as the decimal separator on a form whose read-only twin uses ,. Fix: mode="currency" currency="EUR" locale="fr-FR" (or explicit :min-fraction-digits="2" :max-fraction-digits="2" plus locale) on all four money inputs, and :min="0" on the three amounts. Exactly what renders today is unverified — see below.
12. English backend messages reach French users — Medium · MSG-02
Where: utils/index.ts:44-50 and :83-86; services/api/src/modules/invoice/invoice.service.ts:64, :68, :73, :80, :431What: extractErrorMessage maps only errors that carry a code (five codes are mapped); everything else falls through to the raw server message before reaching the fallback. The invoice endpoints throw uncoded English text: "Invalid projectId in allocations for this organization", "Invalid vehicleId for this organization", Invoice ${id} not found, "Invoice has no file attached", "Only PDF files are accepted". Those strings land verbatim in the toast raised by handleUpdate/handleSet/handleReprocess. Why it matters: A French-only admin saving an allocation gets an English sentence containing a raw field name and no instruction on what to do. Fix: Either attach codes to the invoice module's exceptions and add them to ERROR_CODE_MESSAGES, or drop the raw-message fall-through so unmapped errors use the caller-supplied French fallback that every call site already passes.
13. Action buttons are disabled while their request is in flight — Medium · FORM-06
Where: ModalInvoiceReview.vue:502 (:disabled="settingStatus"), :538 (requestingApproval), :485 and InvoiceList.vue:13 (PrimeVue :loading, which also disables); ModalAddInvoice.vue:125 (uploading) What: Every mutating control in this feature disables itself for the duration of the request. The label swap and spinner are good; the disabled is the problem — a just-activated button that becomes disabled drops focus to <body>. Why it matters: A keyboard user who validates an invoice loses their place in the drawer and has to tab from the top of the document to reach the next control; a screen-reader user gets no announcement of what happened, since focus is on nothing. Fix: Keep the buttons enabled, set aria-busy="true", and guard re-entry in the handler — handleSet already returns early on settingStatus.value, so the guard exists and the disabled is redundant.
14. Icon-only controls with no accessible name — Medium · A11Y-05
Where: components/Forms/FieldList.vue:20-30; components/AutoIndicator.vue:1-7What: The allocation delete control is a PrimeVue Button with icon="pi pi-trash" and no label, no aria-label — in the review drawer this is the button that removes a project's share of an invoice. AutoIndicator, the sparkle marking which fields the AI filled, is a bare <i> carrying a v-tooltip and nothing else: no text, no aria-label, not focusable, so the meaning is mouse-hover-only. Credit where due — the drawer's own prev/next and document buttons (:32-52, :103-133) all carry proper aria-labels. Why it matters: "button" with no name, repeated once per allocation row, on a control that changes how money is split. Fix: aria-label="Supprimer cette affectation" on the FieldList remove button (ideally naming the project), and give AutoIndicator an aria-label or an adjacent visually-hidden span.
15. The page has no <h1> — Medium · A11Y-04
Where: components/Layout/LayoutMain.vue:6 and :74What: The layout renders the page title ("Factures") as <h2> in both the mobile and desktop headers, and nothing else on the page is an <h1>. The drawer then adds its own <h2> (ModalInvoiceReview.vue:21). Sibling views that do not use LayoutMain — Hours.vue:8, UserInvoices.vue:8, UserDetails.vue:22, ProjectDetails.vue:20 — all use <h1>, so the repo is inconsistent with itself. Why it matters: Heading navigation starts at level 2 with no document title above it, and every admin page inherits this. Fix: Promote the LayoutMain title slots to <h1>. Project-wide fix, one line each.
16. The archive confirmation contradicts the button that opened it — Low · CONTENT-02
Where: ModalInvoiceReview.vue:484 vs :790What: The button label is conditional — "Refuser" at the approval step, "Archiver" otherwise — but confirmArchive hard-codes the header "Refuser cette facture ?". Pressing "Archiver" on a new or already-paid invoice opens a dialog asking whether to refuse it. Why it matters: On a money path, "refuse" and "archive" are different decisions; the mismatch makes the user stop and re-read to work out which one they are about to perform. Fix: Derive the header from isApprovalStep, as the button label already does.
17. No document title for either route — Low · NAV-03
Where: services/client/src/router/routes.ts:55-58; services/client/index.html:17What: No route sets a title and there is no document.title write anywhere in src/ — every page in the app is "Tim". invoices and invoice-details are therefore indistinguishable in the tab bar, in browser history and in the back-button menu, which is a real cost for a deep-linkable drawer that admins are expected to open in new tabs. Why it matters: Ten open invoice tabs are ten identical "Tim" entries. Fix: Add meta.title and a global afterEach hook. Like CONTENT-01, this is architectural — better handled once for the project than per feature.
Unverified
- A11Y-01 (contrast). Not checkable from code. Several spots would repay a measurement: the
text-surface-400empty-state description (ListEmptyState.vue:15), thetext-[10px]/text-[8px]confidence badge (ModalInvoiceReview.vue:181-184), thetext-[11px] text-surface-300allocation dash (:389), and the fourStatusPilltones over their-bgbackgrounds. - A11Y-06 (short viewport / mobile keyboard). The drawer is a
h-[80dvh]bottom sheet on mobile containing amin-h-[32rem]document panel above a scrolling details column, with a two-button sticky footer. Whether the footer and the amount fields stay usable with a keyboard open needs a rendered device. - Finding 11's exact rendering.
node_modulesis not installed in this checkout, so PrimeVue 4.5'sInputNumberdefaults could not be read from source. The code-level fact — nomode, nolocale, no fraction digits, hand-rolledsuffix=" €", against output pinned tofr-FR/EUR— is verified; the precise digits shown are not. - A11Y-02 (target size). Measured from classes only: the drawer's icon buttons are
size-7(28 px) and the nav arrowssize-9(36 px), both above the 24 px floor. The PrimeVuetext smallbuttons (FieldList remove, "Réinitialiser") depend on theme padding and were not verified. ModalInvoiceDetails.vuewas deliberately not reviewed: it is imported only byUserInvoices.vue, i.e. queue item #3, not this feature.
Baseline additions
FORM-12— Inert controls. A control that appears active must affect the result. A filter, sort or column that is rendered, counted as active, or reflected in a "reset" affordance, but which the request or the render never uses, is a defect regardless of severity. Findings 1, 7, 9 and 11 are all instances: a filter that never reaches the query, a column that resolves toundefined, an export column reading the wrong field, an input whose formatting contract differs from its display twin. This is the single most common shape of defect in this feature and none of the existing 36 rules covers it — the closest, FORM-05, is about disabled submits, not about controls that are enabled and do nothing.MSG-06— Failure is not emptiness. A failed fetch renders an error state with a retry, never an empty state. "No results" and "the request failed" must be visually and semantically distinct. Finding 10. This repo already agrees with the rule in code (Table.vue:192-194) and violates it on one branch, which suggests it is the kind of thing that regresses quietly and is worth being a rule.CONTENT-01—not-applicable — see project-level i18n finding.
Cross-project note
- FORM-12 / inert filters — worth a targeted check in customer-portal and playout, both of which have list screens with multi-field filter panels. The failure mode here (store watcher lists a filter, request builder omits it) is a specific, greppable shape: compare the watcher's dependency list against the keys the request builder actually sets.
- A11Y-03 / click-only table rows — this is
Table.vue+InfiniteTable.vue, used across most of tt-time-tracker's admin lists, so it will recur in queue items 11, 12, 14, 16, 19, 20, 21 and 22. Other projects use PrimeVueDataTable(customer-portal) or the BCC library (members), which may handle row activation for them; playout is the likeliest to share a hand-rolled table. - MSG-05 / unsaved edits lost on modal dismiss —
useChangeTracker+ a dismissable drawer is the standard editing idiom in this repo, so every "details modal" in tt-time-tracker probably shares it. members and customer-portal should be checked for the same pattern; the query-refetch variant specifically needs TanStack Query, which is used in tt-time-tracker and customer-portal. - A11Y-04 / no
<h1>— layout-level here. Worth confirming which of the four projects put an<h1>in their app shell; the fix is one line per layout and a good candidate for the final consistency pass. - NAV-03 / no document titles — check all four. If more than one is missing them, promote to a shared alignment issue rather than 25 per-feature findings.