Skip to content

[UX] members — Cotisations (dues) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch: develop @ 2c22f5a · Files reviewed: 19 Patterns: data-display/table (1 call; forms/currency-input not called — this view has no currency input, only currency display and one generated email)

Summary ​

The Cotisations table itself is competently built — capability-gated, sensible column meta feeding the FilterDrawer, a real confirmation modal for the surplus transfer, and correct 2-decimal money rendering through the admin's own useCurrency. The serious problems are on the two write paths it owns. The « Transférer l'excédent » bulk write has no per-item outcome and derives its transaction IDs from the amount, so a partial failure leaves next year credited without this year debited and the « Réessayer » button it offers can never succeed. Separately the generated reminder email — the only artefact in this feature that reaches an actual member — rounds the dues owed to whole euros and hardcodes the year 2025 and one named treasurer.

Money-path answer (per brief): this admin view does not carry the maximumFractionDigits: 0 defect. admin/src/client/composables/useCurrency.ts:1 is correct (minimumFractionDigits: 0, maximumFractionDigits: 2) and is what TooltipAdhesions and CellObjectifProgress use, so every displayed euro figure in this view is right. The precision loss here is a different mechanism — the reminder email bypasses the formatter with .toFixed(0) (finding 2). One other admin file does have the shared flaw, admin/src/client/sponsor/SponsorApp.vue:421 (maximumFractionDigits: 0), but it belongs to queue row #22 (Sponsors), not this feature.

Cotisations_B_Active.Total and .Montant and Transactions.Montant are all Decimal(10, 2) in api/prisma/schema.prisma:106,108,405, so cents are really stored and really lost.

MSG-05 (per brief): passes for all three consequential actions. The transfer is behind a modal that lists every famille, every amount to 2 dp and the 10 € threshold (ModalTransferSurplus.vue:61-85); the reminder modal is a full review-before-send of recipients, subject and body; and both « Recalculer » buttons show the recomputed figure in the popover before you press them (TooltipAdhesions.vue:69-77). No MSG-05 finding is raised. What fails is what happens after confirmation, not the confirmation.

Project-level items this feature inherits, not re-filed: CONTENT-01 — not-applicable, see project-level i18n finding. MSG-02 at the shared-client level — the admin equivalent is narrowed to finding 12 below.

Findings ​

1. Surplus transfer can half-apply, and its retry is guaranteed to fail — Blocker · MSG-04 + proposed BULK-PARTIAL-01 ​

Where: admin/src/client/views/BActive/Cotisations.vue:75-107, admin/src/client/components/app/modals/ModalTransferSurplus.vue:161-176, api/src/transactions/transactions.service.ts:35

