Skip to content

[UX] members — Events ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch: develop @ 2c22f5a · Files reviewed: 12 Patterns: forms/date-picker (consulted), data-display/table, forms/form-validation

Summary ​

The Events admin surface (list, detail with Informations / Programme / Inscriptions tabs, create modal, CSV/XLSX import) works for the happy path but has two genuine data-integrity problems: the shared date picker serialises the wrong calendar day for evening times, and the large detail form has no guard against navigating away with unsaved edits. Neither the create modal nor the detail form validates anything — an event with an empty name, or an end date before its start, saves silently. The bulk-inscription importer is the richest surface here and is the one most worth hardening (raw error strings, no success confirmation, keyboard-inaccessible file picker).

Findings ​

1. Date picker stores a UTC-derived day with a local time — dates land one day early — High · FORM-13 (proposed) ​

Where: admin/src/client/components/app/forms/SelectDateTime.vue:11-13What: The setter builds the stored string from mismatched clocks: dateStr = val.toISOString().slice(0,10) (UTC) but timeStr = val.toTimeString().slice(0,8) (local), then concatenates them. In France (UTC+1/+2), any time the user picks after ~22:00 local rolls the date back a day while keeping the local time — e.g. selecting 1 Aug 00:30 stores 2025-07-31T00:30:00. Why it matters: All four Events date fields (Debut, Fin, Date_limite_inscription, Date_limite_desinscription) flow through this component. A camp start, or a registration deadline, can be silently saved on the wrong day — and the deadline logic and ensureTypeConditions year-derivation (Detail.vue:524-531) then key off that wrong date. This is data corruption the admin cannot see. Fix: Derive both parts from the same clock. Use local getters (getFullYear/getMonth/getDate + the existing local timeStr), or format with date-fns format(val, "yyyy-MM-dd'T'HH:mm:ss"), rather than mixing toISOString() with toTimeString().

2. No unsaved-changes guard on the detail form — edits are lost silently — High · FORM-14 (proposed) ​

Where: admin/src/client/views/Evenements/Detail.vue:10-18, 27-40What: isDirty is computed and a "Modifications non sauvegardées" hint is shown, but nothing acts on it. The breadcrumb router-link back to evenements (and the sidebar, and browser back) navigates away with no onBeforeRouteLeave / beforeunload guard. The Programme tab in particular can hold substantial hand-built work. Why it matters: A user who edits the event or builds a multi-day programme and then clicks the breadcrumb — right next to the dirty hint — loses everything with no prompt. The app already knows it is dirty; it just does not stop the navigation. Fix: Add onBeforeRouteLeave (and a beforeunload listener) that, when isDirty, confirms before discarding. Reuse a BccDialog confirm for consistency with the rest of the app.

3. Neither the create modal nor the detail form validates anything — Medium · FORM-04 ​

Where: admin/src/client/components/app/modals/ModalEvenement.vue:142-144; admin/src/client/views/Evenements/Detail.vue:572-582What: handleSave POSTs whatever is in the form. There is no required-field check (an event with empty Nom saves), no cross-field date validation (Fin before Debut, or Date_limite_inscription after Debut, all save), and no constraints shown up front. The forms/date-picker and forms/form-validation guidance both call for range constraints ("cannot select dates outside the allowed range") shown before submission. Why it matters: Non-tech-savvy staff (per CLAUDE.md) get no feedback that an event is nonsensical; the bad record surfaces later as broken registration windows or a nameless row in the list. Fix: Validate before save — require Nom and Debut; enforce Fin >= Debut and deadlines within the event window; wire min/max on the paired SelectDateTimes; show inline messages and keep the invalid save from firing.

4. Import file picker is keyboard-inaccessible and unlabeled — Medium · A11Y-03 ​

Where: admin/src/client/components/app/forms/InputFile.vue:6-27What: The drop zone is a <div @click="fileInput?.click()"> wrapping an <input type="file"> that is display:none (.file-input { display:none }, line 57-59). A hidden input is not focusable, so keyboard-only users cannot reach or operate the file selector, and there is no <label> association or accessible name. Why it matters: The inscription importer (Detail.vue:226-231) is the primary bulk-entry path for an event; a keyboard or screen-reader user cannot start an import at all. Fix: Use sr-only (visually hidden but focusable) instead of display:none, associate a <label for>, and make the drop zone a real <button> or add tabindex="0" + Enter/Space handling.

5. Numeric inputs coerce cleared/invalid values to 0 — a paid event silently becomes "Gratuit" — Medium · FORM-04 ​

Where: admin/src/client/components/app/forms/InputNumber.vue:11What: @update:model-value="model = Number($event)" — Number("") is 0 and Number("abc") is NaN. Clearing the Prix field does not null it; it sets it to 0, which every price renderer in this feature maps to "Gratuit" (List.vue:52, Detail.vue:948). Why it matters: An admin blanking a price to "leave it unset" instead marks the event free; on Limite_inscriptions the same coercion turns a blanked field into a hard cap of 0. Fix: Emit null for empty input and reject NaN (model = $event === "" ? null : Number($event)), or use PrimeVue's InputNumber with :min.

6. Bulk import finishes with no confirmation — Medium · CONTENT-03 ​

