Skip to content

[UX] members — Transactions (admin SPA) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch: develop @ 2c22f5a · Files reviewed: 21 Patterns: data-display/table (consulted; body returned as mangled compiled MDX with the prose stripped, only the toc was readable — findings below rest on BASELINE.md, not on the pattern text)

Summary ​

The two transaction views are the admin SPA's ledger surface, and the write paths are where they fail. Splitting a transaction on the main list silently destroys money: the new row's primary key is sha1(Date-Montant-Libellé) with no uniqueness salt, so the commonest split of all — one amount halved between two members — produces two identical ids, the second POST is rejected by the database, nothing is caught, and the user is left with the original reduced and only one of the two splits created. The sibling view already fixed this by salting the same hash with a UUID, so the defect is a known one that was not carried across. Beyond that, the "À catégoriser" batch commit is neither atomic nor honest about partial failure, every inline edit in that grid is fire-and-forget with no error surface, and the amount and date cells corrupt French input.

The good news: admin/.../useCurrency.ts is not affected by the maximumFractionDigits: 0 flaw (see finding 12), heading structure is correct on both views, and FilterDrawer is a genuinely well-built filter panel.

Findings ​

1. Splitting a transaction into equal parts silently loses money — Blocker · MSG-06 (proposed: DATA-ID-01) ​

