Appearance
[UX] members — Imports
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 12 Patterns:data-display/table
Summary
The Imports feature is not an import wizard: release 1.8.0 removed the Excel import assistant and replaced it with the Pennylane sync on the « À catégoriser » page, leaving imports as a read-only history of past runs. So the checks this audit was briefed on — preview-before-commit, partial-failure honesty, reversibility, keyboard-reachable file input — mostly do not apply, because there is no file input, no submit and no destructive action anywhere in this feature (Imports.vue is 58 lines of table config; the API exposes only GET /imports). What is left is a four-column archive table, and it is broken: the "Transactions" column renders the literal string "undefined transactions" on every row, a regression introduced when the endpoint moved from Directus to NestJS. Everything the page could tell an operator about why an import failed is either hidden in a mouse-only tooltip or not surfaced at all.
Findings
1. Every row's Transactions column reads "undefined transactions" — Blocker · DATA-NULLISH-CELL (proposed)
Where: admin/src/client/views/BActive/Imports.vue:52-57 · api/src/transactions/transactions.controller.ts:92-96What: The column is columnHelper.accessor("Transactions.length", { … cell: info => info.getValue() + " transactions" }). The endpoint backing it is this.prisma.imports.findMany({ where: { Sub_Organisation: … }, take: 1000 }) — a Prisma findMany with no include and no select, so the payload carries scalar columns only and has no Transactions key at all. The deep accessor therefore resolves to undefined, and undefined + " transactions" renders the literal text "undefined transactions" in every row of the column. The client type global.d.ts:207-219 declares Transactions: any[] | Transactions[] as non-optional, so TypeScript never catches the gap either. This is a migration regression: before a6a3aef ("Move away from Directus and use Nestjs") the same store fetched with useDirectusData({ collection: "Imports", query: { limit: 1000, fields: ["*"] } }), and Directus returns o2m relations as id arrays under fields: ["*"], so .length resolved. (The current breakage is asserted from source; the Directus-era behaviour that made it work is inferred from the query shape.) Why it matters: One of the four columns on the page is wrong for 100% of rows, and it is the only column that says what the import actually did. An operator auditing whether an old import landed its transactions gets a debug artefact instead of a number. Rated Blocker rather than High under the normalisation rule "the feature is wrong for a normal user": this is not friction or polish, it is incorrect output on every render, visible without any special condition. Fix: Add the relation count on the server — findMany({ …, select: { …scalars, _count: { select: { Transactions: true } } } }) or include: { Transactions: { select: { id: true } } } — and make the cell defensive: cell: info => \${info.getValue() ?? 0} transactions`. Change the client type to Transactions?: Transactions[]` so the compiler sees the optionality.
2. The failure log is reachable only by hovering with a mouse — High · A11Y-03, MSG-03
Where: admin/src/client/components/app/tables/imports/BadgeImportStatus.vue:3 (v-tooltip="log || undefined"), called from Imports.vue:50What: When an import failed, the entire diagnostic is the Log column, and the only place it is rendered is a PrimeVue tooltip attached to a BccTag. The call site supplies no tabindex, no role, no visible affordance and no text alternative; a tag component renders a non-focusable <span>, so the tooltip cannot be opened by keyboard and does not exist on touch. There is no row expansion, no detail modal, and no other surface anywhere in the feature that shows Log. The source file is not reachable either: Imports.CSV is a directus_files uuid (schema.prisma:198) and nothing in the view links to it. Why it matters: A failed bulk import is exactly the case where the user needs to know what happened. Keyboard and touch users get a red « Échoué » badge and nothing else — no cause, no next step, no way to retrieve the file that failed. That is MSG-03 (error text says what to do next) failing completely, on top of the keyboard barrier. Fix: Render the log inline in a fifth column (or an expandable row / detail drawer) rather than only in a tooltip; at minimum make the badge a real focusable control with an accessible name when a log exists. Add a link to the stored CSV. Note: whether BccTag renders a focusable element is Unverified — node_modules is not installed. The finding stands on the call site, which supplies nothing that would make it focusable.
3. A failed load renders as "Aucun élement" — High · MSG-06 (members instance, admin surface)
Where: admin/src/client/components/app/tables/TableData.vue:118-140What: TableData branches on loading || props.data?.isFetching and then on table.getRowCount() === 0. It never reads props.data.isError or props.data.error, both of which useApiData exposes (useApiData.ts:31-40, 75). A 403 « Module inactif », a 500, or an offline browser therefore produces an empty array and the table prints the empty state, « Aucun élement ». There is no retry control either, although data.fetch() exists. Why it matters: An operator checking whether an import ran is told, in plain French, that there are none — the same words the app uses for a genuinely empty history. Silent failure is indistinguishable from a real answer, and the user has no reason to retry. Fix: Add an error branch to TableData that renders the failure with role="alert" and a « Réessayer » button calling props.data.fetch(), and keep the empty state for the !isError && rowCount === 0 case only. Scope: This is the shared admin table, so it affects roughly 15 list features in this repo, not just Imports. PROJECT-LEVEL.md already records the same shape on the widget side (DonsProgress.vue, objectifs.store.ts, MyDecharges.vue); this is the admin-side instance in a different file and should be promoted to the project-level list rather than re-filed per table.
4. The page never says imports are retired, and offers no route to what replaced them — Medium · MSG-04
Where: admin/src/client/views/BActive/Imports.vue:1-10; nav entry admin/src/client/composables/useNav.ts:64; context in admin/src/client/release-notes/1.8.0.mdWhat: The sidebar still offers « Imports » under B-Active, and the route still gates on caps: { Imports: "read" } (router.ts:76). Release 1.8.0 removed the Excel import assistant — « L'ancien assistant d'Import Excel a été retiré ; l'historique des imports passés reste consultable » — and replaced it with the Pennylane sync on « À catégoriser ». The page renders none of that: no description, no empty-state copy, no link. TableData accepts a description prop built for exactly this (TableData.vue:15-20, 252-253) and Imports passes neither it nor entityLabel. Why it matters: A member of staff who clicks « Imports » intending to import something lands on a table of historical rows with no action and no explanation, and has to already know that the replacement lives under a differently-named menu item. For a user base explicitly described in CLAUDE.md as a mix of tech-savvy and non-tech-savvy, this is a dead end. Fix: Pass description="Historique des imports Excel (assistant retiré en 1.8.0). Les transactions arrivent désormais via la synchronisation Pennylane." with a RouterLink to transactions/to-categorize, and give the empty state the same sentence via the #empty slot.
5. Sortable column headers are click-only and expose no sort state — Medium · A11Y-03, A11Y-05
Where: admin/src/client/components/app/tables/TableData.vue:91-114What: <th class="cursor-pointer …" @click="header.column.getToggleSortingHandler()?.($event)"> — a bare <th> with a click handler. No tabindex, no nested <button>, no aria-sort, no keydown handling. Sort direction is conveyed solely by a decorative <i class="pi pi-sort-up"> with no accessible name (:104-111). Imports ships an initial sort (:initial-sort="{ id: 'id', desc: true }"), so a sorted state exists on first paint and is announced to nobody. Why it matters: Keyboard-only users cannot re-sort any table in the admin SPA, and screen-reader users are never told the table is sorted or by which column. The data-display/table pattern treats keyboard operation of sort controls and semantic sort state as baseline, not enhancement. Fix: Wrap the header content in a <button type="button"> and set :aria-sort="col.getIsSorted() === 'asc' ? 'ascending' : col.getIsSorted() === 'desc' ? 'descending' : 'none'" on the <th>. Give the sort icon aria-hidden="true" and let aria-sort carry the state. Shared component — one fix covers every table.
6. The table search field has no label — Medium · FORM-01
Where: admin/src/client/components/app/tables/TableData.vue:44-51What: <BccInput v-model="query" placeholder="Rechercher…" type="search" /> — placeholder only, no <label>, no aria-label. Imports opts into it via the searchable prop (Imports.vue:8). Why it matters: The placeholder disappears on first keystroke and is not a programmatic label, so a screen-reader user hears an unlabelled edit box and a user who is interrupted mid-search loses the only cue to what the field does. Fix: aria-label="Rechercher dans le tableau" (or a visually-hidden <label>), scoped to the table via the name prop, e.g. Rechercher dans ${name}. Shared component.
7. A null import date renders as 01/01/1970 in the row-identity column — Medium · CONTENT-04 (adjacent; see baseline additions)
Where: admin/src/client/views/BActive/Imports.vue:37-42What: cell: info => format(new Date(info.getValue()!), "dd/MM/yyyy HH:mm"). The non-null assertion is unfounded: Imports.Date is DateTime? in Prisma (schema.prisma:197) and Date?: string | null in the client type (global.d.ts:209). new Date(null) is the Unix epoch, so a row with no Date prints « 01/01/1970 00:00 ». This column carries meta: { primary: true }, i.e. it is the row's identity, rendered semibold. A nullable date_created fallback exists on the model and is unused. Why it matters: A legacy row with no timestamp is presented as a real import dated 1970 rather than as unknown, and it sorts and date-filters as such (the column declares filter: { type: "date" }). Fix: cell: info => info.getValue() ? format(new Date(info.getValue()!), "dd/MM/yyyy HH:mm") : "—", preferring row.original.date_created before falling back to the dash.
8. Unknown statuses are silently filed under « En cours », so filter and badge disagree — Low · CONTENT-04
Where: admin/src/client/views/BActive/Imports.vue:19-28 vs BadgeImportStatus.vue:20-26What: normalizeStatus maps only completed|termine and failed|echoue; everything else returns "en_cours", including values the badge does not recognise. The badge renders the raw value with a neutral colour (capitalCase(status)), so a row whose stored status is, say, queued displays as « Queued » in neutral grey while the « En cours » filter claims it. The comment at :43-44 documents the French/English dual-spelling intent but the else branch goes further than that intent. Why it matters: Filtering by « En cours » returns rows that do not say « En cours », and any status added server-side later is invisible to the filter without a client change. Low because status is @default("en_cours") and non-nullable in Prisma, so today's data probably only holds the six known values — this is a latent divergence, not an observed one. Fix: Return the raw value for unrecognised statuses and derive STATUS_OPTIONS from the faceted unique values, or add an explicit « Autre » bucket so the filter and the badge cannot disagree.
9. The list is capped at 1000 rows with no ordering and no truncation notice — Low · DATA-TRUNCATION-SILENT (proposed)
Where: api/src/transactions/transactions.controller.ts:95What: findMany({ where: …, take: 1000 }) — no orderBy, so the 1000 rows are an arbitrary storage-order slice, and the client's initial-sort by id desc only sorts what it received. TableData.vue:13 then prints {{ table.getRowCount() }} éléments next to the heading, presenting the capped count as the total. Why it matters: Past the cap, « les imports les plus récents » may not be in the fetched set at all, and the header states a number that is not the truth. Rated Low and not High deliberately: the Excel import workflow was retired in 1.8.0, so this table no longer grows and a single association is very unlikely to have accumulated 1000 rows. The defect is real but almost certainly not reached. Fix: Add orderBy: { Date: "desc" } (with an id tiebreak) so the cap keeps the newest rows, and either drop the cap for a table that no longer grows or return the true total so the UI can say « 1000 sur N ».
Project-wide, not re-filed here
- NAV-03 — no route sets a document title anywhere in the admin SPA;
admin/index.html:13is « BCC Nancy Admin » on every route (grep fordocument.title/useHead/useTitleacrossadmin/srcreturns zero hits). Members is currently marked "—" for NAV-03 in the PROJECT-LEVEL cross-project table; it should read "✗ one static", matching customer-portal and tt-time-tracker. - A11Y-04 (landmarks) —
Layout.vue:2-32has no<main>; content sits in plain<div>s (a<nav>exists in the sidebar and breadcrumb only). Also project-wide. Heading structure on this page passes:TableData.vue:11supplies exactly one<h1>and Layout adds none. - CONTENT-01 —
not-applicable — see project-level i18n finding.
Explicit checks requested by PROJECT-LEVEL.md — all pass or N/A here
- DATA-01 (undeclared
suborgprop / cross-sub-org exposure): not applicable. This is a routed admin view, not a mounted widget; it declares no props. The server scopes the query withwhere: { Sub_Organisation: req.org.subOrganisationId }(transactions.controller.ts:95) and the route is capability-gated (@RequireCapability("Imports", "read")+meta: { caps: { Imports: "read" } }). No cross-sub-org leak on this path. - "Does archive/disable actually revoke access?": not applicable — no user or membership management on this screen.
- Currency precision (
maximumFractionDigits): not applicable — this feature displays no money. - MSG-05 (destructive confirmation): not applicable — Imports passes no
collectionprop toTableData, socanCreate/canUpdate/canDeleteare all false (TableData.vue:419-421), no row action or context-menu item is produced, and rows are inert. There is nothing on this page to undo. Worth recording for the alignment pass:TableData's shared delete dialog says only « Cette action est irréversible » and never names what is being deleted (TableData.vue:191-214) — an MSG-05 gap belonging to the tables that do passcollection.
Unverified
- A11Y-01 (contrast) — the
BccTagstatus colours (success,danger,blue-subtler,neutral-subtler) andtext-neutral-500on the row count and header labels need computed colour values. Not asserted. - A11Y-06 (responsive / short viewport) — needs a rendered viewport. Flagged for a follow-up pass: the table is
style="table-layout: fixed"with declared sizes summing to 470px across four columns, no column carriesmeta.hiddenOnMobile, and cells areoverflow-hiddenwith no ellipsis or wrap treatment (TableData.vue:167-172), so narrow viewports plausibly hard-clip cell text.CLAUDE.mdstates "Mobile: 3 key columns max", which this view does not follow. Thedata-display/tablepattern calls for handling text truncation with ellipsis or wrapping explicitly. BccTag/BccInput/BccPaginatorinternals —node_modulesis not installed, so whether@bcc-code/component-library-vuesupplies focusability, labels or ARIA of its own could not be read from source. Findings 2, 5 and 6 are argued from the call sites, which supply none.
Baseline additions
Descriptive IDs with definitions; the orchestrator should renumber.
DATA-NULLISH-CELL— A rendered cell never exposes a raw language-level nullish or non-finite value to the user.undefined,null,NaNand[object Object]are formatting failures, not data; a missing value renders as an explicit placeholder ("—", "0") chosen by the view. Covers finding 1. Adjacent to MSG-02 (raw internal strings must not reach users) but distinct: MSG-02 is about error prose from a provider, this is about the happy path leaking JavaScript primitives into a table.DATA-TRUNCATION-SILENT— When a list endpoint caps its result set, the cap is ordered deterministically (newest-first, not storage order) and the UI never presents the capped length as the total. Covers finding 9. This repo caps at 1000/2000/5000 in at least four endpoints (transactions.controller.ts:44, 54, 95, 108), andTableData.vue:13printsgetRowCount()as an authoritative count for all of them, so this generalises well beyond Imports.- Contract-drift note (no new rule proposed): finding 1 was invisible to TypeScript because
global.d.tshand-declares the API response shape and claimsTransactionsis always present. A generated-from-Prisma or validated-at-the-boundary response type would have caught it. This is an engineering-practice observation rather than a UX rule — recording it because three of the nine findings above (1, 7, 8) are the same class of defect: optional server fields treated as guaranteed by the view.
Cross-project note
- Finding 3 (failed load rendered as empty state) is the highest-value cross-project item and is already confirmed in playout, members (widgets) and tt-time-tracker in PROJECT-LEVEL.md; this adds the members admin surface. customer-portal is the counter-example — its
DataTabledoes render an error branch, though the catalog audit found the copy interpolates TanStack's status string. The alignment issue should be "every list surface distinguishes failure from emptiness and offers retry". - Findings 5 and 6 (click-only sortable
<th>, unlabelled search input) are likely present in tt-time-tracker, which uses the same TanStack Table + PrimeVue combination and hand-rolls its admin tables; worth a targeted grep foraria-sortthere and in customer-portal'spackages/uiDataTable. - Finding 9 (silent truncation) — customer-portal and tt-time-tracker both paginate server-side in places and cap in others; a grep for
take:/limit:against what each list header claims as a total would settle it. - Finding 1 is repo-specific (a members migration regression) and should not be expected elsewhere, but the class — a view reading a relation the migrated endpoint stopped returning — is worth one grep per project that has migrated data layers. customer-portal's Prisma layer is the obvious candidate.