Where: admin/src/client/views/Evenements/Detail.vue:934-936What: On success submitImport closes the modal and clears rows but fires no toast. Contrast the event save, which does toast (:578). The user who just imported N inscriptions gets a silent modal-close and must re-scan the table to confirm anything happened. Why it matters: Design principle 4 in CLAUDE.md is "feedback that reassures"; a bulk write that could create/update dozens of records is exactly where a confirmation matters. Silence reads as failure and invites a duplicate re-import. Fix: Add a success toast summarising counts ("X inscriptions créées, Y mises à jour"), matching the event-save pattern.

7. Import errors surface raw exception text to the user — Medium · MSG-02 ​

Where: admin/src/client/views/Evenements/Detail.vue:887, 938 (importError.value = e.message) What: Both the file-parse and the submit paths assign the raw Error.message straight into importError, rendered verbatim in a BccMessage. Parse/network/API failures reach the user as developer prose. (Members' shared client already surfaces raw HTTP <status> strings — MSG-02 at project level; this is a second admin-side instance.) Why it matters: A non-technical admin sees an untranslated stack-ish string with no next step. Fix: Map known failures to a human French sentence with an action, keep the raw text to the console.

8. Detail page has no <h1> and codes section titles as <div>s — Medium · A11Y-04 ​

Where: admin/src/client/views/Evenements/Detail.vue:49-51, 90-92, 106-108What: On the Informations and Programme tabs there is no <h1> at all (the event name lives in a breadcrumb <span>), and the visual section headings ("Informations générales", "Limites d'inscription", "Détails") are <div class="uppercase">, not headings. The only <h1> on the page appears when the Inscriptions tab mounts TableData (:11), so heading structure changes by tab. Why it matters: Screen-reader users get no page heading and cannot navigate the form by heading level; heading presence flickers with the active tab. Fix: Render the event name as a single <h1> in the sticky header and promote the section labels to <h2>.

9. Registration cap is set only at create time, then invisible and unenforced — Medium · FORM-09 ​

Where: ModalEvenement.vue:67-70 (field present) vs Detail.vue:44-131 (field absent); import at Detail.vue:891-914What: Limite_inscriptions can be entered in the create modal but is never shown or editable on the detail form, and nothing (manual add or bulk import) checks the current inscription count against it. A value the user carefully set on creation cannot be seen or changed later, and does not do anything. Why it matters: Either the field is a real capacity limit — in which case import silently overfills it — or it is decorative, in which case collecting it at create time misleads. Both readings are a defect. Fix: Surface Limite_inscriptions in the detail Informations tab, and enforce it (client warning + server check) when creating/importing inscriptions; or remove it from the create modal.

10. Facture tags and table rows are mouse-only — Low · A11Y-03 ​

Where: admin/src/client/views/Evenements/Detail.vue:1099-1109 (facture BccTag with onClick opening a popover); shared TableData.vue rows/sort/context-menu. What: The Documents-column facture tags open the Pennylane popover via a raw onClick on a non-interactive BccTag (no tabindex, role, or key handler). Row edit, sort headers and the right-click-only Supprimer action are the shared TableData mouse-only defects already logged project-wide. Why it matters: Keyboard users cannot open a facture's detail/Pennylane link, nor edit inscriptions. Fix: Make facture tags real buttons; the TableData items are covered by the shared fix — see PROJECT-LEVEL.md (TableData.vue mouse-only edit/sort/actions).

Unverified ​

  • A11Y-01 (contrast): the muted greys (text-neutral-300/400/500, text-2xs micro sizes, text-warning-fg warning icon) need a contrast tool on rendered output.
  • A11Y-06 (short viewport / mobile): the sticky header + min-height:70vh Programme rail/canvas split and the max-h-80 import preview table need a rendered short viewport to judge.
  • MSG-01 on BccMessage / useToast: whether the import error block and save toasts carry role="alert" / aria-live lives in @bcc-code/component-library-vue, whose node_modules are not installed — cannot confirm from source.

Baseline additions ​

  • FORM-13 (proposed): date/time serialisation must preserve the wall-clock day and time the user selected; never mix a UTC-derived date with a local time (or vice-versa). (Finding 1.)
  • FORM-14 (proposed): a form that tracks a dirty state must warn before that state is discarded by navigation or unload. (Finding 2; overlaps the separately-proposed FORM-12(d) "warn before discarding entered form data" — reconcile.)
  • Findings 5 (empty-vs-zero numeric coercion) fit within a form-validation rule if the family is expanded; flagged rather than given a new ID.
  • CONTENT-01: not-applicable — see project-level i18n finding (members has no i18n layer by design).
  • NAV-03: fails project-wide (one static « BCC Nancy Admin » title) — see PROJECT-LEVEL.md; not re-filed here.

Cross-project note ​

  • Finding 1 (date TZ serialisation) is the highest-value cross-check: any custom date wrapper that concatenates toISOString() with a local time string is suspect. tt-time-tracker and customer-portal both have date/deadline inputs — grep their date components for toISOString().slice(0,10).
  • Finding 2 (no unsaved-changes guard) likely recurs on every large edit form in all three sibling projects; it pairs with the customer-portal "list state is component-local, Back resets everything" theme.
  • Finding 4 (hidden display:none file input) — check tt-time-tracker / customer-portal file-upload surfaces for the same keyboard trap.