Grille des besoins : corrige le chevauchement de la ligne d'en-tête

La cellule « Affectés / idéal » (figée en haut à gauche) et le libellé
« N postes » partageaient le même z-index (6) — une égalité entre deux
éléments sticky peut se départager de façon imprévisible pendant un
défilement selon le navigateur. Par ailleurs, l'infobulle de survol
d'une cellule de créneau (z-20) n'était pas isolée dans son propre
contexte d'empilement et rivalisait donc directement avec les en-têtes
figés (max z-6) : un survol déclenché par la molette pendant un
défilement (le curseur reste immobile, ce sont les cellules qui
défilent dessous) pouvait la faire passer au-dessus de l'en-tête le
temps de sa transition de disparition.

Hiérarchie refaite sans égalité (coins figés 20/21 > en-têtes 11/12 >
libellés de poste 10 > cellules, chacune isolée via `relative z-0`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-28 19:34:34 +02:00
co-authored by Claude Sonnet 5
parent 8b9c0ac498
commit 5f7718adfd
2 changed files with 41 additions and 56 deletions
+26 -50
View File
@@ -75,21 +75,14 @@ les tables sauf les tables d'association.
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
Renseignables aussi via le formulaire public (§ 9) : `photo`/
`photo_content_type` (image, servie séparément par
`GET .../volunteers/{id}/photo`, COORD/LEAD) — recadrée en carré côté
navigateur avant l'envoi (`PublicSignupPage.tsx:cropToSquareJpeg`, centré,
tronque le surplus, réencodée en JPEG ≤ 1024 px de côté), donc toujours
carrée en base sans recadrage serveur ; aperçu agrandi (180 px) dès que la
photo est choisie. `companion_wish_note` (« je
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je
voudrais être avec » en texte libre — la personne visée n'existe pas
forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
main depuis la fiche une fois les deux bénévoles inscrits),
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
informatives** : rien ne vérifie qu'un paiement a réellement eu lieu,
aucun lien avec `Availability`). Si `FormTemplate.yapla_url` (ci-dessous)
est configuré, un encart « Payer ma cotisation » apparaît sous la case
`WILLING_TO_PAY_FEE`, vers la page de cotisation Yapla (externe) — la
déclaration et le paiement restent deux choses distinctes, non reliées.
informatives**, sans lien avec `Availability` ni aucun workflow de
paiement).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
@@ -195,29 +188,13 @@ les tables sauf les tables d'association.
bénévole ; une feuille identifie un poste pour la journée) : une page par
poste et par jour, avec son référent (`Position.lead_account_id`, nom +
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
avec les bénévoles affectés, leur téléphone et leur photo (`Volunteer.photo`
— vignette carrée 14 mm, proportions préservées, case vide si le bénévole
n'en a pas), pour affichage sur place. Sous le référent, la programmation
du jour (`Artist`, § 2) : tous les passages de cette date, quel que soit le
poste — repère utile même hors de la régie (affluence au bar, sécurité...)
— triés par heure, omise si aucun artiste ne joue ce jour-là.
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer).
- **`FormTemplate`** : configuration du formulaire public d'inscription
d'une édition (une ligne, créée à la demande). `is_open` coupe les
nouvelles soumissions sans effacer la configuration. `logo`/
`logo_content_type` (image, `PUT .../form/logo`, stockée en base comme les
autres images de l'appli), `description` (texte libre) et `yapla_url`
habillent la page publique — aucun rapport avec les champs collectés
(`FormField` ci-dessous) : affichés au-dessus/dans le formulaire, jamais
soumis par le bénévole. `yapla_url` : lien vers la page de cotisation sur
Yapla (yapla.com, plateforme externe de paiement pour associations) —
affiché en encart sous la case `WILLING_TO_PAY_FEE` si activée (voir
`Volunteer` ci-dessus) ; doit commencer par `http://`/`https://`
(422 `form.invalid_yapla_url` sinon), aucune autre vérification (rien ne
confirme qu'un paiement a eu lieu). `PATCH /editions/{id}/form` prend
`is_open`/`description`/`yapla_url`, chacun optionnel (absent = inchangé).
nouvelles soumissions sans effacer la configuration.
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
@@ -229,16 +206,13 @@ les tables sauf les tables d'association.
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
**seuls endpoints de toute l'API sans authentification qui écrivent ou
révèlent des données de l'édition** — protégés par hCaptcha
(`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification désactivée si
absente, comme `EMAIL_ENABLED=false`) et un filet de sécurité global par
édition (`app/auth/rate_limit.py:form_submission_limiter`, hCaptcha reste
la défense principale). `GET .../form/logo` est également public (affiché
sur la page), mais ne sert qu'une image choisie par la coordination elle-
même — aucune saisie ni donnée nominative, pas de protection hCaptcha
nécessaire. Le bénévole créé par une soumission apparaît immédiatement
dans la liste de la coordination, comme n'importe quel autre bénévole.
**deux seuls endpoints de toute l'API sans authentification** — protégés
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
hCaptcha reste la défense principale). Le bénévole créé par une soumission
apparaît immédiatement dans la liste de la coordination, comme n'importe
quel autre bénévole.
Conventions techniques :
@@ -277,8 +251,8 @@ Matrice appliquée par les endpoints :
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — |
| `position-sheets` (génération) | ✔ | — | — |
| `form` (config, dont logo) | ✔ | — | — |
| `form/public`, `form/public/submit`, `form/logo` | *(aucune authentification — voir § 2, `FormTemplate`/`FormField`)* |||
| `form` (config) | ✔ | — | — |
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
**Authentification — deux systèmes selon le compte**, une même session JWT
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
@@ -475,10 +449,16 @@ suivantes — bug corrigé une fois (postes manquants au-delà du premier), un
positionnement implicite (auto-flow) recréerait la même classe de bug.
Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row`
rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible
dans un simple rendu SSR). Largeur adaptative par jour (`columnWidthPx`, en
fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, beaucoup
⇒ largeur compacte avec défilement horizontal — jamais de colonnes étroites
par défaut.
dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et
sans égalité (coins figés 20/21 > en-têtes jour/heure 12/11 > libellés de
poste 10 > cellules de créneau, chacune isolée dans son propre contexte
d'empilement — `relative z-0` — pour que son infobulle au survol, `z-20`,
ne rivalise jamais avec les en-têtes figés) : deux éléments sticky à égalité
de `z-index` peuvent se chevaucher de façon imprévisible pendant un
défilement selon le navigateur. Largeur adaptative par jour (`columnWidthPx`,
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut.
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle,
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`)
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus
@@ -541,7 +521,7 @@ l'étape 21.)*
│ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session
│ │ ├── logging_config.py # pas de PII dans les logs
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
│ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers,
@@ -670,11 +650,7 @@ Aucune dépendance locale requise hors Docker.
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
| 24 | Feuilles de poste : photo des bénévoles affectés à côté de leur nom (vignette 14 mm, case vide si absente) | à valider |
| 25 | Formulaire public : photo recadrée en carré côté navigateur avant l'envoi, aperçu agrandi ; personnalisation de la page (logo, texte d'introduction) depuis l'écran de configuration (`/inscriptions`) | à valider |
| 26 | Feuilles de poste : programmation du jour (horaires des artistes, tous postes confondus) sous le référent | à valider |
| 27 | Formulaire public : encart de paiement de la cotisation via Yapla (lien externe configurable), affiché sous la case correspondante | à valider |
| 28 | Correctif grille des besoins : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule — un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants (noms de poste manquants au-delà du premier) | à valider |
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+15 -6
View File
@@ -223,7 +223,7 @@ export function GrillePage() {
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
className="sticky left-0 top-0 z-[6] flex items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"
className="sticky left-0 top-0 z-[20] flex items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"
>
{t('grille.positionsCount', { count: matrix.data.positions.length })}
<HelpTip text={t('grille.footnote')} side="bottom" />
@@ -231,7 +231,7 @@ export function GrillePage() {
{shownDays.map((day, di) => (
<div
key={day.date}
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
className="sticky top-0 z-[12] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
style={{
gridColumn: `${dayColumnStarts[di]} / span ${day.slotLabels.length}`,
gridRow: 1,
@@ -245,7 +245,7 @@ export function GrillePage() {
<div
style={{ gridColumn: '1 / span 1', gridRow: 2 }}
className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60"
className="sticky left-0 top-[27px] z-[21] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60"
>
{t('grille.assignedRequired')}
</div>
@@ -254,7 +254,7 @@ export function GrillePage() {
<div
key={`${day.date}|${i}`}
style={{ gridColumn: `${dayColumnStarts[di] + i} / span 1`, gridRow: 2 }}
className="sticky top-[27px] z-[3] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
className="sticky top-[27px] z-[11] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
>
{label}
</div>
@@ -354,7 +354,7 @@ function PositionRow({
<>
<div
style={{ gridColumn: '1 / span 1', gridRow: rowIndex }}
className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2"
className="sticky left-0 z-[10] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2"
>
<span className="truncate font-display text-[12px] font-bold tracking-tight">
{position.name}
@@ -443,7 +443,16 @@ function ShiftCell({
onClick={() => onSelect(shift.shift_id)}
style={{ gridColumn, gridRow }}
className={cx(
'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
// `relative z-0` (plutôt que `relative` seul) crée un contexte
// d'empilement local : l'infobulle interne (`z-20`, voir plus bas)
// ne rivalise qu'avec les autres enfants de cette cellule, jamais
// avec les en-têtes figés de la grille (z-10 à 21) — sans ça, un
// survol déclenché par la molette pendant un défilement (le
// curseur reste immobile à l'écran, ce sont les cellules qui
// défilent dessous) pouvait faire passer l'infobulle au-dessus de
// la ligne d'en-tête « Affectés / idéal » pendant sa transition de
// disparition.
'group relative z-0 flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
undefinedNeed && 'bg-sand-dark hover:bg-sand',
isCrit && 'bg-hatch-orange',
isOver && 'bg-hatch-mauve hover:bg-mauve-hover',