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). `wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
Renseignables aussi via le formulaire public (§ 9) : `photo`/ Renseignables aussi via le formulaire public (§ 9) : `photo`/
`photo_content_type` (image, servie séparément par `photo_content_type` (image, servie séparément par
`GET .../volunteers/{id}/photo`, COORD/LEAD) — recadrée en carré côté `GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je
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
voudrais être avec » en texte libre — la personne visée n'existe pas 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 forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
main depuis la fiche une fois les deux bénévoles inscrits), main depuis la fiche une fois les deux bénévoles inscrits),
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement `self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
informatives** : rien ne vérifie qu'un paiement a réellement eu lieu, informatives**, sans lien avec `Availability` ni aucun workflow de
aucun lien avec `Availability`). Si `FormTemplate.yapla_url` (ci-dessous) paiement).
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.
- **`Artist`** : programmation de l'édition (`name`, `performance_date`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La 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 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 + 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 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` avec les bénévoles affectés et leur téléphone, pour affichage sur place.
— 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à.
`POST /editions/{id}/position-sheets/generate` (COORDINATION, `POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune `{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer). affectation n'apparaît pas (rien à imprimer).
- **`FormTemplate`** : configuration du formulaire public d'inscription - **`FormTemplate`** : configuration du formulaire public d'inscription
d'une édition (une ligne, créée à la demande). `is_open` coupe les d'une édition (une ligne, créée à la demande). `is_open` coupe les
nouvelles soumissions sans effacer la configuration. `logo`/ nouvelles soumissions sans effacer la configuration.
`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é).
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈ - **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH` `EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par / `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`). souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; `GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les `GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
**seuls endpoints de toute l'API sans authentification qui écrivent ou **deux seuls endpoints de toute l'API sans authentification** — protégés
révèlent des données de l'édition** — protégés par hCaptcha par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
(`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification désactivée si désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
absente, comme `EMAIL_ENABLED=false`) et un filet de sécurité global par sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
édition (`app/auth/rate_limit.py:form_submission_limiter`, hCaptcha reste hCaptcha reste la défense principale). Le bénévole créé par une soumission
la défense principale). `GET .../form/logo` est également public (affiché apparaît immédiatement dans la liste de la coordination, comme n'importe
sur la page), mais ne sert qu'une image choisie par la coordination elle- quel autre bénévole.
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.
Conventions techniques : Conventions techniques :
@@ -277,8 +251,8 @@ Matrice appliquée par les endpoints :
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — | | `badges` (config + génération) | ✔ | — | — |
| `position-sheets` (génération) | ✔ | — | — | | `position-sheets` (génération) | ✔ | — | — |
| `form` (config, dont logo) | ✔ | — | — | | `form` (config) | ✔ | — | — |
| `form/public`, `form/public/submit`, `form/logo` | *(aucune authentification — voir § 2, `FormTemplate`/`FormField`)* ||| | `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
**Authentification — deux systèmes selon le compte**, une même session JWT **Authentification — deux systèmes selon le compte**, une même session JWT
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée. 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. positionnement implicite (auto-flow) recréerait la même classe de bug.
Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row` 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 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 dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et
fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, beaucoup sans égalité (coins figés 20/21 > en-têtes jour/heure 12/11 > libellés de
⇒ largeur compacte avec défilement horizontal — jamais de colonnes étroites poste 10 > cellules de créneau, chacune isolée dans son propre contexte
par défaut. 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, La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle,
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`) distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`)
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus
@@ -541,7 +521,7 @@ l'étape 21.)*
│ │ ├── main.py # instance FastAPI │ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings) │ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session │ │ ├── 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 │ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions, │ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers, │ │ │ # 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 | | 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 | | 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 | | 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 | | 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 |
| 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 |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + 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 className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div <div
style={{ gridColumn: '1 / span 1', gridRow: 1 }} 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 })} {t('grille.positionsCount', { count: matrix.data.positions.length })}
<HelpTip text={t('grille.footnote')} side="bottom" /> <HelpTip text={t('grille.footnote')} side="bottom" />
@@ -231,7 +231,7 @@ export function GrillePage() {
{shownDays.map((day, di) => ( {shownDays.map((day, di) => (
<div <div
key={day.date} 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={{ style={{
gridColumn: `${dayColumnStarts[di]} / span ${day.slotLabels.length}`, gridColumn: `${dayColumnStarts[di]} / span ${day.slotLabels.length}`,
gridRow: 1, gridRow: 1,
@@ -245,7 +245,7 @@ export function GrillePage() {
<div <div
style={{ gridColumn: '1 / span 1', gridRow: 2 }} 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')} {t('grille.assignedRequired')}
</div> </div>
@@ -254,7 +254,7 @@ export function GrillePage() {
<div <div
key={`${day.date}|${i}`} key={`${day.date}|${i}`}
style={{ gridColumn: `${dayColumnStarts[di] + i} / span 1`, gridRow: 2 }} 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} {label}
</div> </div>
@@ -354,7 +354,7 @@ function PositionRow({
<> <>
<div <div
style={{ gridColumn: '1 / span 1', gridRow: rowIndex }} 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"> <span className="truncate font-display text-[12px] font-bold tracking-tight">
{position.name} {position.name}
@@ -443,7 +443,16 @@ function ShiftCell({
onClick={() => onSelect(shift.shift_id)} onClick={() => onSelect(shift.shift_id)}
style={{ gridColumn, gridRow }} style={{ gridColumn, gridRow }}
className={cx( 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', undefinedNeed && 'bg-sand-dark hover:bg-sand',
isCrit && 'bg-hatch-orange', isCrit && 'bg-hatch-orange',
isOver && 'bg-hatch-mauve hover:bg-mauve-hover', isOver && 'bg-hatch-mauve hover:bg-mauve-hover',