Compare commits
17
Commits
1a23881e81
..
main
@@ -57,7 +57,14 @@ les tables sauf les tables d'association.
|
||||
|
||||
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
|
||||
`retention_days` (durée de rétention des données nominatives en jours,
|
||||
`NULL` = pas de purge).
|
||||
`NULL` = pas de purge), `volunteer_message` (**message de
|
||||
l'organisation**, le même pour tous les bénévoles, affiché en bas de
|
||||
l'écran bénévole — saisi par la coordination, onglet « Message aux
|
||||
bénévoles » de la configuration, `ConfigMessage.tsx`, via `PATCH
|
||||
/editions/{id}`) et `volunteer_message_updated_at` (date affichée sous le
|
||||
message). Même règle que `Position.message` : `app/api/_common.py:
|
||||
set_message` — bords retirés, vide ⇒ `NULL`, date posée seulement si le
|
||||
texte change, 2 000 caractères au plus.
|
||||
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
|
||||
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
|
||||
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
|
||||
@@ -68,7 +75,11 @@ les tables sauf les tables d'association.
|
||||
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
|
||||
peuvent partager une même adresse, ex. un couple ou une famille ; c'est le
|
||||
couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul),
|
||||
`first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance,
|
||||
`first_name`, `last_name`, `phone`, `comment`. **Aucun champ obligatoire
|
||||
à l'ajout manuel** par la coordination (`POST .../volunteers`, prénom et
|
||||
nom vides par défaut — fiche complétée plus tard) ; le formulaire public
|
||||
et l'import exigent toujours prénom et nom. Un bénévole sans nom
|
||||
s'affiche « Sans nom », initiales « ? » (`lib/volunteer.ts`). Pas de date de naissance,
|
||||
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
|
||||
exposées par la fiche (`artist_ids`, `wished_position_ids`,
|
||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||
@@ -89,7 +100,10 @@ les tables sauf les tables d'association.
|
||||
paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
|
||||
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
|
||||
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
|
||||
remplace pour lui le texte commun de l'éditeur.
|
||||
remplace pour lui le texte commun de l'éditeur. `badge_count` : nombre
|
||||
d'exemplaires de son badge imprimés sur les planches (1 par défaut, 1 à
|
||||
100 — `CHECK >= 1` en base, borne haute à la saisie contre une faute de
|
||||
frappe), « Nombre de badges » sur la fiche.
|
||||
- **`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
|
||||
@@ -122,7 +136,21 @@ les tables sauf les tables d'association.
|
||||
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
|
||||
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
|
||||
compte est rattaché à une fiche bénévole, sa vue de bénévole classique
|
||||
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
|
||||
(`VolunteerHomeView`) plutôt qu'un écran référent vide. Un référent peut
|
||||
aussi être affecté comme bénévole (même soirée comprise) : si son compte
|
||||
est rattaché à une fiche bénévole, `/mobile-referent` a deux onglets,
|
||||
« Mon équipe » et « Mon planning » (sa vue bénévole), ouvert sur « Mon
|
||||
planning » quand il tient en ce moment un créneau de bénévole hors de ses
|
||||
postes sans qu'aucun de ses postes ait un créneau en cours ; dans « Mon
|
||||
équipe », il est marqué « TOI » s'il fait partie de l'équipe pointée.
|
||||
- **`Position.message`** / `message_updated_at` : **message du poste**
|
||||
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
|
||||
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
|
||||
coordination (`ConfigMessage.tsx`), via `PATCH .../positions/{id}` (même
|
||||
contrôle d'écriture que le reste du poste : COORDINATION ou son
|
||||
référent). Montré à chaque bénévole affecté au poste : repris dans
|
||||
`GET .../volunteers/{vid}/planning` (`position_message`,
|
||||
`position_message_updated_at`).
|
||||
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
||||
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
||||
(peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
|
||||
@@ -146,6 +174,17 @@ les tables sauf les tables d'association.
|
||||
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
|
||||
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
|
||||
qui en a besoin.
|
||||
**Pointage** : `attendance` (`AttendanceStatus` `PRESENT` / `ABSENT`,
|
||||
`NULL` = pas pointé) et `attendance_marked_at` (heure du dernier
|
||||
pointage), posés par `PUT .../assignments/{id}/attendance`
|
||||
(`{attendance}`, `null` annule — même contrôle d'écriture que
|
||||
l'affectation : COORDINATION ou référent du poste). Écran référent : un
|
||||
second appui sur le même bouton annule, mise à jour immédiate à l'écran
|
||||
puis enregistrée (`hooks.ts:useSetAttendance`, retour arrière et message
|
||||
si l'enregistrement échoue). Ce n'est pas le statut d'une affectation
|
||||
(toujours valide dès sa création), juste la présence constatée sur
|
||||
place. Avant l'étape 58, ce pointage n'était gardé que dans le
|
||||
navigateur et se perdait au rechargement.
|
||||
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
|
||||
`volunteer_id` (nullable, unique).
|
||||
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
||||
@@ -162,15 +201,39 @@ les tables sauf les tables d'association.
|
||||
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
|
||||
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
|
||||
déformation, `preserveAspectRatio=True`) à ce même format.
|
||||
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈
|
||||
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`,
|
||||
au plus un par édition —
|
||||
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/
|
||||
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
|
||||
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer)
|
||||
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`),
|
||||
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel
|
||||
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`).
|
||||
- **`BadgeField`** : un champ positionné sur le **recto ou le verso**
|
||||
(`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
|
||||
`CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
|
||||
édition — unique (`edition_id`, `face`, `field_type`), 422
|
||||
`badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
|
||||
remplace tout, les deux faces dans la même liste), coordonnées `x_mm`/
|
||||
`y_mm`/`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du
|
||||
badge, `font_size`, `align`, `bold`, `color`, `bg_color` (fond plein de la
|
||||
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
|
||||
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
|
||||
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
|
||||
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`),
|
||||
`z_order` (ordre d'empilement sur la face : les champs sont dessinés du
|
||||
plus petit au plus grand, à égalité la photo dessous — avant, l'ordre
|
||||
alphabétique des types faisait passer la photo par-dessus le nom et le
|
||||
texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1,
|
||||
`badges/model.ts:stackOrder`/`restack`, boutons « Superposition » de
|
||||
l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu
|
||||
suit le même ordre, le cadre du champ sélectionné restant par-dessus pour
|
||||
qu'un champ recouvert se saisisse encore).
|
||||
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
|
||||
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
|
||||
ligne. Édité visuellement depuis l'écran « Badges »
|
||||
(`frontend/src/pages/badges/` — refonte d'après `design/Créateur de
|
||||
badges.dc.html` : liste des champs recto/verso, aperçu des deux faces avec
|
||||
les données d'un vrai bénévole, inspecteur, formats prédéfinis via
|
||||
`PUT .../template/size`, annuler, flèches, zoom, repères de découpe
|
||||
1,5 mm, contrôle de tous les badges avant génération — `issues.ts`).
|
||||
L'aperçu reprend la mise en page du PDF à l'identique
|
||||
(`badges/layout.ts`, avec les métriques Helvetica de ReportLab recopiées
|
||||
dans `helveticaMetrics.ts`, pas une mesure du navigateur) : toute
|
||||
règle de mise en page changée dans `app/badges/pdf.py` doit l'être aussi
|
||||
dans `layout.ts`, sinon le contrôle avant génération ment.
|
||||
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
|
||||
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
|
||||
**regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
|
||||
@@ -188,23 +251,38 @@ les tables sauf les tables d'association.
|
||||
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
|
||||
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
|
||||
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
|
||||
photo illisible, le cadre reste vide plutôt que de faire échouer la
|
||||
planche. Le texte d'un champ passe à la ligne pour tenir dans la largeur
|
||||
photo illisible, **rien n'est imprimé** (cadre vide, choix de la
|
||||
coordination — avant l'étape 61, les initiales sur fond beige), sans
|
||||
faire échouer la planche ; l'éditeur n'y montre qu'un repère pointillé
|
||||
« sans photo », non imprimé ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur
|
||||
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
|
||||
milieu d'un mot trop long seul ; les `\n` restent des retours à la
|
||||
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
|
||||
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
|
||||
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
|
||||
(`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier
|
||||
(`badges/model.ts:fitToBadge`) et met le champ sélectionné au premier
|
||||
plan : les positions par défaut visent un CR80 paysage, et sur un badge
|
||||
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
|
||||
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
|
||||
impossible à déplacer ni redimensionner).
|
||||
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
|
||||
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
|
||||
absent = tous ; chaque bénévole y figure `badge_count` fois, à la suite)
|
||||
génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
|
||||
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
|
||||
par planche, de la page de verso correspondante (même image répétée à
|
||||
chaque emplacement) — impression recto-verso classique.
|
||||
par planche, de la page de verso correspondante (image de verso puis
|
||||
champs `BACK` de chaque bénévole) — impression recto-verso classique
|
||||
(retournement bord long) : sur la page de verso, chaque badge est placé
|
||||
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
|
||||
True)`) pour tomber derrière son recto. La grille est **centrée et symétrique** sur la
|
||||
page (`app/badges/pdf.py:_spacing`) : l'espace libre est réparti à parts
|
||||
égales — même écart à gauche, entre les badges et à droite, et de même en
|
||||
haut, entre les rangées et en bas (CR80 sur A4 : 12,9 mm et 16,2 mm),
|
||||
marge d'impression de 10 mm au moins ; une fois la feuille retournée,
|
||||
chaque verso tombe exactement derrière son recto. Avant l'étape 71, la
|
||||
grille partait du coin haut-gauche et l'espace restant tombait à droite
|
||||
et en bas, d'où un verso décalé (bug corrigé une fois). Indispensable depuis que le verso
|
||||
est personnalisé ; avant, un verso identique pour tous masquait le
|
||||
décalage.
|
||||
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
|
||||
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
|
||||
`Volunteer` existants. Complément des badges (un badge identifie un
|
||||
@@ -213,8 +291,24 @@ les tables sauf les tables d'association.
|
||||
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
|
||||
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).
|
||||
`{position_ids?}` — absent = tous). Mise en page compacte, pour tenir
|
||||
sur une page par poste et par jour autant que possible : bénévoles **deux
|
||||
par ligne** (vignette de 10 mm, nom en gras et téléphone dessous),
|
||||
en-têtes et marges resserrés ; un créneau à besoin 0/0 sans aucune
|
||||
affectation n'apparaît pas (rien à imprimer). Le **message de
|
||||
l'organisation** (`Edition.volunteer_message`, s'il y en a un) est
|
||||
imprimé en pied de **chaque** page, suite d'une feuille comprise
|
||||
(`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu
|
||||
ne passe jamais dessous). Photo de chaque bénévole en
|
||||
vignette (10 mm) : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`,
|
||||
décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une
|
||||
photo illisible laissant la case vide. Avant l'étape 62, la photo
|
||||
d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle
|
||||
pour chaque bénévole de chaque créneau : génération de plusieurs minutes,
|
||||
serveur à 300 % de CPU, PDF énorme et « erreur inattendue » (bug corrigé
|
||||
une fois). Même principe pour les badges (`_fit_photo` + `PhotoCache` :
|
||||
une photo recadrée une fois par génération, malgré recto, verso et
|
||||
exemplaires multiples).
|
||||
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
|
||||
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
|
||||
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
|
||||
@@ -231,7 +325,13 @@ les tables sauf les tables d'association.
|
||||
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
|
||||
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
|
||||
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
|
||||
de démo (`registeredAt`).
|
||||
de démo (`registeredAt`). **Présence** (`attendance`,
|
||||
`_attendance` / mock `buildAttendance`) : présents, absents et non
|
||||
pointés (`Assignment.attendance`) par poste, sur les créneaux terminés
|
||||
et sur ceux en cours à `as_of` (créneaux à venir ignorés), avec le taux
|
||||
de présence parmi les bénévoles pointés ; la section passe en tête de
|
||||
l'écran tant qu'un créneau est en cours, et l'écran se rafraîchit
|
||||
toutes les 30 s (`useStats`, `refetchInterval`).
|
||||
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
|
||||
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
||||
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
|
||||
@@ -486,7 +586,26 @@ compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun
|
||||
personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive,
|
||||
pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès
|
||||
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
|
||||
cet endpoint avec `role=VOLUNTEER`.
|
||||
cet endpoint avec `role=VOLUNTEER`. Seule exception : envoyer un
|
||||
récapitulatif de planning contenant `{lien_connexion}` vaut aussi « donner
|
||||
un accès » (voir plus bas).
|
||||
|
||||
*Lien de connexion permanent* — celui du récapitulatif de planning
|
||||
(`{lien_connexion}`, `app/auth/permanent_link.py`) : connexion directe en
|
||||
un clic, **réutilisable et sans expiration** (contrairement au lien magique
|
||||
de la page de connexion, 30 min et usage unique), pour que le bénévole
|
||||
retrouve son planning depuis n'importe quel email reçu. Rien n'est stocké :
|
||||
jeton `pl.<id du compte>.<HMAC(JWT_SECRET, id + login_link_version)>`,
|
||||
vérifié par le même `POST .../auth/verify` (même page `/connexion?token=`
|
||||
côté front). Bénévoles et référents seulement — jamais un compte de la
|
||||
coordination ni à mot de passe. Révocation : bouton « Renouveler le lien
|
||||
de connexion » sur la fiche (`POST .../accounts/{id}/reset-login-link`,
|
||||
COORDINATION, incrémente `Account.login_link_version`, migration `0031`) —
|
||||
tous les liens déjà envoyés à ce compte cessent de fonctionner ; changer
|
||||
`JWT_SECRET` les révoque tous. Un bénévole sans compte en reçoit un
|
||||
(`VOLUNTEER`, à son email) à l'envoi d'un récapitulatif avec
|
||||
`{lien_connexion}` ; si son email est déjà celui d'un autre compte (couple,
|
||||
famille), pas de compte possible : lien vers la page de connexion seule.
|
||||
|
||||
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
|
||||
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
|
||||
@@ -512,6 +631,22 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
alias validé.
|
||||
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
|
||||
<destinataire>` — affiche la config effective et la cause exacte d'un échec.
|
||||
- **Email de test depuis l'appli** : bloc « Tester l'envoi d'emails » de
|
||||
l'onglet « Messages » de la configuration (`ConfigMail.tsx`) — une
|
||||
adresse et un bouton. `POST /editions/{id}/notifications/test` (`{email}`,
|
||||
COORDINATION) envoie **tout de suite** (pas en tâche de fond) le
|
||||
**récapitulatif de planning tel que les bénévoles le recevront** : le
|
||||
modèle en cours d'édition (`subject`/`body`, case « avec le lien de
|
||||
connexion » = même corps que le bouton du même nom), variables remplies
|
||||
avec le planning d'un vrai bénévole (`volunteer_id`, à défaut le premier
|
||||
par nom qui a un créneau, à défaut un exemple fictif), sujet préfixé
|
||||
« [Test] », même mise en forme et même logo — code partagé avec l'envoi
|
||||
réel (`notifications.py:_planning_email`). `{lien_connexion}` y mène à la
|
||||
page de connexion **sans jeton** (jamais de vrai lien magique vers la
|
||||
boîte de test). Renvoie le résultat réel (`app/email/sender.py:deliver_test`) : `sent`, `disabled`
|
||||
(`EMAIL_ENABLED=false`), `connection_failed`, `rejected`, avec la réponse
|
||||
du serveur SMTP (jamais l'adresse du destinataire), le serveur et
|
||||
l'expéditeur configurés — sans accès au serveur ni à ses logs.
|
||||
- **Mise en forme** : chaque email part en `multipart/alternative` — texte
|
||||
brut (source de vérité, utilisé par les tests) **et** une version HTML au
|
||||
thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie
|
||||
@@ -533,11 +668,20 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
|
||||
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
|
||||
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
|
||||
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est
|
||||
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles
|
||||
`{creneaux}`, `{lien_connexion}` (lien de connexion **permanent** du
|
||||
bénévole, voir « Lien de connexion permanent » plus haut — compte créé
|
||||
s'il n'en a pas). `volunteer_ids` absent ⇒ tous les bénévoles
|
||||
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`,
|
||||
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
|
||||
recipients }`.
|
||||
recipients }`. `{creneaux}` est un **tableau**, comme sur les badges : le jour de
|
||||
festival en en-tête (une nuit reste sur le jour où elle a commencé),
|
||||
puis une ligne par créneau — horaire, poste, référent du poste avec son
|
||||
téléphone (« à désigner » sinon). Vrai tableau HTML dans la version HTML
|
||||
(`app/email/html.py:render_planning_table`, inséré à la place d'un repère
|
||||
via `build_message(html_body=…, html_blocks=…)`), et version texte une
|
||||
ligne par créneau sous chaque jour (`email.planning.table.text_line`).
|
||||
Composition partagée par l'envoi réel et l'email de test
|
||||
(`notifications.py:_planning_email`, `_load_context`).
|
||||
- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer`
|
||||
est effectivement créé et a un email — que ce soit tout de suite (pas de
|
||||
paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
|
||||
@@ -714,10 +858,18 @@ bug. 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
|
||||
adapté à son tableau bénévole × créneau.
|
||||
La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des
|
||||
temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par
|
||||
jour) : chaque affectation est une barre étirée sur toute la durée de son
|
||||
créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent
|
||||
sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en
|
||||
jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans
|
||||
l'en-tête des tranches. Barre de recherche (nom du bénévole ou d'un de
|
||||
ses postes, sans accents ni casse) : ne garde que les bénévoles qui
|
||||
correspondent, et cerne d'orange les postes trouvés. Avant l'étape 60, une colonne par créneau distinct
|
||||
(`buildLattice`, supprimé) : une affectation n'apparaissait que dans la
|
||||
colonne de son propre créneau, jamais sur les autres colonnes qu'elle
|
||||
couvrait, et deux postes simultanés se masquaient.
|
||||
|
||||
---
|
||||
|
||||
@@ -747,10 +899,11 @@ adapté à son tableau bénévole × créneau.
|
||||
|
||||
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
||||
|
||||
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
|
||||
SMS · fonctions d'IA. *(L'envoi d'emails par SMTP est en
|
||||
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
|
||||
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
|
||||
l'étape 21.)*
|
||||
l'étape 21. Le pointage présent / absent par le référent est en place depuis
|
||||
l'étape 58 — `Assignment.attendance`, § 2.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -953,7 +1106,28 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
|
||||
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
|
||||
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
|
||||
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
|
||||
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
|
||||
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
|
||||
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
|
||||
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
|
||||
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
|
||||
| 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider |
|
||||
| 58 | Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (`Assignment.attendance` + heure, migration `0030`, `PUT .../assignments/{id}/attendance`, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue | à valider |
|
||||
| 59 | Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau | à valider |
|
||||
| 60 | Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau | à valider |
|
||||
| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider |
|
||||
| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider |
|
||||
| 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider |
|
||||
| 64 | Vue par bénévole : barre de recherche sur le nom du bénévole ou d'un de ses postes (sans accents ni casse), postes trouvés cernés d'orange, cumulable avec les filtres et le tri | à valider |
|
||||
| 65 | Feuilles de poste compactées : bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — sur un jeu réaliste de 6 feuilles, 11 pages ⇒ 6 (une par poste et par jour) | à valider |
|
||||
| 66 | Ajout manuel d'un bénévole sans champ obligatoire (prénom et nom facultatifs, côté formulaire et API) ; un bénévole sans nom s'affiche « Sans nom » / « ? » dans les listes, la grille, la vue par bénévole, les badges et l'écran référent | à valider |
|
||||
| 67 | Email de test : adresse + bouton dans l'onglet « Messages » de la configuration, envoi immédiat et résultat réel affiché (envoyé, envoi désactivé, connexion refusée, message refusé, avec la réponse du serveur SMTP) — `POST .../notifications/test` | à valider |
|
||||
| 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider |
|
||||
| 69 | Récapitulatif de planning : `{creneaux}` devient un tableau par jour de festival (horaire, poste, référent avec son téléphone), en HTML dans l'email et en lignes lisibles dans la version texte — envoi réel et email de test | à valider |
|
||||
| 70 | `{lien_connexion}` devient un lien de connexion directe permanent (réutilisable, sans expiration, signé HMAC, rien de stocké — avant : lien magique de 30 min à usage unique, ou simple lien vers le site sans compte) ; compte bénévole créé à l'envoi s'il manque ; révocation par « Renouveler le lien de connexion » sur la fiche (`Account.login_link_version`, migration `0031`) ; jamais pour la coordination | à valider |
|
||||
| 71 | Planches de badges centrées : espace libre réparti à parts égales (mêmes écarts à gauche, entre les badges, à droite ; en haut, entre les rangées, en bas), grille symétrique — le verso, imprimé en miroir, tombe exactement derrière le recto au lieu d'être décalé de l'espace restant | à valider |
|
||||
|
||||
**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, IA. Frontend : squelette + 6 écrans +
|
||||
connexion en place (voir `frontend/`).
|
||||
|
||||
@@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish
|
||||
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
|
||||
|
||||
|
||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||
@@ -119,3 +119,15 @@ async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
) from exc
|
||||
return content, content_type
|
||||
|
||||
|
||||
def set_message(target: object, field: str, value: str | None) -> None:
|
||||
"""Message affiché aux bénévoles (`Edition.volunteer_message`,
|
||||
`Position.message`) : espaces de bord retirés, vide = plus de message.
|
||||
`{field}_updated_at` n'est daté que si le texte change vraiment — il dit
|
||||
aux bénévoles de quand date le message."""
|
||||
text = (value or "").strip() or None
|
||||
if text == getattr(target, field):
|
||||
return
|
||||
setattr(target, field, text)
|
||||
setattr(target, f"{field}_updated_at", local_now() if text else None)
|
||||
|
||||
@@ -109,6 +109,24 @@ def update_account(
|
||||
return account
|
||||
|
||||
|
||||
@router.post("/{account_id}/reset-login-link", response_model=AccountRead)
|
||||
def reset_login_link(
|
||||
account_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Account:
|
||||
"""Révoque le lien de connexion permanent du compte (`app/auth/
|
||||
permanent_link.py`) : tous les liens déjà envoyés cessent de
|
||||
fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex.
|
||||
email transféré, téléphone perdu."""
|
||||
account = owned_or_404(session.get(Account, account_id), edition.id)
|
||||
account.login_link_version += 1
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_account(
|
||||
account_id: uuid.UUID,
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.api.schemas import (
|
||||
AssignmentValidateRequest,
|
||||
AssignmentValidateResult,
|
||||
AssignmentWriteResult,
|
||||
AttendanceUpdate,
|
||||
)
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
@@ -24,7 +25,7 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer, local_now
|
||||
from app.validation import has_blocking, validate
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
@@ -206,6 +207,30 @@ def update_assignment(
|
||||
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
|
||||
|
||||
|
||||
@router.put("/{assignment_id}/attendance", response_model=AssignmentRead)
|
||||
def set_attendance(
|
||||
assignment_id: uuid.UUID,
|
||||
payload: AttendanceUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Assignment:
|
||||
"""Pointe un bénévole présent ou absent sur son créneau (`None` annule) —
|
||||
le référent du poste ou la coordination. Enregistré en base : le
|
||||
pointage survit à un rechargement et se retrouve sur tout appareil."""
|
||||
assignment = owned_or_404(
|
||||
session.get(Assignment, assignment_id), edition.id
|
||||
)
|
||||
ensure_can_write_shift(
|
||||
account, session, session.get(Shift, assignment.shift_id)
|
||||
)
|
||||
assignment.attendance = payload.attendance
|
||||
assignment.attendance_marked_at = local_now() if payload.attendance else None
|
||||
session.commit()
|
||||
session.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_assignment(
|
||||
assignment_id: uuid.UUID,
|
||||
|
||||
@@ -25,6 +25,8 @@ from app.api.schemas import (
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
|
||||
from app.auth.permanent_link import PREFIX as PERMANENT_PREFIX
|
||||
from app.auth.permanent_link import account_from_permanent_token
|
||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
||||
from app.auth.tokens import create_access_token, hash_token
|
||||
from app.config import get_settings
|
||||
@@ -184,6 +186,16 @@ def verify_magic_link(
|
||||
payload: VerifyRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
raw = payload.token.strip()
|
||||
if raw.startswith(PERMANENT_PREFIX):
|
||||
# Lien permanent du récapitulatif de planning : réutilisable, sans
|
||||
# expiration, révocable (voir `app/auth/permanent_link.py`).
|
||||
account = account_from_permanent_token(session, raw)
|
||||
if account is None or account.edition_id != edition_id:
|
||||
logger.info("Lien permanent refusé (édition %s)", edition_id)
|
||||
raise _invalid_token()
|
||||
logger.info("Connexion par lien permanent : compte %s (édition %s)", account.id, edition_id)
|
||||
return _issue(account)
|
||||
token = session.exec(
|
||||
select(MagicLinkToken).where(
|
||||
MagicLinkToken.token_hash == hash_token(payload.token.strip())
|
||||
|
||||
+55
-25
@@ -1,15 +1,16 @@
|
||||
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
||||
et génération du PDF.
|
||||
|
||||
Le verso est une image fixe, la même pour tous les bénévoles. Le recto
|
||||
combine une image de fond et des champs positionnés (nom, poste(s) affectés,
|
||||
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
|
||||
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||
Chaque face (recto, verso) combine une image de fond (la même pour tous les
|
||||
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
|
||||
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
|
||||
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||
`BadgeField`) pour le modèle.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -42,6 +43,7 @@ from app.i18n import t
|
||||
from app.models import (
|
||||
Account,
|
||||
Assignment,
|
||||
BadgeFace,
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
BadgeTemplate,
|
||||
@@ -58,6 +60,9 @@ router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
||||
|
||||
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
|
||||
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
|
||||
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
|
||||
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -80,7 +85,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
|
||||
session.exec(
|
||||
select(BadgeField)
|
||||
.where(BadgeField.edition_id == edition_id)
|
||||
.order_by(BadgeField.field_type)
|
||||
# Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
|
||||
# photo dessous, pour ne jamais masquer un texte.
|
||||
.order_by(
|
||||
BadgeField.face,
|
||||
BadgeField.z_order,
|
||||
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
|
||||
BadgeField.field_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -206,9 +218,11 @@ def set_fields(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[BadgeField]:
|
||||
seen: set[BadgeFieldType] = set()
|
||||
# Au plus un champ de chaque type par face ; le même sur les deux faces
|
||||
# est permis.
|
||||
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
|
||||
for f in payload.fields:
|
||||
if f.field_type in seen:
|
||||
if (f.face, f.field_type) in seen:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
@@ -216,7 +230,12 @@ def set_fields(
|
||||
"params": {"field_type": f.field_type},
|
||||
},
|
||||
)
|
||||
seen.add(f.field_type)
|
||||
seen.add((f.face, f.field_type))
|
||||
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
|
||||
)
|
||||
|
||||
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
|
||||
for f in payload.fields:
|
||||
@@ -297,7 +316,11 @@ def _field_text(
|
||||
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
|
||||
(`_positions_table`)."""
|
||||
if field.field_type == BadgeFieldType.FULL_NAME:
|
||||
return f"{volunteer.first_name} {volunteer.last_name}"
|
||||
# Prénom puis nom, chacun sur sa ligne.
|
||||
return f"{volunteer.first_name}\n{volunteer.last_name}"
|
||||
if field.field_type == BadgeFieldType.PHOTO:
|
||||
# Une image, pas un texte (rien d'imprimé sans photo).
|
||||
return ""
|
||||
if field.field_type == BadgeFieldType.FREE_TEXT:
|
||||
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
|
||||
# le « Texte du badge » de sa fiche s'il en a un.
|
||||
@@ -359,13 +382,9 @@ def generate_badges(
|
||||
if account.volunteer_id is not None
|
||||
}
|
||||
|
||||
badges = [
|
||||
BadgeSpec(
|
||||
fields=tuple(
|
||||
BadgeFieldSpec(
|
||||
text=_field_text(
|
||||
field, volunteer, assignments_by_volunteer, lead_volunteer_ids
|
||||
),
|
||||
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
|
||||
return BadgeFieldSpec(
|
||||
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
|
||||
x_mm=field.x_mm,
|
||||
y_mm=field.y_mm,
|
||||
width_mm=field.width_mm,
|
||||
@@ -375,19 +394,30 @@ def generate_badges(
|
||||
bold=field.bold,
|
||||
color=field.color,
|
||||
is_photo=field.field_type == BadgeFieldType.PHOTO,
|
||||
image=volunteer.photo
|
||||
if field.field_type == BadgeFieldType.PHOTO
|
||||
else None,
|
||||
table=_positions_table(
|
||||
assignments_by_volunteer.get(volunteer.id, []), boundary
|
||||
)
|
||||
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
|
||||
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
|
||||
if field.field_type == BadgeFieldType.POSITIONS
|
||||
else None,
|
||||
bg_color=field.bg_color,
|
||||
auto_fit=field.auto_fit,
|
||||
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
|
||||
photo_shape=field.photo_shape.value,
|
||||
)
|
||||
for field in fields
|
||||
)
|
||||
)
|
||||
|
||||
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
|
||||
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
|
||||
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
|
||||
# à la suite les uns des autres.
|
||||
badges = [
|
||||
badge
|
||||
for volunteer in volunteers
|
||||
for badge in [
|
||||
BadgeSpec(
|
||||
fields=tuple(spec(field, volunteer) for field in front_fields),
|
||||
back_fields=tuple(spec(field, volunteer) for field in back_fields),
|
||||
)
|
||||
]
|
||||
* volunteer.badge_count
|
||||
]
|
||||
|
||||
pdf_bytes = generate_badge_sheets_pdf(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.api._common import set_message
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import EditionRead, EditionUpdate
|
||||
from app.auth.deps import current_account_in_edition, require_coordinator
|
||||
@@ -27,7 +28,10 @@ def update_edition(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Edition:
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "volunteer_message" in changes:
|
||||
set_message(edition, "volunteer_message", changes.pop("volunteer_message"))
|
||||
for field, value in changes.items():
|
||||
setattr(edition, field, value)
|
||||
if edition.end_date < edition.start_date:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
PlanningNotificationRequest,
|
||||
PlanningNotificationResult,
|
||||
TestEmailRequest,
|
||||
TestEmailResult,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.auth.magic_link import build_magic_link_url
|
||||
from app.auth.permanent_link import permanent_login_token
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.email import edition_logo, render_planning, send_many
|
||||
from app.email import edition_logo, render_planning
|
||||
from app.email.html import render_planning_table
|
||||
from app.email.sender import build_message, deliver_test, send_messages
|
||||
from app.i18n import t
|
||||
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
||||
from app.models import Account, Assignment, DayPart, Edition, Position, Role, Shift, Volunteer
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
logger = logging.getLogger("app.notifications")
|
||||
|
||||
@@ -29,16 +40,244 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}"
|
||||
|
||||
|
||||
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
|
||||
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil."""
|
||||
"""Lien de connexion **permanent** du bénévole (`app/auth/
|
||||
permanent_link.py` : connexion directe en un clic, réutilisable, sans
|
||||
expiration, révocable). Envoyer ce lien vaut « donner un accès de
|
||||
connexion » : un bénévole qui n'a pas encore de compte en reçoit un
|
||||
(rôle `VOLUNTEER`, à son email). Si son email est déjà celui d'un autre
|
||||
compte de l'édition (couple, famille partageant une adresse), pas de
|
||||
compte possible : simple lien vers la page de connexion."""
|
||||
account = session.exec(
|
||||
select(Account)
|
||||
.where(Account.edition_id == edition.id)
|
||||
.where(Account.volunteer_id == volunteer.id)
|
||||
.where(Account.password_hash.is_(None))
|
||||
).first()
|
||||
if account is None:
|
||||
if account is None and volunteer.email:
|
||||
email = volunteer.email.strip()
|
||||
taken = session.exec(
|
||||
select(Account.id)
|
||||
.where(Account.edition_id == edition.id)
|
||||
.where(func.lower(Account.email) == email.lower())
|
||||
).first()
|
||||
if taken is None:
|
||||
account = Account(
|
||||
edition_id=edition.id, email=email, role=Role.VOLUNTEER, volunteer_id=volunteer.id
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
logger.info(
|
||||
"Compte créé pour le lien de connexion : bénévole %s (compte %s)",
|
||||
volunteer.id,
|
||||
account.id,
|
||||
)
|
||||
if (
|
||||
account is None
|
||||
or account.password_hash is not None
|
||||
or account.role not in (Role.VOLUNTEER, Role.LEAD)
|
||||
):
|
||||
return _login_page_url()
|
||||
return build_magic_link_url(permanent_login_token(account))
|
||||
|
||||
|
||||
def _login_page_url() -> str:
|
||||
"""Page de connexion, sans jeton."""
|
||||
return build_magic_link_url("").split("?")[0]
|
||||
return build_magic_link_url(issue_magic_link_token(session, account))
|
||||
|
||||
|
||||
# Repère du tableau des créneaux dans la version HTML (caractère d'usage
|
||||
# privé : jamais saisi dans un modèle), remplacé par le vrai tableau.
|
||||
_SHIFTS_MARKER = "\ue000creneaux\ue000"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlanningContext:
|
||||
"""Ce qui sert à composer le récapitulatif de chaque bénévole, chargé
|
||||
une fois par envoi."""
|
||||
|
||||
positions: dict[uuid.UUID, Position]
|
||||
# Nom + téléphone du référent de chaque poste qui en a un.
|
||||
referents: dict[uuid.UUID, str]
|
||||
shifts_by_volunteer: dict[uuid.UUID, list[Shift]]
|
||||
boundary: dt.time
|
||||
|
||||
|
||||
def _load_context(session: Session, edition: Edition) -> _PlanningContext:
|
||||
positions = {
|
||||
p.id: p for p in session.exec(select(Position).where(Position.edition_id == edition.id))
|
||||
}
|
||||
referents: dict[uuid.UUID, str] = {}
|
||||
for position_id, referent in session.exec(
|
||||
select(Position.id, Volunteer)
|
||||
.join(Account, Account.id == Position.lead_account_id)
|
||||
.join(Volunteer, Volunteer.id == Account.volunteer_id)
|
||||
.where(Position.edition_id == edition.id)
|
||||
):
|
||||
name = f"{referent.first_name} {referent.last_name}".strip()
|
||||
referents[position_id] = f"{name} · {referent.phone}" if referent.phone else name
|
||||
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
|
||||
for volunteer_id, shift in session.exec(
|
||||
select(Assignment.volunteer_id, Shift)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.where(Assignment.edition_id == edition.id)
|
||||
):
|
||||
shifts_by_volunteer[volunteer_id].append(shift)
|
||||
boundary = day_boundary(session.exec(select(DayPart).where(DayPart.edition_id == edition.id)))
|
||||
return _PlanningContext(positions, referents, shifts_by_volunteer, boundary)
|
||||
|
||||
|
||||
def _day_label(day: dt.date) -> str:
|
||||
"""« Samedi 11 » — même en-tête de jour que le tableau des badges."""
|
||||
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
|
||||
|
||||
|
||||
def _shift_sections(
|
||||
shifts: list[Shift], ctx: _PlanningContext
|
||||
) -> list[tuple[str, list[tuple[str, str, str]]]]:
|
||||
"""Créneaux regroupés par journée de festival (une nuit reste sur le
|
||||
jour où elle a commencé), comme le tableau des badges :
|
||||
`[(jour, [(horaire, poste, référent)])]`."""
|
||||
by_day: dict[dt.date, list[tuple[str, str, str]]] = defaultdict(list)
|
||||
for s in sorted(shifts, key=lambda s: s.starts_at):
|
||||
by_day[festival_day(s.starts_at, ctx.boundary)].append(
|
||||
(
|
||||
f"{s.starts_at:%H:%M}–{s.ends_at:%H:%M}",
|
||||
ctx.positions[s.position_id].name,
|
||||
ctx.referents.get(s.position_id) or t("email.planning.table.no_referent"),
|
||||
)
|
||||
)
|
||||
return [(_day_label(day), rows) for day, rows in sorted(by_day.items())]
|
||||
|
||||
|
||||
def _shifts_text(sections: list[tuple[str, list[tuple[str, str, str]]]]) -> str:
|
||||
"""Version texte du tableau (clients mail sans HTML) : le jour, puis
|
||||
une ligne par créneau."""
|
||||
if not sections:
|
||||
return t("email.planning.no_shifts")
|
||||
return "\n\n".join(
|
||||
day
|
||||
+ "\n"
|
||||
+ "\n".join(
|
||||
t("email.planning.table.text_line", time=time, position=position, referent=referent)
|
||||
for time, position, referent in rows
|
||||
)
|
||||
for day, rows in sections
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlanningEmail:
|
||||
subject: str
|
||||
body: str
|
||||
# Corps de la version HTML : `{creneaux}` y devient un vrai tableau.
|
||||
html_body: str
|
||||
html_blocks: dict[str, str]
|
||||
|
||||
|
||||
def _planning_email(
|
||||
*,
|
||||
ctx: _PlanningContext,
|
||||
subject_template: str,
|
||||
body_template: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
shifts: list[Shift],
|
||||
login_link: str,
|
||||
) -> _PlanningEmail:
|
||||
"""Récapitulatif de planning d'un bénévole — partagé par l'envoi réel
|
||||
et l'email de test. `{creneaux}` : tableau par jour (horaire, poste,
|
||||
référent avec son téléphone), en HTML et en texte."""
|
||||
minutes = sum((s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts)
|
||||
sections = _shift_sections(shifts, ctx)
|
||||
|
||||
def render(shifts_text: str) -> tuple[str, str]:
|
||||
return render_planning(
|
||||
subject_template=subject_template,
|
||||
body_template=body_template,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
shift_count=len(shifts),
|
||||
hours=round(minutes / 60, 1),
|
||||
shifts_text=shifts_text,
|
||||
login_link=login_link,
|
||||
)
|
||||
|
||||
subject, body = render(_shifts_text(sections))
|
||||
if not sections:
|
||||
return _PlanningEmail(subject, body, body, {})
|
||||
_, html_body = render(_SHIFTS_MARKER)
|
||||
table = render_planning_table(
|
||||
sections,
|
||||
(
|
||||
t("email.planning.table.time"),
|
||||
t("email.planning.table.position"),
|
||||
t("email.planning.table.referent"),
|
||||
),
|
||||
)
|
||||
return _PlanningEmail(subject, body, html_body, {_SHIFTS_MARKER: table})
|
||||
|
||||
|
||||
@router.post("/test", response_model=TestEmailResult)
|
||||
def send_test_email(
|
||||
payload: TestEmailRequest,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> TestEmailResult:
|
||||
"""Envoie tout de suite à l'adresse saisie le récapitulatif de planning
|
||||
**tel que les bénévoles le recevront** : le modèle en cours d'édition
|
||||
(`subject`/`body`), variables remplies avec les données d'un vrai
|
||||
bénévole, même mise en forme et même logo — sujet préfixé « [Test] ».
|
||||
Renvoie le résultat réel du serveur SMTP, pour vérifier à la fois le
|
||||
contenu et la configuration d'envoi depuis l'écran « Messages ».
|
||||
`{lien_connexion}` y est remplacé par l'adresse de la page de connexion,
|
||||
**sans jeton** : un vrai lien magique ouvrirait le compte du bénévole à
|
||||
quiconque lit la boîte de test."""
|
||||
email = payload.email.strip()
|
||||
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
|
||||
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
|
||||
|
||||
ctx = _load_context(session, edition)
|
||||
shifts_by_volunteer = ctx.shifts_by_volunteer
|
||||
volunteers = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
example = next((v for v in volunteers if v.id == payload.volunteer_id), None) or next(
|
||||
(v for v in volunteers if shifts_by_volunteer.get(v.id)),
|
||||
volunteers[0] if volunteers else None,
|
||||
)
|
||||
composed = _planning_email(
|
||||
ctx=ctx,
|
||||
subject_template=payload.subject or t("email.planning.default_subject"),
|
||||
body_template=payload.body or t("email.planning.default_body"),
|
||||
first_name=example.first_name if example else t("email.test.example_first_name"),
|
||||
last_name=example.last_name if example else t("email.test.example_last_name"),
|
||||
shifts=shifts_by_volunteer.get(example.id, []) if example else [],
|
||||
login_link=_login_page_url(),
|
||||
)
|
||||
message = build_message(
|
||||
email,
|
||||
t("email.test.subject_prefix", subject=composed.subject),
|
||||
composed.body,
|
||||
logo=edition_logo(session, edition.id),
|
||||
html_body=composed.html_body,
|
||||
html_blocks=composed.html_blocks,
|
||||
)
|
||||
status, detail = deliver_test(message)
|
||||
settings = get_settings()
|
||||
return TestEmailResult(
|
||||
status=status,
|
||||
detail=detail,
|
||||
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
|
||||
smtp_from=settings.smtp_from,
|
||||
example_volunteer_id=example.id if example else None,
|
||||
example_volunteer_name=(
|
||||
f"{example.first_name} {example.last_name}".strip() if example else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/planning", response_model=PlanningNotificationResult)
|
||||
@@ -60,70 +299,51 @@ def send_planning_notification(
|
||||
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
|
||||
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
|
||||
|
||||
positions = {
|
||||
p.id: p
|
||||
for p in session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id)
|
||||
)
|
||||
}
|
||||
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
|
||||
for volunteer_id, shift in session.exec(
|
||||
select(Assignment.volunteer_id, Shift)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.where(Assignment.edition_id == edition.id)
|
||||
):
|
||||
shifts_by_volunteer[volunteer_id].append(shift)
|
||||
ctx = _load_context(session, edition)
|
||||
logo = edition_logo(session, edition.id)
|
||||
|
||||
items: list[tuple[str, str, str]] = []
|
||||
messages: list[EmailMessage] = []
|
||||
recipients: list[uuid.UUID] = []
|
||||
skipped_no_email = 0
|
||||
for volunteer in volunteers:
|
||||
if not volunteer.email:
|
||||
skipped_no_email += 1
|
||||
continue
|
||||
shifts = sorted(
|
||||
shifts_by_volunteer.get(volunteer.id, []),
|
||||
key=lambda s: s.starts_at,
|
||||
)
|
||||
minutes = sum(
|
||||
(s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts
|
||||
)
|
||||
shifts_text = "\n".join(
|
||||
t(
|
||||
"email.planning.shift_line",
|
||||
position=positions[s.position_id].name,
|
||||
start=s.starts_at.strftime("%d/%m %H:%M"),
|
||||
end=s.ends_at.strftime("%d/%m %H:%M"),
|
||||
)
|
||||
for s in shifts
|
||||
) or t("email.planning.no_shifts")
|
||||
subject, body = render_planning(
|
||||
composed = _planning_email(
|
||||
ctx=ctx,
|
||||
subject_template=subject_template,
|
||||
body_template=body_template,
|
||||
first_name=volunteer.first_name,
|
||||
last_name=volunteer.last_name,
|
||||
shift_count=len(shifts),
|
||||
hours=round(minutes / 60, 1),
|
||||
shifts_text=shifts_text,
|
||||
shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
|
||||
login_link=(
|
||||
_login_link(session, edition, volunteer)
|
||||
if wants_login_link
|
||||
else ""
|
||||
),
|
||||
)
|
||||
items.append((volunteer.email, subject, body))
|
||||
messages.append(
|
||||
build_message(
|
||||
volunteer.email,
|
||||
composed.subject,
|
||||
composed.body,
|
||||
logo=logo,
|
||||
html_body=composed.html_body,
|
||||
html_blocks=composed.html_blocks,
|
||||
)
|
||||
)
|
||||
recipients.append(volunteer.id)
|
||||
|
||||
session.commit() # persiste les éventuels MagicLinkToken émis
|
||||
session.commit() # persiste les comptes créés pour les liens de connexion
|
||||
logger.info(
|
||||
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
|
||||
edition.id,
|
||||
len(items),
|
||||
len(messages),
|
||||
skipped_no_email,
|
||||
)
|
||||
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
|
||||
background.add_task(send_messages, messages)
|
||||
return PlanningNotificationResult(
|
||||
queued=len(items),
|
||||
queued=len(messages),
|
||||
skipped_no_email=skipped_no_email,
|
||||
recipients=recipients,
|
||||
)
|
||||
|
||||
@@ -17,10 +17,12 @@ from app.api.deps import get_edition
|
||||
from app.api.schemas import PositionSheetsRequest
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.db import get_session
|
||||
from app.i18n import t
|
||||
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
|
||||
from app.sheets.pdf import (
|
||||
PositionDaySheet,
|
||||
SheetArtist,
|
||||
SheetFooter,
|
||||
SheetShift,
|
||||
SheetVolunteer,
|
||||
generate_position_sheets_pdf,
|
||||
@@ -151,7 +153,14 @@ def generate_position_sheets(
|
||||
status_code=422, detail={"code": "position_sheets.no_shifts"}
|
||||
)
|
||||
|
||||
pdf_bytes = generate_position_sheets_pdf(sheets)
|
||||
# Message de l'organisation (le même que sur l'écran bénévole) en bas de
|
||||
# chaque page.
|
||||
footer = (
|
||||
SheetFooter(title=t("position_sheets.footer_title"), message=edition.volunteer_message)
|
||||
if edition.volunteer_message
|
||||
else None
|
||||
)
|
||||
pdf_bytes = generate_position_sheets_pdf(sheets, footer)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
||||
@@ -10,7 +10,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
from app.api._common import owned_or_404, set_message
|
||||
from app.api._shiftgen import generate_missing_shifts
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
@@ -103,6 +103,8 @@ def update_position(
|
||||
ensure_can_write_position(account, position)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
_check_lead_account(session, edition, changes.get("lead_account_id"))
|
||||
if "message" in changes:
|
||||
set_message(position, "message", changes.pop("message"))
|
||||
for field, value in changes.items():
|
||||
setattr(position, field, value)
|
||||
session.commit()
|
||||
|
||||
+104
-4
@@ -7,8 +7,11 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
@@ -33,23 +36,34 @@ class EditionRead(ApiModel):
|
||||
start_date: dt.date
|
||||
end_date: dt.date
|
||||
retention_days: int | None
|
||||
volunteer_message: str | None
|
||||
volunteer_message_updated_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
|
||||
# document.
|
||||
MESSAGE_MAX_LENGTH = 2000
|
||||
|
||||
|
||||
class EditionUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
year: int | None = None
|
||||
start_date: dt.date | None = None
|
||||
end_date: dt.date | None = None
|
||||
retention_days: int | None = None
|
||||
# Vide ou blanc = plus de message.
|
||||
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Volunteer #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VolunteerCreate(ApiModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||||
# tard) ; le formulaire public et l'import les exigent toujours.
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
@@ -76,6 +90,9 @@ class VolunteerUpdate(ApiModel):
|
||||
companion_wish_note: str | None = None
|
||||
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
||||
badge_text: str | None = None
|
||||
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
|
||||
# faute de frappe qui imprimerait des centaines de pages).
|
||||
badge_count: int | None = Field(default=None, ge=1, le=100)
|
||||
self_declared_available: bool | None = None
|
||||
willing_to_pay_fee: bool | None = None
|
||||
|
||||
@@ -94,6 +111,7 @@ class VolunteerRead(ApiModel):
|
||||
has_photo: bool
|
||||
companion_wish_note: str | None
|
||||
badge_text: str | None
|
||||
badge_count: int
|
||||
self_declared_available: bool
|
||||
willing_to_pay_fee: bool
|
||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
||||
@@ -135,6 +153,9 @@ class VolunteerPlanningEntry(ApiModel):
|
||||
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
|
||||
referent_name: str | None
|
||||
referent_phone: str | None
|
||||
# Message du poste (voir `Position.message`).
|
||||
position_message: str | None
|
||||
position_message_updated_at: dt.datetime | None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -181,6 +202,8 @@ class PositionUpdate(ApiModel):
|
||||
location: str | None = None
|
||||
category: PositionCategory | None = None
|
||||
lead_account_id: uuid.UUID | None = None
|
||||
# Vide ou blanc = plus de message.
|
||||
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||||
|
||||
|
||||
class PositionRead(ApiModel):
|
||||
@@ -190,6 +213,8 @@ class PositionRead(ApiModel):
|
||||
location: str | None
|
||||
category: PositionCategory
|
||||
lead_account_id: uuid.UUID | None
|
||||
message: str | None
|
||||
message_updated_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -283,10 +308,17 @@ class AssignmentRead(ApiModel):
|
||||
shift_id: uuid.UUID
|
||||
source: AssignmentSource
|
||||
locked: bool
|
||||
attendance: AttendanceStatus | None
|
||||
attendance_marked_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class AttendanceUpdate(ApiModel):
|
||||
# `None` = annuler le pointage.
|
||||
attendance: AttendanceStatus | None
|
||||
|
||||
|
||||
class AssignmentWriteResult(ApiModel):
|
||||
assignment: AssignmentRead
|
||||
warnings: list[Violation] = Field(default_factory=list)
|
||||
@@ -346,6 +378,7 @@ class MatrixResponse(ApiModel):
|
||||
# Badges (planches PDF) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BadgeFieldIn(ApiModel):
|
||||
face: BadgeFace = BadgeFace.FRONT
|
||||
field_type: BadgeFieldType
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
@@ -357,11 +390,19 @@ class BadgeFieldIn(ApiModel):
|
||||
color: str = "#000000"
|
||||
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||||
text: str | None = None
|
||||
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
|
||||
bg_color: str | None = None
|
||||
auto_fit: bool = False
|
||||
# `PHOTO` seulement, ignoré sinon.
|
||||
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
||||
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
|
||||
z_order: int = 0
|
||||
|
||||
|
||||
class BadgeFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs du recto — au plus un par
|
||||
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
|
||||
"""Remplace l'intégralité des champs du recto et du verso — au plus un
|
||||
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
|
||||
ligne)."""
|
||||
|
||||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||||
|
||||
@@ -475,6 +516,33 @@ class PlanningNotificationResult(ApiModel):
|
||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TestEmailRequest(ApiModel):
|
||||
email: str
|
||||
# Modèle du récapitulatif de planning en cours d'édition (mêmes
|
||||
# variables que `PlanningNotificationRequest`) ; absent = modèle par
|
||||
# défaut.
|
||||
subject: str | None = None
|
||||
body: str | None = None
|
||||
# Bénévole dont les données remplissent les variables ; absent = le
|
||||
# premier (par nom) qui a au moins un créneau.
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TestEmailResult(ApiModel):
|
||||
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
|
||||
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
|
||||
SMTP en cas d'échec."""
|
||||
|
||||
status: str
|
||||
detail: str | None = None
|
||||
smtp_host: str
|
||||
smtp_from: str
|
||||
# Bénévole dont les données ont rempli le modèle (`None` : exemple
|
||||
# fictif, l'édition n'a encore aucun bénévole).
|
||||
example_volunteer_id: uuid.UUID | None = None
|
||||
example_volunteer_name: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -727,9 +795,41 @@ class StatsNeeds(ApiModel):
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
class StatsAttendanceCounts(ApiModel):
|
||||
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
|
||||
`assigned` = `present` + `absent` + `unmarked`."""
|
||||
|
||||
assigned: int = 0
|
||||
present: int = 0
|
||||
absent: int = 0
|
||||
unmarked: int = 0
|
||||
|
||||
|
||||
class StatsAttendancePosition(ApiModel):
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
|
||||
|
||||
class StatsAttendance(ApiModel):
|
||||
"""Présents / absents pointés par les référents, par poste : sur les
|
||||
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
|
||||
locale). Les créneaux à venir ne comptent pas."""
|
||||
|
||||
as_of: dt.datetime
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
by_position: list[StatsAttendancePosition]
|
||||
|
||||
|
||||
class EditionStats(ApiModel):
|
||||
registrations: StatsRegistrations
|
||||
answers: StatsAnswers
|
||||
payments: StatsPayments
|
||||
artist_wishes: list[StatsArtistWish]
|
||||
needs: StatsNeeds
|
||||
attendance: StatsAttendance
|
||||
|
||||
@@ -16,6 +16,9 @@ from app.api.schemas import (
|
||||
EditionStats,
|
||||
StatsAnswers,
|
||||
StatsArtistWish,
|
||||
StatsAttendance,
|
||||
StatsAttendanceCounts,
|
||||
StatsAttendancePosition,
|
||||
StatsNeedDay,
|
||||
StatsNeedPosition,
|
||||
StatsNeeds,
|
||||
@@ -30,6 +33,7 @@ from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
AttendanceStatus,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
@@ -108,6 +112,59 @@ def _payments(
|
||||
)
|
||||
|
||||
|
||||
def _count(counts: StatsAttendanceCounts, assignment: Assignment) -> None:
|
||||
counts.assigned += 1
|
||||
if assignment.attendance == AttendanceStatus.PRESENT:
|
||||
counts.present += 1
|
||||
elif assignment.attendance == AttendanceStatus.ABSENT:
|
||||
counts.absent += 1
|
||||
else:
|
||||
counts.unmarked += 1
|
||||
|
||||
|
||||
def _attendance(
|
||||
positions: list[Position], shifts: list[Shift], assignments: list[Assignment]
|
||||
) -> StatsAttendance:
|
||||
"""Pointage par poste, sur les créneaux terminés et ceux en cours."""
|
||||
now = local_now()
|
||||
past = StatsAttendanceCounts()
|
||||
live = StatsAttendanceCounts()
|
||||
by_position = {
|
||||
p.id: StatsAttendancePosition(
|
||||
position_id=p.id,
|
||||
name=p.name,
|
||||
past=StatsAttendanceCounts(),
|
||||
live=StatsAttendanceCounts(),
|
||||
live_shifts=0,
|
||||
)
|
||||
for p in positions
|
||||
}
|
||||
# Créneau terminé ⇒ « past », en cours ⇒ « live », à venir : ignoré.
|
||||
phase: dict = {}
|
||||
for s in shifts:
|
||||
if s.ends_at <= now:
|
||||
phase[s.id] = ("past", s.position_id)
|
||||
elif s.starts_at <= now:
|
||||
phase[s.id] = ("live", s.position_id)
|
||||
by_position[s.position_id].live_shifts += 1
|
||||
for a in assignments:
|
||||
if a.shift_id not in phase:
|
||||
continue
|
||||
when, position_id = phase[a.shift_id]
|
||||
row = by_position[position_id]
|
||||
_count(past if when == "past" else live, a)
|
||||
_count(row.past if when == "past" else row.live, a)
|
||||
return StatsAttendance(
|
||||
as_of=now,
|
||||
past=past,
|
||||
live=live,
|
||||
live_shifts=sum(r.live_shifts for r in by_position.values()),
|
||||
by_position=[
|
||||
r for r in by_position.values() if r.past.assigned or r.live.assigned or r.live_shifts
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=EditionStats)
|
||||
def get_stats(
|
||||
edition: Edition = Depends(get_edition),
|
||||
@@ -256,4 +313,5 @@ def get_stats(
|
||||
payments=_payments(volunteers, pending_unfinished),
|
||||
artist_wishes=artist_stats,
|
||||
needs=needs,
|
||||
attendance=_attendance(positions, shifts, assignments),
|
||||
)
|
||||
|
||||
@@ -317,6 +317,8 @@ def get_volunteer_planning(
|
||||
position_location=p.location,
|
||||
referent_name=f"{r.first_name} {r.last_name}" if r else None,
|
||||
referent_phone=r.phone if r else None,
|
||||
position_message=p.message,
|
||||
position_message_updated_at=p.message_updated_at,
|
||||
)
|
||||
for a, s, p, r in rows
|
||||
]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Lien de connexion permanent — celui du récapitulatif de planning
|
||||
(`{lien_connexion}`, `app/api/notifications.py`).
|
||||
|
||||
Contrairement au lien magique de la page de connexion (`magic_link.py` :
|
||||
aléatoire, haché en base, 30 min, usage unique), ce lien se réutilise sans
|
||||
limite de durée : le bénévole retrouve son planning en un clic depuis
|
||||
n'importe quel email reçu. Il n'est stocké nulle part : c'est une signature
|
||||
HMAC (clé `JWT_SECRET`) de l'identifiant du compte et de sa
|
||||
`login_link_version`, donc le même lien figure dans tous les emails d'un
|
||||
bénévole. Révocation : incrémenter la version (`POST .../accounts/{id}/
|
||||
reset-login-link`) invalide tous les liens déjà envoyés à ce compte ;
|
||||
changer `JWT_SECRET` les invalide tous. Bénévoles et référents seulement,
|
||||
jamais la coordination ni un compte à mot de passe."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Account, Role
|
||||
|
||||
# Jamais la coordination, même sans mot de passe : son accès complet ne
|
||||
# doit pas tenir à un lien qui circule par email.
|
||||
_ALLOWED_ROLES = (Role.VOLUNTEER, Role.LEAD)
|
||||
|
||||
PREFIX = "pl."
|
||||
|
||||
|
||||
def _signature(account_id: uuid.UUID, version: int) -> str:
|
||||
digest = hmac.new(
|
||||
get_settings().jwt_secret.encode("utf-8"),
|
||||
f"permanent-login:{account_id}:{version}".encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def permanent_login_token(account: Account) -> str:
|
||||
"""Jeton en clair du lien permanent d'un compte (toujours le même tant
|
||||
que sa version ne change pas)."""
|
||||
return f"{PREFIX}{account.id.hex}.{_signature(account.id, account.login_link_version)}"
|
||||
|
||||
|
||||
def account_from_permanent_token(session: Session, token: str) -> Account | None:
|
||||
"""Compte désigné par un jeton permanent valide, `None` sinon (format,
|
||||
compte inconnu, signature ou version périmée, compte à mot de passe ou
|
||||
de la coordination)."""
|
||||
if not token.startswith(PREFIX):
|
||||
return None
|
||||
raw_id, _, signature = token[len(PREFIX) :].partition(".")
|
||||
try:
|
||||
account_id = uuid.UUID(hex=raw_id)
|
||||
except ValueError:
|
||||
return None
|
||||
account = session.get(Account, account_id)
|
||||
if account is None or account.password_hash is not None or account.role not in _ALLOWED_ROLES:
|
||||
return None
|
||||
expected = _signature(account.id, account.login_link_version)
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
return None
|
||||
return account
|
||||
+257
-77
@@ -8,11 +8,12 @@ Format du badge : dérivé de l'image de fond du recto (pas de format fixe
|
||||
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
|
||||
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
|
||||
taille standard des badges/cartes d'événement) servent de repère à
|
||||
l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique
|
||||
pour tous les bénévoles : la planche imprime toutes les pages de recto
|
||||
(personnalisées) puis, pour chacune, la page de verso correspondante (même
|
||||
disposition, même image répétée à chaque emplacement rempli) — impression
|
||||
recto-verso classique, page de verso n directement après la page de recto n.
|
||||
l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
|
||||
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
|
||||
plus les champs du verso de chaque bénévole) — impression recto-verso
|
||||
classique, retournement sur le bord long : sur la page de verso, chaque
|
||||
badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
|
||||
pour tomber exactement derrière son recto — voir `_cell_origin`.
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -28,6 +29,9 @@ from reportlab.pdfgen import canvas
|
||||
|
||||
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||
DEFAULT_BADGE_HEIGHT_MM = 54.0
|
||||
# Minimums pour compter combien de badges tiennent sur la page (marge
|
||||
# d'impression, écart entre deux badges) ; l'espace effectivement laissé est
|
||||
# ensuite réparti à parts égales — voir `_spacing`.
|
||||
_MARGIN_MM = 10.0
|
||||
_GUTTER_MM = 4.0
|
||||
_LINE_HEIGHT_FACTOR = 1.25
|
||||
@@ -36,6 +40,11 @@ _LINE_HEIGHT_FACTOR = 1.25
|
||||
_CELL_PAD_X_MM = 0.8
|
||||
_CELL_PAD_Y_MM = 0.5
|
||||
_TABLE_LINE_WIDTH_PT = 0.4
|
||||
# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
|
||||
_AUTO_FIT_STEP_PT = 0.5
|
||||
_AUTO_FIT_MIN_PT = 4.0
|
||||
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
|
||||
# la largeur du cadre).
|
||||
|
||||
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
|
||||
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
|
||||
@@ -73,14 +82,20 @@ class BadgeTable:
|
||||
@dataclass(frozen=True)
|
||||
class BadgeFieldSpec:
|
||||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
||||
possibles — ex. un poste par ligne avec son horaire), positionné depuis
|
||||
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).
|
||||
possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
|
||||
du badge (comme dans l'éditeur visuel du front-end).
|
||||
|
||||
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
|
||||
bruts tels que stockés) plutôt qu'un texte — `text` et le style sont
|
||||
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.
|
||||
bruts tels que stockés) plutôt qu'un texte, découpée selon
|
||||
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
|
||||
n'est imprimé : le cadre reste vide.
|
||||
|
||||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
|
||||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
|
||||
|
||||
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
|
||||
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
|
||||
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
|
||||
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
|
||||
|
||||
text: str
|
||||
x_mm: float
|
||||
@@ -94,13 +109,19 @@ class BadgeFieldSpec:
|
||||
is_photo: bool = False
|
||||
image: bytes | None = None
|
||||
table: BadgeTable | None = None
|
||||
bg_color: str | None = None
|
||||
auto_fit: bool = False
|
||||
valign: str = "TOP" # "TOP" | "MIDDLE"
|
||||
photo_shape: str = "RECT" # "RECT" | "OVAL"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeSpec:
|
||||
"""Un badge = un bénévole, avec ses champs déjà résolus."""
|
||||
"""Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
|
||||
le recto, `back_fields` pour le verso."""
|
||||
|
||||
fields: tuple[BadgeFieldSpec, ...]
|
||||
back_fields: tuple[BadgeFieldSpec, ...] = ()
|
||||
|
||||
|
||||
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
|
||||
@@ -111,14 +132,45 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
|
||||
return cols, rows
|
||||
|
||||
|
||||
def _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]:
|
||||
"""`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace
|
||||
libre est réparti **à parts égales** — même écart à gauche, entre chaque
|
||||
badge et à droite (resp. en haut, entre les rangées et en bas) — pour une
|
||||
grille centrée et symétrique. Si cet écart passait sous la marge
|
||||
d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le
|
||||
reste réparti entre les badges : toujours symétrique. Avant, la grille
|
||||
partait du coin haut-gauche et tout l'espace restant tombait à droite et
|
||||
en bas ; une fois la feuille retournée, le verso se décalait d'autant par
|
||||
rapport au recto (bug corrigé une fois)."""
|
||||
free = page_mm - count * size_mm
|
||||
even = free / (count + 1)
|
||||
if even >= _MARGIN_MM or count == 1:
|
||||
return (even, even) if count > 1 else (free / 2, 0.0)
|
||||
return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1)
|
||||
|
||||
|
||||
def _cell_origin(
|
||||
index: int, cols: int, badge_width_mm: float, badge_height_mm: float
|
||||
index: int,
|
||||
cols: int,
|
||||
badge_width_mm: float,
|
||||
badge_height_mm: float,
|
||||
mirrored: bool = False,
|
||||
) -> tuple[float, float]:
|
||||
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
|
||||
en mm depuis le coin bas-gauche de la page."""
|
||||
en mm depuis le coin bas-gauche de la page.
|
||||
|
||||
`mirrored` : page de verso. Une feuille retournée sur son bord long
|
||||
inverse gauche et droite : le verso du badge de la colonne `col` doit
|
||||
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
|
||||
derrière son recto — y compris sur une dernière planche incomplète."""
|
||||
col, row = index % cols, index // cols
|
||||
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
|
||||
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
|
||||
if mirrored:
|
||||
col = cols - 1 - col
|
||||
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
|
||||
outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm)
|
||||
outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm)
|
||||
x0 = outer_x + col * (badge_width_mm + gap_x)
|
||||
y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y
|
||||
return x0, y0
|
||||
|
||||
|
||||
@@ -161,6 +213,9 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
||||
)
|
||||
try:
|
||||
with Image.open(io.BytesIO(image)) as img:
|
||||
# JPEG : décodage directement à taille réduite (bien plus rapide
|
||||
# qu'une photo de téléphone décodée en pleine résolution).
|
||||
img.draft("RGB", (target[0] * 2, target[1] * 2))
|
||||
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
return None
|
||||
@@ -169,23 +224,72 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _frame_rect(
|
||||
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
|
||||
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
|
||||
reportlab dessine depuis le BAS de la page."""
|
||||
return (
|
||||
(x0_mm + f.x_mm) * mm,
|
||||
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
||||
f.width_mm * mm,
|
||||
f.height_mm * mm,
|
||||
)
|
||||
|
||||
|
||||
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
|
||||
path = c.beginPath()
|
||||
if shape == "OVAL":
|
||||
path.ellipse(x, y, w, h)
|
||||
else:
|
||||
path.rect(x, y, w, h)
|
||||
return path
|
||||
|
||||
|
||||
# Photos déjà recadrées pendant une génération, par (photo, largeur,
|
||||
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
|
||||
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
|
||||
# fois.
|
||||
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
|
||||
|
||||
|
||||
def _draw_photo(
|
||||
c: canvas.Canvas,
|
||||
f: BadgeFieldSpec,
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_mm: float,
|
||||
cache: PhotoCache,
|
||||
) -> None:
|
||||
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
|
||||
photo = None
|
||||
if f.image:
|
||||
key = (f.image, f.width_mm, f.height_mm)
|
||||
if key not in cache:
|
||||
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
|
||||
photo = cache[key]
|
||||
if photo is None:
|
||||
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
|
||||
# coordination — avant, les initiales étaient imprimées à la place.
|
||||
return
|
||||
c.saveState()
|
||||
if f.photo_shape == "OVAL":
|
||||
# Découpe en ellipse inscrite dans le cadre.
|
||||
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
|
||||
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def _draw_zone_background(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
if not f.image:
|
||||
if not f.bg_color:
|
||||
return
|
||||
photo = _fit_photo(f.image, f.width_mm, f.height_mm)
|
||||
if photo is None:
|
||||
return
|
||||
c.drawImage(
|
||||
ImageReader(io.BytesIO(photo)),
|
||||
(x0_mm + f.x_mm) * mm,
|
||||
# `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab
|
||||
# place l'image par son coin bas-gauche, depuis le BAS de la page.
|
||||
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
||||
width=f.width_mm * mm,
|
||||
height=f.height_mm * mm,
|
||||
)
|
||||
c.saveState()
|
||||
c.setFillColor(HexColor(f.bg_color))
|
||||
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
|
||||
@@ -242,6 +346,45 @@ def _column_widths(natural: list[float], available: float) -> list[float]:
|
||||
return widths
|
||||
|
||||
|
||||
_TableLine = tuple[list[list[str]], str, bool, float]
|
||||
|
||||
|
||||
def _table_layout(
|
||||
table: BadgeTable, width_pt: float, font_size: float
|
||||
) -> tuple[list[float], list[_TableLine]]:
|
||||
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
|
||||
chaque ligne, (texte replié de chaque cellule, police, en-tête de
|
||||
section ?, hauteur en points)."""
|
||||
regular, bold = "Helvetica", "Helvetica-Bold"
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||
body = [row for section in table.sections for row in section.rows]
|
||||
columns = max(len(row) for row in body) if body else 1
|
||||
natural = [
|
||||
max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x
|
||||
for col in range(columns)
|
||||
] if body else [0.0]
|
||||
widths = _column_widths(natural, width_pt)
|
||||
total_width = sum(widths)
|
||||
|
||||
# Un en-tête n'a qu'une cellule, sur toute la largeur.
|
||||
cells_to_draw = []
|
||||
for section in table.sections:
|
||||
cells_to_draw.append(((section.title,), bold, True))
|
||||
cells_to_draw.extend((row, regular, False) for row in section.rows)
|
||||
|
||||
lines: list[_TableLine] = []
|
||||
for cells, font, is_title in cells_to_draw:
|
||||
cell_widths = [total_width] if is_title else widths
|
||||
wrapped = [
|
||||
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
||||
for col, text in enumerate(cells)
|
||||
]
|
||||
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
|
||||
lines.append((wrapped, font, is_title, row_height))
|
||||
return widths, lines
|
||||
|
||||
|
||||
def _draw_table(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
@@ -250,52 +393,43 @@ def _draw_table(
|
||||
filets à la couleur du champ, texte de chaque cellule replié dans sa
|
||||
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
|
||||
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
|
||||
texte."""
|
||||
texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
|
||||
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
|
||||
table = f.table
|
||||
if table is None or not table.sections:
|
||||
return
|
||||
regular, bold = "Helvetica", "Helvetica-Bold"
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
line_height = f.font_size * _LINE_HEIGHT_FACTOR
|
||||
body = [row for section in table.sections for row in section.rows]
|
||||
columns = max(len(row) for row in body) if body else 1
|
||||
natural = [
|
||||
max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x
|
||||
for col in range(columns)
|
||||
] if body else [0.0]
|
||||
widths = _column_widths(natural, f.width_mm * mm)
|
||||
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
|
||||
font_size = f.font_size
|
||||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||
if f.auto_fit:
|
||||
while (
|
||||
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
|
||||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||
):
|
||||
font_size -= _AUTO_FIT_STEP_PT
|
||||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||
total_width = sum(widths)
|
||||
|
||||
# (cellules, police, en-tête de section ?) — un en-tête n'a qu'une
|
||||
# cellule, sur toute la largeur.
|
||||
lines_to_draw = []
|
||||
for section in table.sections:
|
||||
lines_to_draw.append(((section.title,), bold, True))
|
||||
lines_to_draw.extend((row, regular, False) for row in section.rows)
|
||||
|
||||
left = (x0_mm + f.x_mm) * mm
|
||||
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
||||
bottom_limit = top - f.height_mm * mm
|
||||
bottom_limit = top - height_pt
|
||||
color = HexColor(f.color)
|
||||
c.setFillColor(color)
|
||||
c.setStrokeColor(color)
|
||||
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
||||
|
||||
y = top
|
||||
for cells, font, is_title in lines_to_draw:
|
||||
for wrapped, font, is_title, row_height in lines_to_draw:
|
||||
cell_widths = [total_width] if is_title else widths
|
||||
wrapped = [
|
||||
wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
||||
for col, text in enumerate(cells)
|
||||
]
|
||||
row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y
|
||||
if y - row_height < bottom_limit - 0.01:
|
||||
break # la ligne déborderait du cadre du champ
|
||||
c.setFont(font, f.font_size)
|
||||
c.setFont(font, font_size)
|
||||
x = left
|
||||
for col, lines in enumerate(wrapped):
|
||||
for i, line in enumerate(lines):
|
||||
baseline = y - pad_y - f.font_size * 0.85 - i * line_height
|
||||
baseline = y - pad_y - font_size * 0.85 - i * line_height
|
||||
c.drawString(x + pad_x, baseline, line)
|
||||
x += cell_widths[col]
|
||||
if not is_title:
|
||||
@@ -312,38 +446,63 @@ def _draw_table(
|
||||
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
||||
|
||||
|
||||
def _draw_fields(
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_mm: float,
|
||||
def _lines_that_fit(font_size: float, height_mm: float) -> int:
|
||||
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
|
||||
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
|
||||
interligne par ligne)."""
|
||||
font_size_mm = font_size / mm
|
||||
first = font_size_mm * 0.85
|
||||
if first > height_mm + 1e-9:
|
||||
return 0
|
||||
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
|
||||
|
||||
|
||||
def fit_text(
|
||||
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
|
||||
auto_fit: bool,
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
|
||||
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
|
||||
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
|
||||
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
|
||||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||
if auto_fit:
|
||||
while (
|
||||
len(lines) > _lines_that_fit(font_size, height_mm)
|
||||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||
):
|
||||
font_size -= _AUTO_FIT_STEP_PT
|
||||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
|
||||
|
||||
|
||||
def _draw_text(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
for f in fields:
|
||||
if f.is_photo:
|
||||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
continue
|
||||
if f.table is not None:
|
||||
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
continue
|
||||
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
||||
c.setFont(font_name, f.font_size)
|
||||
font_size, lines = fit_text(
|
||||
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
|
||||
)
|
||||
if not lines:
|
||||
return
|
||||
c.setFont(font_name, font_size)
|
||||
c.setFillColor(HexColor(f.color))
|
||||
left_x = x0_mm + f.x_mm
|
||||
# `f.font_size` est en points (unité reportlab) ; le reste du calcul
|
||||
# `font_size` est en points (unité reportlab) ; le reste du calcul
|
||||
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
|
||||
# ici le facteur de conversion pt/mm de reportlab).
|
||||
font_size_mm = f.font_size / mm
|
||||
font_size_mm = font_size / mm
|
||||
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
|
||||
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
|
||||
# dessine depuis le BAS de la page.
|
||||
top_y = y0_mm + badge_height_mm - f.y_mm
|
||||
bottom_limit = top_y - f.height_mm
|
||||
lines = wrap_text(f.text, font_name, f.font_size, f.width_mm * mm)
|
||||
if f.valign == "MIDDLE":
|
||||
# Hauteur du bloc : interlignes entre les lignes + une hauteur de
|
||||
# caractère pour la dernière.
|
||||
block = (len(lines) - 1) * line_height_mm + font_size_mm
|
||||
top_y -= max(0.0, (f.height_mm - block) / 2)
|
||||
for i, line in enumerate(lines):
|
||||
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
||||
if baseline_y < bottom_limit:
|
||||
break # la ligne déborderait de la boîte du champ
|
||||
if f.align == "CENTER":
|
||||
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
||||
elif f.align == "RIGHT":
|
||||
@@ -352,6 +511,25 @@ def _draw_fields(
|
||||
c.drawString(left_x * mm, baseline_y * mm, line)
|
||||
|
||||
|
||||
def _draw_fields(
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_mm: float,
|
||||
photos: PhotoCache,
|
||||
) -> None:
|
||||
for f in fields:
|
||||
if f.is_photo:
|
||||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
|
||||
continue
|
||||
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
if f.table is not None:
|
||||
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
else:
|
||||
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
|
||||
|
||||
def generate_badge_sheets_pdf(
|
||||
badges: list[BadgeSpec],
|
||||
front_image: bytes | None,
|
||||
@@ -367,16 +545,18 @@ def generate_badge_sheets_pdf(
|
||||
|
||||
buffer = io.BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
photos: PhotoCache = {}
|
||||
for page_badges in pages:
|
||||
for i, badge in enumerate(page_badges):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
|
||||
_draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
|
||||
c.showPage()
|
||||
|
||||
for i in range(len(page_badges)):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||
for i, badge in enumerate(page_badges):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
|
||||
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
+63
-10
@@ -9,6 +9,8 @@ que dans le texte déjà journalisable). Pas de CSS externe ni de police web
|
||||
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
|
||||
|
||||
import html as _html
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
_INK = "#14110E"
|
||||
_CREAM = "#F5EFE1"
|
||||
@@ -26,21 +28,72 @@ _FONT_MONO = "'Courier New', Courier, monospace"
|
||||
LOGO_CID = "edition-logo"
|
||||
|
||||
|
||||
def _paragraphs(body: str) -> str:
|
||||
_P_OPEN = '<p style="margin:0 0 14px;">'
|
||||
|
||||
|
||||
def _paragraphs(body: str, blocks: Mapping[str, str]) -> str:
|
||||
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
|
||||
ligne — même convention que les modèles texte (`app/email/templates.py`)."""
|
||||
blocks = body.strip().split("\n\n")
|
||||
return "".join(
|
||||
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>'
|
||||
for block in blocks
|
||||
if block.strip()
|
||||
ligne — même convention que les modèles texte (`app/email/templates.py`).
|
||||
`blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
|
||||
inséré tel quel à la place du repère, hors de tout paragraphe."""
|
||||
out = []
|
||||
for block in body.strip().split("\n\n"):
|
||||
if not block.strip():
|
||||
continue
|
||||
html = _P_OPEN + _html.escape(block).replace(chr(10), "<br>") + "</p>"
|
||||
for marker, fragment in blocks.items():
|
||||
html = html.replace(_html.escape(marker), f"</p>{fragment}{_P_OPEN}")
|
||||
# Paragraphes vidés par l'insertion d'un bloc (repère seul, ou
|
||||
# entouré de retours à la ligne).
|
||||
html = re.sub(rf"{re.escape(_P_OPEN)}(?:<br>|\s)*</p>", "", html)
|
||||
out.append(html)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_CELL = f"padding:6px 8px;border:1px solid {_INK};vertical-align:top;"
|
||||
|
||||
|
||||
def render_planning_table(
|
||||
sections: Sequence[tuple[str, Sequence[tuple[str, str, str]]]],
|
||||
headers: tuple[str, str, str],
|
||||
) -> str:
|
||||
"""Tableau des créneaux d'un bénévole, comme sur les badges : le jour
|
||||
en en-tête sur toute la largeur, puis une ligne par créneau (horaire,
|
||||
poste, référent). `sections` = `[(jour, [(horaire, poste, référent)])]`."""
|
||||
head = "".join(
|
||||
f'<th align="left" style="{_CELL}background:{_INK};color:{_CREAM};'
|
||||
f'font-family:{_FONT_DISPLAY};font-size:11px;text-transform:uppercase;">'
|
||||
f"{_html.escape(h)}</th>"
|
||||
for h in headers
|
||||
)
|
||||
rows = [f"<tr>{head}</tr>"]
|
||||
for day, lines in sections:
|
||||
rows.append(
|
||||
f'<tr><td colspan="3" style="{_CELL}background:{_YELLOW};'
|
||||
f'font-family:{_FONT_DISPLAY};font-weight:800;">{_html.escape(day)}</td></tr>'
|
||||
)
|
||||
for time, position, referent in lines:
|
||||
rows.append(
|
||||
f'<tr><td style="{_CELL}white-space:nowrap;font-weight:700;">'
|
||||
f"{_html.escape(time)}</td>"
|
||||
f'<td style="{_CELL}font-family:{_FONT_DISPLAY};font-weight:700;">'
|
||||
f"{_html.escape(position)}</td>"
|
||||
f'<td style="{_CELL}">{_html.escape(referent).replace(chr(10), "<br>")}</td></tr>'
|
||||
)
|
||||
return (
|
||||
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
||||
f'style="border-collapse:collapse;margin:0 0 14px;font-size:12px;'
|
||||
f'font-family:{_FONT_MONO};background:{_PAPER};">' + "".join(rows) + "</table>"
|
||||
)
|
||||
|
||||
|
||||
def render_html(*, subject: str, body: str, has_logo: bool) -> str:
|
||||
def render_html(
|
||||
*, subject: str, body: str, has_logo: bool, blocks: Mapping[str, str] | None = None
|
||||
) -> str:
|
||||
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
|
||||
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
|
||||
simple bandeau texte (le sujet, en capitales)."""
|
||||
simple bandeau texte (le sujet, en capitales). `blocks` : voir
|
||||
`_paragraphs`."""
|
||||
if has_logo:
|
||||
header = (
|
||||
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
|
||||
@@ -72,7 +125,7 @@ def render_html(*, subject: str, body: str, has_logo: bool) -> str:
|
||||
<td style="background:{_PAPER};border:3px solid {_INK};
|
||||
border-top:none;padding:22px;font-size:13px;
|
||||
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
|
||||
{_paragraphs(body)}
|
||||
{_paragraphs(body, blocks or {})}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
@@ -44,8 +44,15 @@ def build_message(
|
||||
*,
|
||||
logo: tuple[bytes, str] | None = None,
|
||||
attachments: Sequence[Attachment] = (),
|
||||
html_body: str | None = None,
|
||||
html_blocks: Mapping[str, str] | None = None,
|
||||
) -> EmailMessage:
|
||||
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
|
||||
"""`html_body` / `html_blocks` : corps de la version HTML quand il
|
||||
diffère du texte — repères remplacés par des blocs HTML déjà construits
|
||||
(ex. tableau des créneaux, `app/email/html.py:render_planning_table`) ;
|
||||
le texte brut garde sa propre version lisible.
|
||||
|
||||
`logo` = (octets, content-type) (voir `app/email/branding.py:
|
||||
edition_logo`) : ajoute une version HTML au thème de l'appli
|
||||
(`app/email/html.py`) en alternative au texte brut — le texte reste la
|
||||
version de référence, envoyée telle quelle si aucun client ne préfère le
|
||||
@@ -61,7 +68,12 @@ def build_message(
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
message.add_alternative(
|
||||
render_html(subject=subject, body=body, has_logo=logo is not None),
|
||||
render_html(
|
||||
subject=subject,
|
||||
body=html_body if html_body is not None else body,
|
||||
has_logo=logo is not None,
|
||||
blocks=html_blocks,
|
||||
),
|
||||
subtype="html",
|
||||
)
|
||||
if logo is not None:
|
||||
@@ -145,6 +157,39 @@ def deliver_many(messages: list[EmailMessage]) -> None:
|
||||
logger.info("Emails remis : %d/%d", sent, len(messages))
|
||||
|
||||
|
||||
def deliver_test(message: EmailMessage) -> tuple[str, str | None]:
|
||||
"""Envoi d'un email de test, **synchrone** et sans « best effort » :
|
||||
renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la
|
||||
coordination voie tout de suite ce qui ne va pas (`POST
|
||||
.../notifications/test`). Statuts : `sent`, `disabled`
|
||||
(`EMAIL_ENABLED=false`), `connection_failed` (connexion ou
|
||||
authentification), `rejected` (le serveur a refusé le message). Détail :
|
||||
message du serveur ou type d'erreur — jamais l'adresse du destinataire
|
||||
(`_smtp_error_summary`)."""
|
||||
settings = get_settings()
|
||||
if not settings.email_enabled:
|
||||
logger.info("Email de test non envoyé (EMAIL_ENABLED=false)")
|
||||
return "disabled", None
|
||||
try:
|
||||
smtp = open_smtp(settings)
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc))
|
||||
return "connection_failed", _smtp_error_summary(exc)
|
||||
except OSError as exc:
|
||||
logger.error("Email de test : SMTP injoignable : %s", exc)
|
||||
return "connection_failed", f"{exc.__class__.__name__} : {exc}"
|
||||
with smtp:
|
||||
try:
|
||||
smtp.send_message(message)
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error(
|
||||
"Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc)
|
||||
)
|
||||
return "rejected", _smtp_error_summary(exc)
|
||||
logger.info("Email de test remis")
|
||||
return "sent", None
|
||||
|
||||
|
||||
def send(
|
||||
to: str,
|
||||
subject: str,
|
||||
@@ -156,6 +201,12 @@ def send(
|
||||
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
|
||||
|
||||
|
||||
def send_messages(messages: list[EmailMessage]) -> None:
|
||||
"""Remet des messages déjà construits (`build_message`) — ex. le
|
||||
récapitulatif de planning, dont la version HTML porte un tableau."""
|
||||
deliver_many(messages)
|
||||
|
||||
|
||||
def send_many(
|
||||
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
|
||||
) -> None:
|
||||
|
||||
@@ -37,6 +37,7 @@ _HEADER = [
|
||||
"Mécénat — pays",
|
||||
"Commentaire",
|
||||
"Texte du badge",
|
||||
"Nombre de badges",
|
||||
"Inscrit le",
|
||||
]
|
||||
|
||||
@@ -125,6 +126,7 @@ def generate_volunteers_xlsx(
|
||||
_safe_text(v.mecenat_country or ""),
|
||||
_safe_text(v.comment or ""),
|
||||
_safe_text(v.badge_text or ""),
|
||||
v.badge_count,
|
||||
_fmt_dt(v.created_at),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -53,18 +53,28 @@
|
||||
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
|
||||
"email.planning.default_subject": "Ton planning bénévole",
|
||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||
"email.test.subject_prefix": "[Test] {subject}",
|
||||
"email.test.example_first_name": "Camille",
|
||||
"email.test.example_last_name": "Exemple",
|
||||
"notifications.invalid_email": "Adresse email invalide.",
|
||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||
"email.planning.table.time": "Horaire",
|
||||
"email.planning.table.position": "Poste",
|
||||
"email.planning.table.referent": "Référent",
|
||||
"email.planning.table.no_referent": "à désigner",
|
||||
"email.planning.table.text_line": " {time} · {position} · référent : {referent}",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
|
||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.",
|
||||
"badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
|
||||
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
"badge.role.referent": "Référent",
|
||||
"badge.role.volunteer": "Bénévole",
|
||||
"position_sheets.footer_title": "Message de l'organisation",
|
||||
"badge.table.day_label": "{weekday} {day}",
|
||||
"badge.weekday.0": "Lundi",
|
||||
"badge.weekday.1": "Mardi",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
|
||||
|
||||
Revision ID: 0026_badge_editor_v2
|
||||
Revises: 0025_badge_field_text
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
|
||||
deviennent des champs du recto. La contrainte d'unicité passe de
|
||||
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
|
||||
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
|
||||
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
|
||||
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0026_badge_editor_v2"
|
||||
down_revision: str | None = "0025_badge_field_text"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
|
||||
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
|
||||
)
|
||||
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
|
||||
)
|
||||
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_badge_field_edition_face_type",
|
||||
"badge_field",
|
||||
["edition_id", "face", "field_type"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
|
||||
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
|
||||
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
|
||||
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
|
||||
)
|
||||
op.drop_column("badge_field", "photo_shape")
|
||||
op.drop_column("badge_field", "auto_fit")
|
||||
op.drop_column("badge_field", "bg_color")
|
||||
op.drop_column("badge_field", "face")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Nombre de badges par bénévole
|
||||
|
||||
Revision ID: 0027_volunteer_badge_count
|
||||
Revises: 0026_badge_editor_v2
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
|
||||
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
|
||||
gardent un seul badge.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0027_volunteer_badge_count"
|
||||
down_revision: str | None = "0026_badge_editor_v2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
|
||||
op.drop_column("volunteer", "badge_count")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Ordre d'empilement des champs du badge
|
||||
|
||||
Revision ID: 0028_badge_field_z_order
|
||||
Revises: 0027_volunteer_badge_count
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au
|
||||
plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et
|
||||
la photo recouvrait le nom et le texte libre. Les champs existants sont
|
||||
numérotés photo d'abord (dessous), puis les autres par type.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0028_badge_field_z_order"
|
||||
down_revision: str | None = "0027_volunteer_badge_count"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE badge_field AS b SET z_order = ranked.rank
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY edition_id, face
|
||||
ORDER BY (field_type = 'PHOTO') DESC, field_type
|
||||
) - 1 AS rank
|
||||
FROM badge_field
|
||||
) AS ranked
|
||||
WHERE b.id = ranked.id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_field", "z_order")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Messages aux bénévoles
|
||||
|
||||
Revision ID: 0029_volunteer_messages
|
||||
Revises: 0028_badge_field_z_order
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`edition.volunteer_message` : message de l'organisation, le même pour tous
|
||||
les bénévoles, saisi par la coordination. `position.message` : message du
|
||||
poste (consignes…), saisi par son référent, montré aux bénévoles affectés.
|
||||
Chacun avec la date de sa dernière modification (`*_updated_at`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0029_volunteer_messages"
|
||||
down_revision: str | None = "0028_badge_field_z_order"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("edition", sa.Column("volunteer_message", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"edition", sa.Column("volunteer_message_updated_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
op.add_column("position", sa.Column("message", sa.String(), nullable=True))
|
||||
op.add_column("position", sa.Column("message_updated_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("position", "message_updated_at")
|
||||
op.drop_column("position", "message")
|
||||
op.drop_column("edition", "volunteer_message_updated_at")
|
||||
op.drop_column("edition", "volunteer_message")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Pointage des bénévoles
|
||||
|
||||
Revision ID: 0030_assignment_attendance
|
||||
Revises: 0029_volunteer_messages
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`assignment.attendance` (`PRESENT`/`ABSENT`, `NULL` = pas pointé) et
|
||||
`attendance_marked_at` : le pointage du référent était gardé dans le
|
||||
navigateur seulement et se perdait au rechargement.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0030_assignment_attendance"
|
||||
down_revision: str | None = "0029_volunteer_messages"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_ATTENDANCE = sa.Enum("PRESENT", "ABSENT", name="attendancestatus", native_enum=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assignment", sa.Column("attendance", _ATTENDANCE, nullable=True))
|
||||
op.add_column(
|
||||
"assignment", sa.Column("attendance_marked_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assignment", "attendance_marked_at")
|
||||
op.drop_column("assignment", "attendance")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Lien de connexion permanent
|
||||
|
||||
Revision ID: 0031_account_login_link_version
|
||||
Revises: 0030_assignment_attendance
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`account.login_link_version` : version du lien de connexion permanent du
|
||||
récapitulatif de planning (`app/auth/permanent_link.py`) — l'incrémenter
|
||||
révoque tous les liens déjà envoyés à ce compte.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0031_account_login_link_version"
|
||||
down_revision: str | None = "0030_assignment_attendance"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"account",
|
||||
sa.Column("login_link_version", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("account", "login_link_version")
|
||||
@@ -27,8 +27,11 @@ from app.models.domain import (
|
||||
)
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
@@ -40,9 +43,12 @@ __all__ = [
|
||||
"Artist",
|
||||
"Assignment",
|
||||
"AssignmentSource",
|
||||
"AttendanceStatus",
|
||||
"BadgeAlign",
|
||||
"BadgeFace",
|
||||
"BadgeField",
|
||||
"BadgeFieldType",
|
||||
"BadgePhotoShape",
|
||||
"BadgeTemplate",
|
||||
"DayPart",
|
||||
"Edition",
|
||||
|
||||
@@ -17,8 +17,11 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||
from app.models.base import TimestampMixin
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
@@ -90,6 +93,11 @@ class Edition(TimestampMixin, table=True):
|
||||
end_date: dt.date
|
||||
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
||||
retention_days: int | None = None
|
||||
# Message de l'organisation, le même pour tous les bénévoles (écran
|
||||
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
|
||||
# sous le message, posée par l'API à chaque changement de texte.
|
||||
volunteer_message: str | None = None
|
||||
volunteer_message_updated_at: dt.datetime | None = None
|
||||
|
||||
day_parts: list["DayPart"] = Relationship(
|
||||
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
||||
@@ -160,6 +168,10 @@ class Artist(TimestampMixin, table=True):
|
||||
|
||||
|
||||
class Volunteer(TimestampMixin, table=True):
|
||||
__table_args__ = (
|
||||
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
@@ -187,6 +199,11 @@ class Volunteer(TimestampMixin, table=True):
|
||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
|
||||
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
|
||||
badge_text: str | None = None
|
||||
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
|
||||
# perdu, plusieurs accréditations…) — 1 par défaut.
|
||||
badge_count: int = Field(
|
||||
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
|
||||
)
|
||||
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||
# logique n'y est attachée (pas de workflow de paiement).
|
||||
self_declared_available: bool = Field(
|
||||
@@ -277,6 +294,12 @@ class Account(TimestampMixin, table=True):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Version du lien de connexion permanent (`app/auth/permanent_link.py`) :
|
||||
# l'incrémenter révoque tous les liens déjà envoyés à ce compte.
|
||||
login_link_version: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return "password" if self.password_hash else "magic_link"
|
||||
@@ -315,6 +338,10 @@ class Position(TimestampMixin, table=True):
|
||||
ondelete="SET NULL",
|
||||
nullable=True,
|
||||
)
|
||||
# Message du poste (consignes…), saisi par son référent ou la
|
||||
# coordination, montré aux bénévoles affectés au poste.
|
||||
message: str | None = None
|
||||
message_updated_at: dt.datetime | None = None
|
||||
|
||||
shifts: list["Shift"] = Relationship(
|
||||
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
||||
@@ -394,6 +421,14 @@ class Assignment(TimestampMixin, table=True):
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Pointage par le référent du poste (ou la coordination) : `None` tant
|
||||
# que personne n'a pointé. `attendance_marked_at` : heure du dernier
|
||||
# pointage, affichée sur l'écran référent.
|
||||
attendance: AttendanceStatus | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(AttendanceStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
attendance_marked_at: dt.datetime | None = None
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||
shift: Shift = Relationship(back_populates="assignments")
|
||||
@@ -429,16 +464,17 @@ class BadgeTemplate(TimestampMixin, table=True):
|
||||
|
||||
|
||||
class BadgeField(TimestampMixin, table=True):
|
||||
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie,
|
||||
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
|
||||
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
|
||||
Au plus un champ par `field_type` et par édition : décocher un champ dans
|
||||
"""Un champ positionné sur une face du badge (`face` : recto ou verso —
|
||||
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
|
||||
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
|
||||
Au plus un champ par `field_type` et par face pour une édition (le même
|
||||
champ peut figurer au recto et au verso) : décocher un champ dans
|
||||
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
||||
|
||||
__tablename__ = "badge_field"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_badge_field_edition_type"
|
||||
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -446,6 +482,14 @@ class BadgeField(TimestampMixin, table=True):
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
face: BadgeFace = Field(
|
||||
default=BadgeFace.FRONT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgeFace, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgeFace.FRONT.value,
|
||||
),
|
||||
)
|
||||
field_type: BadgeFieldType = Field(
|
||||
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
||||
)
|
||||
@@ -471,6 +515,29 @@ class BadgeField(TimestampMixin, table=True):
|
||||
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
|
||||
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
|
||||
text: str | None = None
|
||||
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
|
||||
# effet sur le champ `PHOTO`.
|
||||
bg_color: str | None = None
|
||||
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
|
||||
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
|
||||
auto_fit: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
|
||||
photo_shape: BadgePhotoShape = Field(
|
||||
default=BadgePhotoShape.RECT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgePhotoShape, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgePhotoShape.RECT.value,
|
||||
),
|
||||
)
|
||||
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
|
||||
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
|
||||
z_order: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
|
||||
class AttendanceStatus(enum.StrEnum):
|
||||
"""Pointage d'un bénévole sur son créneau, par le référent du poste."""
|
||||
|
||||
PRESENT = "PRESENT"
|
||||
ABSENT = "ABSENT"
|
||||
|
||||
|
||||
class PositionCategory(enum.StrEnum):
|
||||
BAR = "BAR"
|
||||
TICKETING = "TICKETING"
|
||||
@@ -31,7 +38,7 @@ class PositionCategory(enum.StrEnum):
|
||||
|
||||
|
||||
class BadgeFieldType(enum.StrEnum):
|
||||
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
|
||||
"""Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
|
||||
|
||||
FULL_NAME = "FULL_NAME"
|
||||
POSITIONS = "POSITIONS"
|
||||
@@ -45,6 +52,22 @@ class BadgeFieldType(enum.StrEnum):
|
||||
FREE_TEXT = "FREE_TEXT"
|
||||
|
||||
|
||||
class BadgeFace(enum.StrEnum):
|
||||
"""Face du badge sur laquelle un champ est imprimé : le verso est
|
||||
personnalisable comme le recto (même champ possible sur les deux)."""
|
||||
|
||||
FRONT = "FRONT"
|
||||
BACK = "BACK"
|
||||
|
||||
|
||||
class BadgePhotoShape(enum.StrEnum):
|
||||
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
|
||||
inscrite dans le cadre)."""
|
||||
|
||||
RECT = "RECT"
|
||||
OVAL = "OVAL"
|
||||
|
||||
|
||||
class BadgeAlign(enum.StrEnum):
|
||||
LEFT = "LEFT"
|
||||
CENTER = "CENTER"
|
||||
|
||||
+156
-31
@@ -12,12 +12,14 @@ bénévole, une feuille identifie un poste pour la journée.
|
||||
import datetime as dt
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from PIL import Image as PILImage
|
||||
from PIL import ImageOps, UnidentifiedImageError
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.platypus import (
|
||||
Image,
|
||||
PageBreak,
|
||||
@@ -28,7 +30,12 @@ from reportlab.platypus import (
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
_PHOTO_BOX_MM = 14 * mm
|
||||
# Mise en page compacte (une feuille = une page autant que possible) :
|
||||
# vignette de 10 mm, deux bénévoles par ligne.
|
||||
_PHOTO_BOX_MM = 10 * mm
|
||||
# Côté de la vignette en pixels : 10 mm à 300 dpi.
|
||||
_PHOTO_BOX_PX = round(10 / 25.4 * 300)
|
||||
_PER_ROW = 2
|
||||
|
||||
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
||||
_MONTHS_FR = [
|
||||
@@ -90,69 +97,187 @@ def _styles() -> dict[str, ParagraphStyle]:
|
||||
base = getSampleStyleSheet()
|
||||
return {
|
||||
"title": ParagraphStyle(
|
||||
"SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2
|
||||
"SheetTitle", parent=base["Title"], alignment=0,
|
||||
fontSize=18, leading=21, spaceBefore=0, spaceAfter=1,
|
||||
),
|
||||
"subtitle": ParagraphStyle(
|
||||
"SheetSubtitle", parent=base["Normal"],
|
||||
fontSize=12, textColor=colors.grey, spaceAfter=4,
|
||||
fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
|
||||
),
|
||||
"referent": ParagraphStyle(
|
||||
"SheetReferent", parent=base["Normal"],
|
||||
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6,
|
||||
fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
|
||||
),
|
||||
"artists": ParagraphStyle(
|
||||
"SheetArtists", parent=base["Normal"],
|
||||
fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16,
|
||||
fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
|
||||
),
|
||||
"shiftHeader": ParagraphStyle(
|
||||
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4
|
||||
"ShiftHeader", parent=base["Heading3"],
|
||||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||||
),
|
||||
"shiftHeaderWarning": ParagraphStyle(
|
||||
"ShiftHeaderWarning", parent=base["Heading3"],
|
||||
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"),
|
||||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||||
textColor=colors.HexColor("#b23a1f"),
|
||||
),
|
||||
"volunteerName": ParagraphStyle(
|
||||
"SheetVolunteerName", parent=base["Normal"],
|
||||
fontName="Helvetica-Bold", fontSize=9.5, leading=11.5,
|
||||
),
|
||||
"volunteerPhone": ParagraphStyle(
|
||||
"SheetVolunteerPhone", parent=base["Normal"], fontSize=9, leading=11,
|
||||
),
|
||||
"footerTitle": ParagraphStyle(
|
||||
"SheetFooterTitle", parent=base["Normal"],
|
||||
fontName="Helvetica-Bold", fontSize=8.5, leading=11,
|
||||
),
|
||||
"footer": ParagraphStyle(
|
||||
"SheetFooter", parent=base["Normal"], fontSize=8.5, leading=11,
|
||||
),
|
||||
"normal": base["Normal"],
|
||||
}
|
||||
|
||||
|
||||
def _photo_thumbnail(photo: bytes) -> Image:
|
||||
"""Vignette carrée (14 mm de côté max), proportions préservées."""
|
||||
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize()
|
||||
@dataclass(frozen=True)
|
||||
class SheetFooter:
|
||||
"""Message imprimé en bas de chaque page (message de l'organisation,
|
||||
`Edition.volunteer_message`) : `title` en gras, puis `message` —
|
||||
retours à la ligne conservés."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
_MARGIN_X = 15 * mm
|
||||
_MARGIN_TOP = 12 * mm
|
||||
_MARGIN_BOTTOM = 12 * mm
|
||||
# Écart entre le bas du contenu et le filet du pied de page.
|
||||
_FOOTER_GAP = 5 * mm
|
||||
|
||||
|
||||
def _footer_paragraphs(footer: SheetFooter, styles: dict) -> list[Paragraph]:
|
||||
message = escape(footer.message).replace("\n", "<br/>")
|
||||
return [
|
||||
Paragraph(escape(footer.title), styles["footerTitle"]),
|
||||
Paragraph(message, styles["footer"]),
|
||||
]
|
||||
|
||||
|
||||
def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
|
||||
"""Photo réduite à la taille de la vignette (orientation EXIF appliquée),
|
||||
en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo
|
||||
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
|
||||
quelle pour chaque bénévole de chaque créneau faisait tourner la
|
||||
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
|
||||
l'erreur (bug corrigé une fois)."""
|
||||
try:
|
||||
with PILImage.open(io.BytesIO(photo)) as img:
|
||||
# JPEG : décodage directement à taille réduite (bien plus rapide).
|
||||
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||||
thumb = ImageOps.exif_transpose(img).convert("RGB")
|
||||
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
return None
|
||||
out = io.BytesIO()
|
||||
thumb.save(out, format="JPEG", quality=85)
|
||||
return out.getvalue(), thumb.width, thumb.height
|
||||
|
||||
|
||||
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
|
||||
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
|
||||
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
|
||||
figure sur plusieurs créneaux."""
|
||||
if not photo:
|
||||
return ""
|
||||
if photo not in cache:
|
||||
cache[photo] = _thumbnail(photo)
|
||||
thumb = cache[photo]
|
||||
if thumb is None:
|
||||
return ""
|
||||
data, width_px, height_px = thumb
|
||||
scale = _PHOTO_BOX_MM / max(width_px, height_px)
|
||||
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale)
|
||||
return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
|
||||
|
||||
|
||||
def _shift_table(shift: SheetShift) -> Table:
|
||||
rows = [["", "Bénévole", "Téléphone"]] + [
|
||||
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
|
||||
def _shift_table(
|
||||
shift: SheetShift,
|
||||
cache: dict[bytes, tuple[bytes, int, int] | None],
|
||||
styles: dict[str, ParagraphStyle],
|
||||
width: float,
|
||||
) -> Table:
|
||||
"""Bénévoles du créneau, deux par ligne : vignette, puis nom (gras) et
|
||||
téléphone l'un sous l'autre. Deux fois moins de lignes qu'une liste à
|
||||
une personne par ligne (avant l'étape 65, souvent deux pages par
|
||||
feuille)."""
|
||||
photo_w = _PHOTO_BOX_MM + 3 * mm
|
||||
text_w = width / _PER_ROW - photo_w
|
||||
cells = [
|
||||
[
|
||||
_photo_cell(v.photo, cache),
|
||||
[
|
||||
Paragraph(escape(v.name), styles["volunteerName"]),
|
||||
Paragraph(escape(v.phone or "—"), styles["volunteerPhone"]),
|
||||
],
|
||||
]
|
||||
for v in shift.volunteers
|
||||
]
|
||||
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 10.5),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
rows = []
|
||||
for i in range(0, len(cells), _PER_ROW):
|
||||
chunk = cells[i : i + _PER_ROW]
|
||||
chunk += [["", ""]] * (_PER_ROW - len(chunk))
|
||||
rows.append([cell for pair in chunk for cell in pair])
|
||||
table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
|
||||
style = [
|
||||
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black),
|
||||
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
|
||||
("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.lightgrey),
|
||||
("LINEABOVE", (0, 0), (-1, 0), 0.25, colors.lightgrey),
|
||||
]
|
||||
)
|
||||
)
|
||||
# Filet vertical entre les deux bénévoles d'une même ligne.
|
||||
for col in range(2, 2 * _PER_ROW, 2):
|
||||
style.append(("LINEBEFORE", (col, 0), (col, -1), 0.25, colors.lightgrey))
|
||||
table.setStyle(TableStyle(style))
|
||||
return table
|
||||
|
||||
|
||||
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
|
||||
def generate_position_sheets_pdf(
|
||||
sheets: list[PositionDaySheet], footer: SheetFooter | None = None
|
||||
) -> bytes:
|
||||
"""`footer` : imprimé en bas de **chaque** page (y compris la suite
|
||||
d'une feuille sur une seconde page) ; la marge du bas s'agrandit de sa
|
||||
hauteur pour que le contenu ne passe jamais dessous."""
|
||||
styles = _styles()
|
||||
buffer = io.BytesIO()
|
||||
width = A4[0] - 2 * _MARGIN_X
|
||||
paragraphs = _footer_paragraphs(footer, styles) if footer else []
|
||||
footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs)
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm,
|
||||
leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP,
|
||||
bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0),
|
||||
)
|
||||
|
||||
def draw_footer(canvas, _doc) -> None:
|
||||
if not paragraphs:
|
||||
return
|
||||
canvas.saveState()
|
||||
top = _MARGIN_BOTTOM + footer_height
|
||||
canvas.setStrokeColor(colors.black)
|
||||
canvas.setLineWidth(0.75)
|
||||
canvas.line(_MARGIN_X, top + 2 * mm, _MARGIN_X + width, top + 2 * mm)
|
||||
y = top
|
||||
for p in paragraphs:
|
||||
_, h = p.wrap(width, A4[1])
|
||||
y -= h
|
||||
p.drawOn(canvas, _MARGIN_X, y)
|
||||
canvas.restoreState()
|
||||
|
||||
story = []
|
||||
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
|
||||
for i, sheet in enumerate(sheets):
|
||||
if i > 0:
|
||||
story.append(PageBreak())
|
||||
@@ -190,10 +315,10 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
|
||||
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
|
||||
story.append(Paragraph(header, style))
|
||||
if shift.volunteers:
|
||||
story.append(_shift_table(shift))
|
||||
story.append(_shift_table(shift, thumbnails, styles, width))
|
||||
else:
|
||||
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
||||
story.append(Spacer(1, 0))
|
||||
|
||||
doc.build(story)
|
||||
doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
|
||||
return buffer.getvalue()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Pointage présent / absent d'un bénévole sur son créneau
|
||||
(`PUT .../assignments/{id}/attendance`) : enregistré en base, par le
|
||||
référent du poste ou la coordination."""
|
||||
|
||||
from app.models import Assignment, Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
|
||||
def _assignment(session, edition, lead=None):
|
||||
position = make_position(session, edition)
|
||||
if lead is not None:
|
||||
position.lead_account_id = lead.id
|
||||
session.add(position)
|
||||
session.commit()
|
||||
shift = make_shift(session, edition, position, start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
volunteer = make_volunteer(session, edition)
|
||||
assignment = Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
|
||||
session.add(assignment)
|
||||
session.commit()
|
||||
session.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
def _url(edition, assignment):
|
||||
return f"/editions/{edition.id}/assignments/{assignment.id}/attendance"
|
||||
|
||||
|
||||
def test_lead_marks_attendance_and_it_is_kept(edition, session, make_account, as_account):
|
||||
lead = make_account(Role.LEAD)
|
||||
assignment = _assignment(session, edition, lead)
|
||||
client = as_account(lead)
|
||||
|
||||
resp = client.put(_url(edition, assignment), json={"attendance": "PRESENT"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["attendance"] == "PRESENT"
|
||||
assert resp.json()["attendance_marked_at"] is not None
|
||||
|
||||
# Relu ensuite (rechargement de l'écran) : le pointage est toujours là.
|
||||
(listed,) = client.get(f"/editions/{edition.id}/assignments").json()
|
||||
assert listed["attendance"] == "PRESENT"
|
||||
|
||||
|
||||
def test_attendance_can_be_switched_and_cleared(client, edition, session):
|
||||
assignment = _assignment(session, edition)
|
||||
assert client.put(_url(edition, assignment), json={"attendance": "ABSENT"}).json()[
|
||||
"attendance"
|
||||
] == "ABSENT"
|
||||
cleared = client.put(_url(edition, assignment), json={"attendance": None}).json()
|
||||
assert cleared["attendance"] is None
|
||||
assert cleared["attendance_marked_at"] is None
|
||||
|
||||
|
||||
def test_lead_of_another_position_cannot_mark(edition, session, make_account, as_account):
|
||||
assignment = _assignment(session, edition, make_account(Role.LEAD))
|
||||
resp = as_account(make_account(Role.LEAD)).put(
|
||||
_url(edition, assignment), json={"attendance": "PRESENT"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_cannot_mark(edition, session, make_account, as_account):
|
||||
assignment = _assignment(session, edition)
|
||||
resp = as_account(make_account(Role.VOLUNTEER)).put(
|
||||
_url(edition, assignment), json={"attendance": "PRESENT"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_unknown_status_is_rejected(client, edition, session):
|
||||
assignment = _assignment(session, edition)
|
||||
resp = client.put(_url(edition, assignment), json={"attendance": "LATE"})
|
||||
assert resp.status_code == 422
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Messages aux bénévoles : celui de l'organisation (`Edition.
|
||||
volunteer_message`, coordination) et celui de chaque poste (`Position.
|
||||
message`, référent du poste ou coordination)."""
|
||||
|
||||
from app.models import Assignment, Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
|
||||
def test_coordination_sets_the_organisation_message(
|
||||
client, edition, session, make_account, as_account
|
||||
):
|
||||
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " Repas à 18h "})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["volunteer_message"] == "Repas à 18h"
|
||||
assert resp.json()["volunteer_message_updated_at"] is not None
|
||||
|
||||
volunteer = make_volunteer(session, edition)
|
||||
reader = as_account(make_account(Role.VOLUNTEER, volunteer_id=volunteer.id))
|
||||
resp = reader.get(f"/editions/{edition.id}")
|
||||
assert resp.json()["volunteer_message"] == "Repas à 18h"
|
||||
|
||||
|
||||
def test_blank_message_clears_it(client, edition):
|
||||
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"})
|
||||
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " "})
|
||||
assert resp.json()["volunteer_message"] is None
|
||||
assert resp.json()["volunteer_message_updated_at"] is None
|
||||
|
||||
|
||||
def test_unchanged_message_keeps_its_date(client, edition):
|
||||
first = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"}).json()
|
||||
again = client.patch(
|
||||
f"/editions/{edition.id}", json={"volunteer_message": "Bonjour", "name": "X"}
|
||||
).json()
|
||||
assert again["volunteer_message_updated_at"] == first["volunteer_message_updated_at"]
|
||||
|
||||
|
||||
def test_volunteer_and_lead_cannot_set_the_organisation_message(edition, make_account, as_account):
|
||||
for role in (Role.VOLUNTEER, Role.LEAD):
|
||||
resp = as_account(make_account(role)).patch(
|
||||
f"/editions/{edition.id}", json={"volunteer_message": "Hack"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_message_too_long_is_rejected(client, edition):
|
||||
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "x" * 2001})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_lead_sets_the_message_of_their_position(edition, session, make_account, as_account):
|
||||
lead = make_account(Role.LEAD)
|
||||
position = make_position(session, edition)
|
||||
position.lead_account_id = lead.id
|
||||
session.add(position)
|
||||
session.commit()
|
||||
|
||||
resp = as_account(lead).patch(
|
||||
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Gilet fourni"
|
||||
assert resp.json()["message_updated_at"] is not None
|
||||
|
||||
|
||||
def test_lead_cannot_set_the_message_of_another_position(
|
||||
edition, session, make_account, as_account
|
||||
):
|
||||
position = make_position(session, edition)
|
||||
resp = as_account(make_account(Role.LEAD)).patch(
|
||||
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Hack"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_position_message_reaches_the_volunteer_planning(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
client.patch(
|
||||
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
|
||||
)
|
||||
|
||||
(entry,) = client.get(f"/editions/{edition.id}/volunteers/{volunteer.id}/planning").json()
|
||||
assert entry["position_message"] == "Gilet fourni"
|
||||
assert entry["position_message_updated_at"] is not None
|
||||
@@ -62,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
|
||||
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "/connexion?token=" in plain_text(outbox[0])
|
||||
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
||||
assert "/connexion?token=pl." in plain_text(outbox[0])
|
||||
# Lien permanent : rien de stocké, pas de jeton à usage unique.
|
||||
assert session.exec(select(MagicLinkToken)).all() == []
|
||||
|
||||
|
||||
def test_no_magic_link_token_without_placeholder(
|
||||
@@ -91,3 +92,111 @@ def test_planning_notification_is_coordinator_only(
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).post(_url(edition), json={})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_shifts_placeholder_is_a_table_by_day_with_the_referent(
|
||||
client, edition, session, outbox, make_account
|
||||
):
|
||||
rita = make_volunteer(session, edition, first_name="Rita", last_name="Ref", email="r@e.fr")
|
||||
rita.phone = "06 11 22 33 44"
|
||||
session.add(rita)
|
||||
lead = make_account(Role.LEAD, volunteer_id=rita.id)
|
||||
bar = make_position(session, edition, name="Bar central")
|
||||
bar.lead_account_id = lead.id
|
||||
session.add(bar)
|
||||
parking = make_position(session, edition, name="Parking")
|
||||
s1 = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
s2 = make_shift(session, edition, parking, start=dt_at(11, 9), end=dt_at(11, 12))
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
session.add_all(
|
||||
[
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s1.id),
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s2.id),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
client.post(
|
||||
_url(edition), json={"volunteer_ids": [str(ana.id)], "body": "Ton planning :\n\n{creneaux}"}
|
||||
)
|
||||
(message,) = outbox
|
||||
html = message.get_body(preferencelist=("html",)).get_content()
|
||||
# Vrai tableau : en-têtes, une ligne de jour, horaire, poste, référent.
|
||||
assert "<table" in html and "Référent" in html
|
||||
assert "18:00–22:00" in html and "Bar central" in html
|
||||
assert "Rita Ref · 06 11 22 33 44" in html
|
||||
assert "à désigner" in html # Parking n'a pas de référent
|
||||
assert "" not in html
|
||||
text = plain_text(message)
|
||||
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text
|
||||
assert "09:00–12:00 · Parking · référent : à désigner" in text
|
||||
|
||||
|
||||
|
||||
def _link(message) -> str:
|
||||
import re
|
||||
|
||||
return re.search(r"token=(\S+)", plain_text(message)).group(1)
|
||||
|
||||
|
||||
def _verify(client, edition, token):
|
||||
return client.post(f"/editions/{edition.id}/auth/verify", json={"token": token})
|
||||
|
||||
|
||||
def test_login_link_is_permanent_and_reusable(client, edition, session, make_account, outbox):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
first, second = (_link(m) for m in outbox)
|
||||
# Le même lien dans chaque email, utilisable plusieurs fois.
|
||||
assert first == second
|
||||
for _ in range(3):
|
||||
resp = _verify(client, edition, first)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account"]["volunteer_id"] == str(ana.id)
|
||||
|
||||
|
||||
def test_reset_revokes_the_links_already_sent(client, edition, session, make_account, outbox):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
old = _link(outbox[0])
|
||||
|
||||
resp = client.post(f"/editions/{edition.id}/accounts/{account.id}/reset-login-link")
|
||||
assert resp.status_code == 200
|
||||
assert _verify(client, edition, old).status_code == 401
|
||||
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
new = _link(outbox[1])
|
||||
assert new != old
|
||||
assert _verify(client, edition, new).status_code == 200
|
||||
|
||||
|
||||
def test_sending_the_link_gives_an_account_to_a_volunteer_without_one(
|
||||
client, edition, session, outbox
|
||||
):
|
||||
from app.models import Account
|
||||
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
(account,) = session.exec(select(Account).where(Account.volunteer_id == ana.id)).all()
|
||||
assert account.role == Role.VOLUNTEER
|
||||
assert _verify(client, edition, _link(outbox[0])).status_code == 200
|
||||
|
||||
|
||||
def test_shared_address_gets_the_login_page_only(client, edition, session, make_account, outbox):
|
||||
# Couple sur la même adresse : un seul compte possible pour cet email.
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="foyer@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="foyer@e.fr")
|
||||
bob = make_volunteer(session, edition, first_name="Bob", email="foyer@e.fr")
|
||||
client.post(_url(edition), json={"volunteer_ids": [str(bob.id)], "body": "{lien_connexion}"})
|
||||
assert "token=" not in plain_text(outbox[0])
|
||||
|
||||
|
||||
def test_forged_or_coordinator_links_are_refused(client, edition, coordinator):
|
||||
from app.auth.permanent_link import permanent_login_token
|
||||
|
||||
assert _verify(client, edition, permanent_login_token(coordinator)).status_code == 401
|
||||
assert _verify(client, edition, "pl.nimportequoi.abc").status_code == 401
|
||||
assert _verify(client, edition, f"pl.{coordinator.id.hex}.faux").status_code == 401
|
||||
|
||||
@@ -187,3 +187,49 @@ def test_filled_slots_never_count_overstaffing(client, edition, session):
|
||||
assert position["assigned"] == 3
|
||||
assert position["filled"] == 2
|
||||
assert position["ideal_slots"] == 4
|
||||
|
||||
|
||||
def test_attendance_splits_past_and_live_shifts_per_position(
|
||||
client, edition, session, monkeypatch
|
||||
):
|
||||
import app.api.stats as stats_api
|
||||
from app.models import AttendanceStatus
|
||||
|
||||
# Maintenant : le 10 à 20:00 — 14h–18h terminé, 18h–22h en cours,
|
||||
# 22h–02h à venir (ignoré).
|
||||
monkeypatch.setattr(stats_api, "local_now", lambda: dt_at(10, 20))
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
parking = make_position(session, edition, name="Parking")
|
||||
past = make_shift(session, edition, bar, start=dt_at(10, 14), end=dt_at(10, 18))
|
||||
live = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
later = make_shift(session, edition, parking, start=dt_at(10, 22), end=dt_at(11, 2))
|
||||
|
||||
def assign(shift, name, attendance=None):
|
||||
volunteer = make_volunteer(session, edition, first_name=name)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
shift_id=shift.id,
|
||||
attendance=attendance,
|
||||
)
|
||||
)
|
||||
|
||||
assign(past, "A", AttendanceStatus.PRESENT)
|
||||
assign(past, "B", AttendanceStatus.ABSENT)
|
||||
assign(past, "C")
|
||||
assign(live, "D", AttendanceStatus.PRESENT)
|
||||
assign(live, "E")
|
||||
assign(later, "F", AttendanceStatus.PRESENT)
|
||||
session.commit()
|
||||
|
||||
att = client.get(_url(edition)).json()["attendance"]
|
||||
assert att["past"] == {"assigned": 3, "present": 1, "absent": 1, "unmarked": 1}
|
||||
assert att["live"] == {"assigned": 2, "present": 1, "absent": 0, "unmarked": 1}
|
||||
assert att["live_shifts"] == 1
|
||||
# Parking n'a encore aucun créneau commencé : absent du détail.
|
||||
(row,) = att["by_position"]
|
||||
assert row["name"] == "Bar"
|
||||
assert row["past"]["assigned"] == 3
|
||||
assert row["live"]["present"] == 1
|
||||
assert row["live_shifts"] == 1
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Email de test depuis l'écran « Messages » (`POST .../notifications/test`) :
|
||||
envoi synchrone, résultat réel du serveur SMTP renvoyé à la coordination."""
|
||||
|
||||
import smtplib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Role
|
||||
|
||||
|
||||
def _url(edition):
|
||||
return f"/editions/{edition.id}/notifications/test"
|
||||
|
||||
|
||||
class _FakeSmtp:
|
||||
def __init__(self, error=None):
|
||||
self.sent = []
|
||||
self.error = error
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def send_message(self, message):
|
||||
if self.error:
|
||||
raise self.error
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp_enabled(monkeypatch):
|
||||
monkeypatch.setenv("EMAIL_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_disabled_email_is_reported(client, edition):
|
||||
resp = client.post(_url(edition), json={"email": "moi@exemple.fr"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "disabled"
|
||||
|
||||
|
||||
def test_sends_the_test_email(client, edition, smtp_enabled, monkeypatch):
|
||||
fake = _FakeSmtp()
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: fake)
|
||||
resp = client.post(_url(edition), json={"email": " moi@exemple.fr "})
|
||||
assert resp.json()["status"] == "sent"
|
||||
(message,) = fake.sent
|
||||
assert message["To"] == "moi@exemple.fr"
|
||||
|
||||
|
||||
def test_connection_failure_is_reported_with_the_server_message(
|
||||
client, edition, smtp_enabled, monkeypatch
|
||||
):
|
||||
def refuse(settings):
|
||||
raise smtplib.SMTPAuthenticationError(535, b"authentication failed")
|
||||
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", refuse)
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
|
||||
assert body["status"] == "connection_failed"
|
||||
assert "535" in body["detail"]
|
||||
|
||||
|
||||
def test_rejected_message_never_echoes_the_address(client, edition, smtp_enabled, monkeypatch):
|
||||
refused = smtplib.SMTPRecipientsRefused({"moi@exemple.fr": (550, b"no such user")})
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: _FakeSmtp(refused))
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
|
||||
assert body["status"] == "rejected"
|
||||
assert "550" in body["detail"]
|
||||
assert "moi@exemple.fr" not in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", ["", "pas-un-email", "@exemple.fr", "moi@", "a b@c.fr"])
|
||||
def test_invalid_address_is_rejected(client, edition, email):
|
||||
resp = client.post(_url(edition), json={"email": email})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = as_account(make_account(Role.LEAD))
|
||||
assert lead.post(_url(edition), json={"email": "moi@exemple.fr"}).status_code == 403
|
||||
|
||||
|
||||
def _send(client, edition, monkeypatch, **payload):
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
"app.api.notifications.deliver_test",
|
||||
lambda message: captured.append(message) or ("sent", None),
|
||||
)
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr", **payload}).json()
|
||||
return body, captured[0]
|
||||
|
||||
|
||||
def test_test_email_is_the_compiled_planning_email(client, edition, session, monkeypatch):
|
||||
from app.models import Assignment
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
from tests.mail import plain_text
|
||||
|
||||
make_volunteer(session, edition, first_name="Zoé", last_name="Zébu") # sans créneau
|
||||
alex = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
shift = make_shift(session, edition, make_position(session, edition, name="Bar"),
|
||||
start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
|
||||
body, message = _send(
|
||||
client, edition, monkeypatch,
|
||||
subject="Planning de {prenom}",
|
||||
body="Salut {prenom} {nom} : {nb_creneaux} créneau(x), {nb_heures} h\n{creneaux}",
|
||||
)
|
||||
# Premier bénévole (par nom) qui a un créneau, pas Zoé.
|
||||
assert body["example_volunteer_name"] == "Alex Martin"
|
||||
assert message["Subject"] == "[Test] Planning de Alex"
|
||||
text = plain_text(message)
|
||||
assert "Salut Alex Martin : 1 créneau(x), 4.0 h" in text
|
||||
assert "Bar" in text
|
||||
assert "{" not in text
|
||||
|
||||
|
||||
def test_login_link_in_test_email_carries_no_token(client, edition, session, monkeypatch):
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import MagicLinkToken
|
||||
from tests.factories import make_volunteer
|
||||
from tests.mail import plain_text
|
||||
|
||||
make_volunteer(session, edition)
|
||||
_, message = _send(client, edition, monkeypatch, body="Connexion : {lien_connexion}")
|
||||
assert "token=" not in plain_text(message)
|
||||
assert session.exec(select(MagicLinkToken)).all() == []
|
||||
|
||||
|
||||
def test_chosen_volunteer_and_fictitious_example(client, edition, session, monkeypatch):
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
body, message = _send(client, edition, monkeypatch, subject="Pour {prenom}")
|
||||
assert body["example_volunteer_id"] is None
|
||||
assert message["Subject"] == "[Test] Pour Camille"
|
||||
|
||||
sam = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||
body, _ = _send(client, edition, monkeypatch, volunteer_id=str(sam.id))
|
||||
assert body["example_volunteer_id"] == str(sam.id)
|
||||
@@ -656,3 +656,12 @@ def test_only_the_coordination_can_change_a_photo(
|
||||
|
||||
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
|
||||
assert lead.delete(url).status_code == 403
|
||||
|
||||
|
||||
def test_manual_add_requires_no_field(client, edition):
|
||||
resp = client.post(_url(edition), json={})
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["first_name"] == ""
|
||||
assert body["last_name"] == ""
|
||||
assert client.get(_url(edition)).status_code == 200
|
||||
|
||||
@@ -247,3 +247,196 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
|
||||
|
||||
def _field(**kw):
|
||||
return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw}
|
||||
|
||||
|
||||
def test_new_field_attributes_default_and_round_trip(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FULL_NAME"),
|
||||
_field(
|
||||
field_type="PHOTO", face="BACK", photo_shape="OVAL",
|
||||
bg_color="#FFD23F", auto_fit=True,
|
||||
),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
by_face = {f["face"]: f for f in resp.json()}
|
||||
front, back = by_face["FRONT"], by_face["BACK"]
|
||||
assert front["field_type"] == "FULL_NAME"
|
||||
assert front["bg_color"] is None
|
||||
assert front["auto_fit"] is False
|
||||
assert front["photo_shape"] == "RECT"
|
||||
assert back["field_type"] == "PHOTO"
|
||||
assert back["photo_shape"] == "OVAL"
|
||||
assert back["bg_color"] == "#FFD23F"
|
||||
assert back["auto_fit"] is True
|
||||
|
||||
again = client.get(f"{_badges_url(edition)}/template").json()["fields"]
|
||||
assert {(f["face"], f["field_type"]) for f in again} == {
|
||||
("FRONT", "FULL_NAME"), ("BACK", "PHOTO"),
|
||||
}
|
||||
|
||||
|
||||
def test_same_field_type_on_front_and_back_is_allowed(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FREE_TEXT", face="FRONT", text="Recto"),
|
||||
_field(field_type="FREE_TEXT", face="BACK", text="Verso"),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert {(f["face"], f["text"]) for f in resp.json()} == {
|
||||
("FRONT", "Recto"), ("BACK", "Verso"),
|
||||
}
|
||||
|
||||
|
||||
def test_same_field_type_twice_on_the_back_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="ROLE", face="BACK"),
|
||||
_field(field_type="ROLE", face="BACK"),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"])
|
||||
def test_invalid_background_color_is_rejected(client, edition, color):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={"fields": [_field(field_type="ROLE", bg_color=color)]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.invalid_color"
|
||||
|
||||
|
||||
def test_generating_with_back_fields(client, edition, session, monkeypatch):
|
||||
import app.api.badges as badges_api
|
||||
|
||||
captured = {}
|
||||
original = badges_api.generate_badge_sheets_pdf
|
||||
|
||||
def spy(badges, *args, **kwargs):
|
||||
captured["badges"] = badges
|
||||
return original(badges, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FULL_NAME", width_mm=60, font_size=12),
|
||||
_field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True),
|
||||
_field(field_type="PHOTO", face="BACK", photo_shape="OVAL"),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
|
||||
(badge,) = captured["badges"]
|
||||
assert [f.text for f in badge.fields] == ["Alex\nMartin"]
|
||||
back = {f.is_photo: f for f in badge.back_fields}
|
||||
assert back[False].text == "Bénévole"
|
||||
assert back[False].valign == "MIDDLE"
|
||||
assert back[False].bg_color == "#FFD23F"
|
||||
assert back[False].auto_fit is True
|
||||
assert back[True].photo_shape == "OVAL"
|
||||
assert back[True].text == "" # pas de photo : rien d'imprimé
|
||||
|
||||
|
||||
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
|
||||
import app.api.badges as badges_api
|
||||
|
||||
captured = {}
|
||||
original = badges_api.generate_badge_sheets_pdf
|
||||
|
||||
def spy(badges, *args, **kwargs):
|
||||
captured["badges"] = badges
|
||||
return original(badges, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]},
|
||||
)
|
||||
many = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||
resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["badge_count"] == 3
|
||||
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
names = [badge.fields[0].text for badge in captured["badges"]]
|
||||
assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, -1, 101])
|
||||
def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
resp = client.patch(
|
||||
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatch):
|
||||
import app.api.badges as badges_api
|
||||
|
||||
captured = {}
|
||||
original = badges_api.generate_badge_sheets_pdf
|
||||
|
||||
def spy(badges, *args, **kwargs):
|
||||
captured["badges"] = badges
|
||||
return original(badges, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FREE_TEXT", text="Staff", z_order=2),
|
||||
_field(field_type="FULL_NAME", z_order=0),
|
||||
_field(field_type="PHOTO", z_order=1),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"]
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
|
||||
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
|
||||
(badge,) = captured["badges"]
|
||||
assert [(f.is_photo, f.text) for f in badge.fields] == [
|
||||
(False, "Alex\nMartin"),
|
||||
(True, ""),
|
||||
(False, "Staff"),
|
||||
]
|
||||
|
||||
|
||||
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]},
|
||||
)
|
||||
assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"]
|
||||
|
||||
@@ -104,3 +104,22 @@ def test_field_text_free_text_falls_back_to_the_common_editor_text():
|
||||
|
||||
assert _field_text(field, own, {}, set()) == "Accès backstage"
|
||||
assert _field_text(field, other, {}, set()) == "Staff 2026"
|
||||
|
||||
|
||||
def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
|
||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
|
||||
|
||||
|
||||
def test_field_text_photo_is_empty():
|
||||
volunteer = _volunteer(first_name="alex", last_name="martin")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
# Une image, pas un texte : rien n'est imprimé sans photo.
|
||||
assert _field_text(field, volunteer, {}, set()) == ""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
@@ -18,6 +19,7 @@ from app.badges.pdf import (
|
||||
_column_widths,
|
||||
_fit_photo,
|
||||
_grid_size,
|
||||
_spacing,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
wrap_text,
|
||||
@@ -236,3 +238,199 @@ def test_table_rows_beyond_the_frame_are_not_drawn():
|
||||
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
|
||||
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
|
||||
assert abs(len(many) - len(few)) < 50
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
|
||||
# --------------------------------------------------------------------------- #
|
||||
from app.badges import pdf as pdf_module # noqa: E402
|
||||
from app.badges.pdf import fit_text # noqa: E402
|
||||
|
||||
|
||||
def _text_field(**kw) -> BadgeFieldSpec:
|
||||
base = dict(
|
||||
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
|
||||
font_size=12, align="LEFT", bold=False, color="#000000",
|
||||
)
|
||||
base.update(kw)
|
||||
return BadgeFieldSpec(**base)
|
||||
|
||||
|
||||
def test_cell_origin_mirrors_the_column_on_the_back_page():
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
|
||||
# verso, même rangée.
|
||||
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
|
||||
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
|
||||
2 * cols - 1, cols, w, h
|
||||
)
|
||||
|
||||
|
||||
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
|
||||
calls = []
|
||||
original = pdf_module._draw_fields
|
||||
|
||||
def spy(c, fields, x0, y0, badge_height_mm, photos):
|
||||
calls.append((fields, x0, y0))
|
||||
original(c, fields, x0, y0, badge_height_mm, photos)
|
||||
|
||||
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
|
||||
back = (_text_field(text="Verso Alex"),)
|
||||
# Un seul badge : planche incomplète, le verso doit quand même être en
|
||||
# miroir (colonne de droite).
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
|
||||
)
|
||||
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||
(front_fields, fx, fy), (back_fields, bx, by) = calls
|
||||
assert (fx, fy) == _cell_origin(0, cols, w, h)
|
||||
assert back_fields == back
|
||||
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
|
||||
|
||||
|
||||
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
|
||||
text = "\n".join(["ligne"] * 10)
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
|
||||
assert size == 12
|
||||
assert 0 < len(lines) < 10
|
||||
|
||||
|
||||
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
|
||||
text = "\n".join(["ligne"] * 4)
|
||||
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||
assert 4 < size < 12
|
||||
assert (size * 2) == int(size * 2) # pas de 0,5 pt
|
||||
assert len(lines) == 4
|
||||
|
||||
|
||||
def test_fit_text_auto_fit_stops_at_the_minimum_size():
|
||||
text = "\n".join(["ligne"] * 200)
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||
assert size == 4.0
|
||||
assert len(lines) < 200
|
||||
|
||||
|
||||
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
|
||||
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
|
||||
|
||||
|
||||
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
|
||||
drawn = []
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawString",
|
||||
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||||
)
|
||||
badge = _table_badge(4, height_mm=20)
|
||||
field = badge.fields[0]
|
||||
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
|
||||
generate_badge_sheets_pdf([badge], None, None)
|
||||
return sum(1 for text in drawn if text == "19:00–22:00")
|
||||
|
||||
|
||||
def test_table_auto_fit_draws_every_row(monkeypatch):
|
||||
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
|
||||
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
|
||||
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
|
||||
|
||||
|
||||
def _filled_rects(monkeypatch) -> list:
|
||||
fills = []
|
||||
original = pdf_module.canvas.Canvas.rect
|
||||
|
||||
def spy(self, x, y, w, h, stroke=1, fill=0):
|
||||
if fill:
|
||||
fills.append((round(w / mm, 3), round(h / mm, 3)))
|
||||
return original(self, x, y, w, h, stroke=stroke, fill=fill)
|
||||
|
||||
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
|
||||
return fills
|
||||
|
||||
|
||||
def test_zone_background_fills_the_field_frame(monkeypatch):
|
||||
fills = _filled_rects(monkeypatch)
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
|
||||
)
|
||||
assert fills == [(60, 10)]
|
||||
|
||||
|
||||
def test_no_zone_background_by_default(monkeypatch):
|
||||
fills = _filled_rects(monkeypatch)
|
||||
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
|
||||
assert fills == []
|
||||
|
||||
|
||||
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
|
||||
baselines = []
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawString",
|
||||
lambda self, x, y, text, *a, **k: baselines.append(y),
|
||||
)
|
||||
for valign in ("TOP", "MIDDLE"):
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
|
||||
)
|
||||
top, middle = baselines
|
||||
assert middle < top
|
||||
|
||||
|
||||
def test_missing_photo_prints_nothing(monkeypatch):
|
||||
calls = []
|
||||
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, name,
|
||||
lambda self, *a, _name=name, **k: calls.append(_name),
|
||||
)
|
||||
for image in (None, b"pas une image"):
|
||||
generate_badge_sheets_pdf([_photo_badge(image)], None, None)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf():
|
||||
for image in (_jpeg(400, 500), None):
|
||||
field = _photo_badge(image).fields[0]
|
||||
badge = BadgeSpec(
|
||||
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),)
|
||||
)
|
||||
pdf = generate_badge_sheets_pdf([badge], None, None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"size", [(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM), (54, 85.6), (50, 50), (74, 105)]
|
||||
)
|
||||
def test_grid_is_centred_with_equal_gaps_and_back_falls_behind_front(size):
|
||||
from app.badges.pdf import _PAGE_HEIGHT_MM, _PAGE_WIDTH_MM
|
||||
|
||||
w, h = size
|
||||
cols, rows = _grid_size(w, h)
|
||||
xs = sorted({_cell_origin(i, cols, w, h)[0] for i in range(cols * rows)})
|
||||
ys = sorted({_cell_origin(i, cols, w, h)[1] for i in range(cols * rows)})
|
||||
left, right = xs[0], _PAGE_WIDTH_MM - (xs[-1] + w)
|
||||
bottom, top = ys[0], _PAGE_HEIGHT_MM - (ys[-1] + h)
|
||||
# Symétrique : même marge à gauche et à droite, en haut et en bas.
|
||||
assert left == pytest.approx(right)
|
||||
assert top == pytest.approx(bottom)
|
||||
# Écarts entre badges tous égaux, et égaux aux marges quand c'est possible.
|
||||
gaps_x = {round(b - a - w, 6) for a, b in zip(xs, xs[1:], strict=False)}
|
||||
gaps_y = {round(b - a - h, 6) for a, b in zip(ys, ys[1:], strict=False)}
|
||||
assert len(gaps_x) <= 1 and len(gaps_y) <= 1
|
||||
_, gap_x = _spacing(cols, _PAGE_WIDTH_MM, w)
|
||||
if gap_x >= 10 and cols > 1:
|
||||
assert left == pytest.approx(gap_x)
|
||||
# Verso : exactement derrière le recto une fois la feuille retournée
|
||||
# sur son bord long (miroir horizontal).
|
||||
for i in range(cols * rows):
|
||||
fx, fy = _cell_origin(i, cols, w, h)
|
||||
bx, by = _cell_origin(i, cols, w, h, mirrored=True)
|
||||
assert bx == pytest.approx(_PAGE_WIDTH_MM - fx - w)
|
||||
assert by == pytest.approx(fy)
|
||||
|
||||
|
||||
def test_cr80_gaps_are_even():
|
||||
# 2 × 85,6 mm sur 210 mm : 12,9 mm à gauche, entre les deux, à droite.
|
||||
assert _spacing(2, 210, 85.6) == pytest.approx((12.9333, 12.9333), abs=1e-3)
|
||||
|
||||
@@ -174,3 +174,25 @@ def test_a_night_that_passes_midnight_stays_on_its_festival_day(client, edition,
|
||||
text = reader.pages[0].extract_text()
|
||||
assert "jeudi 9 juillet" in text.lower()
|
||||
assert "01:00" in text
|
||||
|
||||
|
||||
def test_organisation_message_is_passed_as_the_page_footer(client, edition, session, monkeypatch):
|
||||
import app.api.position_sheets as sheets_api
|
||||
|
||||
captured = {}
|
||||
original = sheets_api.generate_position_sheets_pdf
|
||||
|
||||
def spy(sheets, footer=None):
|
||||
captured["footer"] = footer
|
||||
return original(sheets, footer)
|
||||
|
||||
monkeypatch.setattr(sheets_api, "generate_position_sheets_pdf", spy)
|
||||
position = make_position(session, edition)
|
||||
make_shift(session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22))
|
||||
|
||||
assert client.post(_url(edition), json={}).status_code == 200
|
||||
assert captured["footer"] is None
|
||||
|
||||
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Repas à 18h30"})
|
||||
assert client.post(_url(edition), json={}).status_code == 200
|
||||
assert captured["footer"].message == "Repas à 18h30"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import io
|
||||
|
||||
from app.sheets.pdf import (
|
||||
PositionDaySheet,
|
||||
@@ -142,3 +143,86 @@ def test_generates_a_valid_pdf_without_a_lineup():
|
||||
assert sheet.artists == ()
|
||||
pdf = generate_position_sheets_pdf([sheet])
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def _phone_photo(seed: int) -> bytes:
|
||||
"""Photo de téléphone : 3000 × 4000 px, plusieurs Mo."""
|
||||
from PIL import Image as PILImage
|
||||
|
||||
img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60))
|
||||
img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200))
|
||||
out = io.BytesIO()
|
||||
img.save(out, format="JPEG", quality=92)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_large_photos_are_shrunk_to_the_thumbnail():
|
||||
# Régression : la photo d'origine était intégrée telle quelle pour
|
||||
# chaque bénévole de chaque créneau — génération interminable, PDF
|
||||
# énorme, puis erreur.
|
||||
photos = [_phone_photo(i) for i in range(5)]
|
||||
volunteers = tuple(
|
||||
SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos)
|
||||
)
|
||||
pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)])
|
||||
assert sum(len(p) for p in photos) > 2_000_000
|
||||
assert len(pdf) < 200_000
|
||||
|
||||
|
||||
def test_unreadable_photo_does_not_break_the_sheet():
|
||||
pdf = generate_position_sheets_pdf(
|
||||
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
|
||||
)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def _page_texts(pdf: bytes) -> list[str]:
|
||||
from pypdf import PdfReader
|
||||
|
||||
return [page.extract_text() for page in PdfReader(io.BytesIO(pdf)).pages]
|
||||
|
||||
|
||||
def test_footer_message_is_printed_at_the_bottom_of_every_page():
|
||||
from app.sheets.pdf import SheetFooter
|
||||
|
||||
# Deux feuilles, dont une assez longue pour déborder sur une 2e page.
|
||||
many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(120))
|
||||
pdf = generate_position_sheets_pdf(
|
||||
[_sheet(volunteers=many), _sheet()],
|
||||
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"),
|
||||
)
|
||||
pages = _page_texts(pdf)
|
||||
assert len(pages) >= 3
|
||||
for text in pages:
|
||||
assert "Message de l'organisation" in text
|
||||
assert "Repas à 18h30" in text
|
||||
assert "Badges à l'accueil" in text
|
||||
|
||||
|
||||
def test_no_footer_without_message():
|
||||
pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
|
||||
assert "Message de l'organisation" not in pages[0]
|
||||
|
||||
|
||||
def test_volunteers_are_listed_two_per_row():
|
||||
from app.sheets.pdf import _shift_table, _styles
|
||||
|
||||
shift = _sheet(
|
||||
volunteers=tuple(SheetVolunteer(name=f"B{i}", phone=None, photo=None) for i in range(5))
|
||||
).shifts[0]
|
||||
table = _shift_table(shift, {}, _styles(), 180)
|
||||
# 5 bénévoles, deux par ligne : 3 lignes (la dernière à moitié vide).
|
||||
assert len(table._cellvalues) == 3
|
||||
|
||||
|
||||
def test_a_typical_sheet_fits_on_one_page():
|
||||
volunteers = tuple(
|
||||
SheetVolunteer(name=f"Prénom{i} Nom{i}", phone="06 12 34 56 78", photo=_PNG)
|
||||
for i in range(8)
|
||||
)
|
||||
shift = _sheet(volunteers=volunteers).shifts[0]
|
||||
sheet = _sheet(volunteers=volunteers)
|
||||
many_shifts = PositionDaySheet(
|
||||
**{**sheet.__dict__, "shifts": (shift, shift, shift, shift)}
|
||||
)
|
||||
assert len(_page_texts(generate_position_sheets_pdf([many_shifts]))) == 1
|
||||
|
||||
@@ -461,6 +461,64 @@ function checkDayBoundary() {
|
||||
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
|
||||
}
|
||||
|
||||
// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu
|
||||
// avec les vraies données (nom du premier bénévole dans l'ordre
|
||||
// d'impression), contrôle avant génération présent ; le mock refuse un même
|
||||
// type de champ deux fois sur une face, mais l'accepte sur les deux.
|
||||
async function checkBadgeEditor() {
|
||||
if (!coordinator) throw new Error('badges : pas de session coordination pour ce test');
|
||||
const base = {
|
||||
x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const,
|
||||
bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const,
|
||||
};
|
||||
let duplicate = 'non';
|
||||
try {
|
||||
await api.badges.setFields(e, {
|
||||
fields: [
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
duplicate = err instanceof ApiError ? err.code : String(err);
|
||||
}
|
||||
if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`);
|
||||
await api.badges.setFields(e, {
|
||||
fields: [
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||
{ ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false },
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' },
|
||||
{ ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' },
|
||||
],
|
||||
});
|
||||
const vols = [...(await api.volunteers.list(e))].sort(
|
||||
(a, b) =>
|
||||
a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'),
|
||||
);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await prefetchCore(client);
|
||||
await Promise.all([
|
||||
client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }),
|
||||
client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }),
|
||||
client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }),
|
||||
]);
|
||||
const html = renderToString(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(
|
||||
AuthContext.Provider,
|
||||
{ value: authValue(coordinator) },
|
||||
createElement(MemoryRouter, null, createElement(BadgesPage)),
|
||||
),
|
||||
),
|
||||
);
|
||||
const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des'];
|
||||
const missing = expected.filter((text) => !html.includes(text));
|
||||
if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`);
|
||||
console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
try {
|
||||
checkDayBoundary();
|
||||
@@ -484,7 +542,7 @@ if (coordinator) {
|
||||
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||
await renderProtected('/badges', coordinator, createElement(BadgesPage));
|
||||
await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err));
|
||||
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
|
||||
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
|
||||
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
|
||||
|
||||
@@ -37,6 +37,9 @@ import type {
|
||||
ShiftUpdate,
|
||||
VolunteerCreate,
|
||||
VolunteerUpdate,
|
||||
AssignmentRead,
|
||||
AttendanceStatus,
|
||||
TestEmailRequest,
|
||||
} from './schema';
|
||||
|
||||
const edition = EDITION_ID;
|
||||
@@ -157,8 +160,14 @@ export function useMatrix() {
|
||||
|
||||
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
|
||||
* recalculées côté serveur à chaque appel. */
|
||||
/** Rafraîchie toutes les 30 s tant que l'écran est ouvert : la section
|
||||
* « Présence » suit le pointage des référents en temps réel. */
|
||||
export function useStats() {
|
||||
return useQuery({ queryKey: ['stats', edition] as const, queryFn: () => api.stats.get(edition) });
|
||||
return useQuery({
|
||||
queryKey: ['stats', edition] as const,
|
||||
queryFn: () => api.stats.get(edition),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Configuration du recto/verso des badges (images, champs). */
|
||||
@@ -342,6 +351,14 @@ export function useUpdateAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetLoginLink() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.accounts.resetLoginLink(edition, id),
|
||||
onSuccess: invalidate.accounts,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAccount() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
@@ -523,6 +540,32 @@ export function useUpdateAssignment() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Pointage présent / absent (`null` annule), enregistré en base. Mis à
|
||||
* jour tout de suite dans les listes d'affectations en cache (le référent
|
||||
* pointe vite, sur un réseau de festival souvent lent), puis rechargé. */
|
||||
export function useSetAttendance() {
|
||||
const client = useQueryClient();
|
||||
const patch = (id: string, change: Partial<AssignmentRead>) =>
|
||||
client.setQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] }, (list) =>
|
||||
list?.map((a) => (a.id === id ? { ...a, ...change } : a)),
|
||||
);
|
||||
return useMutation({
|
||||
mutationFn: ({ id, attendance }: { id: string; attendance: AttendanceStatus | null }) =>
|
||||
api.assignments.setAttendance(edition, id, { attendance }),
|
||||
onMutate: async ({ id, attendance }) => {
|
||||
await client.cancelQueries({ queryKey: ['assignments', edition] });
|
||||
const previous = client.getQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] });
|
||||
patch(id, { attendance });
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _vars, context) => {
|
||||
for (const [key, data] of context?.previous ?? []) client.setQueryData(key, data);
|
||||
},
|
||||
onSuccess: (saved) => patch(saved.id, saved),
|
||||
onSettled: () => client.invalidateQueries({ queryKey: ['assignments', edition] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAssignment() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
@@ -552,6 +595,12 @@ export function useGeneratePositionSheets() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestEmail() {
|
||||
return useMutation({
|
||||
mutationFn: (body: TestEmailRequest) => api.notifications.sendTest(edition, body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendPlanningNotification() {
|
||||
return useMutation({
|
||||
mutationFn: (body: PlanningNotificationRequest) =>
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
VerifyRequest,
|
||||
VolunteerCreate,
|
||||
VolunteerUpdate,
|
||||
AttendanceUpdate,
|
||||
TestEmailRequest,
|
||||
} from './schema';
|
||||
import { getAuthToken, notifyUnauthorized } from './session';
|
||||
import { ApiError, type PlanningApi } from './types';
|
||||
@@ -181,6 +183,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
remove: (editionId, id) =>
|
||||
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
||||
resetLoginLink: (editionId, id) =>
|
||||
request(`${base(editionId)}/accounts/${id}/reset-login-link`, { method: 'POST' }),
|
||||
},
|
||||
imports: {
|
||||
analyze: (editionId, file: File) => {
|
||||
@@ -232,6 +236,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
remove: (editionId, id) =>
|
||||
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
|
||||
setAttendance: (editionId, id, body: AttendanceUpdate) =>
|
||||
request(`${base(editionId)}/assignments/${id}/attendance`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
validate: (editionId, body: AssignmentValidateRequest) =>
|
||||
request(`${base(editionId)}/assignments/validate`, {
|
||||
method: 'POST',
|
||||
@@ -372,6 +381,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
sendTest: (editionId, body: TestEmailRequest) =>
|
||||
request(`${base(editionId)}/notifications/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
login: (editionId, body: LoginRequest) =>
|
||||
|
||||
@@ -195,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string {
|
||||
return `${n(firstName)}.${n(lastName)}@mail.fr`;
|
||||
}
|
||||
|
||||
const DEMO_POSITION_MESSAGES: Record<string, string> = {
|
||||
'Parking — Nord': 'Gilet jaune fourni à la base bénévoles. Lampe frontale conseillée le soir.',
|
||||
'Bar — Grande scène': 'Caisse à récupérer auprès du référent en début de créneau. Pas de vente aux mineurs.',
|
||||
};
|
||||
|
||||
export function buildMockState(): MockState {
|
||||
const rnd = mulberry32(20260715);
|
||||
const edition_id = MOCK_EDITION_ID;
|
||||
@@ -206,6 +211,9 @@ export function buildMockState(): MockState {
|
||||
location: p.location,
|
||||
category: p.category,
|
||||
lead_account_id: null,
|
||||
// Quelques consignes de démo (voir `PositionRead.message`).
|
||||
message: DEMO_POSITION_MESSAGES[p.name] ?? null,
|
||||
message_updated_at: DEMO_POSITION_MESSAGES[p.name] ? NOW : null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
}));
|
||||
@@ -290,6 +298,7 @@ export function buildMockState(): MockState {
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -373,6 +382,8 @@ export function buildMockState(): MockState {
|
||||
shift_id: shift.id,
|
||||
source: 'SOLVER',
|
||||
locked: false,
|
||||
attendance: null,
|
||||
attendance_marked_at: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
@@ -402,6 +413,8 @@ export function buildMockState(): MockState {
|
||||
shift_id: shift.id,
|
||||
source: 'MANUAL',
|
||||
locked: false,
|
||||
attendance: null,
|
||||
attendance_marked_at: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
@@ -465,6 +478,9 @@ export function buildMockState(): MockState {
|
||||
start_date: DAY_DATES[0],
|
||||
end_date: DAY_DATES[DAY_DATES.length - 1],
|
||||
retention_days: 365,
|
||||
volunteer_message:
|
||||
"Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00. Repas chaud servi au catering de 17:30 à 18:30.",
|
||||
volunteer_message_updated_at: NOW,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
||||
import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
|
||||
|
||||
import type {
|
||||
AccountRead,
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
VolunteerRead,
|
||||
EditionStats,
|
||||
} from '../schema';
|
||||
import { MESSAGE_MAX_LENGTH } from '../schema';
|
||||
import { getAuthToken } from '../session';
|
||||
import { ApiError, type PlanningApi } from '../types';
|
||||
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
|
||||
@@ -235,6 +236,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
has_photo: pending.photo !== null,
|
||||
companion_wish_note: pending.companionWishNote,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: pending.available,
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: pending.paymentLabel,
|
||||
@@ -592,6 +594,53 @@ function buildStats(): EditionStats {
|
||||
}),
|
||||
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
|
||||
},
|
||||
attendance: buildAttendance(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Même calcul que `app/api/stats.py:_attendance`. */
|
||||
function buildAttendance(): EditionStats['attendance'] {
|
||||
const now = nowNaiveMinutes();
|
||||
const empty = () => ({ assigned: 0, present: 0, absent: 0, unmarked: 0 });
|
||||
const count = (c: ReturnType<typeof empty>, a: AssignmentRead) => {
|
||||
c.assigned += 1;
|
||||
if (a.attendance === 'PRESENT') c.present += 1;
|
||||
else if (a.attendance === 'ABSENT') c.absent += 1;
|
||||
else c.unmarked += 1;
|
||||
};
|
||||
const past = empty();
|
||||
const live = empty();
|
||||
const rows = new Map(
|
||||
state.positions.map((p) => [
|
||||
p.id,
|
||||
{ position_id: p.id, name: p.name, past: empty(), live: empty(), live_shifts: 0 },
|
||||
]),
|
||||
);
|
||||
const phase = new Map<string, 'past' | 'live'>();
|
||||
for (const s of state.shifts) {
|
||||
if (naiveMinutes(s.ends_at) <= now) phase.set(s.id, 'past');
|
||||
else if (naiveMinutes(s.starts_at) <= now) {
|
||||
phase.set(s.id, 'live');
|
||||
const row = rows.get(s.position_id);
|
||||
if (row) row.live_shifts += 1;
|
||||
}
|
||||
}
|
||||
for (const a of state.assignments) {
|
||||
const when = phase.get(a.shift_id);
|
||||
const shift = state.shifts.find((s) => s.id === a.shift_id);
|
||||
const row = shift ? rows.get(shift.position_id) : undefined;
|
||||
if (!when || !row) continue;
|
||||
count(when === 'past' ? past : live, a);
|
||||
count(when === 'past' ? row.past : row.live, a);
|
||||
}
|
||||
return {
|
||||
as_of: touch(),
|
||||
past,
|
||||
live,
|
||||
live_shifts: [...rows.values()].reduce((n, r) => n + r.live_shifts, 0),
|
||||
by_position: [...rows.values()]
|
||||
.filter((r) => r.past.assigned || r.live.assigned || r.live_shifts)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -835,6 +884,14 @@ function runSolver(): SolverRunResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** Message (organisation, poste) comme l'API : bords retirés, vide = plus
|
||||
* de message, 2000 caractères au plus. */
|
||||
function checkMessage(value: string | null | undefined): string | null {
|
||||
const text = (value ?? '').trim() || null;
|
||||
if (text && text.length > MESSAGE_MAX_LENGTH) throw new ApiError(422, 'error.generic');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createMockApi(): PlanningApi {
|
||||
return {
|
||||
editions: {
|
||||
@@ -846,6 +903,13 @@ export function createMockApi(): PlanningApi {
|
||||
if (body.start_date) next.start_date = body.start_date;
|
||||
if (body.end_date) next.end_date = body.end_date;
|
||||
if ('retention_days' in body) next.retention_days = body.retention_days ?? null;
|
||||
if ('volunteer_message' in body) {
|
||||
const text = checkMessage(body.volunteer_message);
|
||||
if (text !== next.volunteer_message) {
|
||||
next.volunteer_message = text;
|
||||
next.volunteer_message_updated_at = text ? touch() : null;
|
||||
}
|
||||
}
|
||||
if (next.end_date < next.start_date) {
|
||||
throw new ApiError(422, 'edition.dates_inconsistent');
|
||||
}
|
||||
@@ -997,6 +1061,7 @@ export function createMockApi(): PlanningApi {
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1042,6 +1107,14 @@ export function createMockApi(): PlanningApi {
|
||||
? { companion_wish_note: body.companion_wish_note ?? null }
|
||||
: {}),
|
||||
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
|
||||
...(body.badge_count != null
|
||||
? (() => {
|
||||
if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) {
|
||||
throw new ApiError(422, 'error.generic');
|
||||
}
|
||||
return { badge_count: body.badge_count };
|
||||
})()
|
||||
: {}),
|
||||
...('self_declared_available' in body && body.self_declared_available != null
|
||||
? { self_declared_available: body.self_declared_available }
|
||||
: {}),
|
||||
@@ -1114,6 +1187,8 @@ export function createMockApi(): PlanningApi {
|
||||
position_location: position?.location ?? null,
|
||||
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
|
||||
referent_phone: referent?.phone ?? null,
|
||||
position_message: position?.message ?? null,
|
||||
position_message_updated_at: position?.message_updated_at ?? null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||
@@ -1165,6 +1240,9 @@ export function createMockApi(): PlanningApi {
|
||||
account.updated_at = touch();
|
||||
return delay(account);
|
||||
},
|
||||
// Pas de lien permanent en démo (liens magiques simulés) : rien à
|
||||
// révoquer, le compte est renvoyé tel quel.
|
||||
resetLoginLink: (_edition, id) => delay(state.accounts.find((a) => a.id === id) ?? notFound()),
|
||||
remove: (_edition, id) => {
|
||||
state.accounts = state.accounts.filter((a) => a.id !== id);
|
||||
return delay(undefined);
|
||||
@@ -1321,6 +1399,7 @@ export function createMockApi(): PlanningApi {
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1373,6 +1452,8 @@ export function createMockApi(): PlanningApi {
|
||||
location: body.location ?? null,
|
||||
category: body.category,
|
||||
lead_account_id: body.lead_account_id ?? null,
|
||||
message: null,
|
||||
message_updated_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1384,6 +1465,13 @@ export function createMockApi(): PlanningApi {
|
||||
update: (_edition, id, body) => {
|
||||
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
||||
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
|
||||
if ('message' in body) {
|
||||
const text = checkMessage(body.message);
|
||||
if (text !== p.message) {
|
||||
p.message = text;
|
||||
p.message_updated_at = text ? touch() : null;
|
||||
}
|
||||
}
|
||||
Object.assign(p, {
|
||||
...('name' in body ? { name: body.name } : {}),
|
||||
...('location' in body ? { location: body.location ?? null } : {}),
|
||||
@@ -1498,6 +1586,8 @@ export function createMockApi(): PlanningApi {
|
||||
shift_id: body.shift_id,
|
||||
source: body.source ?? 'MANUAL',
|
||||
locked: body.locked ?? false,
|
||||
attendance: null,
|
||||
attendance_marked_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1513,6 +1603,13 @@ export function createMockApi(): PlanningApi {
|
||||
});
|
||||
return delay({ assignment: a, warnings: [] as Violation[] });
|
||||
},
|
||||
setAttendance: (_edition, id, body) => {
|
||||
const a = state.assignments.find((x) => x.id === id) ?? notFound();
|
||||
a.attendance = body.attendance;
|
||||
a.attendance_marked_at = body.attendance ? touch() : null;
|
||||
a.updated_at = touch();
|
||||
return delay({ ...a });
|
||||
},
|
||||
remove: (_edition, id) => {
|
||||
state.assignments = state.assignments.filter((x) => x.id !== id);
|
||||
return delay(undefined);
|
||||
@@ -1577,13 +1674,25 @@ export function createMockApi(): PlanningApi {
|
||||
return (await fetch(state.badgeTemplate.backImage)).blob();
|
||||
},
|
||||
setFields: (_edition, body: BadgeFieldsSet) => {
|
||||
// Un même type de champ peut exister une fois par face (recto/verso),
|
||||
// comme côté back-end.
|
||||
const seen = new Set<string>();
|
||||
const hexColor = /^#[0-9a-fA-F]{6}$/;
|
||||
for (const f of body.fields) {
|
||||
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||
seen.add(f.field_type);
|
||||
const key = `${f.face ?? 'FRONT'}:${f.field_type}`;
|
||||
if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||
seen.add(key);
|
||||
if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) {
|
||||
throw new ApiError(422, 'badge.invalid_color');
|
||||
}
|
||||
}
|
||||
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
||||
...f,
|
||||
face: f.face ?? 'FRONT',
|
||||
bg_color: f.bg_color ?? null,
|
||||
auto_fit: f.auto_fit ?? true,
|
||||
photo_shape: f.photo_shape ?? 'RECT',
|
||||
z_order: f.z_order ?? 0,
|
||||
id: `bf${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
}));
|
||||
@@ -1792,6 +1901,7 @@ export function createMockApi(): PlanningApi {
|
||||
has_photo: photo !== null,
|
||||
companion_wish_note: companionWishNote,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||
fee_payment_label: null,
|
||||
@@ -1945,6 +2055,25 @@ export function createMockApi(): PlanningApi {
|
||||
},
|
||||
|
||||
notifications: {
|
||||
// Pas de serveur SMTP en démo : rien n'est envoyé, comme
|
||||
// `EMAIL_ENABLED=false` côté serveur.
|
||||
sendTest: (_edition, body) => {
|
||||
if (!/^[^\s@]+@[^\s@]+$/.test(body.email.trim())) {
|
||||
throw new ApiError(422, 'notifications.invalid_email');
|
||||
}
|
||||
const assigned = new Set(state.assignments.map((a) => a.volunteer_id));
|
||||
const sorted = [...state.volunteers].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr'));
|
||||
const example =
|
||||
sorted.find((v) => v.id === body.volunteer_id) ?? sorted.find((v) => assigned.has(v.id)) ?? sorted[0];
|
||||
return delay({
|
||||
status: 'disabled' as const,
|
||||
detail: null,
|
||||
smtp_host: 'localhost:25',
|
||||
smtp_from: 'noreply@festival.local',
|
||||
example_volunteer_id: example?.id ?? null,
|
||||
example_volunteer_name: example ? `${example.first_name} ${example.last_name}`.trim() : '',
|
||||
});
|
||||
},
|
||||
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
||||
const ids = body.volunteer_ids ?? null;
|
||||
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
|
||||
|
||||
@@ -39,16 +39,25 @@ export interface EditionRead {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
retention_days: number | null;
|
||||
/** Message de l'organisation, le même pour tous les bénévoles (écran
|
||||
* bénévole), et la date de sa dernière modification. */
|
||||
volunteer_message: string | null;
|
||||
volunteer_message_updated_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Longueur maximale d'un message (organisation, poste) — comme l'API. */
|
||||
export const MESSAGE_MAX_LENGTH = 2000;
|
||||
|
||||
export interface EditionUpdate {
|
||||
name?: string | null;
|
||||
year?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
retention_days?: number | null;
|
||||
/** Vide = plus de message. */
|
||||
volunteer_message?: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -110,6 +119,8 @@ export interface VolunteerRead {
|
||||
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
|
||||
* coordination. */
|
||||
badge_text: string | null;
|
||||
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
|
||||
badge_count: number;
|
||||
self_declared_available: boolean;
|
||||
willing_to_pay_fee: boolean;
|
||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||
@@ -161,6 +172,8 @@ export interface VolunteerUpdate {
|
||||
wished_companion_ids?: string[] | null;
|
||||
companion_wish_note?: string | null;
|
||||
badge_text?: string | null;
|
||||
/** 1 à 100. */
|
||||
badge_count?: number | null;
|
||||
self_declared_available?: boolean | null;
|
||||
willing_to_pay_fee?: boolean | null;
|
||||
}
|
||||
@@ -181,6 +194,9 @@ export interface VolunteerPlanningEntry {
|
||||
* rattaché à une fiche bénévole (pas de téléphone à donner alors). */
|
||||
referent_name: string | null;
|
||||
referent_phone: string | null;
|
||||
/** Message du poste (voir `PositionRead.message`). */
|
||||
position_message: string | null;
|
||||
position_message_updated_at: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -286,6 +302,10 @@ export interface PositionRead {
|
||||
location: string | null;
|
||||
category: PositionCategory;
|
||||
lead_account_id: string | null;
|
||||
/** Message du poste (consignes…), saisi par son référent, montré aux
|
||||
* bénévoles affectés au poste. */
|
||||
message: string | null;
|
||||
message_updated_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -302,6 +322,8 @@ export interface PositionUpdate {
|
||||
location?: string | null;
|
||||
category?: PositionCategory;
|
||||
lead_account_id?: string | null;
|
||||
/** Vide = plus de message. */
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -348,10 +370,20 @@ export interface AssignmentRead {
|
||||
shift_id: string;
|
||||
source: AssignmentSource;
|
||||
locked: boolean;
|
||||
/** Pointage du référent (`null` = pas encore pointé) et son heure. */
|
||||
attendance: AttendanceStatus | null;
|
||||
attendance_marked_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
|
||||
|
||||
export interface AttendanceUpdate {
|
||||
/** `null` = annuler le pointage. */
|
||||
attendance: AttendanceStatus | null;
|
||||
}
|
||||
|
||||
export interface AssignmentCreate {
|
||||
volunteer_id: string;
|
||||
shift_id: string;
|
||||
@@ -433,9 +465,16 @@ export type BadgeFieldType =
|
||||
| 'PHOTO'
|
||||
| 'FREE_TEXT';
|
||||
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||
/** Face du badge où se trouve un champ : chaque type de champ peut exister
|
||||
* une fois par face. */
|
||||
export type BadgeFace = 'FRONT' | 'BACK';
|
||||
/** Forme du cadre de la photo (`PHOTO`). */
|
||||
export type BadgePhotoShape = 'RECT' | 'OVAL';
|
||||
|
||||
export interface BadgeFieldIn {
|
||||
field_type: BadgeFieldType;
|
||||
/** Défaut `FRONT`. */
|
||||
face: BadgeFace;
|
||||
x_mm: number;
|
||||
y_mm: number;
|
||||
width_mm: number;
|
||||
@@ -444,6 +483,16 @@ export interface BadgeFieldIn {
|
||||
align: BadgeAlign;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
/** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */
|
||||
bg_color: string | null;
|
||||
/** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il
|
||||
* ne tient pas dans le cadre. */
|
||||
auto_fit: boolean;
|
||||
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
|
||||
photo_shape: BadgePhotoShape;
|
||||
/** Ordre d'empilement sur la face : un champ recouvre ceux d'un
|
||||
* `z_order` inférieur (photo dessous à égalité). */
|
||||
z_order: number;
|
||||
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
||||
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
||||
text?: string | null;
|
||||
@@ -555,6 +604,28 @@ export interface PlanningNotificationResult {
|
||||
recipients: string[];
|
||||
}
|
||||
|
||||
/** Résultat de l'email de test (`POST .../notifications/test`). */
|
||||
export interface TestEmailResult {
|
||||
status: 'sent' | 'disabled' | 'connection_failed' | 'rejected';
|
||||
/** Message du serveur SMTP en cas d'échec. */
|
||||
detail: string | null;
|
||||
smtp_host: string;
|
||||
smtp_from: string;
|
||||
/** Bénévole dont les données ont rempli le modèle (`null` : exemple
|
||||
* fictif). */
|
||||
example_volunteer_id: string | null;
|
||||
example_volunteer_name: string;
|
||||
}
|
||||
|
||||
/** Email de test : le récapitulatif de planning en cours d'édition, rempli
|
||||
* avec les données d'un bénévole, envoyé à `email`. */
|
||||
export interface TestEmailRequest {
|
||||
email: string;
|
||||
subject?: string | null;
|
||||
body?: string | null;
|
||||
volunteer_id?: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Formulaire public d'inscription //
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -792,4 +863,30 @@ export interface EditionStats {
|
||||
by_position: StatsNeedPosition[];
|
||||
volunteers_with_assignment: number;
|
||||
};
|
||||
attendance: StatsAttendance;
|
||||
}
|
||||
|
||||
/** Pointage d'un ensemble d'affectations : `assigned` = `present` +
|
||||
* `absent` + `unmarked`. */
|
||||
export interface StatsAttendanceCounts {
|
||||
assigned: number;
|
||||
present: number;
|
||||
absent: number;
|
||||
unmarked: number;
|
||||
}
|
||||
|
||||
/** Présents / absents pointés, sur les créneaux terminés (`past`) et en
|
||||
* cours (`live`) à `as_of` ; les créneaux à venir ne comptent pas. */
|
||||
export interface StatsAttendance {
|
||||
as_of: string;
|
||||
past: StatsAttendanceCounts;
|
||||
live: StatsAttendanceCounts;
|
||||
live_shifts: number;
|
||||
by_position: Array<{
|
||||
position_id: string;
|
||||
name: string;
|
||||
past: StatsAttendanceCounts;
|
||||
live: StatsAttendanceCounts;
|
||||
live_shifts: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ import type {
|
||||
VolunteerRead,
|
||||
VolunteerUpdate,
|
||||
EditionStats,
|
||||
AttendanceUpdate,
|
||||
TestEmailRequest,
|
||||
TestEmailResult,
|
||||
} from './schema';
|
||||
|
||||
/**
|
||||
@@ -108,6 +111,9 @@ export interface PlanningApi {
|
||||
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
|
||||
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
/** Révoque le lien de connexion permanent (récapitulatif de planning) :
|
||||
* les liens déjà envoyés cessent de fonctionner. */
|
||||
resetLoginLink(editionId: string, id: string): Promise<AccountRead>;
|
||||
};
|
||||
imports: {
|
||||
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
||||
@@ -140,6 +146,7 @@ export interface PlanningApi {
|
||||
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
|
||||
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
|
||||
validate(
|
||||
editionId: string,
|
||||
body: AssignmentValidateRequest,
|
||||
@@ -222,6 +229,8 @@ export interface PlanningApi {
|
||||
editionId: string,
|
||||
body: PlanningNotificationRequest,
|
||||
): Promise<PlanningNotificationResult>;
|
||||
/** Email de test, envoyé tout de suite (COORDINATION). */
|
||||
sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
|
||||
};
|
||||
auth: {
|
||||
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import { MESSAGE_MAX_LENGTH } from '@/api/schema';
|
||||
import { Btn } from '@/components/ui';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { messageDateLabel } from '@/lib/slotTimeline';
|
||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
/**
|
||||
* Saisie d'un message montré aux bénévoles — celui de l'organisation
|
||||
* (coordination, `ConfigMessage`) ou celui d'un poste (référent,
|
||||
* `MobileLeadPage`). Enregistré sur demande ; vider le champ retire le
|
||||
* message.
|
||||
*/
|
||||
export function MessageEditor({
|
||||
value,
|
||||
updatedAt,
|
||||
placeholder,
|
||||
saving,
|
||||
error,
|
||||
onSave,
|
||||
dark = false,
|
||||
}: {
|
||||
value: string | null;
|
||||
updatedAt: string | null;
|
||||
placeholder: string;
|
||||
saving: boolean;
|
||||
error: unknown;
|
||||
onSave: (text: string) => Promise<unknown>;
|
||||
/** Sur fond sombre (en-tête de l'écran référent). */
|
||||
dark?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const now = useNowMinutes();
|
||||
// Le message enregistré change (autre poste choisi, autre appareil) :
|
||||
// le brouillon repart de lui.
|
||||
useEffect(() => setDraft(value ?? ''), [value]);
|
||||
|
||||
const dirty = draft.trim() !== (value ?? '');
|
||||
const tooLong = draft.length > MESSAGE_MAX_LENGTH;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
setSaved(false);
|
||||
}}
|
||||
rows={4}
|
||||
placeholder={placeholder}
|
||||
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[14px] font-semibold leading-snug text-ink"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Btn
|
||||
disabled={!dirty || tooLong || saving}
|
||||
onClick={() => {
|
||||
void onSave(draft.trim()).then(() => setSaved(true), () => undefined);
|
||||
}}
|
||||
>
|
||||
{saving ? t('message.saving') : draft.trim() ? t('message.save') : t('message.remove')}
|
||||
</Btn>
|
||||
<span
|
||||
className={cx(
|
||||
'font-mono text-[10px] leading-snug',
|
||||
tooLong ? 'font-bold text-orange' : 'opacity-65',
|
||||
)}
|
||||
>
|
||||
{saved && !dirty
|
||||
? t('message.saved')
|
||||
: value && updatedAt && !dirty
|
||||
? t('message.updatedAt', { when: messageDateLabel(updatedAt, now) })
|
||||
: t('message.length', { count: draft.length, max: MESSAGE_MAX_LENGTH })}
|
||||
</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className={cx('font-mono text-[10.5px] font-bold', dark ? 'text-orange' : 'text-orange-dark')}>
|
||||
{error instanceof ApiError ? t(error.code) : t('error.generic')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { t } from '@/i18n';
|
||||
|
||||
/** Repère horizontal « MAINTENANT », à insérer dans une liste triée
|
||||
* chronologiquement (créneaux d'un bénévole, créneaux d'un poste…) au point
|
||||
* correspondant à l'heure réelle. Purement visuel. */
|
||||
export function NowLine() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1" aria-hidden="true">
|
||||
<div className="h-[3px] flex-1 bg-orange" />
|
||||
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
|
||||
{t('common.now')}
|
||||
</div>
|
||||
<div className="h-[3px] flex-1 bg-orange" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
"action.remove": "Retirer",
|
||||
"action.add": "Ajouter",
|
||||
"app.loading": "Chargement…",
|
||||
"common.now": "MAINTENANT",
|
||||
"app.error.generic": "Une erreur est survenue",
|
||||
"app.error.reference": "Référence à communiquer : {id}",
|
||||
"error.generic": "Erreur inattendue",
|
||||
@@ -107,28 +106,89 @@
|
||||
"volunteers.filter.all": "TOUS",
|
||||
"volunteers.filter.unassigned": "SANS AFFECTATION",
|
||||
"volunteers.legend.assigned": "AFFECTATION",
|
||||
"volunteers.legend.overlap": "DEUX POSTES EN MÊME TEMPS",
|
||||
"volunteers.legend.now": "MAINTENANT",
|
||||
"volunteers.bar.title": "{position} · {time}",
|
||||
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste",
|
||||
"volunteers.search.placeholder": "Rechercher un bénévole ou un poste…",
|
||||
"volunteers.search.label": "Rechercher un bénévole ou un poste",
|
||||
"volunteers.search.empty": "Aucun bénévole ni poste ne correspond à « {query} ».",
|
||||
"volunteer.unnamed": "Sans nom",
|
||||
"volunteers.count": "{count} BÉNÉVOLES",
|
||||
"volunteers.totalHours": "TOTAL H",
|
||||
"volunteers.sort.label": "TRIER PAR",
|
||||
"volunteers.sort.name": "NOM",
|
||||
"volunteers.sort.hoursAsc": "HEURES ↑",
|
||||
"volunteers.sort.hoursDesc": "HEURES ↓",
|
||||
"volunteers.sort.toggle": "Trier par total d'heures (croissant / décroissant)",
|
||||
"volunteers.name": "NOM",
|
||||
"volunteers.cumulative": "CUMUL",
|
||||
"volunteers.badge.unassigned": "○ OUBLIÉ",
|
||||
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
|
||||
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
|
||||
"volunteers.footnote": "Oublié = aucune affectation.",
|
||||
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
|
||||
"mobileVol.greeting": "SALUT {name}",
|
||||
"mobileVol.slots": "{count} CRÉNEAUX",
|
||||
"mobileVol.hours": "{count} HEURES",
|
||||
"mobileVol.location": "📍 {location}",
|
||||
"mobileVol.referent": "👤 Responsable : {name}",
|
||||
"mobileVol.phone": "📞 {phone}",
|
||||
"mobileVol.greeting": "Salut {name}",
|
||||
"mobileVol.summary.one": "{count} créneau · {duration} sur {days} jour",
|
||||
"mobileVol.summary.other": "{count} créneaux · {duration} sur {days} jour(s)",
|
||||
"mobileVol.summary.none": "Pas encore de créneau.",
|
||||
"mobileVol.featured.now": "● EN COURS",
|
||||
"mobileVol.featured.next": "PROCHAIN CRÉNEAU",
|
||||
"mobileVol.featured.overlap": "⚠ Tu es aussi sur {position} de {start} à {end}. Vérifie avec la coordination.",
|
||||
"mobileVol.featured.referent": "TON RÉFÉRENT",
|
||||
"mobileVol.featured.call": "APPELER",
|
||||
"mobileVol.allDone": "Festival terminé. Merci {name}, tu as assuré !",
|
||||
"mobileVol.day.one": "{count} créneau · {duration}",
|
||||
"mobileVol.day.other": "{count} créneaux · {duration}",
|
||||
"mobileVol.slot.done": "TERMINÉ",
|
||||
"mobileVol.slot.now": "EN COURS",
|
||||
"mobileVol.slot.referent": " · réf. {name}",
|
||||
"mobileVol.slot.overlap": "⚠ Chevauche {position} de {start} à {end}. Vérifie avec la coordination où tu dois être.",
|
||||
"slot.duration.hours": "{h} h",
|
||||
"slot.duration.hoursMinutes": "{h} h {m}",
|
||||
"slot.countdown.endsAt": "FIN À {time}",
|
||||
"slot.countdown.minutes": "DANS {count} MIN",
|
||||
"slot.countdown.in": "DANS {duration}",
|
||||
"slot.countdown.days": "DANS {count} J",
|
||||
"mobileVol.orgMessage.title": "MESSAGE DE L'ORGANISATION",
|
||||
"mobileVol.orgMessage.signature": "Coordination · {when}",
|
||||
"message.save": "Enregistrer",
|
||||
"message.remove": "Retirer le message",
|
||||
"message.saving": "Enregistrement…",
|
||||
"message.saved": "✓ Enregistré",
|
||||
"message.updatedAt": "Publié {when}",
|
||||
"message.length": "{count} / {max} caractères",
|
||||
"message.date.today": "aujourd'hui {time}",
|
||||
"message.date.yesterday": "hier {time}",
|
||||
"message.date.other": "{day} · {time}",
|
||||
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
|
||||
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
|
||||
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
|
||||
"mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT",
|
||||
"mobileLead.kicker": "Ton équipe",
|
||||
"mobileLead.kickerNamed": "{name} · ton équipe",
|
||||
"mobileLead.status.done": "terminé",
|
||||
"mobileLead.status.now": "en cours",
|
||||
"mobileLead.status.next": "à venir",
|
||||
"mobileLead.count.present": "PRÉSENTS",
|
||||
"mobileLead.count.waiting": "ATTENDUS",
|
||||
"mobileLead.count.absent": "ABSENTS",
|
||||
"mobileLead.absentAlert.one": "⚠ 1 absent sur le créneau — il manque du monde",
|
||||
"mobileLead.absentAlert.other": "⚠ {count} absents sur le créneau — il manque du monde",
|
||||
"mobileLead.roll": "POINTAGE · {count} BÉNÉVOLE(S)",
|
||||
"mobileLead.when.now": "Créneau en cours",
|
||||
"mobileLead.when.next": "Début {time}",
|
||||
"mobileLead.when.done": "Créneau terminé",
|
||||
"mobileLead.call": "APPEL",
|
||||
"mobileLead.callLabel": "Appeler {name}",
|
||||
"mobileLead.message.title": "MESSAGE À TON ÉQUIPE",
|
||||
"mobileLead.message.audience": "{count} bénévole(s) le verront",
|
||||
"mobileLead.message.help": "Consignes du poste (tenue, matériel, point de rendez-vous…), affichées sur l'écran de chaque bénévole affecté au poste.",
|
||||
"mobileLead.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.",
|
||||
"mobileLead.view.team": "Mon équipe",
|
||||
"mobileLead.view.planning": "Mon planning · {count}",
|
||||
"mobileLead.you": "TOI",
|
||||
"mobileLead.markedAt": "pointé à {time}",
|
||||
"mobileLead.attendanceError": "Le pointage n'a pas pu être enregistré (réseau ?). Réessaie.",
|
||||
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
|
||||
"mobileLead.noShift": "Aucun créneau sur ce poste.",
|
||||
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
|
||||
"mobileLead.badgePresent": "✓ PRÉSENT",
|
||||
"mobileLead.badgeAbsent": "✕ ABSENT",
|
||||
"mobileLead.markPresent": "PRÉSENT",
|
||||
@@ -138,6 +198,31 @@
|
||||
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
||||
"config.tab.grid": "POSTES & CRÉNEAUX",
|
||||
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
||||
"config.mail.test.title": "Tester l'envoi d'emails",
|
||||
"config.mail.test.help": "Envoie tout de suite à l'adresse de votre choix le récapitulatif ci-contre tel que les bénévoles le recevront : sujet (précédé de « [Test] ») et texte en cours de saisie, variables remplies avec le planning d'un bénévole. Le lien de connexion y mène à la page de connexion, sans ouvrir le compte de ce bénévole.",
|
||||
"config.mail.test.example": "Rempli avec le planning de",
|
||||
"config.mail.test.exampleAuto": "Automatique (premier bénévole avec un créneau)",
|
||||
"config.mail.test.withLink": "Avec le lien de connexion (comme « Envoyer avec lien de connexion »)",
|
||||
"config.mail.test.filledWith": "Contenu rempli avec le planning de {name}.",
|
||||
"config.mail.test.filledWithExample": "Aucun bénévole : contenu rempli avec un exemple fictif.",
|
||||
"config.mail.test.email": "Adresse de test",
|
||||
"config.mail.test.placeholder": "moi@exemple.fr",
|
||||
"config.mail.test.send": "Envoyer un email de test",
|
||||
"config.mail.test.sending": "Envoi en cours…",
|
||||
"config.mail.test.result.sent": "✓ Email envoyé. Vérifiez la boîte de réception (et les indésirables).",
|
||||
"config.mail.test.result.disabled": "L'envoi d'emails est désactivé sur ce serveur (EMAIL_ENABLED=false) : rien n'a été envoyé.",
|
||||
"config.mail.test.result.connection_failed": "✕ Connexion au serveur d'envoi impossible (adresse, port ou identifiants SMTP).",
|
||||
"config.mail.test.result.rejected": "✕ Le serveur d'envoi a refusé l'email (souvent : expéditeur non autorisé).",
|
||||
"config.mail.test.serverSaid": "Réponse du serveur : {detail}",
|
||||
"config.mail.test.config": "Serveur {host} · expéditeur {from}",
|
||||
"notifications.invalid_email": "Adresse email invalide.",
|
||||
"config.tab.message": "MESSAGE AUX BÉNÉVOLES",
|
||||
"config.message.orgTitle": "Message de l'organisation",
|
||||
"config.message.orgHelp": "Le même pour tous les bénévoles, affiché en bas de leur écran planning. Laissez vide pour ne rien afficher.",
|
||||
"config.message.orgPlaceholder": "Ex. Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00.",
|
||||
"config.message.positionsTitle": "Messages des postes",
|
||||
"config.message.positionsHelp": "Saisis par chaque référent depuis son écran et montrés aux bénévoles du poste. Vous pouvez aussi les modifier ici.",
|
||||
"config.message.none": "Pas de message",
|
||||
"config.tab.mail": "MESSAGES",
|
||||
"config.tab.rules": "RÈGLES & ÉDITION",
|
||||
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
||||
@@ -178,7 +263,7 @@
|
||||
"config.mail.subject": "OBJET",
|
||||
"config.mail.body": "CORPS DU MESSAGE",
|
||||
"config.mail.variables": "VARIABLES DISPONIBLES",
|
||||
"config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère un lien de connexion personnel.",
|
||||
"config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère le lien de connexion directe permanent du bénévole (un clic suffit, sans limite de durée ; crée son accès s'il n'en a pas encore) — révocable depuis sa fiche. {creneaux} insère le tableau de ses créneaux.",
|
||||
"config.mail.send": "ENVOYER LE RÉCAPITULATIF",
|
||||
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
|
||||
"config.mail.sending": "ENVOI EN COURS…",
|
||||
@@ -231,6 +316,8 @@
|
||||
"config.vol.field.phone": "Téléphone",
|
||||
"config.vol.field.comment": "Commentaire",
|
||||
"config.vol.field.badgeText": "Texte du badge",
|
||||
"config.vol.field.badgeCount": "Nombre de badges",
|
||||
"config.vol.field.badgeCount.help": "Nombre d'exemplaires de son badge imprimés sur les planches (1 à 100). 1 par défaut.",
|
||||
"config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
|
||||
"config.vol.photo.title": "Photo",
|
||||
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
|
||||
@@ -240,11 +327,15 @@
|
||||
"config.vol.photo.remove": "Retirer",
|
||||
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
|
||||
"config.vol.access.title": "Accès de connexion",
|
||||
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.",
|
||||
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : il faut un accès donné ici, ou l'envoi d'un récapitulatif de planning avec {lien_connexion} (qui en crée un).",
|
||||
"config.vol.access.grant": "Donner un accès de connexion",
|
||||
"config.vol.access.active": "Accès actif : {email}",
|
||||
"config.vol.access.revoke": "Révoquer",
|
||||
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
|
||||
"config.vol.access.resetLink": "Renouveler le lien de connexion",
|
||||
"config.vol.access.resetLinkHint": "Le lien de connexion des récapitulatifs de planning est permanent. Le renouveler rend inutilisables tous les liens déjà envoyés (email transféré, téléphone perdu…) ; le prochain récapitulatif en portera un nouveau.",
|
||||
"config.vol.access.resetLinkConfirm": "Rendre inutilisables les liens de connexion déjà envoyés à ce bénévole ?",
|
||||
"config.vol.access.resetLinkDone": "✓ Anciens liens révoqués",
|
||||
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
|
||||
"config.vol.field.referentPositions": "Référent des postes",
|
||||
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
|
||||
@@ -253,7 +344,6 @@
|
||||
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
|
||||
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
|
||||
"config.vol.create": "CRÉER LE BÉNÉVOLE",
|
||||
"config.vol.requiredNames": "Le prénom et le nom sont obligatoires.",
|
||||
"config.vol.wiz.title": "IMPORTER UN FICHIER",
|
||||
"config.vol.wiz.step1": "1 · FICHIER",
|
||||
"config.vol.wiz.step2": "2 · COLONNES",
|
||||
@@ -384,13 +474,129 @@
|
||||
"badges.saved": "Enregistré ✓",
|
||||
"badges.generate.title": "Génération de la planche PDF",
|
||||
"badges.generate.all": "Tous les bénévoles ({count})",
|
||||
"badges.generate.total": "{count} badge(s) à imprimer",
|
||||
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
|
||||
"badges.generate.action": "Générer le PDF",
|
||||
"badges.generating": "Génération…",
|
||||
"badges.face.front": "Recto",
|
||||
"badges.face.back": "Verso",
|
||||
"badges.face.front.short": "recto",
|
||||
"badges.face.back.short": "verso",
|
||||
"badges.dims": "{width} × {height} mm",
|
||||
"badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)",
|
||||
"badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)",
|
||||
"badges.format.title": "Format du badge",
|
||||
"badges.format.current": "Format actuel",
|
||||
"badges.preset.cr80Portrait": "Carte CR80 portrait",
|
||||
"badges.preset.cr80Landscape": "Carte CR80 paysage",
|
||||
"badges.preset.a7": "A7 tour de cou",
|
||||
"badges.fieldsOf": "Champs du {face}",
|
||||
"badges.fieldsShown": "{count} affiché(s)",
|
||||
"badges.layer.toggle": "Afficher / masquer",
|
||||
"badges.layer.hidden": "masqué",
|
||||
"badges.layer.meta": "{size} pt · {weight}",
|
||||
"badges.layer.bold": "gras",
|
||||
"badges.layer.regular": "normal",
|
||||
"badges.layer.rect": "rectangle",
|
||||
"badges.layer.oval": "ovale",
|
||||
"badges.layer.issueCount": "▲ {count}",
|
||||
"badges.layer.issueTruncated.one": "{count} badge avec texte coupé",
|
||||
"badges.layer.issueTruncated.other": "{count} badges avec texte coupé",
|
||||
"badges.layer.issueReduced.one": "{count} badge réduit automatiquement",
|
||||
"badges.layer.issueReduced.other": "{count} badges réduits automatiquement",
|
||||
"badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.",
|
||||
"badges.undo": "↶ Annuler",
|
||||
"badges.undo.title": "Ctrl/Cmd + Z",
|
||||
"badges.dirty": "Modifications non enregistrées",
|
||||
"badges.clean": "✓ Enregistré",
|
||||
"badges.savedAt": "✓ Enregistré à {time}",
|
||||
"badges.sample.firstName": "Prénom",
|
||||
"badges.sample.lastName": "Nom",
|
||||
"badges.preview.with": "Aperçu avec",
|
||||
"badges.preview.prev": "Bénévole précédent",
|
||||
"badges.preview.next": "Bénévole suivant",
|
||||
"badges.preview.counter": "Badge {index} / {total} · {role}",
|
||||
"badges.preview.noVolunteer": "Aucun bénévole : exemple fictif",
|
||||
"badges.preview.longest": "Tester le nom le plus long",
|
||||
"badges.preview.guides": "Repères",
|
||||
"badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ",
|
||||
"badges.preview.zoomIn": "Agrandir l'aperçu",
|
||||
"badges.preview.zoomOut": "Réduire l'aperçu",
|
||||
"badges.preview.zoom": "{percent} %",
|
||||
"badges.preview.noPhoto": "sans photo",
|
||||
"badges.preview.truncated": "▲ TEXTE COUPÉ",
|
||||
"badges.preview.dims": "{width} × {height} mm",
|
||||
"badges.preview.reducedTo": " · réduit à {size} pt",
|
||||
"badges.pt": "{size} pt",
|
||||
"badges.color.white": "Blanc",
|
||||
"badges.color.black": "Noir",
|
||||
"badges.color.yellow": "Jaune",
|
||||
"badges.color.coral": "Corail",
|
||||
"badges.color.none": "Aucun",
|
||||
"badges.color.custom": "Autre",
|
||||
"badges.align.left": "Gauche",
|
||||
"badges.align.center": "Centre",
|
||||
"badges.align.right": "Droite",
|
||||
"badges.inspector.kicker": "{face} · champ sélectionné",
|
||||
"badges.inspector.content": "Contenu",
|
||||
"badges.inspector.contentHelp": "Identique sur tous les badges — sauf pour un bénévole dont la fiche porte un « Texte du badge », qui le remplace.",
|
||||
"badges.inspector.fontSize": "Taille du texte",
|
||||
"badges.inspector.reducedHere": "Ici réduit à {size} pt",
|
||||
"badges.inspector.capHeight": "Hauteur ≈ {mm} mm",
|
||||
"badges.inspector.smaller": "Diminuer la taille",
|
||||
"badges.inspector.larger": "Augmenter la taille",
|
||||
"badges.inspector.autoFit": "Réduire automatiquement si le texte est trop long",
|
||||
"badges.inspector.align": "Alignement",
|
||||
"badges.inspector.weight": "Graisse",
|
||||
"badges.inspector.regular": "Normal",
|
||||
"badges.inspector.bold": "Gras",
|
||||
"badges.inspector.textColor": "Couleur du texte",
|
||||
"badges.inspector.background": "Fond de la zone",
|
||||
"badges.inspector.lowContrast": "⚠ Contraste faible : le texte risque d'être illisible à l'impression.",
|
||||
"badges.inspector.photoShape": "Forme de la photo",
|
||||
"badges.inspector.photoRect": "▢ Rectangle",
|
||||
"badges.inspector.photoOval": "◯ Ovale",
|
||||
"badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo, le cadre reste vide.",
|
||||
"badges.inspector.geometry": "Position et taille (mm)",
|
||||
"badges.inspector.x": "X",
|
||||
"badges.inspector.y": "Y",
|
||||
"badges.inspector.w": "L",
|
||||
"badges.inspector.h": "H",
|
||||
"badges.inspector.center": "↔ Centrer",
|
||||
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
|
||||
"badges.inspector.stack": "Superposition",
|
||||
"badges.inspector.stack.position": "{rank} / {total} · 1 = dessus",
|
||||
"badges.inspector.stack.front": "⤒ Premier plan",
|
||||
"badges.inspector.stack.forward": "↑ Avancer",
|
||||
"badges.inspector.stack.backward": "↓ Reculer",
|
||||
"badges.inspector.stack.back": "⤓ Arrière-plan",
|
||||
"badges.inspector.stack.help": "Quand des champs se chevauchent, celui du dessus recouvre les autres, sur l'aperçu comme à l'impression.",
|
||||
"badges.inspector.hide": "Masquer ce champ",
|
||||
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
|
||||
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
|
||||
"badges.inspector.empty.drop": "Pour changer le visuel de fond, déposez une image directement sur le recto ou le verso.",
|
||||
"badges.check.title": "Contrôle des {count} badges",
|
||||
"badges.check.subtitle": "Avant génération des planches",
|
||||
"badges.check.critical": "⛔ À corriger",
|
||||
"badges.check.warning": "⚠ À vérifier",
|
||||
"badges.check.fixAutoFit": "Activer la réduction auto",
|
||||
"badges.check.showExample": "Voir un exemple",
|
||||
"badges.check.ok": "Les {count} badges s'affichent sans texte coupé ni champ hors zone de découpe.",
|
||||
"badges.check.estimate": "Estimation faite dans le navigateur : le PDF généré reste la référence.",
|
||||
"badges.issue.where": "{field} ({face})",
|
||||
"badges.issue.truncated.one": "{where} : texte coupé sur {count} badge.",
|
||||
"badges.issue.truncated.other": "{where} : texte coupé sur {count} badges.",
|
||||
"badges.issue.reduced.one": "{where} : réduit automatiquement sur {count} badge, jusqu'à {size} pt.",
|
||||
"badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.",
|
||||
"badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.",
|
||||
"badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.",
|
||||
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : le cadre restera vide sur son badge.",
|
||||
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : le cadre restera vide sur leur badge.",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.",
|
||||
"badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).",
|
||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
@@ -401,6 +607,22 @@
|
||||
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
||||
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
||||
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
|
||||
"stats.attendance.title": "Présence",
|
||||
"stats.attendance.help": "Présents et absents pointés par les référents sur leur écran, par poste. « En ce moment » : créneaux commencés et pas encore finis ; « Créneaux terminés » : tout ce qui est fini. Les créneaux à venir ne comptent pas. Mis à jour toutes les 30 secondes.",
|
||||
"stats.attendance.live": "En ce moment",
|
||||
"stats.attendance.past": "Créneaux terminés",
|
||||
"stats.attendance.asOf": "mis à jour à {time}",
|
||||
"stats.attendance.noLive": "Aucun créneau en cours.",
|
||||
"stats.attendance.noPast": "Aucun créneau terminé pour l'instant.",
|
||||
"stats.attendance.liveSummary": "{shifts} créneau(x) en cours · {expected} bénévole(s) attendu(s)",
|
||||
"stats.attendance.shiftsCount": "{count} en cours",
|
||||
"stats.attendance.expected": "Attendus",
|
||||
"stats.attendance.present": "Présents",
|
||||
"stats.attendance.absent": "Absents",
|
||||
"stats.attendance.unmarked": "Non pointés",
|
||||
"stats.attendance.rate": "Présence",
|
||||
"stats.attendance.rateTip": "{present} présent(s) sur {marked} pointé(s)",
|
||||
"stats.attendance.unmarkedHint": "{count} affectation(s) sur des créneaux terminés n'ont jamais été pointées : le taux de présence ne porte que sur les bénévoles pointés.",
|
||||
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
|
||||
"stats.showTable": "Voir le tableau",
|
||||
"stats.unit.hours": "h",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
DayPartRead,
|
||||
MatrixResponse,
|
||||
PositionCategory,
|
||||
ShiftRead,
|
||||
Violation,
|
||||
@@ -108,22 +107,6 @@ export function candidateChanceScore(params: {
|
||||
return score;
|
||||
}
|
||||
|
||||
/** Colonne de la grille : un créneau distinct (date, heure de début, heure de
|
||||
* fin) — pas seulement une heure de début : deux créneaux qui démarrent à la
|
||||
* même heure mais durent différemment ont chacun leur colonne. */
|
||||
export interface LatticeColumn {
|
||||
key: string; // `YYYY-MM-DD|HH:MM|HH:MM`
|
||||
date: string;
|
||||
time: string;
|
||||
endTime: string;
|
||||
label: string; // « 17:00 → 22:00 »
|
||||
}
|
||||
|
||||
export interface LatticeDay {
|
||||
date: string;
|
||||
columns: LatticeColumn[];
|
||||
}
|
||||
|
||||
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
|
||||
* long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
|
||||
* le dernier de la journée se termine) — même règle que
|
||||
@@ -169,68 +152,6 @@ export function dayBoundaryFromDayParts(
|
||||
return clockLabel(bestStart);
|
||||
}
|
||||
|
||||
/** Construit le treillis de colonnes à partir de créneaux (grille alignée).
|
||||
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par
|
||||
* « journée de festival » plutôt que par date calendaire brute : un créneau
|
||||
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
|
||||
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
|
||||
* 19:00 — voir CLAUDE.md § 3. */
|
||||
export function buildLattice(
|
||||
starts: Array<{ starts_at: string; ends_at: string }>,
|
||||
dayBoundary = '00:00',
|
||||
): {
|
||||
days: LatticeDay[];
|
||||
columns: LatticeColumn[];
|
||||
} {
|
||||
const boundaryMinutes = minutesOfDay(dayBoundary);
|
||||
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
|
||||
for (const s of starts) {
|
||||
const date = datePart(s.starts_at);
|
||||
const time = timePart(s.starts_at);
|
||||
const endTime = timePart(s.ends_at);
|
||||
const key = `${date}|${time}|${endTime}`;
|
||||
if (!seen.has(key)) {
|
||||
const minutes = minutesOfDay(time);
|
||||
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
|
||||
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
|
||||
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
|
||||
}
|
||||
}
|
||||
const ordered = [...seen.values()].sort(
|
||||
(a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes,
|
||||
);
|
||||
const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({
|
||||
key,
|
||||
date,
|
||||
time,
|
||||
endTime,
|
||||
label,
|
||||
}));
|
||||
const days: LatticeDay[] = [];
|
||||
for (const column of ordered) {
|
||||
let day = days.find((d) => d.date === column.festivalDay);
|
||||
if (!day) {
|
||||
day = { date: column.festivalDay, columns: [] };
|
||||
days.push(day);
|
||||
}
|
||||
day.columns.push({
|
||||
key: column.key,
|
||||
date: column.date,
|
||||
time: column.time,
|
||||
endTime: column.endTime,
|
||||
label: column.label,
|
||||
});
|
||||
}
|
||||
return { days, columns };
|
||||
}
|
||||
|
||||
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
|
||||
return buildLattice(
|
||||
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
|
||||
dayBoundary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Largeur (px) des tranches de 30 min d'une journée, en fonction de leur
|
||||
* nombre : une journée courte (peu de tranches) reste lisible en large,
|
||||
@@ -247,9 +168,8 @@ export function columnWidthPx(slotsInDay: number): number {
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
|
||||
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
|
||||
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. //
|
||||
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », //
|
||||
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
|
||||
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
|
||||
// pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
|
||||
// --------------------------------------------------------------------------- //
|
||||
const TIME_GRID_SLOT_MINUTES = 30;
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Aides des écrans bénévole et référent (`VolunteerHomeView`,
|
||||
// `MobileLeadPage`) : statut d'un créneau par rapport à maintenant, durées,
|
||||
// compte à rebours et frise horaire d'une journée. Tout en minutes
|
||||
// « murales » (`naiveMinutes` / `nowNaiveMinutes`), jamais en `Date` locale.
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import { longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
|
||||
export type SlotStatus = 'done' | 'now' | 'next';
|
||||
|
||||
export function slotStatus(start: number, end: number, now: number): SlotStatus {
|
||||
if (now >= end) return 'done';
|
||||
return now >= start ? 'now' : 'next';
|
||||
}
|
||||
|
||||
/** `HH:MM` d'un instant en minutes naïves (passe minuit sans broncher). */
|
||||
export function clockLabel(minutes: number): string {
|
||||
const m = ((minutes % 1440) + 1440) % 1440;
|
||||
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** « 9 h », « 2 h 30 ». */
|
||||
export function durationLabel(minutes: number): string {
|
||||
const total = Math.round(minutes);
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return m ? t('slot.duration.hoursMinutes', { h, m: String(m).padStart(2, '0') }) : t('slot.duration.hours', { h });
|
||||
}
|
||||
|
||||
/** Compte à rebours du créneau mis en avant : « FIN À 03:00 » s'il est en
|
||||
* cours, sinon « DANS 20 MIN », « DANS 2 h 30 » ou « DANS 3 J ». */
|
||||
export function countdownLabel(start: number, end: number, now: number): string {
|
||||
if (now >= start) return t('slot.countdown.endsAt', { time: clockLabel(end) });
|
||||
const diff = start - now;
|
||||
if (diff < 60) return t('slot.countdown.minutes', { count: Math.max(1, Math.round(diff)) });
|
||||
if (diff < 24 * 60) return t('slot.countdown.in', { duration: durationLabel(diff) });
|
||||
return t('slot.countdown.days', { count: Math.round(diff / (24 * 60)) });
|
||||
}
|
||||
|
||||
export interface Interval {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Plage commune à deux créneaux qui se chevauchent (`null` sinon) —
|
||||
* jointifs (fin = début) : pas de chevauchement, comme `SHIFT_OVERLAP`. */
|
||||
export function overlapOf(a: Interval, b: Interval): Interval | null {
|
||||
const start = Math.max(a.start, b.start);
|
||||
const end = Math.min(a.end, b.end);
|
||||
return start < end ? { start, end } : null;
|
||||
}
|
||||
|
||||
export interface RibbonBar {
|
||||
/** Position et largeur en % de la frise. */
|
||||
left: number;
|
||||
width: number;
|
||||
/** Ligne de la frise : deux créneaux qui se chevauchent ne se masquent
|
||||
* pas, le second descend d'une ligne. */
|
||||
lane: number;
|
||||
}
|
||||
|
||||
export interface Ribbon {
|
||||
bars: RibbonBar[];
|
||||
lanes: number;
|
||||
/** Repères horaires, en % de la frise. */
|
||||
ticks: Array<{ left: number; label: string }>;
|
||||
/** Nombre d'heures couvertes (une graduation par heure). */
|
||||
hours: number;
|
||||
/** Position de maintenant en %, `null` hors de la frise. */
|
||||
now: number | null;
|
||||
}
|
||||
|
||||
const MIN_RIBBON_HOURS = 4;
|
||||
const MAX_TICKS = 4;
|
||||
|
||||
/** Frise d'une journée : de l'heure pleine qui précède le premier créneau à
|
||||
* celle qui suit le dernier (4 h au moins), `slots` triés par début. */
|
||||
export function buildRibbon(slots: Interval[], now: number): Ribbon {
|
||||
const first = Math.floor(Math.min(...slots.map((s) => s.start)) / 60) * 60;
|
||||
const last = Math.ceil(Math.max(...slots.map((s) => s.end)) / 60) * 60;
|
||||
const hours = Math.max(MIN_RIBBON_HOURS, (last - first) / 60);
|
||||
const span = hours * 60;
|
||||
const pct = (minutes: number) => ((minutes - first) / span) * 100;
|
||||
|
||||
const laneEnds: number[] = [];
|
||||
const bars = slots.map((s) => {
|
||||
let lane = 0;
|
||||
while (laneEnds[lane] !== undefined && laneEnds[lane] > s.start) lane++;
|
||||
laneEnds[lane] = s.end;
|
||||
return { left: pct(s.start), width: ((s.end - s.start) / span) * 100, lane };
|
||||
});
|
||||
|
||||
const step = Math.ceil(hours / (MAX_TICKS - 1));
|
||||
const ticks = [];
|
||||
for (let h = 0; h <= hours; h += step) ticks.push({ left: (h / hours) * 100, label: clockLabel(first + h * 60) });
|
||||
|
||||
return {
|
||||
bars,
|
||||
lanes: Math.max(1, laneEnds.length),
|
||||
ticks,
|
||||
hours,
|
||||
now: now >= first && now <= first + span ? pct(now) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Date d'un message (organisation, poste) : « AUJOURD'HUI 18:42 »,
|
||||
* « HIER 18:42 », sinon « Samedi 31 · 18:42 ». */
|
||||
export function messageDateLabel(iso: string, now: number): string {
|
||||
const at = naiveMinutes(iso);
|
||||
const days = Math.floor(now / 1440) - Math.floor(at / 1440);
|
||||
const time = clockLabel(at);
|
||||
if (days === 0) return t('message.date.today', { time });
|
||||
if (days === 1) return t('message.date.yesterday', { time });
|
||||
return t('message.date.other', { day: longDayLabel(iso), time });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Nom et initiales d'un bénévole à l'écran. Prénom et nom ne sont pas
|
||||
// obligatoires à l'ajout manuel (étape 66) : un bénévole peut n'avoir ni
|
||||
// l'un ni l'autre, il doit rester visible et sélectionnable partout.
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
type Named = { first_name: string; last_name: string };
|
||||
|
||||
/** « Prénom Nom », ou « Sans nom » si les deux sont vides. */
|
||||
export function volunteerName(v: Named): string {
|
||||
return `${v.first_name} ${v.last_name}`.trim() || t('volunteer.unnamed');
|
||||
}
|
||||
|
||||
/** Initiales (prénom puis nom), « ? » si les deux sont vides. */
|
||||
export function volunteerInitials(v: Named): string {
|
||||
return `${v.first_name.trim().charAt(0)}${v.last_name.trim().charAt(0)}`.toUpperCase() || '?';
|
||||
}
|
||||
@@ -1,158 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
useBadgeImageUrl,
|
||||
useBadgeTemplate,
|
||||
useGenerateBadges,
|
||||
useSetBadgeFields,
|
||||
useSetBadgeSize,
|
||||
useUploadBadgeBackImage,
|
||||
useUploadBadgeFrontImage,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
|
||||
import { useGenerateBadges, useVolunteers } from '@/api/hooks';
|
||||
import { ApiError } from '@/api';
|
||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { Btn, Chip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { volunteerName } from '@/lib/volunteer';
|
||||
|
||||
// Le format du badge est dérivé de l'image de fond du recto (voir
|
||||
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
|
||||
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
|
||||
// l'écran ; les coordonnées enregistrées restent en mm.
|
||||
const DEFAULT_BADGE_W_MM = 85.6;
|
||||
const DEFAULT_BADGE_H_MM = 54;
|
||||
const SCALE = 4.6; // px par mm à l'écran (au plus)
|
||||
// Marges horizontales autour de l'aperçu (page + panneau + bordures), pour
|
||||
// le faire tenir dans un écran étroit.
|
||||
const PREVIEW_GUTTER_PX = 90;
|
||||
import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
|
||||
|
||||
/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un
|
||||
* badge à pleine taille (téléphone, tablette en portrait). Les coordonnées
|
||||
* enregistrées restent en mm, seule la conversion px ↔ mm suit l'échelle. */
|
||||
function useEditorScale(badgeWidthMm: number): number {
|
||||
const [viewport, setViewport] = useState(() =>
|
||||
typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth,
|
||||
);
|
||||
useEffect(() => {
|
||||
const onResize = () => setViewport(window.innerWidth);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
return Math.min(SCALE, Math.max(1, (viewport - PREVIEW_GUTTER_PX) / badgeWidthMm));
|
||||
}
|
||||
|
||||
type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||
|
||||
const FIELD_TYPES: BadgeFieldType[] = [
|
||||
'FULL_NAME',
|
||||
'POSITIONS',
|
||||
'CATEGORY',
|
||||
'ROLE',
|
||||
'PHOTO',
|
||||
'FREE_TEXT',
|
||||
];
|
||||
|
||||
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
|
||||
FULL_NAME: 'badges.field.fullName',
|
||||
POSITIONS: 'badges.field.positions',
|
||||
CATEGORY: 'badges.field.category',
|
||||
ROLE: 'badges.field.role',
|
||||
PHOTO: 'badges.field.photo',
|
||||
FREE_TEXT: 'badges.field.freeText',
|
||||
};
|
||||
|
||||
// Indication affichée à côté du nom du champ, pour ceux dont l'origine du
|
||||
// contenu n'est pas évidente.
|
||||
const FIELD_HELP_KEY: Partial<Record<BadgeFieldType, string>> = {
|
||||
PHOTO: 'badges.field.photo.help',
|
||||
FREE_TEXT: 'badges.field.freeText.help',
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
FULL_NAME: 'badges.field.fullName.placeholder',
|
||||
POSITIONS: 'badges.field.positions.placeholder',
|
||||
CATEGORY: 'badges.field.category.placeholder',
|
||||
ROLE: 'badges.field.role.placeholder',
|
||||
PHOTO: 'badges.field.photo.placeholder',
|
||||
FREE_TEXT: 'badges.field.freeText.placeholder',
|
||||
};
|
||||
|
||||
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||
// verticalement pour ne pas se superposer (repères pour un badge CR80 ;
|
||||
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
|
||||
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
|
||||
// que les autres champs.
|
||||
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
|
||||
FULL_NAME: {
|
||||
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
|
||||
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
|
||||
},
|
||||
POSITIONS: {
|
||||
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
|
||||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||||
},
|
||||
CATEGORY: {
|
||||
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
|
||||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||||
},
|
||||
ROLE: {
|
||||
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
|
||||
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
|
||||
},
|
||||
// Format portrait d'identité (≈ 3,5 × 4,5 cm réduit), en haut à droite.
|
||||
// Taille de police/couleur sans effet : la photo est une image.
|
||||
PHOTO: {
|
||||
x_mm: 58, y_mm: 4, width_mm: 22, height_mm: 28,
|
||||
font_size: 8, align: 'CENTER', bold: false, color: '#000000',
|
||||
},
|
||||
FREE_TEXT: {
|
||||
x_mm: 5, y_mm: 39, width_mm: 50, height_mm: 7,
|
||||
font_size: 9, align: 'LEFT', bold: true, color: '#000000',
|
||||
},
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
|
||||
* décalé s'il dépasse). Les positions par défaut sont pensées pour un CR80
|
||||
* paysage (85,6 × 54 mm) : sur un badge plus étroit (portrait…), un champ
|
||||
* activé pouvait tomber hors du badge — invisible, et les bornes négatives
|
||||
* du glisser-déposer le bloquaient (bug corrigé une fois : photo
|
||||
* impossible à déplacer ni redimensionner). */
|
||||
function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
|
||||
const width_mm = Math.min(field.width_mm, badgeW);
|
||||
const height_mm = Math.min(field.height_mm, badgeH);
|
||||
return {
|
||||
...field,
|
||||
width_mm,
|
||||
height_mm,
|
||||
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
|
||||
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
|
||||
};
|
||||
}
|
||||
|
||||
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
|
||||
const byType = new Map(saved.map((f) => [f.field_type, f]));
|
||||
const out = {} as Record<BadgeFieldType, FieldState>;
|
||||
for (const type of FIELD_TYPES) {
|
||||
const existing = byType.get(type);
|
||||
out[type] = existing
|
||||
? { ...existing, enabled: true }
|
||||
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
|
||||
// données, contrôle avant génération) vit dans `./badges/`.
|
||||
|
||||
export function BadgesPage() {
|
||||
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||
const tabBar = (
|
||||
<>
|
||||
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
|
||||
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
|
||||
{t('badges.tab.customize')}
|
||||
@@ -160,518 +24,21 @@ export function BadgesPage() {
|
||||
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
|
||||
{t('badges.tab.generate')}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Personnalisation //
|
||||
// --------------------------------------------------------------------------- //
|
||||
function BadgeCustomizeTab() {
|
||||
const template = useBadgeTemplate();
|
||||
const frontUrl = useBadgeImageUrl(
|
||||
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
|
||||
);
|
||||
const backUrl = useBadgeImageUrl(
|
||||
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
|
||||
);
|
||||
const uploadFront = useUploadBadgeFrontImage();
|
||||
const uploadBack = useUploadBadgeBackImage();
|
||||
const setFields = useSetBadgeFields();
|
||||
const setSize = useSetBadgeSize();
|
||||
|
||||
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
||||
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
||||
|
||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
||||
|
||||
useEffect(() => {
|
||||
if (!template.data) return;
|
||||
const loaded = fieldsFromServer(template.data.fields);
|
||||
for (const type of FIELD_TYPES) loaded[type] = fitToBadge(loaded[type], badgeW, badgeH);
|
||||
setFieldsState(loaded);
|
||||
}, [template.data, badgeW, badgeH]);
|
||||
const scale = useEditorScale(badgeW);
|
||||
|
||||
if (template.isLoading || !fields) return <Loading />;
|
||||
if (template.error) return <ErrorNote error={template.error} />;
|
||||
|
||||
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
||||
setFieldsState((prev) =>
|
||||
prev
|
||||
? { ...prev, [type]: fitToBadge({ ...prev[type], ...patch }, badgeW, badgeH) }
|
||||
: prev,
|
||||
);
|
||||
// Un champ qu'on vient d'activer passe au premier plan, sélectionné.
|
||||
if (patch.enabled) setSelected(type);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map(
|
||||
(type) => {
|
||||
const f = fields[type];
|
||||
return {
|
||||
field_type: f.field_type,
|
||||
x_mm: f.x_mm,
|
||||
y_mm: f.y_mm,
|
||||
width_mm: f.width_mm,
|
||||
height_mm: f.height_mm,
|
||||
font_size: f.font_size,
|
||||
align: f.align,
|
||||
bold: f.bold,
|
||||
color: f.color,
|
||||
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
setFields.mutate({ fields: payload });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4 lg:flex-row lg:items-start">
|
||||
<div className="flex flex-none flex-col gap-4">
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
{t('badges.front')}
|
||||
<HelpTip text={t('badges.front.help')} className="ml-[6px]" />
|
||||
</PanelHeader>
|
||||
<div className="p-3">
|
||||
<ImageUploadRow
|
||||
url={frontUrl}
|
||||
pending={uploadFront.isPending}
|
||||
error={uploadFront.error}
|
||||
onPick={(file) => uploadFront.mutate(file)}
|
||||
/>
|
||||
<BadgeSizeControl
|
||||
badgeW={badgeW}
|
||||
badgeH={badgeH}
|
||||
pending={setSize.isPending}
|
||||
onApply={(width_mm, height_mm) =>
|
||||
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||||
style={{
|
||||
width: badgeW * scale,
|
||||
height: badgeH * scale,
|
||||
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
||||
}}
|
||||
onPointerDown={() => setSelected(null)}
|
||||
>
|
||||
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
|
||||
<FieldBox
|
||||
key={type}
|
||||
field={fields[type]}
|
||||
badgeW={badgeW}
|
||||
badgeH={badgeH}
|
||||
scale={scale}
|
||||
selected={selected === type}
|
||||
onSelect={() => setSelected(type)}
|
||||
onChange={(patch) => updateField(type, patch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
|
||||
{t('badges.editor.hint', {
|
||||
width: Math.round(badgeW * 10) / 10,
|
||||
height: Math.round(badgeH * 10) / 10,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
{t('badges.back')}
|
||||
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
|
||||
</PanelHeader>
|
||||
<div className="p-3">
|
||||
<ImageUploadRow
|
||||
url={backUrl}
|
||||
pending={uploadBack.isPending}
|
||||
error={uploadBack.error}
|
||||
onPick={(file) => uploadBack.mutate(file)}
|
||||
/>
|
||||
<div
|
||||
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
||||
style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
|
||||
<PanelHeader>{t('badges.fields')}</PanelHeader>
|
||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||
{FIELD_TYPES.map((type) => (
|
||||
<FieldEditorRow
|
||||
key={type}
|
||||
type={type}
|
||||
field={fields[type]}
|
||||
active={selected === type}
|
||||
onFocus={() => setSelected(type)}
|
||||
onChange={(patch) => updateField(type, patch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
|
||||
<Btn onClick={save} disabled={setFields.isPending}>
|
||||
{setFields.isPending ? t('badges.saving') : t('badges.save')}
|
||||
</Btn>
|
||||
{setFields.isSuccess ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
|
||||
) : null}
|
||||
{setFields.isError ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||
{errorMessage(setFields.error)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageUploadRow({
|
||||
url,
|
||||
pending,
|
||||
error,
|
||||
onPick,
|
||||
}: {
|
||||
url: string | null;
|
||||
pending: boolean;
|
||||
error: unknown;
|
||||
onPick: (file: File) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
|
||||
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
|
||||
</Btn>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onPick(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
|
||||
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
|
||||
* l'image de fond n'était pas à une résolution d'impression (300 dpi
|
||||
* supposés à l'import, voir `useBadgeTemplate`). */
|
||||
function BadgeSizeControl({
|
||||
badgeW,
|
||||
badgeH,
|
||||
pending,
|
||||
onApply,
|
||||
}: {
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
pending: boolean;
|
||||
onApply: (width_mm: number, height_mm: number) => void;
|
||||
}) {
|
||||
const rounded = Math.round(badgeW * 10) / 10;
|
||||
const [width, setWidth] = useState(String(rounded));
|
||||
|
||||
useEffect(() => setWidth(String(rounded)), [rounded]);
|
||||
|
||||
const apply = () => {
|
||||
const w = Number(width);
|
||||
if (!Number.isFinite(w) || w <= 0) {
|
||||
setWidth(String(rounded));
|
||||
return;
|
||||
}
|
||||
const ratio = badgeH / badgeW;
|
||||
onApply(w, Math.round(w * ratio * 10) / 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-[8px]">
|
||||
<FieldLabel>{t('badges.size.width')}</FieldLabel>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
step={1}
|
||||
value={width}
|
||||
disabled={pending}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
onBlur={apply}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') apply();
|
||||
}}
|
||||
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
||||
/>
|
||||
<span className="font-mono text-[9.5px] opacity-60">
|
||||
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
badgeW,
|
||||
badgeH,
|
||||
scale,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldState;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
scale: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
}) {
|
||||
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
||||
null,
|
||||
);
|
||||
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const startDrag = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
||||
};
|
||||
|
||||
const onDragMove = (e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const d = dragRef.current;
|
||||
const dxMm = (e.clientX - d.pointerXPx) / scale;
|
||||
const dyMm = (e.clientY - d.pointerYPx) / scale;
|
||||
onChange({
|
||||
x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, Math.max(0, badgeH - field.height_mm)),
|
||||
});
|
||||
};
|
||||
|
||||
const startResize = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
||||
};
|
||||
|
||||
const onResizeMove = (e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const d = resizeRef.current;
|
||||
const dwMm = (e.clientX - d.pointerXPx) / scale;
|
||||
const dhMm = (e.clientY - d.pointerYPx) / scale;
|
||||
const maxW = badgeW - field.x_mm;
|
||||
const maxH = badgeH - field.y_mm;
|
||||
onChange({
|
||||
width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW),
|
||||
height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={() => (dragRef.current = null)}
|
||||
className={cx(
|
||||
'absolute flex cursor-move touch-none items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
|
||||
selected ? 'border-blue-strong' : 'border-ink/40',
|
||||
)}
|
||||
style={{
|
||||
left: field.x_mm * scale,
|
||||
top: field.y_mm * scale,
|
||||
width: field.width_mm * scale,
|
||||
height: field.height_mm * scale,
|
||||
// Le champ sélectionné passe devant les autres : sinon un champ
|
||||
// recouvert (ex. la photo sous un texte) ne se saisit plus.
|
||||
zIndex: selected ? 10 : undefined,
|
||||
}}
|
||||
>
|
||||
{field.field_type === 'POSITIONS' ? (
|
||||
<PositionsTablePreview field={field} scale={scale} />
|
||||
) : field.field_type === 'PHOTO' ? (
|
||||
<span className="pointer-events-none flex h-full w-full items-center justify-center bg-sand-dark/70 font-mono text-[10px] font-bold uppercase text-ink/60">
|
||||
{t(FIELD_PLACEHOLDER_KEY.PHOTO)}
|
||||
</span>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{tab === 'customize' ? (
|
||||
<BadgeCustomizeTab tabBar={tabBar} />
|
||||
) : (
|
||||
<span
|
||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||
style={{
|
||||
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
|
||||
fontWeight: field.bold ? 800 : 400,
|
||||
color: field.color,
|
||||
width: '100%',
|
||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{field.field_type === 'FREE_TEXT' && field.text?.trim()
|
||||
? field.text
|
||||
: t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
</span>
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
|
||||
{tabBar}
|
||||
</div>
|
||||
<BadgeGenerateTab />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={() => (resizeRef.current = null)}
|
||||
className="absolute bottom-0 right-0 h-[14px] w-[14px] cursor-nwse-resize touch-none lg:h-[10px] lg:w-[10px] border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aperçu du champ « postes » : le jour en en-tête, puis une ligne
|
||||
* horaire / poste par créneau, comme dans le PDF
|
||||
* (`backend/app/badges/pdf.py:_draw_table`), avec des lignes d'exemple. */
|
||||
function PositionsTablePreview({ field, scale }: { field: FieldState; scale: number }) {
|
||||
const fontSize = field.font_size * (scale / 2.83); // pt -> px approx. à l'échelle du badge
|
||||
const sections = [1, 2].map((n) => ({
|
||||
title: t(`badges.field.positions.sample${n}.day`),
|
||||
rows: t(`badges.field.positions.sample${n}.rows`)
|
||||
.split('\n')
|
||||
.map((row) => row.split('|')),
|
||||
}));
|
||||
const cell = 'border px-[0.8mm] py-[0.5mm] text-left align-top';
|
||||
const border = { borderColor: field.color };
|
||||
return (
|
||||
<table
|
||||
className="pointer-events-none w-full border-collapse font-sans leading-tight"
|
||||
style={{ fontSize, color: field.color }}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<tbody key={section.title}>
|
||||
<tr>
|
||||
<th colSpan={2} className={cell} style={border}>
|
||||
{section.title}
|
||||
</th>
|
||||
</tr>
|
||||
{section.rows.map(([time, position]) => (
|
||||
<tr key={time}>
|
||||
<td className={cx(cell, 'w-px whitespace-nowrap')} style={border}>
|
||||
{time}
|
||||
</td>
|
||||
<td className={cell} style={border}>
|
||||
{position}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldEditorRow({
|
||||
type,
|
||||
field,
|
||||
active,
|
||||
onFocus,
|
||||
onChange,
|
||||
}: {
|
||||
type: BadgeFieldType;
|
||||
field: FieldState;
|
||||
active: boolean;
|
||||
onFocus: () => void;
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.enabled}
|
||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||
className="h-[16px] w-[16px] accent-ink"
|
||||
/>
|
||||
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
||||
{t(FIELD_LABEL_KEY[type])}
|
||||
</span>
|
||||
</label>
|
||||
{FIELD_HELP_KEY[type] ? (
|
||||
<HelpTip text={t(FIELD_HELP_KEY[type])} className="ml-[6px]" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{field.enabled && type === 'FREE_TEXT' ? (
|
||||
<input
|
||||
type="text"
|
||||
value={field.text ?? ''}
|
||||
onChange={(e) => onChange({ text: e.target.value })}
|
||||
placeholder={t('badges.field.freeText.input')}
|
||||
className="ml-[24px] border-2 border-ink bg-cream px-[8px] py-[5px] font-display text-[12px] font-bold"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* La photo n'a pas de style de texte : seuls sa place et son cadre
|
||||
(sur l'aperçu) comptent. */}
|
||||
{field.enabled && type !== 'PHOTO' ? (
|
||||
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
|
||||
<label className="flex items-center gap-[5px]">
|
||||
<FieldLabel>{t('badges.field.size')}</FieldLabel>
|
||||
<input
|
||||
type="number"
|
||||
min={6}
|
||||
max={36}
|
||||
value={field.font_size}
|
||||
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
|
||||
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-[4px]">
|
||||
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
|
||||
<button
|
||||
key={align}
|
||||
type="button"
|
||||
onClick={() => onChange({ align })}
|
||||
className={cx(
|
||||
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
|
||||
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
||||
)}
|
||||
>
|
||||
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ bold: !field.bold })}
|
||||
className={cx(
|
||||
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
|
||||
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
||||
)}
|
||||
>
|
||||
G
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="color"
|
||||
value={field.color}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -734,6 +101,18 @@ function BadgeGenerateTab() {
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{/* Un bénévole peut demander plusieurs exemplaires (fiche,
|
||||
« Nombre de badges ») : le total imprimé peut dépasser le
|
||||
nombre de bénévoles. */}
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{t('badges.generate.total', {
|
||||
count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce(
|
||||
(sum, v) => sum + v.badge_count,
|
||||
0,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
|
||||
{mode === 'pick' ? (
|
||||
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
||||
{sorted.map((v) => (
|
||||
@@ -747,7 +126,10 @@ function BadgeGenerateTab() {
|
||||
onChange={() => toggle(v.id)}
|
||||
className="h-[14px] w-[14px] accent-ink"
|
||||
/>
|
||||
{v.first_name} {v.last_name}
|
||||
{volunteerName(v)}
|
||||
{v.badge_count > 1 ? (
|
||||
<span className="ml-auto opacity-60">× {v.badge_count}</span>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,16 +4,18 @@ import { Chip } from '@/components/ui';
|
||||
import { ConfigArtists } from '@/pages/config/ConfigArtists';
|
||||
import { ConfigGrid } from '@/pages/config/ConfigGrid';
|
||||
import { ConfigMail } from '@/pages/config/ConfigMail';
|
||||
import { ConfigMessage } from '@/pages/config/ConfigMessage';
|
||||
import { ConfigRules } from '@/pages/config/ConfigRules';
|
||||
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'mail' | 'rules';
|
||||
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'message' | 'mail' | 'rules';
|
||||
|
||||
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
|
||||
{ id: 'grid', labelKey: 'config.tab.grid' },
|
||||
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
|
||||
{ id: 'artists', labelKey: 'config.tab.artists' },
|
||||
{ id: 'message', labelKey: 'config.tab.message' },
|
||||
{ id: 'mail', labelKey: 'config.tab.mail' },
|
||||
{ id: 'rules', labelKey: 'config.tab.rules' },
|
||||
];
|
||||
@@ -38,6 +40,7 @@ export function ConfigPage() {
|
||||
{tab === 'grid' ? <ConfigGrid /> : null}
|
||||
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
|
||||
{tab === 'artists' ? <ConfigArtists /> : null}
|
||||
{tab === 'message' ? <ConfigMessage /> : null}
|
||||
{tab === 'mail' ? <ConfigMail /> : null}
|
||||
{tab === 'rules' ? <ConfigRules /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,61 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { ShiftRead } from '@/api/schema';
|
||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import type { PositionRead, ShiftRead } from '@/api/schema';
|
||||
import {
|
||||
useAssignments,
|
||||
useDayParts,
|
||||
usePositions,
|
||||
useSetAttendance,
|
||||
useShifts,
|
||||
useUpdatePosition,
|
||||
useVolunteer,
|
||||
useVolunteerPlanning,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import { useAuth } from '@/auth/auth-context';
|
||||
import { MessageEditor } from '@/components/MessageEditor';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { NowLine } from '@/components/NowLine';
|
||||
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import { clockLabel, slotStatus, type SlotStatus } from '@/lib/slotTimeline';
|
||||
import { longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
|
||||
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
|
||||
|
||||
// Page réellement consultée par le référent (accueil de son rôle LEAD, cf.
|
||||
// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à
|
||||
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être
|
||||
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa
|
||||
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec
|
||||
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier
|
||||
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait
|
||||
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à
|
||||
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu
|
||||
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un
|
||||
// écran référent vide (`VolunteerHomeView`).
|
||||
// `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole
|
||||
// mobile.dc.html` : son poste, ses créneaux à choisir (celui en cours ou le
|
||||
// prochain par défaut), les compteurs présents / attendus / absents, puis
|
||||
// l'équipe du créneau à pointer. Pensée pour le téléphone ; sur un écran
|
||||
// plus large, la même colonne, centrée. Un compte peut être référent de
|
||||
// plusieurs postes (`Position.lead_account_id`) : on passe de l'un à l'autre
|
||||
// par des onglets. Aucun repli vers « le premier poste de l'édition » si le
|
||||
// compte n'est référent d'aucun poste — ce serait montrer un poste qui n'est
|
||||
// pas le sien ; s'il est par ailleurs rattaché à une fiche bénévole (cas
|
||||
// courant : « Donner un accès de connexion » promu en référent), il voit
|
||||
// alors sa vue de bénévole classique (`VolunteerHomeView`).
|
||||
//
|
||||
// Le pointage présent/absent reste local à l'écran (état React, jamais
|
||||
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
|
||||
// Un référent peut aussi être affecté comme bénévole, y compris la même
|
||||
// soirée : s'il est rattaché à une fiche bénévole, deux onglets — « Mon
|
||||
// équipe » (cet écran) et « Mon planning » (`VolunteerHomeView`, ses
|
||||
// propres créneaux). On ouvre sur « Mon planning » quand il tient en ce
|
||||
// moment un créneau de bénévole hors de ses postes et qu'aucun de ses
|
||||
// postes n'a de créneau en cours ; sinon sur « Mon équipe ».
|
||||
//
|
||||
// Le pointage présent/absent est enregistré sur l'affectation
|
||||
// (`Assignment.attendance`, `PUT .../assignments/{id}/attendance`) : il
|
||||
// survit à un rechargement et se retrouve sur un autre téléphone.
|
||||
|
||||
type Presence = Record<string, 'present' | 'absent'>;
|
||||
type View = 'team' | 'planning';
|
||||
|
||||
const STATUS_KEY: Record<SlotStatus, string> = {
|
||||
done: 'mobileLead.status.done',
|
||||
now: 'mobileLead.status.now',
|
||||
next: 'mobileLead.status.next',
|
||||
};
|
||||
|
||||
export function MobileLeadPage() {
|
||||
const { account } = useAuth();
|
||||
@@ -35,26 +63,25 @@ export function MobileLeadPage() {
|
||||
const shifts = useShifts();
|
||||
const volunteers = useVolunteers();
|
||||
const assignments = useAssignments();
|
||||
const nowMinutes = useNowMinutes();
|
||||
const [presence, setPresence] = useState<Presence>({});
|
||||
|
||||
const myPositions = (positions.data ?? []).filter(
|
||||
(p) => account && p.lead_account_id === account.id,
|
||||
);
|
||||
const dayParts = useDayParts();
|
||||
const me = useVolunteer(account?.volunteer_id);
|
||||
const myPlanning = useVolunteerPlanning(account?.volunteer_id);
|
||||
const [view, setView] = useState<View | null>(null);
|
||||
const updatePosition = useUpdatePosition();
|
||||
const now = useNowMinutes();
|
||||
const setAttendance = useSetAttendance();
|
||||
const [positionId, setPositionId] = useState<string | null>(null);
|
||||
const [shiftId, setShiftId] = useState<string | null>(null);
|
||||
|
||||
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
const shiftsByPosition = useMemo(() => {
|
||||
const map = new Map<string, ShiftRead[]>();
|
||||
for (const s of shifts.data ?? []) {
|
||||
const list = map.get(s.position_id) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.position_id, list);
|
||||
}
|
||||
for (const s of shifts.data ?? []) map.set(s.position_id, [...(map.get(s.position_id) ?? []), s]);
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
|
||||
}
|
||||
return map;
|
||||
}, [shifts.data]);
|
||||
|
||||
const volunteersById = useMemo(
|
||||
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
|
||||
[volunteers.data],
|
||||
@@ -64,6 +91,10 @@ export function MobileLeadPage() {
|
||||
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
|
||||
const myPositions = (positions.data ?? []).filter(
|
||||
(p) => account && p.lead_account_id === account.id,
|
||||
);
|
||||
|
||||
if (myPositions.length === 0) {
|
||||
if (account?.volunteer_id) {
|
||||
return (
|
||||
@@ -82,149 +113,356 @@ export function MobileLeadPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||
{t('mobileLead.positionsCount', { count: myPositions.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6">
|
||||
{myPositions.map((position) => {
|
||||
// Par défaut : le poste dont un créneau est en cours ou arrive le plus tôt.
|
||||
const nextEnd = (p: PositionRead) =>
|
||||
(shiftsByPosition.get(p.id) ?? []).find((s) => now < naiveMinutes(s.ends_at));
|
||||
const position =
|
||||
myPositions.find((p) => p.id === positionId) ??
|
||||
[...myPositions].sort((a, b) => {
|
||||
const na = nextEnd(a);
|
||||
const nb = nextEnd(b);
|
||||
if (!na || !nb) return na ? -1 : nb ? 1 : 0;
|
||||
return naiveMinutes(na.starts_at) - naiveMinutes(nb.starts_at);
|
||||
})[0];
|
||||
const positionShifts = shiftsByPosition.get(position.id) ?? [];
|
||||
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
||||
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex;
|
||||
// Par défaut : le créneau en cours ou le prochain, sinon le dernier.
|
||||
const shift =
|
||||
positionShifts.find((s) => s.id === shiftId) ??
|
||||
nextEnd(position) ??
|
||||
positionShifts[positionShifts.length - 1] ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="bg-ink px-[14px] pb-[11px] pt-[11px] text-cream">
|
||||
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
{position.location ? (
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">
|
||||
{position.location}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
|
||||
{positionShifts.length === 0 ? (
|
||||
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.noShift')}
|
||||
</div>
|
||||
) : null}
|
||||
{positionShifts.map((shift, i) => {
|
||||
const team = (assignments.data ?? [])
|
||||
const team = shift
|
||||
? (assignments.data ?? [])
|
||||
.filter((a) => a.shift_id === shift.id)
|
||||
.map((a) => {
|
||||
const volunteer = volunteersById.get(a.volunteer_id);
|
||||
return {
|
||||
id: a.id,
|
||||
volunteerId: a.volunteer_id,
|
||||
name: volunteer
|
||||
? `${volunteer.first_name} ${volunteer.last_name}`
|
||||
: a.volunteer_id,
|
||||
initials: volunteer
|
||||
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
|
||||
: '?',
|
||||
name: volunteer ? volunteerName(volunteer) : a.volunteer_id,
|
||||
initials: volunteer ? volunteerInitials(volunteer) : '?',
|
||||
hasPhoto: volunteer?.has_photo ?? false,
|
||||
phone: volunteer?.phone ?? '',
|
||||
present: presence[a.id] === 'present',
|
||||
absent: presence[a.id] === 'absent',
|
||||
state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null,
|
||||
markedAt: a.attendance_marked_at,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
: [];
|
||||
const presentCount = team.filter((m) => m.state === 'present').length;
|
||||
const absentCount = team.filter((m) => m.state === 'absent').length;
|
||||
// Un second appui sur le même bouton annule le pointage.
|
||||
const mark = (member: (typeof team)[number], value: 'present' | 'absent') =>
|
||||
setAttendance.mutate({
|
||||
id: member.id,
|
||||
attendance: member.state === value ? null : value === 'present' ? 'PRESENT' : 'ABSENT',
|
||||
});
|
||||
const presentCount = team.filter((m) => m.present).length;
|
||||
|
||||
// Bénévoles distincts affectés à au moins un créneau du poste.
|
||||
const positionShiftIds = new Set(positionShifts.map((s) => s.id));
|
||||
const teamSize = new Set(
|
||||
(assignments.data ?? []).filter((a) => positionShiftIds.has(a.shift_id)).map((a) => a.volunteer_id),
|
||||
).size;
|
||||
|
||||
const shiftStatus = shift
|
||||
? slotStatus(naiveMinutes(shift.starts_at), naiveMinutes(shift.ends_at), now)
|
||||
: null;
|
||||
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
|
||||
|
||||
const inProgress = (start: string, end: string) =>
|
||||
slotStatus(naiveMinutes(start), naiveMinutes(end), now) === 'now';
|
||||
const ledShiftIds = new Set(myPositions.flatMap((p) => (shiftsByPosition.get(p.id) ?? []).map((s) => s.id)));
|
||||
const leadingNow = (shifts.data ?? []).some((s) => ledShiftIds.has(s.id) && inProgress(s.starts_at, s.ends_at));
|
||||
const myEntries = myPlanning.data ?? [];
|
||||
const volunteeringNow = myEntries.some(
|
||||
(e) => !ledShiftIds.has(e.shift_id) && inProgress(e.starts_at, e.ends_at),
|
||||
);
|
||||
const currentView: View = view ?? (volunteeringNow && !leadingNow ? 'planning' : 'team');
|
||||
const tabs = account?.volunteer_id ? (
|
||||
<div className="mx-auto grid w-full max-w-[640px] grid-cols-2 border-b-[3px] border-ink">
|
||||
<ViewTab active={currentView === 'team'} live={leadingNow} onClick={() => setView('team')}>
|
||||
{t('mobileLead.view.team')}
|
||||
</ViewTab>
|
||||
<ViewTab active={currentView === 'planning'} live={volunteeringNow} onClick={() => setView('planning')}>
|
||||
{t('mobileLead.view.planning', { count: myEntries.length })}
|
||||
</ViewTab>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (currentView === 'planning' && account?.volunteer_id) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{tabs}
|
||||
<VolunteerHomeView volunteerId={account.volunteer_id} fallbackName={account.email.split('@')[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={shift.id}>
|
||||
{i === nowInsertAt ? <NowLine /> : null}
|
||||
<div className="border-2 border-ink">
|
||||
<div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
{longDayLabel(shift.starts_at)} ·{' '}
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{tabs}
|
||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
|
||||
{myPositions.length > 1 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-[6px]">
|
||||
{myPositions.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPositionId(p.id);
|
||||
setShiftId(null);
|
||||
}}
|
||||
className={cx(
|
||||
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
|
||||
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||
{t('mobileLead.present', { present: presentCount, total: team.length })}
|
||||
) : null}
|
||||
<div className="flex items-center gap-3">
|
||||
{me.data ? (
|
||||
<VolunteerAvatar
|
||||
volunteerId={me.data.id}
|
||||
hasPhoto={me.data.has_photo}
|
||||
initials={volunteerInitials(me.data)}
|
||||
className="h-[60px] w-[60px] !border-blue text-[19px]"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
|
||||
{myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
|
||||
</div>
|
||||
<div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
{position.location ? (
|
||||
<div className="mt-[3px] font-display text-[13px] font-semibold leading-snug text-sand-dark">
|
||||
{position.location}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[11px] p-[11px]">
|
||||
{positionShifts.length ? (
|
||||
<div className="mt-[13px] grid grid-cols-2 gap-[6px]">
|
||||
{positionShifts.map((s) => {
|
||||
const on = s.id === shift?.id;
|
||||
const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setShiftId(s.id)}
|
||||
aria-pressed={on}
|
||||
className={cx(
|
||||
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left',
|
||||
on ? 'bg-blue text-ink' : 'bg-transparent text-cream',
|
||||
)}
|
||||
>
|
||||
<span className="block font-display text-[12px] font-black leading-tight">
|
||||
{longDayLabel(`${festivalDay(s.starts_at, boundary)}T12:00`)}
|
||||
</span>
|
||||
<span className="block font-mono text-[11px] font-semibold leading-snug">
|
||||
{clockLabel(naiveMinutes(s.starts_at))}–{clockLabel(naiveMinutes(s.ends_at))} ·{' '}
|
||||
{t(STATUS_KEY[status])}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shift ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 border-b-[3px] border-ink">
|
||||
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
|
||||
<Counter
|
||||
value={team.length - presentCount - absentCount}
|
||||
label={t('mobileLead.count.waiting')}
|
||||
className="border-r-2 bg-paper"
|
||||
/>
|
||||
<Counter
|
||||
value={absentCount}
|
||||
label={t('mobileLead.count.absent')}
|
||||
className={absentCount ? 'bg-orange' : 'bg-paper'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{absentCount ? (
|
||||
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
|
||||
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
|
||||
count: absentCount,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
|
||||
{setAttendance.isError ? (
|
||||
<div className="border-[3px] border-ink bg-orange px-3 py-2 font-display text-[12.5px] font-bold leading-snug">
|
||||
{t('mobileLead.attendanceError')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-display text-[12px] font-black tracking-wider">
|
||||
{t('mobileLead.roll', { count: team.length })}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
|
||||
{shiftStatus === 'now'
|
||||
? t('mobileLead.when.now')
|
||||
: shiftStatus === 'next'
|
||||
? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) })
|
||||
: t('mobileLead.when.done')}
|
||||
</span>
|
||||
</div>
|
||||
{team.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
|
||||
className={cx(
|
||||
'border-[3px] border-ink shadow-brutal-md',
|
||||
member.state === 'absent' ? 'bg-orange-blush' : member.state === 'present' ? 'bg-mint-pale' : 'bg-paper',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
||||
<div className="flex items-center gap-[10px] px-[10px] py-[9px]">
|
||||
<VolunteerAvatar
|
||||
volunteerId={member.volunteerId}
|
||||
hasPhoto={member.hasPhoto}
|
||||
initials={member.initials}
|
||||
className="h-[38px] w-[38px] text-[13px]"
|
||||
className="h-12 w-12 !border-2 text-[15px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-display text-[17px] font-extrabold leading-tight">
|
||||
<span className="block break-words font-display text-[16px] font-extrabold leading-tight">
|
||||
{member.name}
|
||||
</span>
|
||||
<span className="block font-mono text-[10.5px] opacity-65">
|
||||
{member.phone}
|
||||
</span>
|
||||
</span>
|
||||
{member.present ? (
|
||||
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
|
||||
{t('mobileLead.badgePresent')}
|
||||
{member.volunteerId === account?.volunteer_id ? (
|
||||
<span className="ml-[6px] inline-block border-2 border-ink bg-blue px-[5px] py-[2px] align-middle font-display text-[9.5px] font-black leading-none">
|
||||
{t('mobileLead.you')}
|
||||
</span>
|
||||
) : null}
|
||||
{member.absent ? (
|
||||
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
|
||||
{t('mobileLead.badgeAbsent')}
|
||||
</span>
|
||||
{member.phone ? (
|
||||
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
|
||||
{member.phone}
|
||||
</span>
|
||||
) : null}
|
||||
{member.state && member.markedAt ? (
|
||||
<span className="block font-mono text-[10px] font-medium leading-snug opacity-60">
|
||||
{t('mobileLead.markedAt', { time: clockLabel(naiveMinutes(member.markedAt)) })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{member.phone ? (
|
||||
<a
|
||||
href={`tel:${member.phone.replace(/\s+/g, '')}`}
|
||||
aria-label={t('mobileLead.callLabel', { name: member.name })}
|
||||
className="flex h-[46px] w-[46px] flex-none items-center justify-center border-2 border-ink bg-cream font-display text-[10px] font-black"
|
||||
>
|
||||
{t('mobileLead.call')}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPresence((p) => ({ ...p, [member.id]: 'present' }))
|
||||
}
|
||||
onClick={() => mark(member, 'present')}
|
||||
aria-pressed={member.state === 'present'}
|
||||
className={cx(
|
||||
'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white',
|
||||
'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
|
||||
member.state === 'present' ? 'bg-green text-white' : 'bg-cream',
|
||||
)}
|
||||
>
|
||||
{t('mobileLead.markPresent')}
|
||||
{t(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPresence((p) => ({ ...p, [member.id]: 'absent' }))
|
||||
}
|
||||
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange"
|
||||
onClick={() => mark(member, 'absent')}
|
||||
aria-pressed={member.state === 'absent'}
|
||||
className={cx(
|
||||
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
|
||||
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
|
||||
)}
|
||||
>
|
||||
{t('mobileLead.markAbsent')}
|
||||
{t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{team.length === 0 ? (
|
||||
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.emptyTeam')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
|
||||
<NowLine />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Message du poste : montré à chaque bénévole affecté, sur son
|
||||
écran (créneau mis en avant et liste des créneaux). */}
|
||||
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-display text-[12px] font-black tracking-wider">
|
||||
{t('mobileLead.message.title')}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] font-medium opacity-65">
|
||||
{t('mobileLead.message.audience', { count: teamSize })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.message.help')}
|
||||
</p>
|
||||
<MessageEditor
|
||||
key={position.id}
|
||||
value={position.message}
|
||||
updatedAt={position.message_updated_at}
|
||||
placeholder={t('mobileLead.message.placeholder')}
|
||||
saving={updatePosition.isPending}
|
||||
error={updatePosition.error}
|
||||
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Counter({ value, label, className }: { value: number; label: string; className: string }) {
|
||||
return (
|
||||
<div className={cx('border-ink px-3 py-[10px]', className)}>
|
||||
<div className="font-mono text-[24px] font-bold leading-none">{value}</div>
|
||||
<div className="mt-[3px] font-display text-[9.5px] font-extrabold leading-tight tracking-wider">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewTab({
|
||||
active,
|
||||
live,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
/** Un créneau de cette vue est en cours : pastille orange. */
|
||||
live: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cx(
|
||||
'flex min-h-[46px] cursor-pointer items-center justify-center gap-[7px] border-r-[3px] border-ink px-2 font-display text-[12px] font-black uppercase tracking-wide last:border-r-0',
|
||||
active ? 'bg-ink text-yellow' : 'bg-paper text-ink hover:bg-yellow-soft',
|
||||
)}
|
||||
>
|
||||
{live ? <span className="h-[9px] w-[9px] flex-none border-2 border-ink bg-orange" aria-hidden="true" /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStats } from '@/api/hooks';
|
||||
import type { EditionStats } from '@/api/schema';
|
||||
import type { EditionStats, StatsAttendanceCounts } from '@/api/schema';
|
||||
import {
|
||||
BarList,
|
||||
CumulativeLineChart,
|
||||
@@ -15,7 +15,8 @@ import { ErrorNote, Loading } from '@/components/state';
|
||||
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { foldAccents } from '@/lib/planning';
|
||||
import { longDayLabel, shortDayLabel } from '@/lib/time';
|
||||
import { longDayLabel, naiveMinutes, shortDayLabel } from '@/lib/time';
|
||||
import { clockLabel } from '@/lib/slotTimeline';
|
||||
|
||||
const fr = (n: number, digits = 0) =>
|
||||
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
||||
@@ -62,6 +63,9 @@ export function StatsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pendant le festival, la présence en direct passe en tête. */}
|
||||
{s.attendance.live_shifts > 0 ? <AttendanceSection stats={s} /> : null}
|
||||
|
||||
{s.registrations.total === 0 ? (
|
||||
<Panel>
|
||||
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
|
||||
@@ -76,6 +80,7 @@ export function StatsPage() {
|
||||
</>
|
||||
)}
|
||||
<NeedsSection stats={s} />
|
||||
{s.attendance.live_shifts === 0 ? <AttendanceSection stats={s} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -309,3 +314,97 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function attendanceSegments(c: StatsAttendanceCounts) {
|
||||
return [
|
||||
{ label: t('stats.attendance.present'), value: c.present, color: SERIES.aqua },
|
||||
{ label: t('stats.attendance.absent'), value: c.absent, color: SERIES.orange },
|
||||
{ label: t('stats.attendance.unmarked'), value: c.unmarked, color: SERIES.muted },
|
||||
];
|
||||
}
|
||||
|
||||
/** Ligne de tableau : attendus, présents, absents, non pointés, et part de
|
||||
* présents parmi les bénévoles déjà pointés. */
|
||||
function attendanceCells(c: StatsAttendanceCounts) {
|
||||
const marked = c.present + c.absent;
|
||||
return [
|
||||
fr(c.assigned),
|
||||
fr(c.present),
|
||||
c.absent ? <b key="a">{fr(c.absent)}</b> : '0',
|
||||
fr(c.unmarked),
|
||||
<span key="m" className="flex justify-end">
|
||||
<Meter
|
||||
value={c.present}
|
||||
max={marked}
|
||||
label={marked ? `${pct(c.present, marked)} %` : '—'}
|
||||
title={t('stats.attendance.rateTip', { present: fr(c.present), marked: fr(marked) })}
|
||||
/>
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
function AttendanceSection({ stats }: { stats: EditionStats }) {
|
||||
const a = stats.attendance;
|
||||
const head = [
|
||||
t('stats.col.position'),
|
||||
t('stats.attendance.expected'),
|
||||
t('stats.attendance.present'),
|
||||
t('stats.attendance.absent'),
|
||||
t('stats.attendance.unmarked'),
|
||||
t('stats.attendance.rate'),
|
||||
];
|
||||
const liveRows = a.by_position.filter((p) => p.live_shifts > 0);
|
||||
const pastRows = a.by_position.filter((p) => p.past.assigned > 0);
|
||||
return (
|
||||
<Section title={t('stats.attendance.title')} help={t('stats.attendance.help')}>
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<SubTitle>{t('stats.attendance.live')}</SubTitle>
|
||||
<span className="ml-auto font-mono text-[10px] opacity-60">
|
||||
{t('stats.attendance.asOf', { time: clockLabel(naiveMinutes(a.as_of)) })}
|
||||
</span>
|
||||
</div>
|
||||
{a.live_shifts === 0 ? (
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noLive')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-mono text-[11px]">
|
||||
{t('stats.attendance.liveSummary', { shifts: fr(a.live_shifts), expected: fr(a.live.assigned) })}
|
||||
</p>
|
||||
<ShareBar segments={attendanceSegments(a.live)} />
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
head={head}
|
||||
rows={liveRows.map((p) => [
|
||||
`${p.name} · ${t('stats.attendance.shiftsCount', { count: p.live_shifts })}`,
|
||||
...attendanceCells(p.live),
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SubTitle>{t('stats.attendance.past')}</SubTitle>
|
||||
{a.past.assigned === 0 ? (
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noPast')}</p>
|
||||
) : (
|
||||
<>
|
||||
<ShareBar segments={attendanceSegments(a.past)} />
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
head={head}
|
||||
rows={[
|
||||
...pastRows.map((p) => [p.name, ...attendanceCells(p.past)]),
|
||||
[<b key="t">{t('stats.payments.total')}</b>, ...attendanceCells(a.past)],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{a.past.unmarked > 0 ? (
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('stats.attendance.unmarkedHint', { count: fr(a.past.unmarked) })}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
||||
import { useDayParts, useEdition, useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
||||
import type { VolunteerPlanningEntry } from '@/api/schema';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { NowLine } from '@/components/NowLine';
|
||||
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import {
|
||||
buildRibbon,
|
||||
clockLabel,
|
||||
countdownLabel,
|
||||
durationLabel,
|
||||
messageDateLabel,
|
||||
overlapOf,
|
||||
slotStatus,
|
||||
type SlotStatus,
|
||||
} from '@/lib/slotTimeline';
|
||||
import { longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||
import { t } from '@/i18n';
|
||||
import { volunteerInitials } from '@/lib/volunteer';
|
||||
|
||||
/**
|
||||
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu,
|
||||
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de
|
||||
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT
|
||||
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce
|
||||
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent
|
||||
* vide (voir `MobileLeadPage.tsx`).
|
||||
* Contenu de l'écran bénévole (maquette `design/Planning bénévole
|
||||
* mobile.dc.html`) : le créneau en cours ou le prochain mis en avant (compte
|
||||
* à rebours, lieu, référent à appeler), puis le planning par journée de
|
||||
* festival (§ 3 — une nuit reste sur le jour où elle a commencé), chacune
|
||||
* avec sa frise horaire et ses créneaux (terminé / en cours / à venir,
|
||||
* chevauchements signalés). Pensé pour le téléphone ; sur un écran plus
|
||||
* large, la même colonne, centrée. Extrait de `MobileVolunteerPage` pour
|
||||
* être également montré à un compte RÉFÉRENT (`LEAD`) qui n'est
|
||||
* actuellement désigné référent d'aucun poste (voir `MobileLeadPage.tsx`).
|
||||
*/
|
||||
export function VolunteerHomeView({
|
||||
volunteerId,
|
||||
@@ -26,13 +42,20 @@ export function VolunteerHomeView({
|
||||
}) {
|
||||
const meVolunteer = useVolunteer(volunteerId);
|
||||
const planning = useVolunteerPlanning(volunteerId);
|
||||
const nowMinutes = useNowMinutes();
|
||||
const dayParts = useDayParts();
|
||||
const edition = useEdition();
|
||||
const now = useNowMinutes();
|
||||
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
|
||||
const slots = useMemo(
|
||||
() =>
|
||||
[...(planning.data ?? [])].sort(
|
||||
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
|
||||
),
|
||||
(planning.data ?? [])
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
start: naiveMinutes(entry.starts_at),
|
||||
end: naiveMinutes(entry.ends_at),
|
||||
}))
|
||||
.sort((a, b) => a.start - b.start),
|
||||
[planning.data],
|
||||
);
|
||||
|
||||
@@ -41,91 +64,368 @@ export function VolunteerHomeView({
|
||||
|
||||
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
||||
const initials = meVolunteer.data
|
||||
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}`
|
||||
? volunteerInitials(meVolunteer.data)
|
||||
: (firstName[0] ?? '?');
|
||||
|
||||
const totalHours =
|
||||
Math.round(
|
||||
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
|
||||
10,
|
||||
) / 10;
|
||||
|
||||
// Le repère « maintenant » se place juste avant le premier créneau pas
|
||||
// encore commencé (le prochain à venir) ; en fin de liste si tous sont déjà
|
||||
// passés.
|
||||
const nowIndex = slots.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
||||
const nowInsertAt = nowIndex === -1 ? slots.length : nowIndex;
|
||||
const days = new Map<string, Slot[]>();
|
||||
for (const slot of slots) {
|
||||
const day = festivalDay(slot.entry.starts_at, boundary);
|
||||
days.set(day, [...(days.get(day) ?? []), slot]);
|
||||
}
|
||||
const totalMinutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
|
||||
const featured = slots.find((s) => now < s.end) ?? null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
|
||||
<div className="flex items-center gap-[12px]">
|
||||
<div className="flex items-center gap-[14px] bg-ink p-4 text-cream sm:px-6 sm:py-6">
|
||||
<VolunteerAvatar
|
||||
volunteerId={volunteerId}
|
||||
hasPhoto={meVolunteer.data?.has_photo ?? false}
|
||||
initials={initials}
|
||||
className="h-[52px] w-[52px] text-[18px]"
|
||||
className="h-[76px] w-[76px] !border-yellow !bg-orange text-[24px]"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-display text-[30px] font-black leading-none tracking-tight">
|
||||
{t('mobileVol.greeting', { name: firstName })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
{t('mobileVol.slots', { count: slots.length })}
|
||||
</div>
|
||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||
{t('mobileVol.hours', { count: totalHours })}
|
||||
<div className="mt-[6px] font-display text-[14px] font-semibold leading-snug text-sand-dark">
|
||||
{slots.length
|
||||
? t(slots.length > 1 ? 'mobileVol.summary.other' : 'mobileVol.summary.one', {
|
||||
count: slots.length,
|
||||
duration: durationLabel(totalMinutes),
|
||||
days: days.size,
|
||||
})
|
||||
: t('mobileVol.summary.none')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
|
||||
{slots.map((entry, i) => (
|
||||
<Fragment key={entry.assignment_id}>
|
||||
{i === nowInsertAt ? <NowLine /> : null}
|
||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||
<span className="font-display text-[15px] font-black">
|
||||
{longDayLabel(entry.starts_at)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
|
||||
{hourRangeLabel(entry.starts_at, entry.ends_at)}
|
||||
</span>
|
||||
{slots.length ? (
|
||||
<div className="px-[14px] pt-4 sm:px-6">
|
||||
{featured ? (
|
||||
<FeaturedSlot slot={featured} all={slots} now={now} boundary={boundary} />
|
||||
) : (
|
||||
<div className="border-[3px] border-ink bg-green p-4 font-display text-[19px] font-black leading-snug text-white shadow-brutal-lg">
|
||||
{t('mobileVol.allDone', { name: firstName })}
|
||||
</div>
|
||||
<div className="p-[11px]">
|
||||
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
||||
{entry.position_name.toUpperCase()}
|
||||
</div>
|
||||
{entry.position_location ? (
|
||||
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
||||
{t('mobileVol.location', { location: entry.position_location })}
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.referent_name ? (
|
||||
<div className="mt-[9px] font-mono text-[12px]">
|
||||
{t('mobileVol.referent', { name: entry.referent_name })}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.referent_phone ? (
|
||||
<div className="mt-[3px] font-mono text-[12px]">
|
||||
{t('mobileVol.phone', { phone: entry.referent_phone })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-[22px] px-[14px] pb-7 pt-[22px] sm:px-6">
|
||||
{[...days.entries()].map(([day, daySlots]) => (
|
||||
<DaySection
|
||||
key={day}
|
||||
day={day}
|
||||
slots={daySlots}
|
||||
all={slots}
|
||||
now={now}
|
||||
featuredId={featured?.entry.assignment_id ?? null}
|
||||
/>
|
||||
))}
|
||||
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
|
||||
{slots.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileVol.noSlots')}
|
||||
</div>
|
||||
) : null}
|
||||
{edition.data?.volunteer_message ? (
|
||||
<div className="border-[3px] border-ink bg-blue p-3">
|
||||
<div className="font-display text-[12px] font-black leading-none tracking-wider">
|
||||
{t('mobileVol.orgMessage.title')}
|
||||
</div>
|
||||
<div className="mt-[7px] whitespace-pre-line break-words font-display text-[14px] font-semibold leading-normal [text-wrap:pretty]">
|
||||
{edition.data.volunteer_message}
|
||||
</div>
|
||||
{edition.data.volunteer_message_updated_at ? (
|
||||
<div className="mt-2 font-mono text-[10.5px] font-medium uppercase leading-none">
|
||||
{t('mobileVol.orgMessage.signature', {
|
||||
when: messageDateLabel(edition.data.volunteer_message_updated_at, now),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
entry: VolunteerPlanningEntry;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Premier autre créneau du bénévole qui chevauche `slot`, et sur quelle
|
||||
* plage. */
|
||||
function findOverlap(slot: Slot, all: Slot[]) {
|
||||
for (const other of all) {
|
||||
if (other.entry.assignment_id === slot.entry.assignment_id) continue;
|
||||
const range = overlapOf(slot, other);
|
||||
if (range) return { other, range };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function FeaturedSlot({
|
||||
slot,
|
||||
all,
|
||||
now,
|
||||
boundary,
|
||||
}: {
|
||||
slot: Slot;
|
||||
all: Slot[];
|
||||
now: number;
|
||||
boundary: string;
|
||||
}) {
|
||||
const running = slotStatus(slot.start, slot.end, now) === 'now';
|
||||
const overlap = findOverlap(slot, all.filter((s) => now < s.end));
|
||||
const { entry } = slot;
|
||||
return (
|
||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div
|
||||
className={cx(
|
||||
'flex items-center gap-2 border-b-[3px] border-ink px-3 py-[9px]',
|
||||
running ? 'bg-orange' : 'bg-yellow',
|
||||
)}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black tracking-wider">
|
||||
{t(running ? 'mobileVol.featured.now' : 'mobileVol.featured.next')}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[12px] font-bold">
|
||||
{countdownLabel(slot.start, slot.end, now)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-[13px] pb-[14px] pt-[13px]">
|
||||
<div className="font-display text-[13px] font-bold opacity-70">
|
||||
{longDayLabel(`${festivalDay(entry.starts_at, boundary)}T12:00`)}
|
||||
</div>
|
||||
<div className="mt-[5px] break-words font-display text-[30px] font-black leading-[1.05] tracking-tight">
|
||||
{entry.position_name.toUpperCase()}
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[24px] font-bold leading-none">
|
||||
{clockLabel(slot.start)} → {clockLabel(slot.end)}
|
||||
</div>
|
||||
{entry.position_location || entry.position_message ? (
|
||||
<div className="mt-[11px] flex flex-col gap-1 border-t-2 border-ink pt-[10px]">
|
||||
{entry.position_location ? (
|
||||
<div className="font-display text-[14px] font-bold leading-snug">{entry.position_location}</div>
|
||||
) : null}
|
||||
{entry.position_message ? <PositionMessage entry={entry} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{overlap ? (
|
||||
<div className="mt-[11px] border-2 border-ink bg-yellow-soft px-[10px] py-2 font-display text-[12.5px] font-bold leading-snug">
|
||||
{t('mobileVol.featured.overlap', {
|
||||
position: overlap.other.entry.position_name,
|
||||
start: clockLabel(overlap.range.start),
|
||||
end: clockLabel(overlap.range.end),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.referent_name ? <ReferentContact entry={entry} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Message du poste (consignes du référent). */
|
||||
function PositionMessage({ entry }: { entry: VolunteerPlanningEntry }) {
|
||||
return (
|
||||
<div className="whitespace-pre-line break-words font-mono text-[12px] font-medium leading-snug opacity-75">
|
||||
{entry.position_message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferentContact({ entry }: { entry: VolunteerPlanningEntry }) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-mono text-[10.5px] font-medium leading-snug opacity-70">
|
||||
{t('mobileVol.featured.referent')}
|
||||
</span>
|
||||
<span className="block font-display text-[15px] font-extrabold leading-tight">
|
||||
{entry.referent_name}
|
||||
</span>
|
||||
</span>
|
||||
{entry.referent_phone ? (
|
||||
<span className="flex-none bg-ink px-3 py-[10px] font-display text-[13px] font-black leading-none text-yellow">
|
||||
{t('mobileVol.featured.call')}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const className =
|
||||
'mt-3 flex min-h-[58px] items-center gap-[10px] border-[3px] border-ink bg-cream px-3 py-[6px]';
|
||||
return entry.referent_phone ? (
|
||||
<a href={`tel:${entry.referent_phone.replace(/\s+/g, '')}`} className={className}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className={className}>{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BAR_COLOR: Record<SlotStatus, string> = {
|
||||
done: 'bg-sand-mid',
|
||||
now: 'bg-orange',
|
||||
next: 'bg-ink',
|
||||
};
|
||||
const LANE_PX = 18;
|
||||
|
||||
function DaySection({
|
||||
day,
|
||||
slots,
|
||||
all,
|
||||
now,
|
||||
featuredId,
|
||||
}: {
|
||||
day: string;
|
||||
slots: Slot[];
|
||||
all: Slot[];
|
||||
now: number;
|
||||
/** Créneau déjà mis en avant plus haut (son message y figure déjà). */
|
||||
featuredId: string | null;
|
||||
}) {
|
||||
const ribbon = buildRibbon(slots, now);
|
||||
const minutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2 border-b-[3px] border-ink pb-[6px]">
|
||||
<span className="font-display text-[18px] font-black leading-none tracking-tight">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[12px] font-semibold leading-none">
|
||||
{t(slots.length > 1 ? 'mobileVol.day.other' : 'mobileVol.day.one', {
|
||||
count: slots.length,
|
||||
duration: durationLabel(minutes),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative mb-1 mt-[10px]"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
height: ribbon.lanes * LANE_PX,
|
||||
// Une graduation par heure.
|
||||
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 calc(100% / ${ribbon.hours} - 1px), #CFC7B4 calc(100% / ${ribbon.hours} - 1px) calc(100% / ${ribbon.hours}))`,
|
||||
}}
|
||||
>
|
||||
{ribbon.bars.map((bar, i) => (
|
||||
<span
|
||||
key={slots[i].entry.assignment_id}
|
||||
className={cx('absolute h-[14px] border-2 border-ink', BAR_COLOR[slotStatus(slots[i].start, slots[i].end, now)])}
|
||||
style={{ left: `${bar.left}%`, width: `${bar.width}%`, top: bar.lane * LANE_PX }}
|
||||
/>
|
||||
))}
|
||||
{ribbon.now !== null ? (
|
||||
<span className="absolute -bottom-1 -top-1 w-[3px] bg-orange" style={{ left: `${ribbon.now}%` }} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative mb-[10px] h-[10px] font-mono text-[9.5px] font-medium leading-none opacity-65" aria-hidden="true">
|
||||
{ribbon.ticks.map((tick, i) => (
|
||||
<span
|
||||
key={tick.label + i}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${tick.left}%`,
|
||||
transform: tick.left === 0 ? undefined : tick.left >= 100 ? 'translateX(-100%)' : 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[9px]">
|
||||
{slots.map((slot) => (
|
||||
<SlotCard
|
||||
key={slot.entry.assignment_id}
|
||||
slot={slot}
|
||||
all={all}
|
||||
now={now}
|
||||
showMessage={slot.entry.assignment_id !== featuredId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCard({
|
||||
slot,
|
||||
all,
|
||||
now,
|
||||
showMessage,
|
||||
}: {
|
||||
slot: Slot;
|
||||
all: Slot[];
|
||||
now: number;
|
||||
showMessage: boolean;
|
||||
}) {
|
||||
const status = slotStatus(slot.start, slot.end, now);
|
||||
const overlap = status === 'done' ? null : findOverlap(slot, all);
|
||||
const { entry } = slot;
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'border-[3px] border-ink',
|
||||
status === 'now' ? 'bg-orange-pale' : 'bg-paper',
|
||||
status === 'done' && 'opacity-55',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
<div
|
||||
className={cx(
|
||||
'flex w-[76px] flex-none flex-col items-center justify-center gap-[5px] border-r-[3px] border-ink py-[10px] font-mono text-[16px] font-bold leading-none',
|
||||
status === 'now' ? 'bg-orange text-ink' : status === 'done' ? 'bg-sand-dark text-ink' : 'bg-ink text-yellow',
|
||||
)}
|
||||
>
|
||||
<span>{clockLabel(slot.start)}</span>
|
||||
<span className="h-[9px] w-[2px] bg-current opacity-60" aria-hidden="true" />
|
||||
<span>{clockLabel(slot.end)}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 px-[11px] py-[10px]">
|
||||
<div className="flex items-start gap-[6px]">
|
||||
<span className="min-w-0 flex-1 break-words font-display text-[18px] font-black leading-tight">
|
||||
{entry.position_name}
|
||||
</span>
|
||||
{status === 'done' ? (
|
||||
<span className="flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
|
||||
{t('mobileVol.slot.done')}
|
||||
</span>
|
||||
) : null}
|
||||
{status === 'now' ? (
|
||||
<span className="flex-none border-2 border-ink bg-orange px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
|
||||
{t('mobileVol.slot.now')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{entry.position_location ? (
|
||||
<div className="mt-1 font-display text-[13px] font-semibold leading-snug">{entry.position_location}</div>
|
||||
) : null}
|
||||
<div className="mt-[2px] font-mono text-[11px] font-medium leading-snug opacity-70">
|
||||
{durationLabel(slot.end - slot.start)}
|
||||
{entry.referent_name ? t('mobileVol.slot.referent', { name: entry.referent_name }) : null}
|
||||
</div>
|
||||
{showMessage && status !== 'done' && entry.position_message ? (
|
||||
<div className="mt-[6px] border-l-[3px] border-ink pl-2">
|
||||
<PositionMessage entry={entry} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{overlap ? (
|
||||
<div className="border-t-[3px] border-ink bg-yellow-soft px-[11px] py-2 font-display text-[12.5px] font-bold leading-snug">
|
||||
{t('mobileVol.slot.overlap', {
|
||||
position: overlap.other.entry.position_name,
|
||||
start: clockLabel(overlap.range.start),
|
||||
end: clockLabel(overlap.range.end),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,78 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
||||
import type { VolunteerRead } from '@/api/schema';
|
||||
import { Chip, HelpTip } from '@/components/ui';
|
||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import {
|
||||
buildTimeGrid,
|
||||
columnWidthPx,
|
||||
dayBoundaryFromDayParts,
|
||||
festivalDay,
|
||||
foldAccents,
|
||||
hoursByVolunteer,
|
||||
placeInTimeGrid,
|
||||
type TimeGridDay,
|
||||
} from '@/lib/planning';
|
||||
import { clockLabel } from '@/lib/slotTimeline';
|
||||
import { minutesOfDay, naiveMinutes, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { volunteerName } from '@/lib/volunteer';
|
||||
|
||||
type Filter = 'all' | 'unassigned';
|
||||
/** Ordre des lignes : celui de la liste des bénévoles (par nom), ou par
|
||||
* total d'heures affectées, croissant ou décroissant. */
|
||||
type Sort = 'name' | 'hoursAsc' | 'hoursDesc';
|
||||
|
||||
const NAME_W = 230;
|
||||
const TOTAL_W = 84;
|
||||
const SLOT_MINUTES = 30;
|
||||
/** Hauteur d'une ligne de poste dans la rangée d'un bénévole : deux postes
|
||||
* en même temps ⇒ deux lignes, la rangée s'agrandit. */
|
||||
const LANE_H = 26;
|
||||
const ROW_PAD = 3;
|
||||
|
||||
interface Bar {
|
||||
assignmentId: string;
|
||||
positionName: string;
|
||||
start: number;
|
||||
end: number;
|
||||
/** Position horizontale (px) dans la piste des créneaux. */
|
||||
left: number;
|
||||
width: number;
|
||||
lane: number;
|
||||
/** Chevauche un autre créneau du même bénévole. */
|
||||
overlap: boolean;
|
||||
/** Le poste correspond à la recherche (barre mise en avant). */
|
||||
match: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue par bénévole (COORDINATION) : un vrai axe des temps, comme la grille
|
||||
* des besoins (une colonne = 30 min, bornée par jour de festival à la plage
|
||||
* utilisée — `buildTimeGrid`). Chaque affectation s'étire sur toute la
|
||||
* durée de son créneau, avec le nom du poste ; deux postes tenus en même
|
||||
* temps s'empilent sur deux lignes (la rangée s'agrandit) et sont signalés.
|
||||
* Ligne rouge verticale à l'heure actuelle quand elle tombe dans la grille.
|
||||
*/
|
||||
export function VolunteerViewPage() {
|
||||
const volunteers = useVolunteers();
|
||||
const assignments = useAssignments();
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
const dayParts = useDayParts();
|
||||
const now = useNowMinutes();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [sort, setSort] = useState<Sort>('name');
|
||||
// Recherche sur le nom du bénévole ou le nom d'un de ses postes, sans
|
||||
// tenir compte des accents ni de la casse.
|
||||
const [search, setSearch] = useState('');
|
||||
const query = foldAccents(search.trim());
|
||||
|
||||
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
const shiftsById = useMemo(
|
||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||
[shifts.data],
|
||||
@@ -27,56 +81,90 @@ export function VolunteerViewPage() {
|
||||
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
||||
[positions.data],
|
||||
);
|
||||
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
const lattice = useMemo(
|
||||
() => buildLattice(shifts.data ?? [], dayBoundary),
|
||||
[shifts.data, dayBoundary],
|
||||
);
|
||||
const hours = useMemo(
|
||||
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
||||
[assignments.data, shiftsById],
|
||||
);
|
||||
|
||||
// Axe des temps : jours de festival, largeur de tranche par jour et
|
||||
// décalage (px) de chaque jour dans la piste.
|
||||
const axis = useMemo(() => {
|
||||
const days = buildTimeGrid(shifts.data ?? [], dayBoundary);
|
||||
let offset = 0;
|
||||
const layout = days.map((day) => {
|
||||
const slotW = columnWidthPx(day.slotLabels.length);
|
||||
const entry = { day, slotW, offset, width: slotW * day.slotLabels.length };
|
||||
offset += entry.width;
|
||||
return entry;
|
||||
});
|
||||
return { days, layout, width: offset };
|
||||
}, [shifts.data, dayBoundary]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = assignments.data ?? [];
|
||||
const byVolunteer = new Map<string, AssignmentRead[]>();
|
||||
for (const a of all) {
|
||||
const list = byVolunteer.get(a.volunteer_id) ?? [];
|
||||
list.push(a);
|
||||
byVolunteer.set(a.volunteer_id, list);
|
||||
const byVolunteer = new Map<string, Array<{ id: string; shiftId: string }>>();
|
||||
for (const a of assignments.data ?? []) {
|
||||
byVolunteer.set(a.volunteer_id, [...(byVolunteer.get(a.volunteer_id) ?? []), { id: a.id, shiftId: a.shift_id }]);
|
||||
}
|
||||
return (volunteers.data ?? [])
|
||||
.filter((v) => {
|
||||
const own = byVolunteer.get(v.id) ?? [];
|
||||
if (filter === 'unassigned') return own.length === 0;
|
||||
return true;
|
||||
})
|
||||
const layoutByDay = new Map(axis.layout.map((l) => [l.day.date, l]));
|
||||
const built = (volunteers.data ?? [])
|
||||
.filter((v) => filter === 'all' || !(byVolunteer.get(v.id)?.length))
|
||||
.map((v) => {
|
||||
const own = byVolunteer.get(v.id) ?? [];
|
||||
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>();
|
||||
for (const a of own) {
|
||||
const shift = shiftsById.get(a.shift_id);
|
||||
if (shift)
|
||||
cellByKey.set(
|
||||
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
|
||||
{ ...a, shift },
|
||||
);
|
||||
const items = (byVolunteer.get(v.id) ?? []).flatMap(({ id, shiftId }) => {
|
||||
const shift = shiftsById.get(shiftId);
|
||||
return shift ? [{ id, shift, starts_at: shift.starts_at, ends_at: shift.ends_at }] : [];
|
||||
});
|
||||
const bars: Bar[] = [];
|
||||
for (const [day, placements] of placeInTimeGrid(axis.days, items, dayBoundary)) {
|
||||
const l = layoutByDay.get(day);
|
||||
if (!l) continue;
|
||||
for (const p of placements) {
|
||||
const { shift } = p.item;
|
||||
bars.push({
|
||||
assignmentId: p.item.id,
|
||||
positionName: positionsById.get(shift.position_id)?.name ?? '?',
|
||||
start: naiveMinutes(shift.starts_at),
|
||||
end: naiveMinutes(shift.ends_at),
|
||||
left: l.offset + p.columnStart * l.slotW,
|
||||
width: p.columnSpan * l.slotW,
|
||||
lane: 0,
|
||||
overlap: false,
|
||||
match: false,
|
||||
label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Lignes : un créneau prend la première ligne libre à son début.
|
||||
bars.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
const laneEnds: number[] = [];
|
||||
for (const bar of bars) {
|
||||
let lane = laneEnds.findIndex((end) => end <= bar.start);
|
||||
if (lane === -1) lane = laneEnds.length;
|
||||
laneEnds[lane] = bar.end;
|
||||
bar.lane = lane;
|
||||
bar.overlap = bars.some((o) => o !== bar && o.start < bar.end && bar.start < o.end);
|
||||
}
|
||||
const nameMatch = !query || foldAccents(`${v.first_name} ${v.last_name}`).includes(query);
|
||||
for (const bar of bars) bar.match = Boolean(query) && foldAccents(bar.positionName).includes(query);
|
||||
return {
|
||||
volunteer: v,
|
||||
cellByKey,
|
||||
visible: nameMatch || bars.some((b) => b.match),
|
||||
bars,
|
||||
lanes: Math.max(1, laneEnds.length),
|
||||
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
|
||||
empty: own.length === 0,
|
||||
empty: items.length === 0,
|
||||
};
|
||||
});
|
||||
}, [volunteers.data, assignments.data, shiftsById, hours, filter]);
|
||||
const shown = built.filter((r) => r.visible);
|
||||
if (sort === 'name') return shown;
|
||||
const direction = sort === 'hoursAsc' ? 1 : -1;
|
||||
// `sort` est stable : à total égal, l'ordre par nom est conservé.
|
||||
return shown.sort((a, b) => direction * (a.total - b.total));
|
||||
}, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort, query]);
|
||||
|
||||
if (
|
||||
volunteers.isLoading ||
|
||||
shifts.isLoading ||
|
||||
assignments.isLoading ||
|
||||
positions.isLoading
|
||||
)
|
||||
// Position (px) de l'heure actuelle dans la piste, `null` hors grille.
|
||||
const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
|
||||
|
||||
if (volunteers.isLoading || shifts.isLoading || assignments.isLoading || positions.isLoading)
|
||||
return <Loading />;
|
||||
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
@@ -89,12 +177,19 @@ export function VolunteerViewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`;
|
||||
const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||
<div className="relative w-full sm:w-[260px]">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('volunteers.search.placeholder')}
|
||||
aria-label={t('volunteers.search.label')}
|
||||
className="w-full border-2 border-ink bg-cream px-[9px] py-[6px] font-mono text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
|
||||
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
@@ -104,58 +199,101 @@ export function VolunteerViewPage() {
|
||||
{t('volunteers.filter.unassigned')}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="font-mono text-[9px] opacity-60">{t('volunteers.sort.label')}</span>
|
||||
<Chip active={sort === 'name'} onClick={() => setSort('name')}>
|
||||
{t('volunteers.sort.name')}
|
||||
</Chip>
|
||||
<Chip active={sort === 'hoursAsc'} onClick={() => setSort('hoursAsc')}>
|
||||
{t('volunteers.sort.hoursAsc')}
|
||||
</Chip>
|
||||
<Chip active={sort === 'hoursDesc'} onClick={() => setSort('hoursDesc')}>
|
||||
{t('volunteers.sort.hoursDesc')}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
||||
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem>
|
||||
<LegendItem swatch="bg-yellow border-ink">{t('volunteers.legend.overlap')}</LegendItem>
|
||||
<LegendItem swatch="bg-orange border-orange !w-[3px]">{t('volunteers.legend.now')}</LegendItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||
{rows.length === 0 ? (
|
||||
<EmptyNote>{t('volunteers.empty')}</EmptyNote>
|
||||
<EmptyNote>{query ? t('volunteers.search.empty', { query: search.trim() }) : t('volunteers.empty')}</EmptyNote>
|
||||
) : (
|
||||
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
||||
<div className="sticky left-0 top-0 z-[7] 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">
|
||||
<div className="w-max border-b-[3px] border-ink">
|
||||
{/* En-tête : jours, puis tranches de 30 min. */}
|
||||
<div className="sticky top-0 z-[7] flex h-[27px]">
|
||||
<div
|
||||
className="sticky left-0 z-[8] flex flex-none items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"
|
||||
style={{ width: NAME_W }}
|
||||
>
|
||||
{t('volunteers.count', { count: rows.length })}
|
||||
<HelpTip text={t('volunteers.footnote')} side="bottom" />
|
||||
</div>
|
||||
{lattice.days.map((day) => (
|
||||
{axis.layout.map(({ day, width }) => (
|
||||
<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"
|
||||
style={{ gridColumn: `span ${day.columns.length}` }}
|
||||
className="flex flex-none items-center border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] text-cream"
|
||||
style={{ width }}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black">
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</span>
|
||||
<span className="font-display text-[12px] font-black">{shortDayLabel(`${day.date}T12:00`)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="sticky right-0 top-0 z-[7] flex items-center border-b-2 border-l-[3px] border-ink bg-yellow px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')}
|
||||
title={t('volunteers.sort.toggle')}
|
||||
className="sticky right-0 z-[8] flex flex-none cursor-pointer items-center gap-1 border-b-2 border-l-[3px] border-ink bg-yellow px-2 font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-yellow-soft"
|
||||
style={{ width: TOTAL_W }}
|
||||
>
|
||||
{t('volunteers.totalHours')}
|
||||
{sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]">
|
||||
<div className="sticky top-[27px] z-[6] flex">
|
||||
<div
|
||||
className="sticky left-0 z-[8] flex flex-none items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]"
|
||||
style={{ width: NAME_W }}
|
||||
>
|
||||
{t('volunteers.name')}
|
||||
</div>
|
||||
{lattice.columns.map((column) => (
|
||||
<div className="relative flex flex-none border-b-[3px] border-ink bg-sand" style={{ width: axis.width }}>
|
||||
{axis.layout.map(({ day, slotW }) =>
|
||||
day.slotLabels.map((label, i) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||
key={`${day.date}-${i}`}
|
||||
className={cx(
|
||||
'flex-none overflow-hidden whitespace-nowrap border-r border-ink py-[5px] text-center font-mono text-[9.5px]',
|
||||
i === day.slotLabels.length - 1 && 'border-r-[3px]',
|
||||
)}
|
||||
style={{ width: slotW }}
|
||||
>
|
||||
{column.time}
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
<div className="sticky right-0 top-[27px] z-[7] flex items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]">
|
||||
)),
|
||||
)}
|
||||
{nowX !== null ? (
|
||||
<span
|
||||
className="absolute top-1/2 z-[1] -translate-x-1/2 -translate-y-1/2 bg-orange px-[5px] py-[2px] font-mono text-[9.5px] font-bold text-white"
|
||||
style={{ left: nowX }}
|
||||
>
|
||||
{clockLabel(now)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="sticky right-0 z-[8] flex flex-none items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]"
|
||||
style={{ width: TOTAL_W }}
|
||||
>
|
||||
{t('volunteers.cumulative')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.map((row) => (
|
||||
<VolunteerRow
|
||||
key={row.volunteer.id}
|
||||
row={row}
|
||||
columns={lattice.columns}
|
||||
abbr={abbr}
|
||||
/>
|
||||
<VolunteerRow key={row.volunteer.id} row={row} layout={axis.layout} width={axis.width} nowX={nowX} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -164,6 +302,20 @@ export function VolunteerViewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function nowPosition(
|
||||
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>,
|
||||
now: number,
|
||||
dayBoundary: string,
|
||||
): number | null {
|
||||
const iso = new Date(now * 60_000).toISOString().slice(0, 16);
|
||||
const entry = layout.find((l) => l.day.date === festivalDay(iso, dayBoundary));
|
||||
if (!entry) return null;
|
||||
const boundary = minutesOfDay(dayBoundary);
|
||||
const offset = ((minutesOfDay(timePart(iso)) - boundary + 1440) % 1440) - entry.day.startOffset;
|
||||
if (offset < 0 || offset > entry.day.slotLabels.length * SLOT_MINUTES) return null;
|
||||
return entry.offset + (offset / SLOT_MINUTES) * entry.slotW;
|
||||
}
|
||||
|
||||
function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
||||
@@ -173,27 +325,26 @@ function LegendItem({ swatch, children }: { swatch: string; children: React.Reac
|
||||
);
|
||||
}
|
||||
|
||||
type Row = {
|
||||
volunteer: VolunteerRead;
|
||||
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
|
||||
total: number;
|
||||
empty: boolean;
|
||||
};
|
||||
|
||||
function VolunteerRow({
|
||||
row,
|
||||
columns,
|
||||
abbr,
|
||||
layout,
|
||||
width,
|
||||
nowX,
|
||||
}: {
|
||||
row: Row;
|
||||
columns: { key: string }[];
|
||||
abbr: (positionId: string) => string;
|
||||
row: { volunteer: VolunteerRead; bars: Bar[]; lanes: number; total: number; empty: boolean };
|
||||
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>;
|
||||
width: number;
|
||||
nowX: number | null;
|
||||
}) {
|
||||
const height = row.lanes * LANE_H + ROW_PAD * 2;
|
||||
return (
|
||||
<>
|
||||
<div className="sticky left-0 z-[2] flex h-8 items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2">
|
||||
<div className="flex" style={{ height }}>
|
||||
<div
|
||||
className="sticky left-0 z-[4] flex flex-none items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2"
|
||||
style={{ width: NAME_W }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
|
||||
{row.volunteer.first_name} {row.volunteer.last_name}
|
||||
{volunteerName(row.volunteer)}
|
||||
</span>
|
||||
{row.empty ? (
|
||||
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
|
||||
@@ -201,21 +352,56 @@ function VolunteerRow({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{columns.map((column) => {
|
||||
const cell = row.cellByKey.get(column.key);
|
||||
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />;
|
||||
return (
|
||||
|
||||
<div className="relative flex-none border-b border-ink bg-sand" style={{ width }}>
|
||||
{/* Tranches de 30 min, filet épais entre deux jours. */}
|
||||
{layout.map((l) => (
|
||||
<span
|
||||
key={l.day.date}
|
||||
className="absolute inset-y-0 border-r-[3px] border-ink"
|
||||
style={{
|
||||
left: l.offset,
|
||||
width: l.width,
|
||||
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 ${l.slotW - 1}px, rgba(20,17,14,.35) ${l.slotW - 1}px ${l.slotW}px)`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
{row.bars.map((bar) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
|
||||
key={bar.assignmentId}
|
||||
title={
|
||||
bar.overlap
|
||||
? t('volunteers.bar.overlapTitle', { position: bar.positionName, time: bar.label })
|
||||
: t('volunteers.bar.title', { position: bar.positionName, time: bar.label })
|
||||
}
|
||||
className={cx(
|
||||
'absolute z-[2] flex items-center overflow-hidden border-2 border-ink px-[5px] font-display text-[10px] font-extrabold leading-none',
|
||||
bar.overlap ? 'bg-yellow' : 'bg-blue',
|
||||
// Poste trouvé par la recherche : cerné d'orange.
|
||||
bar.match && 'outline outline-[3px] outline-offset-0 outline-orange',
|
||||
)}
|
||||
style={{
|
||||
left: bar.left + 1,
|
||||
width: Math.max(4, bar.width - 2),
|
||||
top: ROW_PAD + bar.lane * LANE_H,
|
||||
height: LANE_H - 3,
|
||||
}}
|
||||
>
|
||||
{abbr(cell.shift.position_id)}
|
||||
<span className="truncate">{bar.positionName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="sticky right-0 z-[2] flex items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold">
|
||||
))}
|
||||
{nowX !== null ? (
|
||||
<span className="absolute inset-y-0 z-[3] w-[2px] -translate-x-1/2 bg-orange" style={{ left: nowX }} aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="sticky right-0 z-[4] flex flex-none items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold"
|
||||
style={{ width: TOTAL_W }}
|
||||
>
|
||||
{row.total}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { BadgeFace, VolunteerRead } from '@/api/schema';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
import {
|
||||
CELL_PAD_X_MM,
|
||||
CELL_PAD_Y_MM,
|
||||
FIRST_BASELINE,
|
||||
LINE_HEIGHT,
|
||||
MM_PER_PT,
|
||||
type TableLayout,
|
||||
type TextLayout,
|
||||
} from './layout';
|
||||
import {
|
||||
FACE_LABEL_KEY,
|
||||
fmt,
|
||||
isVerticallyCentered,
|
||||
renderField,
|
||||
SAFE_MARGIN_MM,
|
||||
stackOrder,
|
||||
type BadgeData,
|
||||
type FieldState,
|
||||
} from './model';
|
||||
|
||||
/** Pas d'accroche du glisser-déposer (mm). */
|
||||
const DRAG_SNAP_MM = 0.5;
|
||||
/** Plus petit cadre possible au redimensionnement (mm). */
|
||||
const MIN_W_MM = 3;
|
||||
const MIN_H_MM = 2;
|
||||
/** Décalage entre la ligne de base d'une ligne CSS (interligne 1,25, police
|
||||
* Helvetica/Arial) et celle du PDF (0,85 × taille sous le haut du cadre). */
|
||||
const CSS_BASELINE = 0.97;
|
||||
const FONT_STACK = 'Helvetica, Arial, sans-serif';
|
||||
|
||||
function snap(value: number): number {
|
||||
return Math.round(value / DRAG_SNAP_MM) * DRAG_SNAP_MM;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export interface BadgeCanvasProps {
|
||||
face: BadgeFace;
|
||||
fields: FieldState[];
|
||||
data: BadgeData;
|
||||
volunteer: VolunteerRead | null;
|
||||
photoUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
scale: number;
|
||||
showGuides: boolean;
|
||||
selectedType: string | null;
|
||||
uploading: boolean;
|
||||
onSelect: (field: FieldState) => void;
|
||||
/** `record` : enregistrer l'état actuel dans l'historique d'annulation
|
||||
* avant d'appliquer `patch` (une seule fois par glisser-déposer). */
|
||||
onChange: (field: FieldState, patch: Partial<FieldState>, record: boolean) => void;
|
||||
onDropImage: (file: File) => void;
|
||||
}
|
||||
|
||||
/** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de
|
||||
* dépôt de son image de fond. */
|
||||
export function BadgeCanvas({
|
||||
face,
|
||||
fields,
|
||||
data,
|
||||
volunteer,
|
||||
photoUrl,
|
||||
imageUrl,
|
||||
badgeW,
|
||||
badgeH,
|
||||
scale,
|
||||
showGuides,
|
||||
selectedType,
|
||||
uploading,
|
||||
onSelect,
|
||||
onChange,
|
||||
onDropImage,
|
||||
}: BadgeCanvasProps) {
|
||||
const [dropping, setDropping] = useState(false);
|
||||
// Du dessous vers le dessus, comme le PDF (`z_order`).
|
||||
const shown = stackOrder(fields, face);
|
||||
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-[9px]">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={cx(
|
||||
'border-2 border-ink px-2 py-1 font-display text-[11px] font-black uppercase tracking-wider',
|
||||
isFaceSelected ? 'bg-ink text-yellow' : 'bg-paper text-ink',
|
||||
)}
|
||||
>
|
||||
{t(FACE_LABEL_KEY[face])}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'relative flex-none select-none border-[3px] border-ink bg-sand-dark bg-contain bg-center bg-no-repeat shadow-brutal-lg',
|
||||
dropping && 'outline outline-[3px] outline-offset-2 outline-blue-strong',
|
||||
)}
|
||||
// + 6 : bordure de 3 px de chaque côté (les champs se placent dans
|
||||
// la boîte intérieure, à l'échelle exacte du badge).
|
||||
style={{
|
||||
width: badgeW * scale + 6,
|
||||
height: badgeH * scale + 6,
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setDropping(true);
|
||||
}}
|
||||
onDragLeave={() => setDropping(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) onDropImage(file);
|
||||
}}
|
||||
>
|
||||
{/* Invitation à déposer une image : seulement sur une face encore
|
||||
vide (elle passerait sous les champs sinon), ou par-dessus tout
|
||||
pendant un dépôt / un envoi. */}
|
||||
{uploading || dropping || (!imageUrl && shown.length === 0) ? (
|
||||
<div
|
||||
className={cx(
|
||||
'pointer-events-none absolute inset-0 flex items-center justify-center p-3 text-center font-mono text-[10px] font-medium leading-relaxed',
|
||||
uploading || dropping ? 'z-20 bg-blue/80 text-ink' : 'text-ink/60',
|
||||
)}
|
||||
>
|
||||
{uploading
|
||||
? t('badges.uploading')
|
||||
: t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')}
|
||||
</div>
|
||||
) : null}
|
||||
{showGuides ? (
|
||||
<div
|
||||
className="pointer-events-none absolute border-[1.5px] border-dashed border-yellow opacity-90"
|
||||
style={{ inset: SAFE_MARGIN_MM * scale }}
|
||||
/>
|
||||
) : null}
|
||||
{shown.map((field, i) => (
|
||||
<FieldBox
|
||||
key={field.field_type}
|
||||
field={field}
|
||||
layer={i + 1}
|
||||
data={data}
|
||||
volunteer={volunteer}
|
||||
photoUrl={photoUrl}
|
||||
badgeW={badgeW}
|
||||
badgeH={badgeH}
|
||||
scale={scale}
|
||||
showGuides={showGuides}
|
||||
selected={selectedType === field.field_type}
|
||||
onSelect={() => onSelect(field)}
|
||||
onChange={(patch, record) => onChange(field, patch, record)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
layer,
|
||||
data,
|
||||
volunteer,
|
||||
photoUrl,
|
||||
badgeW,
|
||||
badgeH,
|
||||
scale,
|
||||
showGuides,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldState;
|
||||
/** Rang dans la pile de la face (`z-index`). */
|
||||
layer: number;
|
||||
data: BadgeData;
|
||||
volunteer: VolunteerRead | null;
|
||||
photoUrl: string | null;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
scale: number;
|
||||
showGuides: boolean;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onChange: (patch: Partial<FieldState>, record: boolean) => void;
|
||||
}) {
|
||||
const dragRef = useRef<{
|
||||
mode: 'move' | 'resize';
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
px: number;
|
||||
py: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
mode,
|
||||
x: field.x_mm,
|
||||
y: field.y_mm,
|
||||
w: field.width_mm,
|
||||
h: field.height_mm,
|
||||
px: e.clientX,
|
||||
py: e.clientY,
|
||||
moved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const move = (e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
e.stopPropagation();
|
||||
const dx = (e.clientX - d.px) / scale;
|
||||
const dy = (e.clientY - d.py) / scale;
|
||||
if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return;
|
||||
const record = !d.moved;
|
||||
d.moved = true;
|
||||
if (d.mode === 'move') {
|
||||
// Bornes jamais négatives : un champ plus grand que le badge reste
|
||||
// saisissable (voir `fitToBadge`).
|
||||
onChange(
|
||||
{
|
||||
x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)),
|
||||
y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)),
|
||||
},
|
||||
record,
|
||||
);
|
||||
} else {
|
||||
const maxW = Math.max(0, badgeW - d.x);
|
||||
const maxH = Math.max(0, badgeH - d.y);
|
||||
onChange(
|
||||
{
|
||||
width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW),
|
||||
height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH),
|
||||
},
|
||||
record,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const end = (e: React.PointerEvent) => {
|
||||
if (dragRef.current) e.stopPropagation();
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
const rendered = volunteer ? renderField(field, volunteer, data) : null;
|
||||
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
|
||||
const truncated = layout?.truncated ?? false;
|
||||
|
||||
const geometry = {
|
||||
left: field.x_mm * scale,
|
||||
top: field.y_mm * scale,
|
||||
width: field.width_mm * scale,
|
||||
height: field.height_mm * scale,
|
||||
};
|
||||
const handlers = {
|
||||
onPointerDown: start('move'),
|
||||
onPointerMove: move,
|
||||
onPointerUp: end,
|
||||
onPointerCancel: end,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
|
||||
<div
|
||||
{...(selected ? {} : handlers)}
|
||||
className={cx('absolute touch-none', selected ? 'pointer-events-none' : 'cursor-move')}
|
||||
style={{
|
||||
...geometry,
|
||||
outline: selected
|
||||
? 'none'
|
||||
: truncated
|
||||
? '2px dashed #FF5C39'
|
||||
: showGuides
|
||||
? '1px dashed rgba(20,17,14,.45)'
|
||||
: 'none',
|
||||
zIndex: layer,
|
||||
}}
|
||||
>
|
||||
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
|
||||
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} />
|
||||
) : rendered?.kind === 'table' ? (
|
||||
<TableContent field={field} layout={rendered.layout} scale={scale} />
|
||||
) : rendered?.kind === 'text' ? (
|
||||
<TextContent field={field} layout={rendered.layout} scale={scale} />
|
||||
) : null}
|
||||
|
||||
{truncated && !selected ? <TruncatedTag /> : null}
|
||||
</div>
|
||||
|
||||
{/* Cadre de sélection par-dessus toute la pile : un champ recouvert
|
||||
(ex. la photo sous un texte) reste saisissable, sans que l'aperçu
|
||||
change l'ordre d'impression. */}
|
||||
{selected ? (
|
||||
<div
|
||||
{...handlers}
|
||||
className="absolute z-[15] cursor-move touch-none"
|
||||
style={{ ...geometry, outline: '2px solid #2A6BFF' }}
|
||||
>
|
||||
{truncated ? <TruncatedTag /> : null}
|
||||
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
|
||||
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
|
||||
{layout?.reduced
|
||||
? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) })
|
||||
: null}
|
||||
</span>
|
||||
<span
|
||||
onPointerDown={start('resize')}
|
||||
onPointerMove={move}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TruncatedTag() {
|
||||
return (
|
||||
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
|
||||
{t('badges.preview.truncated')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) {
|
||||
const fontPx = layout.fontSize * MM_PER_PT * scale;
|
||||
const lineHeightPx = fontPx * LINE_HEIGHT;
|
||||
// Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut —
|
||||
// même calcul que le PDF : bloc = interlignes + une hauteur de caractère.
|
||||
const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx;
|
||||
const boxPx = field.height_mm * scale;
|
||||
const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0;
|
||||
const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none relative h-full w-full overflow-hidden"
|
||||
style={{ background: field.bg_color ?? 'transparent' }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0"
|
||||
style={{
|
||||
top,
|
||||
color: field.color,
|
||||
fontFamily: FONT_STACK,
|
||||
fontSize: fontPx,
|
||||
lineHeight: `${lineHeightPx}px`,
|
||||
fontWeight: field.bold ? 700 : 400,
|
||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{layout.visible.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre">
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes
|
||||
* retenues que le PDF (`_draw_table`). */
|
||||
function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) {
|
||||
const fontPx = layout.fontSize * MM_PER_PT * scale;
|
||||
const lineHeightPx = fontPx * LINE_HEIGHT;
|
||||
const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`;
|
||||
const padX = CELL_PAD_X_MM * scale;
|
||||
const padY = CELL_PAD_Y_MM * scale;
|
||||
const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none h-full w-full overflow-hidden"
|
||||
style={{ background: field.bg_color ?? 'transparent' }}
|
||||
>
|
||||
{layout.rows.length ? (
|
||||
<div
|
||||
style={{
|
||||
width: fullWidth,
|
||||
border: rule,
|
||||
color: field.color,
|
||||
fontFamily: FONT_STACK,
|
||||
fontSize: fontPx,
|
||||
lineHeight: `${lineHeightPx}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{layout.rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex"
|
||||
style={{
|
||||
height: row.heightMm * scale,
|
||||
borderTop: i > 0 ? rule : undefined,
|
||||
fontWeight: row.isTitle ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{row.cells.map((lines, col) => (
|
||||
<div
|
||||
key={col}
|
||||
className="flex-none overflow-hidden"
|
||||
style={{
|
||||
width: row.isTitle ? '100%' : layout.widthsMm[col] * scale,
|
||||
padding: `${padY}px ${padX}px`,
|
||||
borderLeft: col > 0 ? rule : undefined,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{lines.map((line, j) => (
|
||||
<div key={j} className="whitespace-pre">
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoContent({
|
||||
field,
|
||||
volunteer,
|
||||
photoUrl,
|
||||
}: {
|
||||
field: FieldState;
|
||||
volunteer: VolunteerRead | null;
|
||||
photoUrl: string | null;
|
||||
}) {
|
||||
const radius = field.photo_shape === 'OVAL' ? '50%' : 0;
|
||||
if (volunteer?.has_photo && photoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
style={{ borderRadius: radius }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Sans photo, rien n'est imprimé : juste un repère pointillé dans
|
||||
// l'éditeur (pas sur le badge) pour savoir où est le cadre.
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none flex h-full w-full items-center justify-center border border-dashed border-ink/40 text-center font-mono text-[9px] leading-tight text-ink/50"
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
{t('badges.preview.noPhoto')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { BadgeAlign, BadgeFieldType } from '@/api/schema';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout';
|
||||
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState, type StackMove } from './model';
|
||||
|
||||
const TEXT_COLORS: Array<{ hex: string; key: string }> = [
|
||||
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
||||
{ hex: '#14110E', key: 'badges.color.black' },
|
||||
{ hex: '#FFD23F', key: 'badges.color.yellow' },
|
||||
{ hex: '#FF5C39', key: 'badges.color.coral' },
|
||||
];
|
||||
|
||||
const BG_COLORS: Array<{ hex: string | null; key: string }> = [
|
||||
{ hex: null, key: 'badges.color.none' },
|
||||
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
||||
{ hex: '#14110E', key: 'badges.color.black' },
|
||||
{ hex: '#FFD23F', key: 'badges.color.yellow' },
|
||||
];
|
||||
|
||||
const ALIGNS: Array<{ value: BadgeAlign; key: string; items: string }> = [
|
||||
{ value: 'LEFT', key: 'badges.align.left', items: 'items-start' },
|
||||
{ value: 'CENTER', key: 'badges.align.center', items: 'items-center' },
|
||||
{ value: 'RIGHT', key: 'badges.align.right', items: 'items-end' },
|
||||
];
|
||||
|
||||
function sameColor(a: string | null, b: string | null): boolean {
|
||||
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function Section({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-[6px] flex items-baseline">
|
||||
<span className="font-display text-[9.5px] font-extrabold uppercase tracking-widest">{title}</span>
|
||||
{aside ? <span className="ml-auto font-mono text-[9px] uppercase opacity-60">{aside}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Segment({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cx(
|
||||
'cursor-pointer border-0 transition-colors',
|
||||
active ? 'bg-ink text-yellow' : 'bg-cream text-ink hover:bg-yellow-soft',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Swatch({
|
||||
hex,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
hex: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className="flex cursor-pointer flex-col items-center gap-1 border-0 bg-transparent p-0"
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'flex h-[30px] w-full items-center justify-center border-2 border-ink font-display text-[12px] font-black',
|
||||
hex === null && 'bg-hatch-sand',
|
||||
hex === '#14110E' ? 'text-yellow' : 'text-ink',
|
||||
)}
|
||||
style={{
|
||||
background: hex ?? undefined,
|
||||
boxShadow: active ? '0 0 0 3px #2A6BFF' : undefined,
|
||||
}}
|
||||
>
|
||||
{active ? '✓' : ''}
|
||||
</span>
|
||||
<span className="font-display text-[9.5px] font-semibold">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Saisie d'une coordonnée en mm, appliquée à la validation (sortie du
|
||||
* champ ou Entrée) pour ne pas bousculer la saisie en cours. */
|
||||
function MmInput({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) {
|
||||
const [text, setText] = useState(fmt(value));
|
||||
useEffect(() => setText(fmt(value)), [value]);
|
||||
const commit = () => {
|
||||
const n = Number(text.replace(',', '.'));
|
||||
if (Number.isFinite(n) && round1(n) !== round1(value)) onCommit(Math.max(0, n));
|
||||
else setText(fmt(value));
|
||||
};
|
||||
return (
|
||||
<label className="flex items-stretch border-2 border-ink bg-cream">
|
||||
<span className="flex w-[30px] flex-none items-center justify-center border-r-2 border-ink bg-sand font-display text-[11px] font-extrabold">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
}}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent px-[7px] py-2 font-mono text-[13px] font-semibold"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function BadgeInspector({
|
||||
field,
|
||||
badgeW,
|
||||
badgeH,
|
||||
effectiveFontSize,
|
||||
photoCount,
|
||||
volunteerCount,
|
||||
stack,
|
||||
onChange,
|
||||
onRestack,
|
||||
onHide,
|
||||
}: {
|
||||
field: FieldState;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
/** Taille réellement utilisée sur le badge affiché (réduction auto). */
|
||||
effectiveFontSize: number | null;
|
||||
photoCount: number;
|
||||
volunteerCount: number;
|
||||
/** Types des champs activés de la face, du dessous vers le dessus. */
|
||||
stack: BadgeFieldType[];
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
onRestack: (move: StackMove) => void;
|
||||
onHide: () => void;
|
||||
}) {
|
||||
const isPhoto = field.field_type === 'PHOTO';
|
||||
const lowContrast = Boolean(field.bg_color) && contrastRatio(field.color, field.bg_color as string) < MIN_CONTRAST;
|
||||
const reduced = effectiveFontSize !== null && effectiveFontSize < field.font_size;
|
||||
const setGeometry = (patch: Partial<FieldState>) =>
|
||||
onChange(fitToBadge({ ...field, ...patch }, badgeW, badgeH));
|
||||
const fontSize = (delta: number) =>
|
||||
onChange({
|
||||
font_size: Math.min(MAX_FONT_PT, Math.max(MIN_FONT_PT, round1(field.font_size + delta))),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-ink px-3 py-[10px] text-cream">
|
||||
<div className="font-mono text-[9.5px] uppercase text-yellow">
|
||||
{t('badges.inspector.kicker', { face: t(FACE_LABEL_KEY[field.face]) })}
|
||||
</div>
|
||||
<div className="mt-[5px] font-display text-[17px] font-black leading-tight tracking-tight">
|
||||
{t(FIELD_LABEL_KEY[field.field_type])}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[15px] p-3">
|
||||
{field.field_type === 'FREE_TEXT' ? (
|
||||
<label className="block">
|
||||
<span className="mb-[6px] block font-display text-[9.5px] font-extrabold uppercase tracking-widest">
|
||||
{t('badges.inspector.content')}
|
||||
</span>
|
||||
<textarea
|
||||
value={field.text ?? ''}
|
||||
onChange={(e) => onChange({ text: e.target.value })}
|
||||
rows={2}
|
||||
placeholder={t('badges.field.freeText.input')}
|
||||
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[13px] font-semibold leading-snug"
|
||||
/>
|
||||
<span className="mt-1 block font-mono text-[9px] leading-normal opacity-60">
|
||||
{t('badges.inspector.contentHelp')}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{!isPhoto ? (
|
||||
<>
|
||||
<Section
|
||||
title={t('badges.inspector.fontSize')}
|
||||
aside={
|
||||
reduced
|
||||
? t('badges.inspector.reducedHere', { size: fmt(effectiveFontSize as number) })
|
||||
: t('badges.inspector.capHeight', { mm: fmt(field.font_size * MM_PER_PT) })
|
||||
}
|
||||
>
|
||||
<div className="flex items-stretch border-2 border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fontSize(-FONT_STEP_PT)}
|
||||
aria-label={t('badges.inspector.smaller')}
|
||||
className="h-10 w-11 cursor-pointer border-0 border-r-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="flex flex-1 items-center justify-center font-mono text-[17px] font-bold">
|
||||
{t('badges.pt', { size: fmt(field.font_size) })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fontSize(FONT_STEP_PT)}
|
||||
aria-label={t('badges.inspector.larger')}
|
||||
className="h-10 w-11 cursor-pointer border-0 border-l-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={field.auto_fit}
|
||||
onClick={() => onChange({ auto_fit: !field.auto_fit })}
|
||||
className="mt-[7px] flex w-full cursor-pointer items-center gap-[9px] border-0 bg-transparent px-0 py-[3px] text-left"
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'relative h-5 w-[34px] flex-none border-2 border-ink',
|
||||
field.auto_fit ? 'bg-yellow' : 'bg-sand-dark',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[1px] h-[14px] w-[14px] bg-ink"
|
||||
style={{ left: field.auto_fit ? 15 : 1 }}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-display text-[11.5px] font-bold leading-snug">
|
||||
{t('badges.inspector.autoFit')}
|
||||
</span>
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.align')}>
|
||||
<div className="grid grid-cols-3 border-2 border-ink">
|
||||
{ALIGNS.map((a, i) => (
|
||||
<Segment
|
||||
key={a.value}
|
||||
active={field.align === a.value}
|
||||
onClick={() => onChange({ align: a.value })}
|
||||
className={cx(
|
||||
'flex h-[46px] flex-col items-center justify-center gap-1',
|
||||
i < 2 && 'border-r-2 border-ink',
|
||||
)}
|
||||
>
|
||||
<span className={cx('flex w-[18px] flex-col gap-[2px]', a.items)}>
|
||||
<span className="h-[2px] w-[18px] bg-current" />
|
||||
<span className="h-[2px] w-[11px] bg-current" />
|
||||
<span className="h-[2px] w-[15px] bg-current" />
|
||||
</span>
|
||||
<span className="font-display text-[9.5px] font-bold">{t(a.key)}</span>
|
||||
</Segment>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.weight')}>
|
||||
<div className="grid grid-cols-2 border-2 border-ink">
|
||||
<Segment
|
||||
active={!field.bold}
|
||||
onClick={() => onChange({ bold: false })}
|
||||
className="h-[38px] border-r-2 border-ink font-display text-[13px] font-medium"
|
||||
>
|
||||
{t('badges.inspector.regular')}
|
||||
</Segment>
|
||||
<Segment
|
||||
active={field.bold}
|
||||
onClick={() => onChange({ bold: true })}
|
||||
className="h-[38px] font-display text-[13px] font-black"
|
||||
>
|
||||
{t('badges.inspector.bold')}
|
||||
</Segment>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.textColor')}>
|
||||
<div className="grid grid-cols-5 gap-[6px]">
|
||||
{TEXT_COLORS.map((c) => (
|
||||
<Swatch
|
||||
key={c.hex}
|
||||
hex={c.hex}
|
||||
label={t(c.key)}
|
||||
active={sameColor(field.color, c.hex)}
|
||||
onClick={() => onChange({ color: c.hex })}
|
||||
/>
|
||||
))}
|
||||
<label className="flex cursor-pointer flex-col items-center gap-1">
|
||||
<input
|
||||
type="color"
|
||||
value={field.color}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
className={cx(
|
||||
'h-[30px] w-full cursor-pointer border-2 border-ink bg-cream p-0',
|
||||
!TEXT_COLORS.some((c) => sameColor(c.hex, field.color)) &&
|
||||
'shadow-[0_0_0_3px_#2A6BFF]',
|
||||
)}
|
||||
/>
|
||||
<span className="font-display text-[9.5px] font-semibold">
|
||||
{t('badges.color.custom')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.background')}>
|
||||
<div className="grid grid-cols-4 gap-[6px]">
|
||||
{BG_COLORS.map((c) => (
|
||||
<Swatch
|
||||
key={c.key}
|
||||
hex={c.hex}
|
||||
label={t(c.key)}
|
||||
active={sameColor(field.bg_color, c.hex)}
|
||||
onClick={() => onChange({ bg_color: c.hex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{lowContrast ? (
|
||||
<div className="mt-[7px] border-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
|
||||
{t('badges.inspector.lowContrast')}
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<Section title={t('badges.inspector.photoShape')}>
|
||||
<div className="grid grid-cols-2 border-2 border-ink">
|
||||
<Segment
|
||||
active={field.photo_shape === 'RECT'}
|
||||
onClick={() => onChange({ photo_shape: 'RECT' })}
|
||||
className="h-10 border-r-2 border-ink font-display text-[12px] font-extrabold"
|
||||
>
|
||||
{t('badges.inspector.photoRect')}
|
||||
</Segment>
|
||||
<Segment
|
||||
active={field.photo_shape === 'OVAL'}
|
||||
onClick={() => onChange({ photo_shape: 'OVAL' })}
|
||||
className="h-10 font-display text-[12px] font-extrabold"
|
||||
>
|
||||
{t('badges.inspector.photoOval')}
|
||||
</Segment>
|
||||
</div>
|
||||
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
|
||||
{t('badges.inspector.photoStat', { count: photoCount, total: volunteerCount })}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="border-t-2 border-ink pt-3">
|
||||
<div className="mb-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-widest">
|
||||
{t('badges.inspector.geometry')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-[7px]">
|
||||
<MmInput label={t('badges.inspector.x')} value={field.x_mm} onCommit={(v) => setGeometry({ x_mm: v })} />
|
||||
<MmInput label={t('badges.inspector.y')} value={field.y_mm} onCommit={(v) => setGeometry({ y_mm: v })} />
|
||||
<MmInput
|
||||
label={t('badges.inspector.w')}
|
||||
value={field.width_mm}
|
||||
onCommit={(v) => setGeometry({ width_mm: Math.max(1, v) })}
|
||||
/>
|
||||
<MmInput
|
||||
label={t('badges.inspector.h')}
|
||||
value={field.height_mm}
|
||||
onCommit={(v) => setGeometry({ height_mm: Math.max(1, v) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-[7px] grid grid-cols-2 gap-[7px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGeometry({ x_mm: (badgeW - field.width_mm) / 2 })}
|
||||
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
|
||||
>
|
||||
{t('badges.inspector.center')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGeometry({ x_mm: 0, width_mm: badgeW })}
|
||||
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
|
||||
>
|
||||
{t('badges.inspector.fullWidth')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StackSection field={field} stack={stack} onRestack={onRestack} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHide}
|
||||
className="cursor-pointer border-2 border-ink bg-paper p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
|
||||
>
|
||||
{t('badges.inspector.hide')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STACK_MOVES: Array<{ move: StackMove; key: string; atTop: boolean }> = [
|
||||
{ move: 'front', key: 'badges.inspector.stack.front', atTop: true },
|
||||
{ move: 'forward', key: 'badges.inspector.stack.forward', atTop: true },
|
||||
{ move: 'backward', key: 'badges.inspector.stack.backward', atTop: false },
|
||||
{ move: 'back', key: 'badges.inspector.stack.back', atTop: false },
|
||||
];
|
||||
|
||||
/** Ordre de superposition du champ parmi ceux de sa face — l'ordre dans
|
||||
* lequel le PDF les dessine (`BadgeField.z_order`). */
|
||||
function StackSection({
|
||||
field,
|
||||
stack,
|
||||
onRestack,
|
||||
}: {
|
||||
field: FieldState;
|
||||
stack: BadgeFieldType[];
|
||||
onRestack: (move: StackMove) => void;
|
||||
}) {
|
||||
const index = stack.indexOf(field.field_type);
|
||||
const isTop = index === stack.length - 1;
|
||||
const isBottom = index === 0;
|
||||
return (
|
||||
<Section
|
||||
title={t('badges.inspector.stack')}
|
||||
aside={t('badges.inspector.stack.position', { rank: stack.length - index, total: stack.length })}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-[7px]">
|
||||
{STACK_MOVES.map(({ move, key, atTop }) => {
|
||||
const disabled = stack.length < 2 || (atTop ? isTop : isBottom);
|
||||
return (
|
||||
<button
|
||||
key={move}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onRestack(move)}
|
||||
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft disabled:cursor-default disabled:opacity-40 disabled:hover:bg-cream"
|
||||
>
|
||||
{t(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
|
||||
{t('badges.inspector.stack.help')}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Métriques de la police Helvetica (et Helvetica-Bold) des 14 polices
|
||||
// standard PDF, telles que ReportLab les utilise pour `stringWidth`
|
||||
// (`backend/app/badges/pdf.py:wrap_text`) : largeur de chaque caractère du
|
||||
// codage WinAnsi, en millièmes de la taille de police. Recopiées des fichiers
|
||||
// `reportlab/pdfbase/_fontdata_widths_helvetica*.py` — mesurer avec ces mêmes
|
||||
// chiffres (plutôt qu'avec la police que le navigateur trouve sous le nom
|
||||
// « Helvetica ») fait tomber les retours à la ligne de l'aperçu au même
|
||||
// endroit que dans le PDF, et marche aussi hors navigateur (smoke).
|
||||
|
||||
const CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ";
|
||||
const REGULAR = [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,350,556,222,556,333,1000,556,556,333,1000,667,333,1000,611,222,222,333,333,350,556,1000,333,1000,500,333,944,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,333,400,584,333,333,333,556,537,278,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,584,611,556,556,556,556,500,556,500];
|
||||
const BOLD = [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,350,556,278,556,500,1000,556,556,333,1000,667,333,1000,611,278,278,500,500,350,556,1000,333,1000,556,333,944,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,333,400,584,333,333,333,611,556,278,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,584,611,611,611,611,611,556,611,556];
|
||||
|
||||
/** Largeur (millièmes d'em) d'un caractère hors WinAnsi : ReportLab le
|
||||
* remplacerait par une police de substitution, on prend une largeur moyenne. */
|
||||
const FALLBACK_WIDTH = 556;
|
||||
|
||||
const regularWidths = new Map<string, number>();
|
||||
const boldWidths = new Map<string, number>();
|
||||
for (let i = 0; i < CHARS.length; i++) {
|
||||
regularWidths.set(CHARS[i], REGULAR[i]);
|
||||
boldWidths.set(CHARS[i], BOLD[i]);
|
||||
}
|
||||
|
||||
/** Largeur de `text` en points, à la taille `size` (pt) — équivalent de
|
||||
* `reportlab.pdfbase.pdfmetrics.stringWidth(text, "Helvetica[-Bold]", size)`. */
|
||||
export function stringWidthPt(text: string, bold: boolean, size: number): number {
|
||||
const widths = bold ? boldWidths : regularWidths;
|
||||
let total = 0;
|
||||
for (const ch of text) total += widths.get(ch) ?? FALLBACK_WIDTH;
|
||||
return (total * size) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Contrôle avant génération : passe chaque champ affiché sur TOUS les
|
||||
// bénévoles (estimation de mise en page, voir `layout.ts`) et relève ce qui
|
||||
// risque de mal s'imprimer.
|
||||
|
||||
import type { BadgeFace, BadgeFieldType } from '@/api/schema';
|
||||
|
||||
import { contrastRatio, MIN_CONTRAST } from './layout';
|
||||
import { renderField, SAFE_MARGIN_MM, type BadgeData, type FieldRef, type FieldState } from './model';
|
||||
|
||||
export interface FieldStats {
|
||||
/** Bénévoles dont le texte a été réduit automatiquement (sans être coupé). */
|
||||
reduced: number;
|
||||
/** Bénévoles dont le texte reste coupé. */
|
||||
truncated: number;
|
||||
/** Plus petite taille atteinte par la réduction automatique. */
|
||||
minPt: number;
|
||||
/** Index (dans `BadgeData.volunteers`) d'un exemple de chaque cas. */
|
||||
reducedExample: number | null;
|
||||
truncatedExample: number | null;
|
||||
}
|
||||
|
||||
export type IssueKind = 'truncated' | 'reduced' | 'margin' | 'contrast' | 'noPhoto';
|
||||
|
||||
export interface BadgeIssue {
|
||||
kind: IssueKind;
|
||||
critical: boolean;
|
||||
ref: FieldRef;
|
||||
count: number;
|
||||
minPt: number;
|
||||
/** Bénévole à afficher pour « Voir un exemple » (null : pas d'exemple). */
|
||||
exampleIndex: number | null;
|
||||
/** « Activer la réduction auto » proposé (texte coupé, réduction coupée). */
|
||||
canEnableAutoFit: boolean;
|
||||
}
|
||||
|
||||
export function statsKey(face: BadgeFace, type: BadgeFieldType): string {
|
||||
return `${face}:${type}`;
|
||||
}
|
||||
|
||||
export function computeIssues(
|
||||
fields: FieldState[],
|
||||
data: BadgeData,
|
||||
badgeW: number,
|
||||
badgeH: number,
|
||||
): { issues: BadgeIssue[]; stats: Map<string, FieldStats> } {
|
||||
const stats = new Map<string, FieldStats>();
|
||||
const issues: BadgeIssue[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (!field.enabled) continue;
|
||||
const ref = { face: field.face, type: field.field_type };
|
||||
const base = { ref, count: 0, minPt: field.font_size, exampleIndex: null, canEnableAutoFit: false };
|
||||
|
||||
if (field.field_type !== 'PHOTO') {
|
||||
const s: FieldStats = {
|
||||
reduced: 0,
|
||||
truncated: 0,
|
||||
minPt: field.font_size,
|
||||
reducedExample: null,
|
||||
truncatedExample: null,
|
||||
};
|
||||
data.volunteers.forEach((volunteer, index) => {
|
||||
const rendered = renderField(field, volunteer, data);
|
||||
if (rendered.kind === 'photo') return;
|
||||
const { truncated, reduced, fontSize } = rendered.layout;
|
||||
if (truncated) {
|
||||
s.truncated += 1;
|
||||
if (s.truncatedExample === null) s.truncatedExample = index;
|
||||
} else if (reduced) {
|
||||
s.reduced += 1;
|
||||
if (fontSize < s.minPt) {
|
||||
s.minPt = fontSize;
|
||||
s.reducedExample = index;
|
||||
}
|
||||
}
|
||||
});
|
||||
stats.set(statsKey(field.face, field.field_type), s);
|
||||
if (s.truncated) {
|
||||
issues.push({
|
||||
...base,
|
||||
kind: 'truncated',
|
||||
critical: true,
|
||||
count: s.truncated,
|
||||
exampleIndex: s.truncatedExample,
|
||||
canEnableAutoFit: !field.auto_fit,
|
||||
});
|
||||
} else if (s.reduced) {
|
||||
issues.push({
|
||||
...base,
|
||||
kind: 'reduced',
|
||||
critical: false,
|
||||
count: s.reduced,
|
||||
minPt: s.minPt,
|
||||
exampleIndex: s.reducedExample,
|
||||
});
|
||||
}
|
||||
if (field.bg_color && contrastRatio(field.color, field.bg_color) < MIN_CONTRAST) {
|
||||
issues.push({ ...base, kind: 'contrast', critical: true });
|
||||
}
|
||||
} else {
|
||||
const missing = data.volunteers.findIndex((v) => !v.has_photo);
|
||||
if (missing >= 0) {
|
||||
issues.push({
|
||||
...base,
|
||||
kind: 'noPhoto',
|
||||
critical: false,
|
||||
count: data.volunteers.filter((v) => !v.has_photo).length,
|
||||
exampleIndex: missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Un bandeau coloré (fond de zone) peut volontairement aller jusqu'au
|
||||
// bord : seul un champ sans fond qui touche la marge est signalé.
|
||||
const bleed = field.bg_color !== null && field.field_type !== 'PHOTO';
|
||||
const m = SAFE_MARGIN_MM - 1e-6;
|
||||
const touchesEdge =
|
||||
field.x_mm < m ||
|
||||
field.y_mm < m ||
|
||||
field.x_mm + field.width_mm > badgeW - m ||
|
||||
field.y_mm + field.height_mm > badgeH - m;
|
||||
if (touchesEdge && !bleed) issues.push({ ...base, kind: 'margin', critical: false });
|
||||
}
|
||||
|
||||
issues.sort((a, b) => Number(b.critical) - Number(a.critical));
|
||||
return { issues, stats };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Estimation de la mise en page d'un champ du badge, côté navigateur — même
|
||||
// algorithme que la génération PDF (`backend/app/badges/pdf.py` : `wrap_text`,
|
||||
// `_draw_table`, mêmes métriques Helvetica via `stringWidthPt`). Sert à
|
||||
// l'aperçu (retours à la ligne, réduction automatique, lignes coupées) et au
|
||||
// contrôle avant génération. Ce n'est qu'une estimation : le PDF reste la
|
||||
// référence.
|
||||
|
||||
import { stringWidthPt } from './helveticaMetrics';
|
||||
|
||||
/** 1 pt = 25,4 / 72 mm. */
|
||||
export const MM_PER_PT = 25.4 / 72;
|
||||
/** Interligne, en multiple de la taille de police (`_LINE_HEIGHT_FACTOR`). */
|
||||
export const LINE_HEIGHT = 1.25;
|
||||
/** Position de la première ligne de base sous le haut du cadre, en multiple
|
||||
* de la taille de police (`font_size * 0.85` dans le PDF). */
|
||||
export const FIRST_BASELINE = 0.85;
|
||||
export const MIN_FONT_PT = 4;
|
||||
export const MAX_FONT_PT = 30;
|
||||
export const FONT_STEP_PT = 0.5;
|
||||
/** Marges intérieures des cellules du tableau des créneaux (mm). */
|
||||
export const CELL_PAD_X_MM = 0.8;
|
||||
export const CELL_PAD_Y_MM = 0.5;
|
||||
|
||||
/** Découpe `text` en lignes de largeur au plus `maxWidthPt` — portage de
|
||||
* `wrap_text` : les `\n` restent des retours à la ligne, puis passage à la
|
||||
* ligne entre deux mots, ou au milieu d'un mot qui seul ne tient pas. */
|
||||
export function wrapText(text: string, bold: boolean, size: number, maxWidthPt: number): string[] {
|
||||
const fits = (candidate: string) => stringWidthPt(candidate, bold, size) <= maxWidthPt;
|
||||
const lines: string[] = [];
|
||||
for (const paragraph of text.split('\n')) {
|
||||
let current = '';
|
||||
for (const word of paragraph.split(' ')) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (fits(candidate)) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) lines.push(current.trimEnd());
|
||||
current = word;
|
||||
while (current && !fits(current)) {
|
||||
let cut = current.length - 1;
|
||||
while (cut > 1 && !fits(current.slice(0, cut))) cut -= 1;
|
||||
lines.push(current.slice(0, cut));
|
||||
current = current.slice(cut);
|
||||
}
|
||||
}
|
||||
lines.push(current.trimEnd());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Nombre de lignes qui tiennent dans `heightMm` : une ligne est dessinée
|
||||
* tant que sa ligne de base reste dans le cadre (règle du PDF). */
|
||||
export function linesThatFit(size: number, heightMm: number): number {
|
||||
const sizeMm = size * MM_PER_PT;
|
||||
if (FIRST_BASELINE * sizeMm > heightMm) return 0;
|
||||
return Math.floor((heightMm - FIRST_BASELINE * sizeMm) / (LINE_HEIGHT * sizeMm) + 1e-9) + 1;
|
||||
}
|
||||
|
||||
export interface TextLayout {
|
||||
/** Taille réellement utilisée (réduite si `reduced`). */
|
||||
fontSize: number;
|
||||
/** Toutes les lignes après retour à la ligne. */
|
||||
lines: string[];
|
||||
/** Lignes imprimées (les suivantes débordent du cadre). */
|
||||
visible: string[];
|
||||
reduced: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface FitOptions {
|
||||
fontSize: number;
|
||||
autoFit: boolean;
|
||||
}
|
||||
|
||||
/** Réduction automatique commune au texte et au tableau : à la taille
|
||||
* demandée si tout tient ; sinon, si `autoFit`, par pas de 0,5 pt jusqu'à
|
||||
* 4 pt ; le texte reste coupé s'il ne tient toujours pas. */
|
||||
function withAutoFit<L>(
|
||||
{ fontSize, autoFit }: FitOptions,
|
||||
layoutAt: (size: number) => L,
|
||||
isComplete: (layout: L) => boolean,
|
||||
): { layout: L; fontSize: number; reduced: boolean; truncated: boolean } {
|
||||
let size = fontSize;
|
||||
let layout = layoutAt(size);
|
||||
if (isComplete(layout)) return { layout, fontSize: size, reduced: false, truncated: false };
|
||||
if (!autoFit) return { layout, fontSize: size, reduced: false, truncated: true };
|
||||
while (size - FONT_STEP_PT >= MIN_FONT_PT - 1e-9) {
|
||||
size = Math.round((size - FONT_STEP_PT) * 10) / 10;
|
||||
layout = layoutAt(size);
|
||||
if (isComplete(layout)) return { layout, fontSize: size, reduced: true, truncated: false };
|
||||
}
|
||||
return { layout, fontSize: size, reduced: size < fontSize, truncated: true };
|
||||
}
|
||||
|
||||
export function layoutText(
|
||||
text: string,
|
||||
box: { width_mm: number; height_mm: number; bold: boolean; font_size: number; auto_fit: boolean },
|
||||
): TextLayout {
|
||||
const result = withAutoFit(
|
||||
{ fontSize: box.font_size, autoFit: box.auto_fit },
|
||||
(size) => {
|
||||
const lines = text ? wrapText(text, box.bold, size, box.width_mm / MM_PER_PT) : [];
|
||||
return { lines, capacity: linesThatFit(size, box.height_mm) };
|
||||
},
|
||||
({ lines, capacity }) => lines.length <= capacity,
|
||||
);
|
||||
const { lines, capacity } = result.layout;
|
||||
return {
|
||||
fontSize: result.fontSize,
|
||||
lines,
|
||||
visible: lines.slice(0, capacity),
|
||||
reduced: result.reduced,
|
||||
truncated: result.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Tableau des créneaux (`POSITIONS`) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface TableSection {
|
||||
title: string;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface TableRowLayout {
|
||||
/** Lignes de texte de chaque cellule (une seule cellule pour un en-tête). */
|
||||
cells: string[][];
|
||||
isTitle: boolean;
|
||||
heightMm: number;
|
||||
}
|
||||
|
||||
export interface TableLayout {
|
||||
fontSize: number;
|
||||
/** Largeur de chaque colonne (mm). */
|
||||
widthsMm: number[];
|
||||
/** Lignes du tableau qui tiennent dans le cadre. */
|
||||
rows: TableRowLayout[];
|
||||
reduced: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Portage de `_column_widths` : une colonne courte garde sa largeur
|
||||
* naturelle, les plus longues se partagent le reste. */
|
||||
function columnWidths(natural: number[], available: number): number[] {
|
||||
const widths = natural.map(() => 0);
|
||||
const pending = natural.map((_, i) => i).sort((a, b) => natural[a] - natural[b]);
|
||||
let remaining = available;
|
||||
while (pending.length) {
|
||||
const share = remaining / pending.length;
|
||||
if (natural[pending[0]] > share) {
|
||||
for (const i of pending) widths[i] = share;
|
||||
return widths;
|
||||
}
|
||||
const i = pending.shift() as number;
|
||||
widths[i] = natural[i];
|
||||
remaining -= natural[i];
|
||||
}
|
||||
widths[widths.length - 1] += remaining;
|
||||
return widths;
|
||||
}
|
||||
|
||||
export function layoutTable(
|
||||
sections: TableSection[],
|
||||
box: { width_mm: number; height_mm: number; font_size: number; auto_fit: boolean },
|
||||
): TableLayout {
|
||||
const total = sections.reduce((n, s) => n + 1 + s.rows.length, 0);
|
||||
const result = withAutoFit(
|
||||
{ fontSize: box.font_size, autoFit: box.auto_fit },
|
||||
(size) => {
|
||||
const body = sections.flatMap((s) => s.rows);
|
||||
const columns = body.length ? Math.max(...body.map((r) => r.length)) : 1;
|
||||
const natural = body.length
|
||||
? Array.from({ length: columns }, (_, col) =>
|
||||
Math.max(...body.map((r) => stringWidthPt(r[col] ?? '', false, size) * MM_PER_PT)) +
|
||||
2 * CELL_PAD_X_MM,
|
||||
)
|
||||
: [0];
|
||||
const widthsMm = columnWidths(natural, box.width_mm);
|
||||
const fullWidth = widthsMm.reduce((a, b) => a + b, 0);
|
||||
const lineMm = size * MM_PER_PT * LINE_HEIGHT;
|
||||
const rows: TableRowLayout[] = [];
|
||||
let used = 0;
|
||||
outer: for (const section of sections) {
|
||||
const entries: Array<{ cells: string[]; isTitle: boolean }> = [
|
||||
{ cells: [section.title], isTitle: true },
|
||||
...section.rows.map((cells) => ({ cells, isTitle: false })),
|
||||
];
|
||||
for (const entry of entries) {
|
||||
const widths = entry.isTitle ? [fullWidth] : widthsMm;
|
||||
const cells = entry.cells.map((text, col) =>
|
||||
wrapText(
|
||||
text,
|
||||
entry.isTitle,
|
||||
size,
|
||||
Math.max(1, (widths[col] - 2 * CELL_PAD_X_MM) / MM_PER_PT),
|
||||
),
|
||||
);
|
||||
const heightMm = Math.max(...cells.map((c) => c.length)) * lineMm + 2 * CELL_PAD_Y_MM;
|
||||
if (used + heightMm > box.height_mm + 0.01 * MM_PER_PT) break outer;
|
||||
rows.push({ cells, isTitle: entry.isTitle, heightMm });
|
||||
used += heightMm;
|
||||
}
|
||||
}
|
||||
return { widthsMm, rows };
|
||||
},
|
||||
(layout) => layout.rows.length >= total,
|
||||
);
|
||||
return {
|
||||
fontSize: result.fontSize,
|
||||
widthsMm: result.layout.widthsMm,
|
||||
rows: result.layout.rows,
|
||||
reduced: result.reduced,
|
||||
truncated: result.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Contraste //
|
||||
// --------------------------------------------------------------------------- //
|
||||
function luminance(hex: string): number {
|
||||
const n = parseInt(hex.slice(1, 7), 16);
|
||||
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
|
||||
const x = c / 255;
|
||||
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/** Rapport de contraste WCAG entre deux couleurs `#rrggbb`. */
|
||||
export function contrastRatio(a: string, b: string): number {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
/** En dessous, le texte risque d'être illisible à l'impression. */
|
||||
export const MIN_CONTRAST = 3;
|
||||
@@ -0,0 +1,318 @@
|
||||
// État de l'éditeur de badges et contenu réel de chaque champ pour un
|
||||
// bénévole donné — mêmes règles que la génération PDF
|
||||
// (`backend/app/api/badges.py:_field_text`, `_positions_table`).
|
||||
|
||||
import type {
|
||||
BadgeFace,
|
||||
BadgeFieldIn,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldType,
|
||||
PositionRead,
|
||||
ShiftRead,
|
||||
VolunteerRead,
|
||||
} from '@/api/schema';
|
||||
import { t } from '@/i18n';
|
||||
import { categoryLabel, festivalDay } from '@/lib/planning';
|
||||
import { longDayLabel, timePart } from '@/lib/time';
|
||||
|
||||
import { layoutTable, layoutText, type TableLayout, type TableSection, type TextLayout } from './layout';
|
||||
|
||||
export const DEFAULT_BADGE_W_MM = 85.6;
|
||||
export const DEFAULT_BADGE_H_MM = 54;
|
||||
/** Marge de découpe (mm) : un champ qui la touche risque d'être rogné. */
|
||||
export const SAFE_MARGIN_MM = 1.5;
|
||||
|
||||
export const FACES: BadgeFace[] = ['FRONT', 'BACK'];
|
||||
|
||||
export const FIELD_TYPES: BadgeFieldType[] = [
|
||||
'PHOTO',
|
||||
'FULL_NAME',
|
||||
'POSITIONS',
|
||||
'CATEGORY',
|
||||
'ROLE',
|
||||
'FREE_TEXT',
|
||||
];
|
||||
|
||||
export const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
|
||||
FULL_NAME: 'badges.field.fullName',
|
||||
POSITIONS: 'badges.field.positions',
|
||||
CATEGORY: 'badges.field.category',
|
||||
ROLE: 'badges.field.role',
|
||||
PHOTO: 'badges.field.photo',
|
||||
FREE_TEXT: 'badges.field.freeText',
|
||||
};
|
||||
|
||||
export const FACE_LABEL_KEY: Record<BadgeFace, string> = {
|
||||
FRONT: 'badges.face.front',
|
||||
BACK: 'badges.face.back',
|
||||
};
|
||||
|
||||
/** Formats prédéfinis (mm). */
|
||||
export const BADGE_PRESETS: Array<{ key: string; width: number; height: number }> = [
|
||||
{ key: 'badges.preset.cr80Portrait', width: 54, height: 85.6 },
|
||||
{ key: 'badges.preset.cr80Landscape', width: 85.6, height: 54 },
|
||||
{ key: 'badges.preset.a7', width: 74, height: 105 },
|
||||
];
|
||||
|
||||
export type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||
|
||||
export interface FieldRef {
|
||||
face: BadgeFace;
|
||||
type: BadgeFieldType;
|
||||
}
|
||||
|
||||
export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
|
||||
return Boolean(a && b && a.face === b.face && a.type === b.type);
|
||||
}
|
||||
|
||||
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
|
||||
|
||||
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT', z_order: 0 } as const;
|
||||
|
||||
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||
// verticalement pour ne pas se superposer (repères pour un badge CR80
|
||||
// paysage, ramenés dans le badge par `fitToBadge` pour un autre format).
|
||||
const FIELD_DEFAULTS: Record<BadgeFieldType, FieldDefaults> = {
|
||||
FULL_NAME: {
|
||||
...COMMON, x_mm: 5, y_mm: 4, width_mm: 50, height_mm: 11,
|
||||
font_size: 11, align: 'LEFT', bold: true, color: '#14110E',
|
||||
},
|
||||
POSITIONS: {
|
||||
...COMMON, x_mm: 5, y_mm: 16, width_mm: 52, height_mm: 22,
|
||||
font_size: 6, align: 'LEFT', bold: false, color: '#14110E',
|
||||
},
|
||||
CATEGORY: {
|
||||
...COMMON, x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 5,
|
||||
font_size: 7, align: 'LEFT', bold: true, color: '#14110E',
|
||||
},
|
||||
ROLE: {
|
||||
...COMMON, x_mm: 5, y_mm: 45, width_mm: 30, height_mm: 5,
|
||||
font_size: 7, align: 'CENTER', bold: true, color: '#14110E', bg_color: '#FFD23F',
|
||||
},
|
||||
// Format portrait d'identité, en haut à droite. Taille de police/couleur
|
||||
// sans effet : la photo est une image.
|
||||
PHOTO: {
|
||||
...COMMON, x_mm: 60, y_mm: 4, width_mm: 21, height_mm: 27,
|
||||
font_size: 8, align: 'CENTER', bold: false, color: '#14110E',
|
||||
},
|
||||
FREE_TEXT: {
|
||||
...COMMON, x_mm: 40, y_mm: 45, width_mm: 40, height_mm: 5,
|
||||
font_size: 7, align: 'CENTER', bold: true, color: '#FFFFFF', bg_color: '#14110E',
|
||||
},
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
|
||||
* décalé s'il dépasse). Sur un badge plus étroit que le CR80 paysage des
|
||||
* positions par défaut, un champ activé pouvait tomber hors du badge —
|
||||
* invisible, et les bornes négatives du glisser-déposer le bloquaient (bug
|
||||
* corrigé une fois : photo impossible à déplacer ni redimensionner). */
|
||||
export function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
|
||||
const width_mm = Math.min(field.width_mm, badgeW);
|
||||
const height_mm = Math.min(field.height_mm, badgeH);
|
||||
return {
|
||||
...field,
|
||||
width_mm,
|
||||
height_mm,
|
||||
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
|
||||
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
|
||||
};
|
||||
}
|
||||
|
||||
/** Les 12 champs possibles (6 types × 2 faces), activés ou non, dans
|
||||
* l'ordre d'affichage. */
|
||||
export function fieldsFromServer(
|
||||
saved: BadgeFieldRead[],
|
||||
badgeW: number,
|
||||
badgeH: number,
|
||||
): FieldState[] {
|
||||
const out: FieldState[] = [];
|
||||
for (const face of FACES) {
|
||||
for (const type of FIELD_TYPES) {
|
||||
const existing = saved.find((f) => (f.face ?? 'FRONT') === face && f.field_type === type);
|
||||
const field: FieldState = existing
|
||||
? {
|
||||
field_type: type,
|
||||
face,
|
||||
x_mm: existing.x_mm,
|
||||
y_mm: existing.y_mm,
|
||||
width_mm: existing.width_mm,
|
||||
height_mm: existing.height_mm,
|
||||
font_size: existing.font_size,
|
||||
align: existing.align,
|
||||
bold: existing.bold,
|
||||
color: existing.color,
|
||||
bg_color: existing.bg_color ?? null,
|
||||
auto_fit: existing.auto_fit ?? true,
|
||||
photo_shape: existing.photo_shape ?? 'RECT',
|
||||
z_order: existing.z_order ?? 0,
|
||||
text: existing.text ?? null,
|
||||
enabled: true,
|
||||
}
|
||||
: { field_type: type, face, ...FIELD_DEFAULTS[type], text: null, enabled: false };
|
||||
out.push(fitToBadge(field, badgeW, badgeH));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
|
||||
* faces. */
|
||||
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
|
||||
const rank = stackRanks(fields);
|
||||
return fields
|
||||
.filter((f) => f.enabled)
|
||||
.map((f) => ({
|
||||
field_type: f.field_type,
|
||||
face: f.face,
|
||||
x_mm: round1(f.x_mm),
|
||||
y_mm: round1(f.y_mm),
|
||||
width_mm: round1(f.width_mm),
|
||||
height_mm: round1(f.height_mm),
|
||||
font_size: f.font_size,
|
||||
align: f.align,
|
||||
bold: f.bold,
|
||||
color: f.color,
|
||||
bg_color: f.bg_color,
|
||||
auto_fit: f.auto_fit,
|
||||
photo_shape: f.photo_shape,
|
||||
z_order: rank.get(f) ?? 0,
|
||||
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Ordre d'empilement //
|
||||
// --------------------------------------------------------------------------- //
|
||||
/** Champs activés d'une face, du dessous vers le dessus — l'ordre de dessin
|
||||
* du PDF. À `z_order` égal, la photo passe dessous, puis l'ordre de
|
||||
* `FIELD_TYPES` (le serveur ne départage que la photo, mais les `z_order`
|
||||
* envoyés sont toujours distincts : voir `stackRanks`). */
|
||||
export function stackOrder(fields: FieldState[], face: BadgeFace): FieldState[] {
|
||||
return fields
|
||||
.filter((f) => f.enabled && f.face === face)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.z_order - b.z_order ||
|
||||
FIELD_TYPES.indexOf(a.field_type) - FIELD_TYPES.indexOf(b.field_type),
|
||||
);
|
||||
}
|
||||
|
||||
/** Rang de chaque champ activé dans la pile de sa face (0 = dessous). */
|
||||
export function stackRanks(fields: FieldState[]): Map<FieldState, number> {
|
||||
const ranks = new Map<FieldState, number>();
|
||||
for (const face of FACES) stackOrder(fields, face).forEach((f, i) => ranks.set(f, i));
|
||||
return ranks;
|
||||
}
|
||||
|
||||
export type StackMove = 'front' | 'forward' | 'backward' | 'back';
|
||||
|
||||
/** Déplace un champ dans la pile de sa face et renumérote la face (0…n-1). */
|
||||
export function restack(fields: FieldState[], ref: FieldRef, move: StackMove): FieldState[] {
|
||||
const stack = stackOrder(fields, ref.face);
|
||||
const from = stack.findIndex((f) => f.field_type === ref.type);
|
||||
if (from < 0) return fields;
|
||||
const to = {
|
||||
front: stack.length - 1,
|
||||
forward: Math.min(from + 1, stack.length - 1),
|
||||
backward: Math.max(from - 1, 0),
|
||||
back: 0,
|
||||
}[move];
|
||||
const [moved] = stack.splice(from, 1);
|
||||
stack.splice(to, 0, moved);
|
||||
const rank = new Map(stack.map((f, i) => [f, i]));
|
||||
return fields.map((f) => (rank.has(f) ? { ...f, z_order: rank.get(f) as number } : f));
|
||||
}
|
||||
|
||||
/** `z_order` d'un champ qu'on active : au-dessus des autres — sauf la
|
||||
* photo, dessous, pour ne pas masquer un texte déjà placé. */
|
||||
export function zOrderOnEnable(fields: FieldState[], ref: FieldRef): number {
|
||||
const stack = stackOrder(fields, ref.face);
|
||||
if (!stack.length) return 0;
|
||||
return ref.type === 'PHOTO' ? stack[0].z_order - 1 : stack[stack.length - 1].z_order + 1;
|
||||
}
|
||||
|
||||
export function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/** Nombre français à une décimale au plus (« 8,5 »). */
|
||||
export function fmt(value: number): string {
|
||||
return String(round1(value)).replace('.', ',');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Contenu réel d'un champ pour un bénévole //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface BadgeData {
|
||||
/** Dans l'ordre d'impression : nom puis prénom (comme le PDF). */
|
||||
volunteers: VolunteerRead[];
|
||||
/** Créneaux affectés de chaque bénévole, dans l'ordre chronologique. */
|
||||
assignments: Map<string, Array<{ position: PositionRead; shift: ShiftRead }>>;
|
||||
/** Bénévoles dont le compte est référent (`LEAD`). */
|
||||
leadVolunteerIds: Set<string>;
|
||||
/** Début de « journée de festival » (`HH:MM`). */
|
||||
dayBoundary: string;
|
||||
}
|
||||
|
||||
export function fullNameText(volunteer: VolunteerRead): string {
|
||||
return `${volunteer.first_name}\n${volunteer.last_name}`;
|
||||
}
|
||||
|
||||
/** Texte d'un champ (hors `POSITIONS`, un tableau, et `PHOTO`). */
|
||||
export function fieldText(field: FieldState, volunteer: VolunteerRead, data: BadgeData): string {
|
||||
switch (field.field_type) {
|
||||
case 'FULL_NAME':
|
||||
return fullNameText(volunteer);
|
||||
case 'FREE_TEXT':
|
||||
// Texte commun saisi dans l'éditeur, remplacé pour un bénévole par le
|
||||
// « Texte du badge » de sa fiche s'il en a un.
|
||||
return volunteer.badge_text || field.text || '';
|
||||
case 'CATEGORY': {
|
||||
const rows = data.assignments.get(volunteer.id) ?? [];
|
||||
return [...new Set(rows.map((r) => categoryLabel(r.position.category)))].sort().join(', ');
|
||||
}
|
||||
case 'ROLE':
|
||||
return data.leadVolunteerIds.has(volunteer.id) ? t('role.lead') : t('role.volunteer');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Créneaux regroupés par jour de festival : le jour en en-tête, puis une
|
||||
* ligne horaire / poste par créneau. */
|
||||
export function positionsTable(volunteer: VolunteerRead, data: BadgeData): TableSection[] {
|
||||
const byDay = new Map<string, string[][]>();
|
||||
for (const { position, shift } of data.assignments.get(volunteer.id) ?? []) {
|
||||
const day = festivalDay(shift.starts_at, data.dayBoundary);
|
||||
const rows = byDay.get(day) ?? [];
|
||||
rows.push([`${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`, position.name]);
|
||||
byDay.set(day, rows);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, rows]) => ({ title: longDayLabel(day), rows }));
|
||||
}
|
||||
|
||||
export type FieldRender =
|
||||
| { kind: 'text'; layout: TextLayout }
|
||||
| { kind: 'table'; layout: TableLayout }
|
||||
| { kind: 'photo' };
|
||||
|
||||
/** Mise en page estimée d'un champ pour un bénévole. */
|
||||
export function renderField(field: FieldState, volunteer: VolunteerRead, data: BadgeData): FieldRender {
|
||||
if (field.field_type === 'PHOTO') return { kind: 'photo' };
|
||||
if (field.field_type === 'POSITIONS') {
|
||||
return { kind: 'table', layout: layoutTable(positionsTable(volunteer, data), field) };
|
||||
}
|
||||
return { kind: 'text', layout: layoutText(fieldText(field, volunteer, data), field) };
|
||||
}
|
||||
|
||||
/** Champs dont le texte est centré verticalement dans le cadre (bandeaux) ;
|
||||
* les autres partent du haut. */
|
||||
export function isVerticallyCentered(type: BadgeFieldType): boolean {
|
||||
return type === 'FREE_TEXT' || type === 'ROLE';
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useSendPlanningNotification } from '@/api/hooks';
|
||||
import { useSendPlanningNotification, useSendTestEmail, useVolunteers } from '@/api/hooks';
|
||||
import type { TestEmailResult } from '@/api/schema';
|
||||
import { ErrorNote } from '@/components/state';
|
||||
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { volunteerName } from '@/lib/volunteer';
|
||||
|
||||
const MAIL_VARIABLES = [
|
||||
'{prenom}',
|
||||
@@ -100,6 +103,8 @@ export function ConfigMail() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<TestEmailPanel subject={subject} body={body} />
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
||||
<div className="p-[13px]">
|
||||
@@ -116,3 +121,102 @@ export function ConfigMail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RESULT_TONE: Record<TestEmailResult['status'], string> = {
|
||||
sent: 'bg-green text-white',
|
||||
disabled: 'bg-yellow-soft',
|
||||
connection_failed: 'bg-orange',
|
||||
rejected: 'bg-orange',
|
||||
};
|
||||
|
||||
/** Envoie à l'adresse saisie le récapitulatif **tel que les bénévoles le
|
||||
* recevront** (le modèle en cours d'édition, variables remplies avec les
|
||||
* données d'un bénévole) et affiche le résultat réel du serveur SMTP. */
|
||||
function TestEmailPanel({ subject, body }: { subject: string; body: string }) {
|
||||
const [email, setEmail] = useState('');
|
||||
// '' = choix automatique (premier bénévole qui a un créneau).
|
||||
const [volunteerId, setVolunteerId] = useState('');
|
||||
const [withLink, setWithLink] = useState(false);
|
||||
const volunteers = useVolunteers();
|
||||
const test = useSendTestEmail();
|
||||
const result = test.data;
|
||||
const options = [...(volunteers.data ?? [])].sort((a, b) =>
|
||||
volunteerName(a).localeCompare(volunteerName(b), 'fr'),
|
||||
);
|
||||
return (
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.mail.test.title')}</PanelHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
test.mutate({
|
||||
email: email.trim(),
|
||||
subject,
|
||||
// Même corps que le bouton « avec lien de connexion » si coché.
|
||||
body: withLink ? withLoginLink(body) : body,
|
||||
volunteer_id: volunteerId || null,
|
||||
});
|
||||
}}
|
||||
className="flex flex-col gap-3 p-[13px]"
|
||||
>
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">{t('config.mail.test.help')}</p>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.test.email')}</FieldLabel>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('config.mail.test.placeholder')}
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.test.example')}</FieldLabel>
|
||||
<select
|
||||
value={volunteerId}
|
||||
onChange={(e) => setVolunteerId(e.target.value)}
|
||||
className="w-full border-2 border-ink bg-cream p-[8px] font-display text-[12.5px] font-bold"
|
||||
>
|
||||
<option value="">{t('config.mail.test.exampleAuto')}</option>
|
||||
{options.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{volunteerName(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 font-display text-[12px] font-bold">
|
||||
<input type="checkbox" checked={withLink} onChange={(e) => setWithLink(e.target.checked)} />
|
||||
{t('config.mail.test.withLink')}
|
||||
</label>
|
||||
<Btn type="submit" disabled={!email.trim() || test.isPending}>
|
||||
{test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')}
|
||||
</Btn>
|
||||
{result && !test.isPending ? (
|
||||
<div className={cx('border-2 border-ink p-[9px]', RESULT_TONE[result.status])}>
|
||||
<div className="font-display text-[12px] font-extrabold leading-snug">
|
||||
{t(`config.mail.test.result.${result.status}`)}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[10.5px] leading-snug">
|
||||
{result.example_volunteer_id
|
||||
? t('config.mail.test.filledWith', { name: result.example_volunteer_name || t('volunteer.unnamed') })
|
||||
: t('config.mail.test.filledWithExample')}
|
||||
</div>
|
||||
{result.detail ? (
|
||||
<div className="mt-1 break-words font-mono text-[10.5px] leading-snug">
|
||||
{t('config.mail.test.serverSaid', { detail: result.detail })}
|
||||
</div>
|
||||
) : null}
|
||||
{result.status !== 'sent' && result.status !== 'disabled' ? (
|
||||
<div className="mt-1 font-mono text-[10px] leading-snug opacity-80">
|
||||
{t('config.mail.test.config', { host: result.smtp_host, from: result.smtp_from })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{test.isError ? <ErrorNote error={test.error} /> : null}
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useEdition, usePositions, useUpdateEdition, useUpdatePosition } from '@/api/hooks';
|
||||
import type { PositionRead } from '@/api/schema';
|
||||
import { MessageEditor } from '@/components/MessageEditor';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { Panel, PanelHeader } from '@/components/ui';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
// Messages montrés sur l'écran bénévole : celui de l'organisation, le même
|
||||
// pour tous (`Edition.volunteer_message`), et, pour suivi, ceux des postes
|
||||
// (`Position.message`) — saisis d'ordinaire par chaque référent depuis son
|
||||
// écran, mais la coordination peut aussi les reprendre ici.
|
||||
export function ConfigMessage() {
|
||||
const edition = useEdition();
|
||||
const positions = usePositions();
|
||||
const updateEdition = useUpdateEdition();
|
||||
|
||||
if (edition.isLoading || positions.isLoading) return <Loading />;
|
||||
const error = edition.error ?? positions.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-4 p-3 sm:p-4 [grid-template-columns:repeat(auto-fit,minmax(min(360px,100%),1fr))]">
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.message.orgTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-[13px]">
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">{t('config.message.orgHelp')}</p>
|
||||
<MessageEditor
|
||||
value={edition.data?.volunteer_message ?? null}
|
||||
updatedAt={edition.data?.volunteer_message_updated_at ?? null}
|
||||
placeholder={t('config.message.orgPlaceholder')}
|
||||
saving={updateEdition.isPending}
|
||||
error={updateEdition.error}
|
||||
onSave={(text) => updateEdition.mutateAsync({ volunteer_message: text })}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.message.positionsTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-[13px]">
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('config.message.positionsHelp')}
|
||||
</p>
|
||||
<div className="flex flex-col border-2 border-ink">
|
||||
{(positions.data ?? []).map((position) => (
|
||||
<PositionMessageRow key={position.id} position={position} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PositionMessageRow({ position }: { position: PositionRead }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const update = useUpdatePosition();
|
||||
return (
|
||||
<div className="border-b-2 border-ink last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className={cx(
|
||||
'flex w-full cursor-pointer items-baseline gap-2 px-[10px] py-2 text-left hover:bg-yellow-soft',
|
||||
open && 'bg-sand',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-display text-[12.5px] font-extrabold">{position.name}</span>
|
||||
<span className="block truncate font-mono text-[10px] opacity-65">
|
||||
{position.message ?? t('config.message.none')}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden className="font-mono text-[10px]">
|
||||
{open ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="border-t-2 border-ink bg-paper p-[10px]">
|
||||
<MessageEditor
|
||||
value={position.message}
|
||||
updatedAt={position.message_updated_at}
|
||||
placeholder={t('mobileLead.message.placeholder')}
|
||||
saving={update.isPending}
|
||||
error={update.error}
|
||||
onSave={(text) => update.mutateAsync({ id: position.id, body: { message: text } })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useCreateAccount,
|
||||
useCreateVolunteer,
|
||||
useDeleteAccount,
|
||||
useResetLoginLink,
|
||||
useDeleteVolunteer,
|
||||
useExecuteImport,
|
||||
useExportMecenatReceipts,
|
||||
@@ -41,6 +42,7 @@ import { foldAccents } from '@/lib/planning';
|
||||
import { t } from '@/i18n';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
|
||||
|
||||
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
||||
{ key: 'first_name', required: true },
|
||||
@@ -111,6 +113,7 @@ function VolunteerAccessSection({
|
||||
const accounts = useAccounts();
|
||||
const createAccount = useCreateAccount();
|
||||
const deleteAccount = useDeleteAccount();
|
||||
const resetLoginLink = useResetLoginLink();
|
||||
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
|
||||
|
||||
return (
|
||||
@@ -156,8 +159,29 @@ function VolunteerAccessSection({
|
||||
{t('config.vol.access.needEmail')}
|
||||
</div>
|
||||
) : null}
|
||||
{account && account.auth_method === 'magic_link' ? (
|
||||
<div className="mt-[6px] flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={resetLoginLink.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('config.vol.access.resetLinkConfirm'))) {
|
||||
resetLoginLink.mutate(account.id);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
|
||||
>
|
||||
{t('config.vol.access.resetLink')}
|
||||
</button>
|
||||
<HelpTip text={t('config.vol.access.resetLinkHint')} />
|
||||
{resetLoginLink.isSuccess ? (
|
||||
<span className="font-mono text-[9.5px] font-bold">{t('config.vol.access.resetLinkDone')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{createAccount.isError ? <ErrorNote error={createAccount.error} /> : null}
|
||||
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
|
||||
{resetLoginLink.isError ? <ErrorNote error={resetLoginLink.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,6 +193,43 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
|
||||
EXPIRED: 'config.vol.feeStatus.expired',
|
||||
};
|
||||
|
||||
/**
|
||||
* Nombre d'exemplaires du badge imprimés sur les planches (1 à 100). Saisie
|
||||
* libre gardée en local : n'enregistre qu'un entier valide, et revient à la
|
||||
* dernière valeur enregistrée si le champ est quitté vide ou hors bornes.
|
||||
*/
|
||||
function BadgeCountInput({ value, onChange }: { value: number; onChange: (count: number) => void }) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
const parse = (raw: string) => {
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 100 ? n : null;
|
||||
};
|
||||
return (
|
||||
<label className="block">
|
||||
<FieldLabel>
|
||||
{t('config.vol.field.badgeCount')}
|
||||
<HelpTip text={t('config.vol.field.badgeCount.help')} className="ml-[6px]" />
|
||||
</FieldLabel>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
const n = parse(e.target.value);
|
||||
if (n !== null && n !== value) onChange(n);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (parse(draft) === null) setDraft(String(value));
|
||||
}}
|
||||
className="w-[90px] border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[13px] font-bold"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
|
||||
* celle envoyée au formulaire public, ou ajoutée ici par la coordination
|
||||
@@ -851,11 +912,10 @@ export function ConfigVolunteers() {
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-[7px]">
|
||||
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
||||
{volunteer.first_name[0]}
|
||||
{volunteer.last_name[0]}
|
||||
{volunteerInitials(volunteer)}
|
||||
</span>
|
||||
<span className="truncate font-display text-[12.5px] font-bold">
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
{volunteerName(volunteer)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
|
||||
@@ -913,11 +973,10 @@ export function ConfigVolunteers() {
|
||||
</div>
|
||||
<div className="mt-[7px] flex items-center gap-[9px]">
|
||||
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
|
||||
{selected.first_name[0]}
|
||||
{selected.last_name[0]}
|
||||
{volunteerInitials(selected)}
|
||||
</span>
|
||||
<span className="font-display text-[18px] font-black tracking-tight">
|
||||
{selected.first_name} {selected.last_name}
|
||||
{volunteerName(selected)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -960,6 +1019,11 @@ export function ConfigVolunteers() {
|
||||
onChange={(v) => patch({ badge_text: v || null })}
|
||||
/>
|
||||
</label>
|
||||
<BadgeCountInput
|
||||
key={`${selected.id}-${selected.badge_count}`}
|
||||
value={selected.badge_count}
|
||||
onChange={(badge_count) => patch({ badge_count })}
|
||||
/>
|
||||
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
|
||||
|
||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||
@@ -1040,10 +1104,7 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const submit = async () => {
|
||||
setLocalError(null);
|
||||
if (!form.first_name.trim() || !form.last_name.trim()) {
|
||||
setLocalError(t('config.vol.requiredNames'));
|
||||
return;
|
||||
}
|
||||
// Aucun champ obligatoire : la fiche peut être complétée plus tard.
|
||||
try {
|
||||
await createVolunteer.mutateAsync({
|
||||
first_name: form.first_name.trim(),
|
||||
@@ -1085,11 +1146,11 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.field.firstName')} *</FieldLabel>
|
||||
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
|
||||
<TextInput value={form.first_name} onChange={(v) => set('first_name', v)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.field.lastName')} *</FieldLabel>
|
||||
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
|
||||
<TextInput value={form.last_name} onChange={(v) => set('last_name', v)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@/lib/planning';
|
||||
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
|
||||
|
||||
interface Props {
|
||||
positionId: string;
|
||||
@@ -250,7 +251,7 @@ export function AssignmentPanel({
|
||||
{initials(volunteer)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
{volunteerName(volunteer)}
|
||||
<span className="block font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
|
||||
</span>
|
||||
@@ -348,7 +349,7 @@ function CandidateRow({
|
||||
{initials(volunteer)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
{volunteerName(volunteer)}
|
||||
{suggested ? (
|
||||
<span className="ml-[6px] inline-block border-[1.5px] border-ink bg-blue px-[5px] py-[1px] align-middle font-display text-[8px] font-extrabold uppercase tracking-wide">
|
||||
{t('grille.panel.suggested')}
|
||||
@@ -436,7 +437,7 @@ function CandidateRow({
|
||||
}
|
||||
|
||||
function initials(v: VolunteerRead): string {
|
||||
return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`;
|
||||
return volunteerInitials(v);
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
|
||||
@@ -12,12 +12,17 @@ export default {
|
||||
sand: '#EFE8D6', // barres secondaires
|
||||
'sand-line': '#E0D8C4', // séparateurs sur paper
|
||||
'sand-dark': '#E7E0CE',
|
||||
'sand-mid': '#CFC7B4', // frise horaire : graduations, créneau terminé
|
||||
yellow: '#FFD23F',
|
||||
'yellow-soft': '#FFE68A', // état « incomplet »
|
||||
'yellow-hover': '#FFDF77',
|
||||
orange: '#FF5C39',
|
||||
'orange-dark': '#E0421F',
|
||||
green: '#0E9F6E',
|
||||
mint: '#C8F0DC', // pointé présent (compteur)
|
||||
'mint-pale': '#EAF7EF', // carte d'un bénévole pointé présent
|
||||
'orange-pale': '#FFF1EC', // créneau en cours
|
||||
'orange-blush': '#FFE3DA', // carte d'un bénévole pointé absent
|
||||
blue: '#A8C4FF',
|
||||
'blue-strong': '#2A6BFF', // « non publié »
|
||||
mauve: '#CBA6E3', // état « sureffectif »
|
||||
|
||||
Reference in New Issue
Block a user