Where: admin/src/client/views/BActive/Transactions.vue:146What: handleSplit mints each new row's primary key as await sha1(\${split.Date}-${split.Montant}-${split.Libelle ?? ""}`). ModalSplitTransaction.defaultSplitRow() (:111-123) copies DateandLibellefrom the original into **every** split row, so two split rows differ only byMembreandMontant. Split 100 € 50/50 between two members — the canonical use of this feature — and both rows hash to the same id. The first addinserts; the second hits the primary-key constraint and the API returns a non-2xx, whichapiRequestturns into a rejected promise.Promise.allat:145rejects,handleSplithas notry/catchand the caller does not await it, so the rejection is unhandled: no toast, no rollback, modal already closed. Meanwhile the original'sMontantwas already overwritten withpayload.remainingAmountat:139-142. **Why it matters:** 100 € becomes 0 € (remaining) + 50 € (one split). 50 € has vanished from the ledger with no error shown and no record of what happened. Accounting staff will not notice until a reconciliation disagrees. **Fix:** salt the hash exactly as the sibling view already does — TransactionsToCategorize.vue:226usessha1(`${split.Date}-${split.Montant}-${split.Libelle ?? ""}-${uuidv4()}`). Better, use uuidv4()` directly; the content hash buys nothing here since the rows are user-authored. Then fix finding 2 so the next such failure is visible. Severity note: Blocker rather than High because a normal user performing the documented action gets silently wrong money data, with no measurement or extra condition required.

2. Split writes are non-atomic and completely unguarded — High · MSG-06, MSG-01 ​

Where: admin/src/client/views/BActive/Transactions.vue:135-159; same shape at TransactionsToCategorize.vue:218-240What: the handler reduces the original's amount first (update.mutateAsync), then creates the splits (Promise.all(... add.mutateAsync)). There is no try/catch, no rollback, no success toast and no failure toast on either path. Any failure after the first write — network drop, 403, the collision in finding 1 — leaves the ledger inconsistent, and the only signal is an unhandled rejection in the console. Why it matters: the user's mental model is "one action, one result". They get a closed modal and no feedback whatsoever, whether it worked or destroyed data. Fix: move the whole split to a single server endpoint that runs the reduce + inserts in one Prisma transaction, and have the client try/catch it with a success and an error toast. Failing that, at minimum wrap the client handler, restore the original Montant on failure, and always toast the outcome.

3. A partially-succeeded import is reported as a total failure, and retry hard-fails — High · MSG-06, MSG-03 ​

Where: api/src/accounting/pennylane-sync.service.ts:352-354 + admin/src/client/views/BActive/TransactionsToCategorize.vue:248-269What: finalizeTransactions validates every row up front, then un-stages them in a plain for loop of separate writes — no enclosing transaction. Each updateTransaction fires linking + MyShare side effects. If row k throws, rows 1…k-1 are already imported but the endpoint throws, so the client takes the catch branch: it shows « Échec de l'import » and — critically — does not call transactionsToCategorize.fetch() (the refetch at :253 is inside the try). The on-screen queue therefore still lists the already-imported rows as pending. The user clicks « Valider et importer » again; there is no per-row selection, so the client resends all ready ids, including the imported ones, and the ROW_NOT_STAGED precheck (:337) rejects the whole batch with Transaction <uuid> n'est plus en attente. Why it matters: the operator is told nothing was imported when some of it was — on a money path where the same rows may already have been pushed to MyShare. The retry loop is unbreakable until they think to hard-reload the page, and the message that blocks them is a raw UUID they cannot act on. Fix: wrap the loop in a Prisma transaction so the endpoint is all-or-nothing; or return { imported: [...], failed: [{id, reason}] } and have the client report the split honestly. Either way, refetch the queue in a finally, not in the try, so the UI can never be more optimistic than the server. Severity note: High rather than Blocker only because a full page reload recovers the queue. Nothing in the UI tells the user that.

4. The amount cell destroys French decimal input — High · FORM-04 (proposed: FORM-DECIMAL) ​

Where: admin/src/client/components/app/transactions/TransactionsStagedTable.vue:144 with admin/src/client/components/app/forms/InputDebounced.vue:2-6What: InputDebounced renders a bare BccInput with no type, no inputmode, no pattern and no validation. The staged table's Montant cell does updateField(id, { Montant: Number(val) }). In an all-French UI whose own useCurrency renders « 12,50 € » with a comma, a user typing 12,50 produces Number("12,50") → NaN → JSON.stringify → {"Montant":null} → the amount is wiped from the row. Clearing the field gives Number("") → 0, also written silently. The same Number(val) cast sits in ModalSplitTransaction.vue:165, where NaN at least fails canSave's Number.isFinite check — silently, see finding 11. Why it matters: the user types the amount the way the app displays amounts, and the value disappears with no error. A row with a null amount still counts as "prête" (readyCount only checks Membre and Categorie, :139-141), so it gets imported. Fix: give InputDebounced an optional numeric mode (type="number" / inputmode="decimal"), normalise ,→. before Number(), reject non-finite results with an inline message instead of writing them, and include a finite positive Montant in the readyCount predicate.

5. Picking a date in the grid stores the previous day — High · FORM-04 ​

Where: admin/src/client/components/app/forms/SelectDate.vue:9What: set: val => { model.value = val ? val.toISOString().slice(0, 10) : undefined }. The date picker hands back a Date at local midnight; in Europe/Paris that is 22:00/23:00 UTC on the previous day, so toISOString().slice(0,10) stores the day before. The getter (:8) parses the stored YYYY-MM-DD as UTC, so the round trip is visibly lossy: pick 30/07, the cell redisplays 29/07. Why it matters: transaction dates drive the accounting year (Annee) and Pennylane reconciliation. Every date touched by hand in the "À catégoriser" grid or the split modal is shifted a day, including across month and year boundaries. Fix: format from local components — `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}` — and parse back with the same local convention. Verification note: the shape of the bug is asserted from our own source. The one link I cannot read is whether BccDatePicker (PrimeVue, node_modules not installed) emits local-midnight Date objects; PrimeVue's DatePicker does, but treat that single step as unconfirmed.

6. Every inline edit in the categorisation grid is fire-and-forget — High · MSG-06, MSG-01 ​

Where: admin/src/client/components/app/transactions/TransactionsStagedTable.vue:64-66What: updateField is await bactive.transactionsToCategorize.update.mutateAsync({ id, ...patch }) with no try/catch, and every cell (Annee, Membre, Date, Montant, Categorie, Commentaire) calls it from an onUpdate:modelValue whose return value is discarded. On failure the TanStack onSuccess cache patch never runs, so the cell silently snaps back to its old value and nothing is shown. Separately, InputDebounced waits 500 ms with no maxWait and no flush on blur or unmount: type an amount or a comment and click « Valider et importer » immediately and the PATCH has not been sent, so the row is imported with the previous value. Why it matters: this screen's entire purpose is assigning a member and a category before an irreversible commit. A save that fails looks identical to one that succeeded, and the user then imports rows they believe they corrected. Fix: route these through useMutationWithToast (already in the repo) so failures surface; flush the debounce on blur and before handleFinalize runs; show a per-row saving/saved/failed indicator, which the status-dot column at :79-90 is already positioned to carry.

7. Deleting a staged transaction is immediate, unconfirmed and unrecoverable — High · MSG-05 ​

Where: admin/src/client/views/BActive/TransactionsToCategorize.vue:209-211, button at TransactionsStagedTable.vue:179-185What: the row's ✕ button emits delete straight into remove.mutateAsync({ id }). No confirmation, no undo, no toast. This is inconsistent with the app's own convention twice over: TableData.vue:191-214 puts a « Êtes-vous sûr ? / Cette action est irréversible » dialog in front of its row delete, and composables/useConfirm.ts exists specifically for this. Why it matters: the ✕ sits directly beside the split button in a dense grid with size="small" controls. One mis-click permanently deletes a synced Pennylane transaction; the only recovery is re-syncing, and only if the row is still within the sync cutoff. Fix: await confirm({ message: "Supprimer cette transaction de la file ?", danger: true }) before removing, and name the transaction (date + amount + libellé) in the message per MSG-05. An undo toast would be better still.

8. The batch import has no confirmation and cannot be undone — High · MSG-05 ​

Where: admin/src/client/views/BActive/TransactionsToCategorize.vue:77-81, 242-259What: « Valider et importer » commits every ready row at once — there is no per-row selection — and the click goes straight to the POST. Server-side each row is un-staged through TransactionsService.updateTransaction, which fires objectif linking and, when MyShare_Import is on, an external MyShare contribution push. Nothing in the admin SPA can set Staged back to true, so there is no un-import. Why it matters: an irreversible, externally-visible money action fires on a single unconfirmed click, and the button appears automatically as a floating bar the moment one row becomes ready. Answering the correctability question explicitly: after import, the categoryis correctable — the row appears in transactions and ModalTransactionEdit lets you change Membre, Annee, Date, Libelle, Categorie, Commentaire. The amount is not editable anywhere post-import (ModalTransactionEdit.vue omits Montant; handleEditSave at Transactions.vue:123-133 never sends it), so a wrong amount can only be fixed by splitting — see finding 1 — or deleting. And the MyShare push cannot be reversed from this UI at all. Fix: confirm first, stating the count and that MyShare will be notified when enabled; add row checkboxes (TableData already supports selectable) so a partial batch is a first-class action; and expose a "renvoyer en attente" action on imported rows.

9. Row editing, sorting and row actions are all mouse-only — High · A11Y-03 ​

Where: admin/src/client/components/app/tables/TableData.vue:146-153 and :91-97What: three separate keyboard failures in the shared table that this feature depends on:

  • the row is <tr … @click="props.onClick?.(row.original)"> with no tabindex, no role="button" and no keydown handler — on transactions this click is the only way to open ModalTransactionEdit;
  • Split and Supprimer exist only in a @contextmenu.prevent menu (:152, items built at :425-435), so they are reachable by right-click and nothing else — no keyboard path, no touch path, and no visible affordance that they exist;
  • <th … @click="header.column.getToggleSortingHandler()"> is a click handler on a non-interactive element with no aria-sort, so sorting is neither operable nor announced. Why it matters: a keyboard-only member of staff cannot edit, split or delete a transaction at all. CLAUDE.md describes the user base as a mix of tech-savvy and non-tech-savvy staff; the discoverability half of this hurts everyone, not only assistive-technology users. Fix: render the row-identity cell as a real <button> (or give the <tr>tabindex="0" + Enter/Space), add a visible per-row overflow ⋯ menu built on the same contextMenuItems, and put the sort control in a <button> inside the <th> with aria-sort on the <th>. Scope note: this is in shared TableData, so it hits every one of the ~15 admin list features. Recommend promoting to PROJECT-LEVEL.md rather than re-deriving per feature.

10. A failed load renders as an empty list — Medium · MSG-06 ​

Where: admin/src/client/components/app/tables/TableData.vue:118-140What: the template branches on loading || props.data?.isFetching, then on table.getRowCount() === 0. useApiData exposes isError and error (useApiData.ts:31-40, 135) and TableData never reads either. A failed fetch leaves list at its initialData: [], so the user sees « Aucun élement » on transactions and « La file est vide » on transactions-to-categorize. Why it matters: on the categorisation queue this is actively misleading — "la file est vide" is the state that means there is nothing to do, so the user walks away from a screen full of unimported transactions. Fix: add an error branch that renders the message plus a Réessayer button calling data.fetch(). Scope note: same shape as the members MSG-06 cluster already in PROJECT-LEVEL.md (DonsProgress.vue, objectifs.store.ts, MyDecharges.vue), but this is the admin-wide instance and is not yet listed there.

11. The split's Save button is disabled with no explanation, and negative amounts can never be split — Medium · FORM-05 ​

Where: admin/src/client/components/app/modals/ModalSplitTransaction.vue:50, 85-100What: :disabled="!canSave" and canSave folds seven distinct conditions — no rows, missing date, missing member, missing category, non-finite amount, amount ≤ 0, remaining < 0 — into one boolean with no message anywhere in the modal. There is no field-level validation either. A negative transaction (a debit or refund, ordinary in a bank feed) makes canSave unsatisfiable: any split must be > 0, which drives remainingAmount below the negative original, so the button is permanently dead with nothing on screen saying why. Why it matters: the user is stuck at a disabled control with nothing to act on — the exact failure FORM-05 exists to prevent. Fix: keep the button enabled and validate on submit with a specific message per condition, or render the blocking reason inline. Allow same-sign splits so negative transactions can be divided.

12. Money is displayed three different ways, one of them raw — Medium · CONTENT-05 (currency precision) ​

Where: admin/src/client/composables/useCurrency.ts:1; ModalSplitTransaction.vue:82-83; TransactionsStagedTable.vue:141-146What — reporting the PROJECT-LEVEL currency question explicitly:

  • This admin view does NOT have the maximumFractionDigits: 0 flaw.useCurrency.ts:1 is Intl.NumberFormat("fr-FR", { style:"currency", currency:"EUR", minimumFractionDigits: 0, maximumFractionDigits: 2 }). Cents are preserved: 12,50 € renders as « 12,50 € ». The Montant column on transactions is therefore correct.
  • The lesser defect here is minimumFractionDigits: 0, so 12 € renders as « 12 € » and 12,50 € as « 12,50 € » in the same right-hand column — ragged decimals in a money column that staff scan vertically.
  • The split modal shows money completely unformatted: initialAmountString and remainingAmountString are String(number) (:82-83), so the user sees 12.5 — a decimal point in a French UI, no € sign — and float artifacts: splitting 100 into 33.33 + 33.33 displays 33.339999999999996 as the remaining amount.
  • The staged grid's Montant cell is a bare text input showing the raw number too, so the same value reads as « 12,50 € » on one screen and 12.5 on the next.
  • While grepping I also found a real instance of the project-level flaw elsewhere in this repo: admin/src/client/sponsor/SponsorApp.vue:421 has maximumFractionDigits: 0. That is queue #22 (Sponsors), not this feature, but it means members now has two confirmed sites, not one: widgets/src/composables/useFormat.ts:2 and SponsorApp.vue:421. Fix: set minimumFractionDigits: 2 in useCurrency for column display, use useCurrency() for the split modal's read-only amounts, and hold amounts in integer cents (or round to 2 dp) so the remaining figure cannot show float noise.

13. The page's two primary buttons are gated on a capability the route does not require — Medium · proposed CAP-01 ​

Where: admin/src/client/router.ts:75 vs api/src/accounting/accounting.controller.ts:38-41What: the route is admitted on meta: { caps: { Transactions: "create" } }. Both endpoints the page calls live on AccountingController, which is decorated @RequireModule("invoicing") and @RequireCapability("Factures", "create"). A user with Transactions:create but without Factures:create, or in a sub-org without the invoicing module, reaches the page and sees « Récupérer de Pennylane » and « Valider et importer » as live controls; both 403. Why it matters: the user is offered the only two actions the page exists for and is refused by a server error toast rather than by the UI never presenting them. The staged list itself loads fine (useApiData gates on the transactions module), which makes the failure look random. Fix: gate the buttons on caps.can("Factures","create") && orgs.hasModule("invoicing") and add the same pair to the route's meta.caps, so the page is either fully usable or not offered. Proposed rule wording: a client control is shown only when the client holds the same capability and module the server enforces for the call it makes.

14. Grid inputs have no accessible names — Medium · FORM-01 ​

Where: TransactionsStagedTable.vue:107-116, 122-129, 141-146, 162-167; SelectMember.vue:3-6; InputDebounced.vue:2-6What: the staged grid passes label: false to SelectMember, which suppresses its <label> and leaves only placeholder="Rechercher un membre". InputDebounced (Montant, Commentaire) renders a BccInput with no label, no aria-label and not even a placeholder. SelectDate is used without its label prop. The column <th>s are not associated with the controls. Why it matters: a screen-reader user tabbing the grid hears "edit text, blank" for the amount and the comment, and a placeholder that disappears on focus for the member. Placeholder-as-label is explicitly what FORM-01 forbids. Fix: pass an aria-label (« Montant », « Commentaire », « Membre », « Date ») plus the row's identity through each cell component; InputDebounced should forward $attrs to BccInput so callers can set one.

15. The row warning icon has no name and no explanation — Medium · A11Y-05 ​

Where: CellTransactionCategory.vue:53-56What: <i v-if="warning" class="pi pi-exclamation-circle text-warning" /> — no aria-label, no title, no adjacent text. It carries three different meanings depending on the caller: on transactions it means "catégorie camps sans objectif" or "cotisation sans cotisation liée" (Transactions.vue:91-93); on the staged grid it means "catégorie manquante" (TransactionsStagedTable.vue:153), and SelectMember's warning prop adds a CSS-class-only variant for a missing member (SelectMember.vue:9), which is colour-only with no icon or text at all. Why it matters: the icon is invisible to assistive technology and uninterpretable to a sighted user who has not been told what it means — on the screen whose whole job is spotting incomplete rows. The SelectMember variant also fails "state not conveyed by colour alone". Fix: give the icon role="img" and a caller-supplied aria-label/title naming the actual problem, and add a text or icon marker to the missing-member state rather than a border colour.

16. The filter drawer never manages focus — Medium · A11Y-03 ​

Where: admin/src/client/components/app/tables/FilterDrawer.vue:21-26, 205-207What: the drawer is a well-built panel — role="dialog", aria-label="Filtres", Escape to close, a labelled close button, faceted counts, « Tout effacer », a result count on the primary button. What is missing is focus management: no aria-modal="true", focus is never moved into the drawer when it opens, there is no focus trap, and focus is not returned to the « Filtrer » button on close. The Teleport to="body" places it after the page content in DOM order, so a keyboard user who opens it must tab through the entire page to reach it. Why it matters: the filter panel is the only way to narrow a long transaction list, and it is effectively unreachable by keyboard despite every control inside it being focusable. Fix: add aria-modal="true", focus the close button (or the first section) on open, trap Tab within the <aside>, and restore focus to the trigger on close. Scope note: shared component; same promotion argument as finding 9.

17. Raw server and network strings are shown to users — Medium · MSG-02, MSG-03 ​

Where: TransactionsToCategorize.vue:186, 264; lib/apiRequest.ts:23-30What: both toasts do detail: err instanceof Error ? err.message : "Une erreur est survenue.". apiRequest builds that message as result.error ?? result.message ?? "HTTP <status>", so the user can be shown HTTP 500, Module inactif, a raw TypeError: Failed to fetch from an offline browser, or Transaction 8f3c…-…-… n'est plus en attente. None of them say what to do next. Why it matters: the members project-level note already records this shape at widgets/src/api.ts; this is the admin-side instance. apiRequest attaches a structured code (:27) that nothing in this view uses. Fix: switch on err.code (EMPTY_SELECTION, ROWS_MISSING, ROW_NOT_STAGED, ROW_MISSING_MEMBER, ROW_MISSING_CATEGORY) for a specific French sentence with a next step, and fall back to one generic sentence — never err.message.

18. The import progress dialog can be dismissed, which does not cancel the import — Medium · MSG-04, NAV-05 ​

Where: TransactionsToCategorize.vue:91-102, 242-270What: finalizing is used both as the button's :loading flag and as the dialog's :visible, and the dialog writes back with @update:visible="finalizing = $event". Pressing Escape or clicking the mask sets finalizing = false, which removes the "Import en cours…" dialog and re-enables the button while the POST is still in flight. handleFinalize has no in-flight guard, so a second click issues a second POST for the same ids. Why it matters: the user reads the dismissal as "cancelled" when the import is still running, then either walks away from a running money operation or re-fires it. The second POST usually loses the race and surfaces the opaque n'est plus en attente message from finding 3. Verified mitigations, stated for honesty: the MyShare contribution sync is documented as idempotent (api/src/transactions/myshare-sync.handler.ts:15, creates only when MyShare_uid is absent), so this is a confusing-state defect rather than a double-charge. That is why it is Medium and not Blocker. Fix: make the dialog non-dismissible (:closable="false", :close-on-escape="false"), drive it from a separate importDialogOpen ref, and add if (finalizing.value) return; at the top of handleFinalize.

19. The timeout that clears the "recently synced" highlight is never cleaned up — Low · NAV-02 ​

Where: TransactionsToCategorize.vue:161What: window.setTimeout(() => { recentlySyncedIds.value = new Set(); }, 2500) with no stored handle and no onUnmounted clear. Navigating away within 2.5 s leaves the callback to fire against an unmounted component; two syncs in quick succession leave two timers racing, so the second sync's highlight can be cleared early by the first timer. Fix: store the id and clearTimeout in onUnmounted and at the start of each sync, or use useTimeoutFn from @vueuse/core, already a dependency.

20. Queue status changes are never announced — Low · MSG-01 ​

Where: TransactionsToCategorize.vue:10-26 and the sticky bar at :68-82What: the sentence « N transactions prêtes à importer » / « Aucune transaction prête — assignez un membre et une catégorie » is a plain <p>, and the floating "ready" bar appears and disappears via <Transition> with no live region. A screen-reader user assigning a member gets no feedback that the row became ready or that a new primary action has appeared on the page. Fix: aria-live="polite" on the status paragraph; announce the sticky bar's appearance through the same region rather than relying on it being seen.

21. English strings in an all-French UI — Low · copy quality ​

Where: ModalSplitTransaction.vue:4 (header="Split transaction"), :51 (button label="Split"), Transactions.vue:120 (context menu label: "Split"), TransactionsStagedTable.vue:175 (title: "Diviser") What: the split feature is called « Split » in three places and « Diviser » in a fourth, in an interface that is otherwise entirely French. The modal header is the only English sentence in either view. Why it matters: CLAUDE.md describes a partly non-tech-savvy French user base. This is not CONTENT-01 (see below) — single-locale French is the design; it is plain copy inconsistency. Fix: pick one French verb (« Diviser ») and use it for the menu item, the button, the icon tooltip and the modal header: « Diviser la transaction ».

22. The bank label is clamped with no way to read the rest — Low · A11Y-02-adjacent, copy ​

Where: CellTransactionLibelle.vue:2What: class="text-xs whitespace-normal line-clamp-2" — 11–12px, silently truncated at two lines, no title, no expand. On the staged grid it is also the only read-only cell in an otherwise fully editable row. Why it matters: the libellé is the primary evidence a user has for deciding which member and category a transaction belongs to, and the repo already has a useTruncated composable for exactly this affordance. Fix: add :title="value" (and ideally a hover popover using useTruncated); consider making it editable in the staged grid for consistency with the split modal, which does allow editing it.

Not applicable ​

  • CONTENT-01 — not-applicable, see project-level i18n finding. members has no i18n layer by design; French literals throughout both views are expected.
  • FORM-02, FORM-03, FORM-07, FORM-10, SEC-01…SEC-05 — no credential or token surface in this feature.
  • A11Y-04 — passes. TransactionsToCategorize.vue:6 renders one <h1> and deliberately passes name="" to TableData so its v-if="name" heading (TableData.vue:11) does not render a second one; the comment at :51 shows this was intentional. Transactions.vue has exactly one <h1>, from TableData.
  • NAV-01 — no timed redirects. The router.push after a successful import is user-initiated, not timed.

Unverified ​

  • A11Y-01 (contrast) — cannot be settled from code. Specific values worth measuring on a rendered page: text-neutral-400 on white for the staged count in the <h1> (TransactionsToCategorize.vue:8), text-neutral-500 on the MyShare status pill at text-xs (:31), the text-2xs (11px) filter bound labels in FilterDrawer.vue:106, the *-subtler BccTag category chips in CellTransactionCategory.vue:75-83, and text-neutral-500 on the uppercase text-xs column headers (TableData.vue:94).
  • A11Y-06 (short viewport / responsive) — needs a rendered viewport. Two specific risks: the sticky import bar is fixed bottom-6 right-6 (TransactionsToCategorize.vue:70) with no bottom padding reserved on the page, so it likely covers the last table rows and the « Ajouter une ligne » button (TransactionsStagedTable.vue:29-36) on a short viewport; and the staged grid has nine columns of interactive controls with no hiddenOnMobile meta on any of them, so on mobile every column stays visible while TableData fixes table-layout and the child overrides it to auto (:193-196).
  • PrimeVue / @bcc-code/component-library-vue internals — node_modules is not installed, so I could not confirm: whether BccButton :loading sets disabled (would make FORM-06 fail on both « Récupérer de Pennylane » and « Valider et importer »); whether useToast's container carries role="alert"/role="status" (MSG-01); whether BccPopover in CellTransactionCategory traps focus, closes on Escape, or exposes aria-expanded on its trigger (the trigger <button> at :10-14 supplies none of these itself and also lacks type="button"); and whether BccAutoComplete announces its suggestion list.
  • data-display/table pattern text — the MCP returned the pattern, but its body is compiled MDX with the prose stripped out; only the toc was readable. No pattern-derived claim is made above.

Baseline additions ​

Descriptive IDs plus one-line definitions; the orchestrator should renumber.

  • DATA-ID-01 — client-minted primary keys must be unique by construction. A record id derived from user-editable content (hash of fields, slug) must include a uniqueness salt, or the second identical record is silently rejected. Instance: finding 1.
  • FORM-DECIMAL — numeric inputs accept the locale's own decimal separator. An app that formats money as « 12,50 € » must parse 12,50; a bare Number(value) cast that yields NaN must never be persisted. Instance: finding 4. Likely a cross-project rule — every one of the four projects has French or Norwegian number formatting with Number() parsing.
  • CAP-01 — client controls are gated on the same capability and module the server enforces. A button whose endpoint will 403 must not be rendered as live. Instance: finding 13.
  • BATCH-01 — a batch action reports per-item outcomes. A bulk operation is either transactional, or it reports which items succeeded and which failed; it never reports a partial result as blanket success or blanket failure, and it always leaves the list refetched so a retry starts from real state. Instance: finding 3. Sibling to the existing MSG-06 family — arguably the bulk-action case of it.
  • Note for the reconciliation pass: findings 2, 6 and 10 all cite MSG-06 in the "(a)/(b)/(d) are one rule" reading recorded in PROJECT-LEVEL.md — a non-throwing or unhandled failure rendered as success, empty, or nothing. No new ID proposed for them.

Cross-project note ​

  • Finding 9 (TableData rows/headers/context menu are mouse-only) — the A11Y-03 row already runs ✗ across all four projects in PROJECT-LEVEL.md's alignment table. This is the members instance and it is the strongest one seen so far, because right-click is the only path to two destructive actions. customer-portal's packages/ui DataTable should be checked for the same three sub-defects (row click, sort header, hidden row actions).
  • Finding 10 (failure rendered as empty state) — already ✗ for playout, members and tt-time-tracker in the alignment table; this adds the members admin SPA to the members widgets already listed.
  • Finding 4 (locale decimal separator) — untested in the other three. customer-portal parses øre bid inputs and tt-time-tracker has invoice amounts; both are French/Norwegian-formatted and both should be grepped for Number( on a money field.
  • Finding 3 / BATCH-01 — customer-portal and tt-time-tracker both have bulk admin actions (reminders, invoice runs); worth checking whether any of them loop over rows outside a transaction and report a single boolean.
  • Finding 12 — closes the members half of the currency-precision question: the admin useCurrency is clean, and the second members instance of the flaw is SponsorApp.vue:421, not this feature.