What: confirmTransfer fans out over every eligible famille with Promise.all, and for each one awaits two sequential writes: a positive transaction in year N+1, then the offsetting negative in year N. Three things compound:

  1. There is no per-item error handling. If the second write of any famille fails, Promise.all rejects, but the positive transactions already committed stay committed. The ledger is left with families credited into next year without the matching debit this year — the surplus is counted twice.
  2. The IDs are sha1(\${item.key}-${filters.year}-${item.amount}`)(lines 80-81), and the API's create path istx.transactions.create({ data }) (transactions.service.ts:35) — a plain Prisma create, **not** an upsert`. Re-running therefore hits a primary-key collision on every row that already succeeded and throws.
  3. ModalTransferSurplus.vue:161 offers « Réessayer », but handleRetry only resets the local flags; pressing « Confirmer » again re-runs the whole batch. Because of (2) it re-collides immediately and lands back in the error state. After any partial failure the advertised recovery control can never succeed.

The amount is baked into the hash, so it is worse than a permanent error: if the treasurer resolves the failure by touching one of those families' transactions, item.amount changes, the hash changes, and a second positive credit is written alongside the orphaned first one.

Why it matters: a treasurer who sees « Erreur lors du transfert » has no way to know whether nothing, some, or almost all of the transfer landed — the modal names no famille and the table behind it has already been invalidated. The only offered next step is a button that cannot work. Recovery means hand-auditing the Transactions table for Commentaire = "Excédent de <year>" rows without their "Transfert vers <year+1>" twin.

Severity rationale: placed at Blocker rather than High under the normalisation rule "the feature is … wrong for a normal user (… money is taken twice)". Real money moves between accounting years, the result is double-counted, and the escape hatch is inert. It is not gated on an attacker or an extra measurement — one flaky request is enough.

Fix: three parts. (a) Make the two writes one server-side operation — a POST /api/transactions/transfer-surplus that writes both legs inside the existing prisma.$transaction, so a famille can never be half-transferred. (b) Change the create path to an upsert on the supplied id (or have the endpoint accept skipDuplicates), which makes replay genuinely idempotent. (c) Drop amount from the hash and iterate with for…of collecting { item, ok, error }, then render the per-famille outcome in the modal so « Réessayer » can re-send only the failures.


2. Reminder email quotes the dues owed rounded to whole euros — High · CONTENT-05 (currency precision) ​

Where: admin/src/client/views/BActive/Cotisations.vue:136

What: the generated body interpolates ${Number(cotisation.Total ?? 0).toFixed(0)}€. Total is Decimal(10, 2) (api/prisma/schema.prisma:106). A cotisation of 12,50 € is emailed as « 13€ »; 187,50 € becomes « 188€ ». toFixed rounds half away from zero, so the figure is usually rounded up — the member is asked in writing for more than they owe. The euro sign is also glued to the digits with a period decimal separator, which is not French convention (12,50 €).

Note this is not the shared-formatter defect recorded in PROJECT-LEVEL.md: useCurrency.ts:1 is correct at 2 digits, and every figure rendered in the table is right. The bug is that this one string bypasses it. That makes the screen and the email disagree about what the famille owes.

Why it matters: the reminder is a payment demand sent to a member. A discrepancy between the emailed amount and the amount the system reconciles against produces either a short payment that keeps the famille flagged as delinquent, or an overpayment that then shows up in next year's surplus transfer — i.e. straight back into finding 1.

Fix: ${useCurrency(Number(cotisation.Total ?? 0))} — the composable is already imported elsewhere in this view's cell components and produces « 12,50 € ».


3. Reminder email hardcodes the year 2025 and one named treasurer — High · proposed CONTENT-TEMPLATE-01 ​

Where: admin/src/client/views/BActive/Cotisations.vue:132-141

What: the subject is correctly parameterised — `B Active : Rappel cotisation ${filters.year}` (line 132) — but the body two lines below is not: « Vous avez adhéré à B Active pour l'année 2025. » is a literal (line 135). The whole view is driven by the filters.year selector, so in 2026 the subject reads "Rappel cotisation 2026" and the body immediately contradicts it. The sign-off is likewise fixed at « Edward Tombre, Trésorier de B Active » (line 141) regardless of who is signed in and sending.

Why it matters: a dues demand that names the wrong year is the first thing a member will dispute, and it undermines the reminder for every famille in the batch, not one. The signature means any other treasurer sends mail over a predecessor's name; the bccSender copy goes to the actual sender, so the mismatch is not even visible in their own copy at a glance.

Fix: interpolate ${filters.year} in the body, and take the signature from the session (useSession().user) or hoist both the salutation and sign-off into an editable template the treasurer maintains once. The body is already editable in the modal before sending, but a default that is wrong by construction will be sent unedited most of the time.


4. A failed load of the dues list renders as "no dues" — High · MSG-06 (members theme) ​

Where: admin/src/client/components/app/tables/TableData.vue:118-140; data source admin/src/client/composables/useApiData.ts:135

What: the table body has exactly two non-data branches: skeleton while loading || props.data?.isFetching, then table.getRowCount() === 0 → « Aucun élement ». There is no error branch. When GET /api/cotisations-bactive?year=… fails, TanStack Query settles with isFetching: false and initialData: [], so the view renders the empty state. useApiData already returns isError and error (line 135) — TableData simply never reads them.

This is the same shape PROJECT-LEVEL.md records for DonsProgress.vue / objectifs.store.ts / MyDecharges.vue, but in a different component: this is the admin SPA's shared table, so the same silent failure covers roughly fifteen admin routes, not the widgets. Flagging it here as this feature's instance and as a candidate to promote alongside the widget one.

Why it matters: for a treasurer opening Cotisations, "Aucun élement" is a plausible and reassuring answer — it reads as "nobody owes anything this year", not "the request failed". There is no retry affordance and nothing in the UI distinguishes the two. The data-display/table pattern names this directly under Common Mistakes: "Displaying a blank table when no data matches … leaves users confused about whether something is broken."

Fix: add a third branch before the empty state — v-else-if="props.data?.isError" — rendering the message plus a « Réessayer » button wired to props.data.fetch(). One change fixes every admin table.


5. Column sorting is mouse-only and its state is not exposed — High · A11Y-03 (+ A11Y-05) ​

Where: admin/src/client/components/app/tables/TableData.vue:91-114

What: the sortable header is a bare <th class="cursor-pointer" @click="…">. No <button>, no tabindex, no role, no keyboard handler, no aria-sort, and no scope="col". The sort direction is conveyed solely by an <i class="pi pi-sort-up"> / pi-sort-down glyph with no accessible name (lines 103-112), and the change is not announced.

Why it matters: Cotisations opens sorted on Pourcent ascending (Cotisations.vue:6). Re-sorting is how a treasurer answers "who owes the most" and "what did we bill this famille" — a keyboard-only or screen-reader user cannot do it at all, and cannot tell which column is currently sorted or in which direction. paginate is false here (line 8), so the whole year's families are in one unsortable list.

The data-display/table pattern's reference markup is explicit about the expected shape: <th scope="col" aria-sort="ascending"><button aria-label="Name, sorted ascending. Click to sort descending">.

Fix: wrap the header content in a <button type="button"> carrying the click handler and an aria-label describing the current state and the effect of activating; put scope="col" and a bound aria-sort on the <th>. Add a <caption class="sr-only"> naming the table.


6. Popover triggers in three cells are unnamed ~12 px icons — Medium · A11Y-05 + A11Y-02 ​

Where: admin/src/client/components/app/tables/objectifs/CellObjectifProgress.vue:8-17, admin/src/client/components/app/tables/CellLastReminder.vue:13-18, admin/src/client/components/app/tables/cotisations/TooltipAdhesions.vue:15-18

What: the Progrès cell's disclosure is a <button class="shrink-0 leading-none"> whose only child is <i class="text-xs pi pi-info-circle"> — no aria-label, no text, no aria-haspopup, no aria-expanded. CellLastReminder repeats it verbatim for the reminder history. In TooltipAdhesions the trigger does have an accessible name (the euro amount), but the inconsistency warning on it is an unlabeled pi-exclamation-triangle plus a decoration-warning dotted underline — colour and icon shape only.

On target size: the button carries no padding and text-xs (0.75rem) overrides PrimeIcons' default 1rem, so the hit area is roughly 12×12 px against the 24×24 floor. Stated from the class list; the exact rendered box is in Unverified.

Why it matters: the popovers are where the money detail lives — the per-adhésion breakdown that explains the Total, the transaction list that explains the Progrès, and the « Recalculer » button that fixes a mismatch. A screen-reader user hears three unlabeled buttons per row and cannot tell that a row is flagged inconsistent. Twelve-pixel targets are a miss-and-retry for anyone with a tremor or on a touchscreen.

Fix: give each trigger an aria-label (« Détail des transactions », « Historique des rappels »), add aria-haspopup="dialog" and a bound aria-expanded; pad the button to at least 24×24 (p-1.5 -m-1.5 keeps the visual size). Give the warning icon a text alternative — e.g. an sr-only « Total incohérent avec les adhésions » — so the flag is not colour-and-glyph only.


7. Both modals swap in success and failure silently — Medium · MSG-01 ​

Where: admin/src/client/components/app/modals/ModalReminder.vue:26-53, admin/src/client/components/app/modals/ModalTransferSurplus.vue:22-48

What: « Rappel envoyé avec succès! », « Erreur lors de l'envoi du rappel », « Transfert effectué avec succès! » and « Erreur lors du transfert » are plain <p> inside <transition> blocks. No role="alert", no role="status", no aria-live. The spinner states (« Envoi du rappel en cours… », « Transfert en cours… ») are equally silent, and because the form content is replaced by v-if/v-else, focus is left on a button that no longer exists.

Why it matters: a screen-reader user activating « Envoyer » or « Confirmer » gets no confirmation that anything happened, and no notification when it fails — on the transfer path that means no signal that a money write went wrong (WCAG 4.1.3).

Fix: role="alert" on the two error blocks, role="status" on the success and in-progress blocks, and move focus to the status container when the state changes.


8. Reminder success auto-dismisses on a timer with no way out — Medium · NAV-01 + NAV-02 ​

Where: admin/src/client/components/app/modals/ModalReminder.vue:238-262 (esp. 241, 257)

What: handleSend holds the spinner open for a synthetic minimum of two seconds (delay at line 241, startTime + 2000 - Date.now()), then on success sets isSent and schedules setTimeout(handleClose, 2000) (line 257). The footer's v-if chain renders no button at all in the isSent state — the « Annuler »/« Envoyer » pair is hidden and the « Réessayer » branch only matches on error — so the confirmation cannot be dismissed deliberately; the user waits out the timer or hunts for the dialog's own close control. Neither setTimeout is captured or cleared on unmount.

ModalTransferSurplus does the opposite and better: it renders an explicit « Fermer » in its done state (line 102-107) and never auto-closes. Two modals in the same feature behave differently after an identical success.

Why it matters: WCAG 2.2.1 — a timed state change with no control. Two seconds is not enough to read a confirmation, and the deliberate two-second minimum spinner makes every reminder feel slower than it is. Anyone reading with a screen magnifier or a screen reader will lose the confirmation before reaching it.

Fix: drop both timers; render a « Fermer » button in the isSent state exactly as ModalTransferSurplus does. If a minimum spinner duration is wanted for perceived stability, cap it at ~300 ms and never gate the success message behind it.


9. text-danger is not a defined token, so error states lose their colour — Medium · proposed STATUS-01 ​

Where: admin/src/client/components/app/modals/ModalTransferSurplus.vue:39, admin/src/client/components/app/modals/ModalReminder.vue:44 and :94; token block admin/src/client/assets/index.css:18-33

What: the @theme inline block defines exactly four status roles — success, warning, error, info, each with -bg/-fg — and grep danger over the admin CSS returns nothing. Under Tailwind v4 a utility is only generated for a defined --color-*, so text-danger emits no rule. Both modals' failure blocks therefore render in inherited body colour, alongside a success block that does get its green from the valid text-success. The recipient-chip remove button's hover:text-danger hover:bg-danger (ModalReminder.vue:94) is dead for the same reason. CLAUDE.md names the four tokens explicitly and forbids raw palette utilities, so this is a drift from the project's own stated convention.

Why it matters: failure and success are the two states these modals exist to distinguish, and the failure one is stripped of its only colour signal — it comes down to the shape of a PrimeIcons glyph. On the transfer path that is the signal that a money write failed. It also silently disables the destructive hover affordance on the remove-recipient control.

Fix: text-error (and hover:text-error-fg hover:bg-error-bg for the chip button). Longer term this class of bug is invisible in review — consider an ESLint/stylelint rule or a build-time check that every text-*/bg-*/border-* status utility resolves to a token defined in index.css.


10. Reminder form labels are not associated with their fields — Medium · FORM-01 ​

Where: admin/src/client/components/app/modals/ModalReminder.vue:63, :75, :110, :136

What: « Mode », « Destinataires », « Sujet » and « Message » are bare <label class="text-xs text-neutral-600"> with no for, sitting next to BccSelect / BccInput / BccTextarea that are given no id and no aria-label. The only labelled control in the modal is the subject field's placeholder « Sujet du rappel », which is a placeholder, not a label.

CLAUDE.md documents a Field primitive in components/ui/ precisely for label+input blocks, and it is not used here.

Why it matters: a screen-reader user tabbing the modal hears "combo box", "edit", "edit" with no indication of which is the subject and which is the body of an email about to go to members. Clicking the visible label also does not focus the field.

Fix: replace the four blocks with the Field primitive, or bind explicit id/for pairs. Whether the underlying PrimeVue components would honour a passed-through id is in Unverified — node_modules is not installed.


11. A famille with no parent email is an unrecoverable dead end — Medium · MSG-04 / FORM-05 ​

Where: admin/src/client/components/app/modals/ModalReminder.vue:76-81, :92-100, :170; recipients built at admin/src/client/views/BActive/Cotisations.vue:119-128

What: openReminderModal populates recipientEmails from (cotisation.Famille as Familles).Parents[].Email, keeping only truthy values and not de-duplicating. The modal can only remove recipients — the chip's × at line 92 is the sole editing control, and there is no input to add one. When the list is empty the modal shows « Aucun destinataire. » and disables « Envoyer » (line 170).

Why it matters: for any famille whose parent records have no email — exactly the families most likely to be behind on dues — the treasurer opens the modal, is told there is nobody to send to, and has no action available: no way to type an address, no link to the famille record to fix it, and no hint that switching to Telegram mode would bypass the check. It is also one-way: removing the last remaining recipient by accident cannot be undone without closing and reopening.

Fix: add a free-text email input (chip-style add) so the treasurer can enter an address ad hoc; and when the list is empty, make the message actionable — « Aucun destinataire. Ajoutez une adresse ou complétez la fiche famille » with a link to the famille. De-duplicate the parents' addresses while you are there.


12. Families who owe nothing lead the list and are offered a reminder — Medium · CONTENT-04-adjacent, proposed DATA-DEFAULT-01 ​

Where: admin/src/client/views/BActive/Cotisations.vue:151-155, :6, :218-234

What: getPercent returns 0 in three different situations that mean completely different things: total is null, total is 0 (nothing owed), or nothing has been paid yet. The table then opens sorted Pourcent ascending (line 6), so all three land together at the top. The Actions column shows the « Rappel » button whenever percent < 100 (line 222), so a famille with Total = 0 gets a reminder button whose email will read « votre cotisation annuelle s'élève à 0€ ».

Why it matters: the default sort is the view's main triage signal — the top of the list is read as "worst offenders". Filling it with families who owe nothing costs the treasurer attention on every visit and makes a wrong-amount email one click away. A famille whose total simply has not been computed yet is indistinguishable from one that has paid nothing.

Fix: treat "nothing owed" as its own state rather than 0 % — return null from getPercent when Total is null or 0, render it as « — » or « Rien dû », sort those rows last, and suppress the Rappel action for them. TooltipAdhesions already knows how to flag a total that disagrees with the adhésions; a total that has never been computed deserves the same treatment.


13. Raw HTTP <status> reaches the recompute toast — Low · MSG-02 ​

Where: admin/src/client/composables/useRecompute.ts:51, source string at admin/src/client/lib/apiRequest.ts:22-27

What: the failure toast sets detail: err instanceof Error ? err.message : "Une erreur est survenue.". apiRequest builds that message as result.error ?? result.message ?? \HTTP ${response.status}`, so whenever the API returns a body without an error/message key — a bare 500, a 502 from the proxy, a 403 — the treasurer sees a toast reading « HTTP 500 ». The generic fallback is only reached for a non-Errorthrow, whichapiRequest` never produces.

This is the admin-side counterpart of the widget-level MSG-02 already recorded in PROJECT-LEVEL.md for widgets/src/api.ts; different file, same root shape.

Why it matters: CLAUDE.md describes the audience as a mix of tech-savvy and non-tech-savvy volunteers. « HTTP 500 » tells them nothing to do. Otherwise this composable is the best-behaved code in the feature — it toasts on both paths, invalidates the right query keys, and guards against double-submit.

Fix: map err.status/err.code to French sentences with a next step, and fall back to « Le recalcul n'a pas abouti. Réessayez dans un instant. » Keep the raw string in the existing console.error.


14. No route sets a document title — Low · NAV-03 (project-wide) ​

Where: admin/src/client/router.ts:78 (and every other record); no document.title or head-management call exists anywhere in admin/src/client/

What: grepping the admin SPA for document.title / useHead returns nothing. route.meta.title is read in exactly one place — admin/src/client/components/app/layout/LayoutBreadcrumb.vue:74 — for the breadcrumb label, never for the tab. The cotisations record carries only meta: { caps: … }.

Why it matters: every admin tab, bookmark and browser-history entry carries the same title. A treasurer who keeps Cotisations, Facturation and Transactions open — the normal working set for reconciling dues — cannot tell the tabs apart.

Note for the orchestrator: PROJECT-LEVEL.md's cross-project table has "—" in the members column for NAV-03. This settles it: members admin fails NAV-03, no mechanism at all, same as playout. It is architectural rather than specific to Cotisations, so it belongs in PROJECT-LEVEL.md and should not be re-filed by the other 30 members admin drafts.


15. One empty-state string for two different situations, and it is misspelled — Low · CONTENT-04 ​

Where: admin/src/client/components/app/tables/TableData.vue:136

What: « Aucun élement » is rendered for both "this year has no cotisations" and "your search or filter matched nothing", with no way to tell them apart and no control to clear the filter. It is also a typo — « élément ». Cotisations is searchable with two filterable columns, so the filtered-to-nothing case is routine here.

Why it matters: a treasurer who has left a Progrès filter applied from a previous visit sees what looks like an empty year. The data-display/table pattern separates empty_state.no_data from empty_state.no_results and pairs the latter with « Clear filters » for exactly this reason.

Fix: branch on activeFilterCount || query — « Aucun résultat pour cette recherche » plus a « Réinitialiser les filtres » button, versus « Aucune cotisation pour {année} » — and fix the spelling.


16. The view's treasurer controls are gated on the wrong capability — Low · no rule (correctness, flagged for the maintainer) ​

Where: admin/src/client/views/BActive/Cotisations.vue:160, :236-237

What: isTresorier = caps.can("Transactions", "update") gates three things: the « Transférer l'excédent » button, the Actions column and the « Dernier rappel » column. But the transfer performs POST /api/transactions, which the API guards with @RequireCapability("Transactions", "create") (api/src/transactions/transactions.controller.ts:59-60) — a different verb. Sending a reminder hits /api/reminder and has nothing to do with Transactions at all.

Why it matters: a role granted Transactions: update but not create sees the transfer button and gets a 403 rendered as the generic « Erreur lors du transfert » — indistinguishable from finding 1's partial-failure state. A role granted Cotisations_B_Active: update (enough to recompute totals) but not Transactions: update silently loses the entire reminder workflow and the reminder-history column, with no explanation.

Worth stating positively: the enforcement itself is sound. Every write in this feature is capability-checked server-side, and useApiData even declines to issue the request when the sub-org lacks the module. This is a mapping mistake, not a bypass.

Fix: gate the transfer button on caps.can("Transactions", "create") and the reminder controls on the capability /api/reminder actually enforces.

Unverified ​

  • A11Y-01 (contrast). Not determinable from code. The status tokens resolve to emerald-600 / amber-600 / red-600 / blue-600 on white with -fg variants at 700-900, which is a sensible ramp, but text-neutral-500 on white for the row count, text-neutral-600 at text-xs for the modal labels and the transfer threshold note, and text-neutral-400 for the inactive popover icon all need measuring.
  • A11Y-06 (short viewport / responsive). Needs a rendered page. Two things to look at when someone does: the header block is sticky right-0 (TableData.vue:5) with no top, which does nothing but suggests an intent that was lost; and columnVisibility hides only PersonID on mobile (Cotisations.vue:169), leaving Nom + Total + Progrès + Rappels + Actions — more than the "3 key columns max" CLAUDE.md sets as the mobile target — in a table-layout: fixed table whose column sizes are pixel values.
  • Exact hit-area of the popover triggers (finding 6). Asserted from the class list (no padding, text-xs); the computed box needs a browser.
  • @bcc-code/component-library-vue internals. node_modules is not installed (standing caveat). Unsettled: whether BccDialog traps focus, restores it on close and closes on Escape; whether BccMessage severity="warn" carries role="alert"; whether BccInput/BccSelect/BccTextarea render an id that a <label for> could target (finding 10); whether BccPopover manages aria-expanded on the trigger it is toggled from (finding 6). If any of these are satisfied upstream the corresponding finding narrows.
  • Whether /api/reminder records the send before or after the provider accepts it. The « Dernier rappel » column is driven by server-written Rappels rows; if the row is written before the mail provider confirms, this feature has the MSG-06 "success recorded for something that did not happen" shape too. Not read — outside this feature's files.

Baseline additions ​

Descriptive IDs with definitions, per the brief — expect the orchestrator to renumber.

  • BULK-PARTIAL-01 — A bulk write that can fail partway must report per-item outcome and be safely replayable. A single collective error message plus an all-or-nothing retry is a dead end when the first attempt already committed some items. (finding 1) Likely subsumes part of the existing MSG-06 cluster but is distinct: MSG-06 is "failure rendered as success", this is "partial success rendered as total failure, with an inert retry".
  • CONTENT-TEMPLATE-01 — Generated copy (emails, PDFs, exported documents) must parameterise every value the surrounding UI parameterises. A hardcoded year, amount or person's name in a template that a selector otherwise drives will be wrong the moment the selector moves. (finding 3)
  • STATUS-01 — Status colour utilities must resolve to a token the project actually defines. Under a token-driven CSS build an undefined utility emits no rule and fails silently, removing the status signal with no visible error at build or review time. (finding 9)
  • DATA-DEFAULT-01 — A derived metric must not collapse "not applicable", "not yet computed" and a genuine zero into the same value, especially when that value drives the default sort or gates an action. (finding 12)

Also worth recording against the existing rules rather than as new ones: A11Y-03 should say explicitly that a click handler on a <th>, <tr> or <div> is a failure regardless of cursor-pointer, since four projects are now confirmed to do it.

Cross-project note ​

  • Findings 4 and 5 are near-certain in all four. PROJECT-LEVEL.md already has MSG-06-shaped "failed load renders as empty state" confirmed in playout, members and tt-time-tracker, and click-only rows/chips (A11Y-03) confirmed in all four. What is new here is that in members-admin both live in one shared component (TableData.vue), which makes them a single cheap fix rather than fifteen. tt-time-tracker uses TanStack Table too and is the most likely to have the identical <th @click> shape — worth checking its table wrapper directly.
  • Finding 2 (currency precision) — members admin is CLEAN on the shared formatter. The programme's cross-project table can now be filled in more precisely for members: the widgets' useFormat.ts is broken, the admin's useCurrency.ts is correct, and the remaining admin exposure is two specific bypasses — SponsorApp.vue:421 (maximumFractionDigits: 0, queue #22) and this view's .toFixed(0) in generated email copy. The lesson that generalises: auditing the shared formatter is not sufficient. Whoever audits tt-time-tracker's invoices and customer-portal's remaining money surfaces should grep for toFixed( and Math.round( on money as well as for maximumFractionDigits, or they will report a false clean.
  • Finding 3 (hardcoded values in generated copy) should be checked wherever a project generates outbound text: playout's lower-third/overlay templates, customer-portal's nb/en notification mail, tt-time-tracker's invoice PDFs. A hardcoded year is the easy grep (grep -rn "20[0-9][0-9]" --include=*.vue).
  • Finding 8 (timed auto-dismiss) is NAV-01 again — already confirmed in playout. The members instance is milder (a modal, not a route change) but has the same root cause and the same fix.
  • Finding 14 (NAV-03) now confirmed for all four projects: playout none, customer-portal one static, tt-time-tracker one static, members none. That row of the alignment table is complete and is a good candidate for a single four-repo alignment issue.