Appearance
[UX] members — Facturation (invoicing)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 9 Patterns:data-display/table(consulted; itsbodyis mangled compiled MDX as the brief warns — only thetocand the reference markup were readable, which confirmaria-sortand<caption>as expected table semantics)
Files read in full: admin/src/client/views/BActive/Facturation.vue, components/app/modals/ModalFamilyInvoice.vue, ModalO18Preview.vue, ModalBulkInvoice.vue, ModalBulkFamilyInvoice.vue, components/app/tables/TableData.vue, composables/useApi.ts, composables/useCurrency.ts, lib/apiRequest.ts, stores/filters.store.ts.
Summary
Facturation is the most consequential admin surface audited so far: it creates and emails real Pennylane invoices and credit notes, in bulk, to families and to over-18 members. The single-row flows are genuinely well built — preview first, an explicit confirm, a hard block when the Pennylane customer is missing an address, and a manual "link an existing invoice" rescue path. The problems are concentrated in the failure and bulk paths: a failed data load renders as an empty worklist, a bulk send that fails is swallowed into console.error, and a family that has only been partially invoiced is classified « Facturé » and disappears from the treasurer's list. The money-precision defect the brief asked about is not present here — admin/src/client/composables/useCurrency.ts uses maximumFractionDigits: 2, so 12,50 € renders correctly; only the widgets' useFormat.ts has the rounding bug.
Findings
1. A failed load of any of the four queries renders as an empty invoicing list — High · MSG-06 (proposed: a failed fetch must not render as an empty state)
Where: admin/src/client/components/app/tables/TableData.vue:118-140; the dead error plumbing is admin/src/client/views/BActive/Facturation.vue:219-224 and :427-436What: Facturation correctly aggregates the error state of its four queries (sharedError) and passes isError and error into TableData via tableData. TableData never reads either one: its <tbody> branches only on loading || props.data?.isFetching, then on table.getRowCount() === 0 → « Aucun élement ». grep -rn "isError" admin/src/client confirms no consumer in TableData (other components such as ModalReminder.vue and TileDonsProgress.vue do handle it, so this is a gap, not a house style). Why it matters: If /api/familles, /api/factures, /api/objectifs or /api/inscriptions fails — expired session, sub-org header mismatch, 500 — the treasurer sees « Facturation familles · 0 éléments » and « Aucun élement ». The honest reading of that screen is "nothing to invoice this year". There is no error text and no retry control, and no bulk button is offered (both are gated on a non-empty derived list), so the failure is completely invisible on a money worklist. Fix: Add an error branch to TableData's <tbody> before the empty branch: render a role="alert" row with a human sentence and a « Réessayer » button calling props.data.fetch(). Being in the shared table, one change fixes every admin list view.
2. A partially-invoiced family is classified « Facturé » and drops off the worklist — High · DATA-STATUS-PARTIAL (proposed)
Where: admin/src/client/views/BActive/Facturation.vue:281-286What: computeFamilleStatus only compares credited against invoiced:
ts
if (objectifsTotal === 0) return "objectif_manquant";
if (invoiced === 0) return "non_facturé";
if (credited > invoiced) return "avoir_en_attente";
return "facturé";The family's _objectifsTotal is used only to detect zero. A family with a 500 € objective that has been invoiced 200 €, or invoiced 500 € and then credited 200 €, returns facturé. The O18 tab, computing the same idea for inscriptions (:361-373), does have a partiellement_facturé state with a net >= prix - 0.01 test — the asymmetry is strong evidence the family branch is an oversight rather than a decision. Why it matters: « Facturé » is a green tag, renders no action button (:865-889 only offers PDF/Recalculer), and is excluded from bulkEligibleFamilies (:754-756, which filters on non_facturé). Money that was never invoiced is silently marked collected, with no control anywhere in the view to correct it except knowing to press « Recalculer » on a row that looks finished. Fix: Give the family status the same partial state as the inscription status: compare invoiced - credited against _objectifsTotal and return partiellement_facturé when it falls short, with a « Compléter » action opening the existing preview modal. Severity call: High rather than Blocker because the common full-amount path works — the defect only bites after a partial invoice or a partial credit note.
3. A failed bulk O18 send tells the user nothing — High · MSG-01, MSG-06 (proposed)
Where: admin/src/client/views/BActive/Facturation.vue:737-741, and the silent early return at :726What:
ts
} catch (err) {
console.error("Bulk invoice error:", err);
} finally {
bulkModal.loading = false;
}bulkModal.results stays [], so ModalBulkInvoice falls back to its v-else preview branch (ModalBulkInvoice.vue:73) — exactly the screen the user was looking at before pressing « Facturer (N) ». Nothing on screen changes except the spinner disappearing. The same silence occurs when the response is a 200 with no results key (:735, res?.results ?? []) and when sendableIds is empty (:726). Why it matters: /api/inscriptions/bulk-invoice is a request that creates and emails invoices. If it times out or the connection drops after the server got it, the invoices may well have been sent; the treasurer sees no change and the obvious next move is to press the button again — duplicate invoices to members. Severity call: High not Blocker because it needs a failure to occur first; the duplicate-send consequence is what keeps it above Medium.Fix: Set bulkModal.results to a per-inscription failure list (or a modal-level error banner with role="alert") in the catch, and give the modal a retry that is explicit about the risk of re-sending. Show a message rather than returning silently when sendableIds.length === 0.
4. Invoice documents on the O18 tab are unreachable by keyboard — High · A11Y-03, A11Y-05
Where: admin/src/client/views/BActive/Facturation.vue:1031-1046 (the Documents cell); secondary location components/app/tables/TableData.vue:91-97What: Each Facture/Avoir chip is a BccTag given style: "cursor:pointer" and an onClick handler, with no tabindex, no role="button", no key handler and no accessible name beyond the word « Facture ». The chip is the only route to facturePopover, which holds the amount and the « Ouvrir dans Pennylane » link — the O18 tab has no equivalent of the Familles tab's PDF button (:868-881). Separately, sorting is bound to @click on the bare <th> (TableData.vue:96) with no tabindex, no aria-sort and no keyboard handler; the pattern's reference markup for data-display/table uses aria-sort on sortable headers. Why it matters: A keyboard or screen-reader user administering invoices cannot open any O18 invoice document at all, and cannot sort any admin table. This is a hard barrier with no alternative path, which is why it is High. Fix: Render the chip as a real <button type="button"> wrapping the tag with an accessible name (« Ouvrir la facture de 120 € »), and make the sortable header a <button> inside the <th> with aria-sort reflecting header.column.getIsSorted().
5. A failed invoice send is a dead end — the send button is removed and the composed recipient list is lost — Medium · MSG-04, MSG-03
Where: components/app/modals/ModalFamilyInvoice.vue:290 and ModalO18Preview.vue:194 (v-if="preview && preview.action !== 'none' && !result") What: Once result is set — including { success: false } — the footer's confirm button is removed and the preview body is replaced by the error panel (ModalFamilyInvoice.vue:11). The only remaining control is « Fermer ». On the family flow the user has usually just curated a recipient list (:369-389); that state is discarded by closeFamilyModal (Facturation.vue:504). Why it matters: After a transient failure the treasurer must close the modal, re-open it (re-running the preview POST), and re-add every extra recipient before trying again. The error text also says only what failed, never what to do next. Fix: Keep the confirm button visible on result.success === false, relabelled « Réessayer », preserving recipientsList; only hide it on success. Append a next step to the error copy ("Réessayez, ou vérifiez la fiche du parent si l'adresse est incomplète").
6. Bulk family invoicing sends to families the single-family flow refuses to invoice — Medium · MSG-05, FORM-05
Where: components/app/modals/ModalBulkFamilyInvoice.vue:88-95 and :171-175; the send loop is views/BActive/Facturation.vue:762-789What: The bulk modal flags each family's billing address with a red « Manquante » tag, but the confirm button is Facturer (${families.length}) — the full list — and sendBulkFamilyInvoices iterates bulkEligibleFamilies with no filter at all. The single-family modal blocks exactly this case: canConfirm requires customer.status !== "blocked" (ModalFamilyInvoice.vue:391-397). The bulk family modal also never fetches a preview (unlike the O18 bulk modal, which does — Facturation.vue:572-591), so the amount column shows the objectif, not the amount that will actually be invoiced. Why it matters: The confirmation overstates what will happen — the user is told N invoices will go out when some are certain (or at best unknown) to fail, and the summary line « N famille(s) … vont recevoir une facture » (:62) is wrong. For an irreversible, money-bearing bulk action, the count in the button is the number the user is actually consenting to. Fix: Filter bulkEligibleFamilies by hasBillingAddress for the send loop, count only sendable families in the button label and the summary sentence, and list the excluded ones explicitly ("3 familles ignorées — adresse incomplète").
7. Success confirmations auto-dismiss after 2 s, and the timers are never cleared — Medium · NAV-01, NAV-02
Where: views/BActive/Facturation.vue:492, :526, :674, :707 (setTimeout(closeFamilyModal, 2000) / setTimeout(closeO18SinglePreview, 2000)) What: After a successful send the modal shows « Facture envoyée avec succès! » and closes itself two seconds later. The timer id is never stored and never cleared on unmount or on manual close. Why it matters: WCAG 2.2.1 — the only confirmation that an irreversible invoice was created and emailed is removed on a fixed timer no user can adjust, and it carries no invoice reference the user could note down. Because the timer is not cancelled, closing the modal and opening a different family's within two seconds closes that modal instead, mid-preview. Fix: Leave the success state up and let « Fermer » dismiss it; if auto-close is kept, store the id and clearTimeout in closeFamilyModal / closeO18SinglePreview and on onUnmounted.
8. Raw server error strings, including « HTTP 500 », are shown to the treasurer — Medium · MSG-02, MSG-03
Where: lib/apiRequest.ts:23-28 produces the message; it reaches users at views/BActive/Facturation.vue:468, :497, :531, :588, :648, :678, :712, each rendered verbatim by the modals (ModalFamilyInvoice.vue:50, ModalO18Preview.vue:50, and the per-row tags in both bulk modals) What: apiRequest throws new Error(result.error ?? result.message ?? \HTTP ${status}`). The views' err instanceof Error ? err.message : "Erreur lors de l'envoi"fallback is unreachable in practice, becauseuseApi.executeguarantees anError. So whatever the API or the Pennylane integration emits — an English Pennylane validation string, HTTP 502— is what the volunteer treasurer reads. InModalBulkInvoice.vue:66that string is even squeezed into a truncatedBccTag. **Why it matters:** This is the admin-side twin of the widgets/src/api.tsfinding already recorded in PROJECT-LEVEL.md; noting it here because it is on a money path and because it is a different file, so fixing the widget client will not fix it. **Fix:** Map knownApiError.codevalues (the field is already carried throughapiRequest.ts:26`) to French sentences with a next step, and fall back to one generic sentence for everything else; keep the raw string for the console.
9. Modal status changes are not announced — Medium · MSG-01
Where: ModalFamilyInvoice.vue:26-53, ModalO18Preview.vue:26-53, ModalBulkInvoice.vue:29-71, ModalBulkFamilyInvoice.vue:19-55What: No role="alert", role="status" or aria-live anywhere in the four modals. Success, failure, and the bulk results table all appear via v-if swaps; the bulk progress counter (ModalBulkFamilyInvoice.vue:15, "({{ results.length }} / {{ families.length }})") likewise updates silently. Why it matters: A screen-reader user pressing « Envoyer la facture » gets no confirmation that anything happened — success and failure are equally silent (WCAG 4.1.3). Fix: role="alert" on the error blocks, role="status" on the success blocks and the bulk progress line and results summary.
10. The failure state uses an undefined colour token, so errors are not red — Medium · A11Y-05-adjacent; also violates the repo's own token rule
Where: ModalFamilyInvoice.vue:44 and ModalO18Preview.vue:44 (class="… text-danger") What: admin/src/client/assets/index.css defines success, warning, error and info (each with -bg/-fg); there is no --color-danger (grep -rn "color-danger" admin/src/client → no hits). Under Tailwind v4 an undeclared token generates no utility, so text-danger is inert and the failure panel — icon and message — renders in the inherited body colour. The sibling success panel uses text-success, which is defined, so success is green and failure is not-red. The same file mixes in raw palette literals the repo's CLAUDE.md forbids: hover:text-red-600 (ModalFamilyInvoice.vue:198, ModalBulkFamilyInvoice.vue:110) and bg-blue-500 (ModalBulkFamilyInvoice.vue:126). Why it matters: On a money action, "the invoice failed" and "the invoice was sent" are distinguished only by the icon glyph and the French sentence, not by the colour the design system reserves for it. Fix: text-error-fg in both files; replace the three raw palette literals with error / info tokens.
11. Nothing in the invoicing flow states which fiscal year is being invoiced — Medium · MSG-05, CONTENT-03
Where: the year comes from stores/filters.store.ts:5 (useLocalStorage("members-filters-year", …)), is chosen in components/app/layout/SidebarContent.vue:44-47, and is interpolated into every write path — views/BActive/Facturation.vue:485, :516, :773 (/api/familles/${id}/invoice/${filters.year}). No modal, table title or button label mentions it. What: « Facturer toutes (12) » issues twelve invoices for whichever year the sidebar select currently holds — a value persisted in localStorage from a previous session, and, on mobile, hidden inside the bottom-sheet menu (Layout.vue's « Menu » pill) rather than visible on the page. Why it matters: MSG-05 requires an irreversible action to say what it will do. Issuing a batch of 2025 invoices while believing you are issuing 2026 ones is a plausible, hard-to-undo mistake, and the confirmation screen gives the user nothing to catch it with. Fix: Put the year in the table name (« Facturation familles — 2026 ») and in each modal's confirmation sentence and confirm-button label.
12. Column filters survive the Familles ↔ O18 tab switch while their meaning changes — Medium · NAV-06 (proposed: view mode must not silently inherit the previous mode's filter state)
Where: components/app/tables/TableData.vue:389 (columnFilters is never reset when props.columns changes); the colliding definitions are views/BActive/Facturation.vue:338-345 and :416-423What: Both tabs expose a filterable column with id: "Statut" whose accessor is a numeric sort rank. The ranks do not agree: on Familles, 2 is « Objectif manquant »; on O18, 2 is « Partiellement facturé ». TableData holds one columnFilters ref across the swap, so a filter set on one tab is still applied — with a different meaning — after switching. Why it matters: Rows silently vanish from an invoicing worklist. The « Filtrer · 1 » badge does stay visible, which is why this is Medium and not higher, but the drawer will show the filter under a label whose options no longer match. Fix: Reset columnFilters (and ideally the search query) when the column set changes — a watch on props.columns in TableData, or a :key on the TableData instance bound to tab in Facturation.vue.
13. Bulk-selection checkboxes have no accessible name — Medium · A11Y-05
Where: views/BActive/Facturation.vue:964-968 (select-all header) and :976-980 (row checkbox) What: Both BccCheckbox instances are given only size and modelValue/ onUpdate:modelValue — no label, aria-label or inputId. The header cell renders the checkbox as the entire header content, so there is no adjacent text either. TableData's own native checkboxes do pass aria-label="Tout sélectionner" / "Sélectionner la ligne" (TableData.vue:83, :160), which is the convention this view departs from. Why it matters: These checkboxes choose which members get invoiced. A screen reader announces "checkbox, not checked" with no indication of which row. Fix: Pass aria-label (« Tout sélectionner », « Sélectionner {{ Nom }} ») at the call site. Whether BccCheckbox synthesises a name internally could not be checked — node_modules is not installed (see the standing caveat) — but no name is supplied by this view either way.
14. Money columns are left-aligned and mix "150 €" with "12,50 €" — Low · CONTENT-05 (proposed: currency formatting)
Where: composables/useCurrency.ts:1; cells at views/BActive/Facturation.vue:152-153, :827, :834, :1010What: useCurrency sets minimumFractionDigits: 0, maximumFractionDigits: 2, so amounts render with 0 or 2 decimals depending on the value, and moneyCell returns a plain <span> in a <td> with no text-right and no tabular-nums (the popover at :49 does use tabular-nums, showing the intent). Why it matters: A column of prices and objectives cannot be scanned or compared vertically when digits neither align nor share a decimal count. Minor, but this is the treasurer's primary reconciliation view. Note (answers the brief's money-path question): the admin formatter does not carry the widgets' rounding defect — maximumFractionDigits: 2 here vs 0 in widgets/src/composables/useFormat.ts:2. 12,50 € is displayed as « 12,50 € » throughout Facturation. No cross-contamination: no file under admin/ imports useFormat. Fix: minimumFractionDigits: 2, and right-align money cells with tabular-nums via meta on the money columns.
15. The route sets no document title — Low · NAV-03 (project-level: raise once, not here)
Where: admin/src/client/router.ts:79 (no meta.title, and no title is set anywhere: grep -rn "document.title\|useTitle" admin/src/client → no hits); admin/index.html:13 is <title>BCC Nancy Admin</title> for all 24 routes. Note: PROJECT-LEVEL.md's cross-project table currently has members as — for NAV-03. This audit confirms members behaves like customer-portal and tt-time-tracker: one static title for the whole app. Recording it so the orchestrator can fill the cell; it should be filed once against the project, not against this feature.
Unverified
- A11Y-01 (contrast). Not checkable from code. The status tags rely on
*-subtlercontexts from@bcc-code/component-library-vue, and the filter-drawer/table chrome usestext-neutral-500on white attext-xs— both worth measuring on a rendered page. - A11Y-06 (short viewport / responsive). The four dialogs are
class="w-full max-w-2xl"/max-w-4xlwith long scrollable bodies (the family preview has six stacked blocks plus a per-event line table); the bulk modals use 12-column CSS grids with no responsive breakpoints, andhiddenOnMobilehides table columns but not modal columns. Needs a rendered viewport to judge. BccCheckbox/BccTag/BccDialoginternals —node_modulesis not installed, so whether the library supplies an accessible name, focus trapping, orroleon these is unverified (standing caveat). Findings 4, 9 and 13 are written against what the call site supplies, which is verifiable.- Server-side gating of blocked families (finding 6). Whether
POST /api/familles/:id/invoice/:yearrefuses a family with an incomplete address was not checked — the API was out of scope for this read. The client-side inconsistency stands regardless. - Contrast/keyboard behaviour of
BccPopover(does it trap focus, is it dismissible with Escape) — same reason.
Baseline additions
Proposed with descriptive IDs per the brief; the orchestrator will renumber.
DATA-STATUS-PARTIAL— A derived status must not report a partially completed state as complete: when a total is known, the status compares against it and exposes an explicit "partial" state with its own action. (Finding 2. Members has both the right and the wrong implementation of this in one file.)MSG-BULK-OUTCOME— A bulk action reports a per-item outcome, and a request-level failure of the bulk call is reported as such — never by returning the user to the pre-submit screen unchanged. (Finding 3. Distinct from the MSG-06 family, which is about a failed read rendering as empty; this is about a failed write.)MSG-05a— An irreversible action's confirmation names every out-of-band parameter it will use, including ones chosen elsewhere in the app (a global period/scope selector) rather than in the confirmation itself. (Finding 11.)- Existing collisions this draft touches:
MSG-06is cited in the "failed fetch must not render as an empty state" sense (variant (b) in PROJECT-LEVEL.md);NAV-06in the "view mode/tab state" sense (variant (e));CONTENT-05in the currency-precision sense (variant (a)). - CONTENT-01:
not-applicable — see project-level i18n finding.
Cross-project note
- Finding 1 (failed load → empty state) is the admin-SPA instance of the members loading-gate theme already in PROJECT-LEVEL.md, and matches tt-time-tracker's worker-facing screens and playout.
TableData.vueis shared by all ~20 admin list views in members, so one fix covers #10–#29 of the members queue — worth filing against the component, not this feature. tt-time-tracker's admin tables already have aListErrorState; members' do not. - Finding 4 (click-only chips) is the members instance of the A11Y-03 row that is already ✗ in all four projects.
- Finding 8 (raw
HTTP <status>) duplicates the shape of customer-portal'sgetErrorMessageand members'widgets/src/api.ts, in a third client (admin/src/client/lib/apiRequest.ts). Three of four projects now confirmed. - Finding 3 should be checked against tt-time-tracker's invoices-admin feature, which is the closest analogue in the programme (bulk invoice generation) — and against members #16/#17 relances, which send email in bulk from the same
TableData+ modal shape. - Finding 7 (2 s auto-dismiss) is the same class as playout's timed redirects (NAV-01) — worth a sweep for
setTimeout(in all four repos.