Skip to content

[UX] members — Demandes (requests) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch: develop @ 2c22f5a · Files reviewed: 20 Patterns: user-feedback/empty-states

Summary ​

The unified Demandes queue (PR #75, c20219b) is the only place staff can act on member-submitted membership change requests, and two of its controls do not work: every selection made in the filter panel returns an empty table (the three filter columns declare TanStack's strict equals filter while the shared FilterDrawer writes arrays), and the whole page is still gated on B-Active capabilities and the memberships_priced module even though it is now the sole review surface for BCC requests too — so in a BCC-only sub-org, member requests arrive and no admin can ever see them. Beyond that, the merge left the pre-unification changeSummary in place next to the new shared diff composable, the sidebar pending badge is never refreshed by a decision taken on the page, and approving — which replays the payload through the save flow and recomputes dues — commits on a single click with no confirmation and no statement of what changes.

Findings ​

1. Every filter-panel selection empties the table — Blocker · proposed FILTER-EFFECTIVE ​

Where: admin/src/client/views/Demandes.vue:114, :121, :140 (with admin/src/client/components/app/tables/FilterDrawer.vue:249-253) What: The Type, RequestType and status columns declare filterFn: "equals", which is TanStack's built-in strict comparison (row.getValue(id) === filterValue). The shared FilterDrawer is a multi-select: toggleOption always writes a plain array (["pending"], ["pending","approved"]). An array is never === a cell string, so as soon as the user ticks any box in the Filtrer drawer the table drops to zero rows. The page only appears to work on load because initialFilters (Demandes.vue:168) seeds the raw string "pending", which equals does match. Demandes is the only view in the admin SPA that sets filterFn: "equals" — every other table relies on the repo's own preciseFilter (admin/src/client/lib/table-filters.ts:215-220), which handles the array shape explicitly and is already registered as defaultColumn.filterFn (TableData.vue:292-293). equals predates #75 on two columns; the unification copied it onto the new RequestType column rather than removing it. Why it matters: The three filters are the whole point of a unified queue — "show me only BCC", "show me new memberships", "show me what I already refused". All three silently produce « Aucun élement », while the drawer's own faceted counts next to each checkbox still show non-zero numbers and its footer button reads « Voir 0 résultat ». A reviewer reasonably concludes the queue is empty. Fix: Delete the three filterFn: "equals" lines and let the columns fall through to preciseFilter, which case-normalises and accepts both the array and scalar shapes.

2. BCC requests are unreachable — the unified page is still gated on B-Active — Blocker · NAV-family, proposed SCOPE-GATE-MATCHES-CONTENT ​

Where: admin/src/client/router.ts:82, admin/src/client/composables/useNav.ts:59, admin/src/client/views/Demandes.vue:42-45What: Three independent gates all name B-Active only:

  • route meta is caps: { Adhesions_B_Active: "read" } (router.ts:82);
  • the nav entry lives inside the module: "memberships_priced" B-Active group (useNav.ts:53-59), so it is hidden for sub-orgs without that module;
  • useApiData({ collection: "Adhesions_B_Active" }) disables the query unless caps.can("Adhesions_B_Active","read") and orgs.hasModule("memberships_priced") (useApiData.ts:71,82-84).

The backend was deliberately built the other way: ChangeRequestsController documents "instead of a static @RequireCapability the handlers check the capability against the request's own Collection" and filters the list to the collections the caller can read (api/src/adhesions/change-requests.controller.ts:13-18,37-44). BCC change requests are real — the BCC memberships widget files them through the same base composable (widgets/src/widgets/my-memberships/stores/bcc.store.ts:25 → useMembershipsBase → useAdhesionLock("Adhesions_BCC")). Why it matters: A sub-org running BCC only (memberships_simple without memberships_priced — the nav models these as independent modules) has no link, a forbidden redirect on the direct URL, and a disabled query. Members' locked BCC memberships keep filing change requests that literally no one in the admin can see or decide. The same happens to any role granted Adhesions_BCC read/update but not Adhesions_B_Active. Rated Blocker under the normalisation rule "a required control is unreachable" — for those orgs the feature does not exist. Fix: Gate on either collection: route meta caps needs an OR form (or drop to a Demandes-specific capability); move the nav entry out of the B-Active group to a top-level/inbox position with module: undefined; and give useApiData a module: null opt-out (the option already exists) so the fetch is not blocked by a module the page no longer belongs to.

3. A failed or disabled fetch renders as « Aucun élement » — High · MSG-06 (proposed variant b) ​

Where: admin/src/client/components/app/tables/TableData.vue:118-140 (consumed by Demandes.vue:3-14) What: The table body branches on loading || props.data?.isFetching, then on table.getRowCount() === 0. props.data.isError / .error are exposed by useApiData (useApiData.ts:75,135) and never read. A 403 (module inactive, wrong sub-org header), a 500 or an offline browser therefore renders exactly the same « Aucun élement » row as a genuinely empty queue. The enabled:false path (finding 2) produces the identical output with no request at all. Why it matters: This is a work queue: "nothing here" is the signal to move on. An admin who sees it after a failed load concludes there is nothing to approve, and members' requests sit untouched for as long as the failure lasts. There is no retry control and no contradicting signal — the sidebar badge that would disagree also swallows its own failure (SidebarContent.vue:135, .catch(() => {})). Fix: Add an error branch to TableData (isError → message + « Réessayer » calling data.fetch()), and pass through data.error. Shared component — this fixes ~15 admin list views at once and is a candidate for PROJECT-LEVEL.md.

4. Approving is irreversible, changes money, and commits on one click — High · MSG-05 ​

Where: admin/src/client/components/app/modals/ModalDemande.vue:84-95 → views/Demandes.vue:54-74What: « Approuver » (primary, immediately adjacent to « Refuser ») fires the POST straight away. Server-side, approval replays the payload through the normal save flow, which recreates club rows and recomputes the cotisation (change-requests.service.ts:221-264, sanitizePayload deletes Mois/Cotisation precisely because "recomputed by the update flow"; the view itself invalidates Cotisations_B_Active afterwards, Demandes.vue:68). Once decided, the request is locked: loadPending rejects any second decision with 409 (change-requests.service.ts:214-219), so there is no undo in the UI. The modal never states any of this — the diff lists the changed fields, but nothing says the member's dues will be recalculated, and there is no confirmation step. Why it matters: A misclick on a row-sized modal permanently applies a member's requested changes and silently re-prices what they owe. The generic delete path in the same codebase gets a full "Êtes-vous sûr ? Cette action est irréversible" dialog (TableData.vue:191-214) while the consequential action here gets none. Fix: Confirm on approve/deny with a sentence naming the consequence ("Les modifications seront appliquées à l'adhésion et la cotisation recalculée."), and show the recomputed/current cotisation in the diff for B-Active requests.

5. The queue is mouse-only — High · A11Y-03 ​

Where: admin/src/client/components/app/tables/TableData.vue:146-153 and :91-97What: The only way to open a request is @click on the <tr>; the row has no tabindex, no role="button", no key handler. Sortable headers are the same shape (<th class="cursor-pointer" @click="…getToggleSortingHandler()">, no tabindex, no aria-sort, sort direction conveyed by an icon only). Worse for this view specifically: the cursor-pointer class is applied only when canUpdate && props.component (TableData.vue:150), and Demandes passes an on-click handler but no component — so the rows are clickable but never even look clickable. Why it matters: A keyboard or screen-reader user cannot open, read or decide a single request; the review modal is unreachable. Everyone else has to discover by accident that rows are interactive. Unverified sub-claim: whether BccDialog traps focus once opened (component library, node_modules not installed). Fix: Render the identity cell as a real <button> (or give the row tabindex="0" + role="button" + Enter/Space), apply the pointer affordance whenever props.onClick is set, and make headers <button>s carrying aria-sort.

6. Right-click « Supprimer » cannot work and fails silently — High · MSG-01 / proposed ACTION-REACHABLE ​

Where: admin/src/client/components/app/tables/TableData.vue:425-435,457-462 (via Demandes.vue:4 collection="Adhesions_B_Active") What: Because the table is bound to a collection, the shared context menu offers « Supprimer » on every row for any role with delete on Adhesions_B_Active. Confirming calls data.remove.mutateAsync, i.e. DELETE /api/adhesion-change-requests/<id> (useApiData.ts:126-133). That route does not exist — the admin controller exposes only GET, POST :id/approve and POST :id/deny (api/src/adhesions/change-requests.controller.ts:35-67). confirmDelete awaits the mutation with no try/catch, so the rejection is unhandled: showDeleteConfirm is never set back to false and no toast is raised. Why it matters: The user is shown "Cette action est irréversible", clicks « Supprimer », and the dialog just sits there — no deletion, no error, no way to tell whether it worked. TableData already has a hideDeleteAction prop for exactly this case and Demandes does not pass it. (High rather than Blocker: nothing is destroyed and the main flow still works, but a destructive-looking control silently no-ops.) Fix: Pass hide-delete-action on the Demandes TableData (deleting a decided request would destroy the audit trail anyway), and wrap confirmDelete in the useMutationWithToast pattern so any failure closes the dialog and reports.

7. The sidebar pending badge never updates after a decision — Medium · proposed DATA-STALE-BADGE ​

Where: admin/src/client/views/Demandes.vue:65-71 vs admin/src/client/components/app/layout/SidebarContent.vue:128-137What: PR #75 moved the pending badge to the sidebar, where it is fed by the Pinia store useChangeRequests fetched once on mount and on org switch. The page it links to was migrated to useApiData/TanStack Query, and its onSuccess invalidates only TanStack keys (Adhesions_B_Active, Adhesions_BCC, Cotisations_B_Active). Nothing refreshes changeRequests.list, so pendingTotal (change-requests.store.ts:48) is frozen until a reload. The store's own approve/deny actions (:50-64), which did refetch, are now dead code — the two clients diverged in the merge. Why it matters: After clearing the queue the sidebar still reads « Demandes 4 ». Staff navigate back to an empty page repeatedly, or believe requests were lost. Fix: Call useChangeRequests().fetch() in the view's onSuccess (or move pendingTotal onto the same TanStack query the page uses, and delete the store's now-unused mutation actions).

8. The queue is truncated to 500 rows server-side while the count reads as a total — Medium · DATA-TRUTHFUL-AGGREGATE (PROJECT-LEVEL theme) ​

Where: api/src/adhesions/change-requests.service.ts:157-166 (take: 500, orderBy date_created desc), consumed by Demandes.vue:42-45 and TableData.vue:13What: The admin list is capped at the 500 most recent requests across all statuses — the page sends no statut query param, so approved and denied history consumes the same budget as the pending queue. The header renders {{ table.getRowCount() }} éléments and the sidebar badge counts pending from the same truncated array; neither says it is a partial view, and nothing tells the user rows were dropped. Why it matters: Once a sub-org passes 500 lifetime requests, the oldest rows fall off first — and since date_created desc puts history at the top, the rows that disappear are the longest-unanswered pending ones, the ones that most need attention. They vanish from the table, from the filter, and from the badge count. Why Medium not High: it needs volume to bite, but when it does it is silent. Fix: Request ?statut=pending for the default queue (the endpoint already supports it, change-requests.controller.ts:36), and either paginate server-side or return a total so the header can say « 500 sur 812 ».

9. One generic empty state for four different situations — Medium · MSG-04 / user-feedback/empty-states ​

Where: admin/src/client/components/app/tables/TableData.vue:129-140What: « Aucun élement » (sic — « élément ») is shown for: no request has ever been filed (first-use), every request has been decided (the completed / inbox-zero state, which is the good outcome and the most common one here since initialFilters defaults to status = pending), the current filter/search matched nothing (no-results), and a failed load (finding 3). The empty-states pattern separates exactly these variations and requires the no-results case to offer a recovery path; there is no « Effacer les filtres » control in the table body — the user must reopen the drawer to find « Tout effacer ». Why it matters: The default view is filtered on load, so the normal state of a healthy queue reads as "there is nothing in this system at all". A reviewer who ticked a filter (which today also returns nothing, finding 1) gets the same sentence. Fix: Branch the empty slot: no rows at all → « Aucune demande pour l'instant » ; rows exist but filters exclude them → « Aucune demande ne correspond aux filtres » + a clear-filters button; all decided → « Aucune demande en attente 🎉 ». Demandes can pass this through the existing #empty slot; better in TableData for every list view.

10. The reviewer's response field has no programmatic label — Medium · FORM-01 ​

Where: admin/src/client/components/app/modals/ModalDemande.vue:64-71 (also :34, :41) What: <label class="text-xs text-neutral-600">Réponse (optionnelle)</label> carries no for, does not wrap the control, and BccTextarea is given no id, aria-label or aria-labelledby. The same bare <label> element is used at :34 and :41 to caption static read-only blocks, which is a misuse of the element. Why it matters: A screen-reader user reaches an unlabelled textarea in the middle of an approve/refuse decision and has no idea what it is for; the only accessible name would be the placeholder « Motif ou remarque... », which BASELINE FORM-01 rules out. Fix: <label for="demande-reponse"> + input-id="demande-reponse" (or aria-label) on the textarea; use <p>/<h4> for the two static captions.

11. Deny accepts an empty reason and the member is told only « Refusée » — Medium · MSG-03 / CONTENT-03 ​

Where: admin/src/client/components/app/modals/ModalDemande.vue:65,84-90 → api/src/adhesions/change-requests.service.ts:266-278What: The note is explicitly optional for both decisions and is stored as null when blank. Change-request outcomes are surfaced to members in the memberships widget (commit b5ddc3d), so a refusal with no note reaches the member as a status word and nothing else. Why it matters: The member cannot tell what was wrong or what to do next — the canonical MSG-03 failure — and their only recourse is to re-file the same request. Fix: Require a note when the decision is « Refuser » (keep it optional on approve), and label it « Motif du refus (visible par le membre) » so the reviewer knows the text is published.

12. The « Demande » column duplicates « Message » and hides the actual change — Medium · CONTENT-04-adjacent, merge leftover ​

Where: admin/src/client/views/Demandes.vue:88-94 and :124-135What: changeSummary returns a club list only for B-Active requests whose payload contains Clubs; in every other case — all BCC requests, and any B-Active request that changes Droit_image, Allergique, Allergies, Renseignements — it returns d.Message, which the adjacent « Message » column (:130-135) already renders. The result is two identical columns, both blank when the member wrote no message, and the requested change never appears in the list. The shared composable added by the same PR already computes exactly this (useChangeRequestDiff.diffFor), and the view imports requestType from it while keeping its own pre-merge changeSummary and a second, subtly different clubIdsOf (Demandes.vue:77-80 has no de-dupe/sort, unlike useChangeRequestDiff.ts:35-42). Why it matters: The reviewer cannot triage from the list at all — every row must be opened to learn what it asks for — which is the specific job a unified queue exists to do. Fix: Render diffFor(row) in the « Demande » column (e.g. the changed field labels, or Aptitude : Non → Oui), drop the duplicate Message column or keep it for the member's free text only, and delete the local clubIdsOf/changeSummary in favour of the composable.

13. A decided request shows an editable response box and no decision metadata — Medium · CONTENT-02/MSG-01-adjacent ​

Where: admin/src/client/components/app/modals/ModalDemande.vue:64-71,81,120-123What: Opening an already approved/denied request loads Decision_Note into the « Réponse (optionnelle) » textarea, which stays fully editable — but the decision buttons are hidden (v-if="… data?.Statut === 'pending'"), so there is no way to save whatever is typed. Decided_At and Decided_By are present on the row type (change-requests.store.ts:15-16) and never rendered. Why it matters: The reviewer edits a field that silently discards their text, and cannot see who decided the request or when — the two things you look up when a member disputes an outcome. Fix: For non-pending requests render the note read-only under a « Décision » heading with « Refusée le 12/07/2026 par … »; keep the textarea only in the pending state. Also hide/disable it when canReview is false (:115), where the buttons vanish today with no explanation of why.

14. Filter drawer takes no focus and traps none — Medium · A11Y-03 ​

Where: admin/src/client/components/app/tables/FilterDrawer.vue:21-26What: The panel is role="dialog" with aria-label="Filtres" but no aria-modal="true", no focus move on open, no focus trap, and no focus restore to the « Filtrer » button on close. Escape is handled (:205-207) and the listener is auto-removed by useEventListener, so NAV-02 passes. Why it matters: A keyboard user who opens the filters keeps tabbing through the table behind the overlay; a screen-reader user is never told the panel opened. Fix: Focus the panel heading (or close button) on open, trap Tab inside, restore focus on close, add aria-modal="true". Shared component — candidate for PROJECT-LEVEL.md alongside finding 3.

15. Decision buttons are disabled while the POST is in flight — Medium · FORM-06 / CONTENT-04 ​

Where: admin/src/client/components/app/modals/ModalDemande.vue:85,91What: :disabled="busy" is applied to the button the user just activated, and no aria-busy, spinner or label change names what is being waited on. Guarding is already done in the handler (useMutationWithToast owns busy), so the disable is redundant. Why it matters: Disabling a focused element drops focus to <body>, so a keyboard user loses their place mid-decision and hears nothing until the toast fires. Unverified: whether BccButton maps disabled to the native attribute (component library, node_modules not installed) — but the prop is passed from source either way. Fix: Keep the buttons enabled, set aria-busy / :loading and switch the label to « Approbation… », and rely on the existing busy guard in the handler.

16. « Programme » filter offers BCC to admins who can never receive BCC rows — Low · proposed FILTER-EFFECTIVE ​

Where: admin/src/client/views/Demandes.vue:158-161 vs api/src/adhesions/change-requests.controller.ts:43-44What: The list endpoint filters rows down to the collections the caller can read. An admin with B-Active-only capabilities is still offered the « BCC » checkbox, which can only ever produce an empty table (and today produces one for everybody, finding 1). Fix: Build filterOptions.Type from caps.can(collection, "read").

Project-level, recorded once and not re-filed here ​

  • CONTENT-01 — not-applicable, see project-level i18n finding.
  • NAV-03 — fails project-wide: admin/src/client/index.html:13 is the single static <title>BCC Nancy Admin</title> and no source file assigns document.title. Not a Demandes defect; belongs in PROJECT-LEVEL.md next to the other three projects, which all fail the same rule.
  • Undeclared suborg prop / DATA-01 — not applicable: this is an admin SPA route, not a widget; sub-org scope arrives from orgHeaders() and is enforced server-side on every query (Sub_Organisation: req.org.subOrganisationId in every service method).
  • "Does archive/disable revoke access?" — not applicable to this screen; Demandes has no archive/deactivate action. The nearest equivalent, request deletion, is offered but non-functional (finding 6). The real answer for members belongs to queue row #25 (Users & roles).
  • Currency precision (maximumFractionDigits: 0) — this view displays no money. Approval does recompute Cotisation server-side, which is why finding 4 asks for it to be shown; if it is added, use a formatter with 2 fraction digits, not widgets/src/composables/useFormat.ts.

Unverified ​

  • A11Y-01 (contrast) — always unverified. Several statuses rely on BccTagcontext tokens (success, danger, blue-subtler, neutral-subtler, purple-subtler, brand-subtler) whose computed colours are inside @bcc-code/component-library-vue; node_modules is not installed.
  • A11Y-06 (short viewport / responsive) — always unverified. Worth a rendered check: the review modal is class="w-lg" with a diff grid of fixed 9rem label columns (ModalDemande.vue:46), and TableData uses table-layout: fixed with eight columns and no hiddenOnMobile meta on any of them, so the Demandes table almost certainly overflows horizontally on a phone. Stated as a hypothesis, not a finding.
  • BccDialog / BccTag / BccButton / BccTextarea internals — focus trap, role="alert" on toasts, disabled semantics: all unreadable from this checkout.
  • Status colour alone — BadgeDemandeStatus.vue pairs colour with a French word, so it passes; noted only because sort direction (finding 5) does not.

Baseline additions ​

The orchestrator will renumber these; definitions matter more than the IDs.

  • FILTER-EFFECTIVE — Every value a filter control can produce must be understood by the code that applies it (client filter function or API parameter). A filter that can return zero rows for a value the panel itself offers is broken, not empty. Collides with the separately-proposed FORM-12(c) ("inert controls / filters that never reach the API") — same rule, two symptoms.
  • SCOPE-GATE-MATCHES-CONTENT — A route's capability/module gate must cover every kind of record the route displays. A page unified across two scopes may not stay gated on one of them.
  • ACTION-REACHABLE — A control must not be offered when no endpoint can satisfy it, and a failed action must always report; a confirmation dialog that stays open with no message is not a failure state.
  • DATA-STALE-BADGE — A count or badge rendered outside the view must be invalidated by the action inside the view that changes it. (Narrower than the aggregate rule below; both apply here.)
  • DATA-TRUTHFUL-AGGREGATE — reuse the PROJECT-LEVEL name as-is; finding 8 is a fourth confirmed sighting (server take: 500 presented as a total).
  • MSG-06 — no new proposal; finding 3 is variant (b), "a failed fetch must not render as an empty state", now confirmed in the members admin shell (TableData.vue) as well as the widgets already listed in PROJECT-LEVEL.md.

Cross-project note ​

  • Finding 3 (error rendered as empty) — already confirmed in all four projects; this adds members' shared admin TableData to the members entry, which previously listed only widget-side instances.
  • Finding 5 (mouse-only rows / headers) — the A11Y-03 row already shows ✗ for all four; the sort-header variant (<th @click> without aria-sort) is worth checking specifically in customer-portal packages/ui/DataTable and tt-time-tracker Table.vue.
  • Finding 8 (truncated set presented as a total) — fourth sighting of the PROJECT-LEVEL "aggregates computed from one page" theme, after customer-portal's dashboard and trade-in machines and tt-time-tracker's projects. members should now be marked ✗ on that row.
  • Finding 1 (filter shape mismatch) — likely members-specific; it comes from mixing a bespoke drawer with TanStack built-ins. Worth one grep in tt-time-tracker, which also uses TanStack Table: grep -rn "filterFn: \"equals\"\|filterFn: 'equals'".
  • Finding 9 (single generic empty state) — customer-portal and tt-time-tracker both have shared list shells with the same one-string treatment; an alignment issue defining the three empty variations (first-use / no-results+clear / completed) would land in all four.