Compare commits
19
Commits
6892e0307d
...
main
@@ -57,7 +57,14 @@ les tables sauf les tables d'association.
|
|||||||
|
|
||||||
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
|
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
|
||||||
`retention_days` (durée de rétention des données nominatives en jours,
|
`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
|
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
|
||||||
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
|
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
|
||||||
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
|
(`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
|
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
|
||||||
peuvent partager une même adresse, ex. un couple ou une famille ; c'est le
|
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),
|
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,
|
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`,
|
exposées par la fiche (`artist_ids`, `wished_position_ids`,
|
||||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||||
@@ -86,9 +97,13 @@ les tables sauf les tables d'association.
|
|||||||
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
|
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
|
||||||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||||||
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
||||||
paiement SumUp plus bas). `badge_text` : texte libre imprimé sur le badge
|
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
|
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
|
||||||
fiche (« Texte du badge ») — jamais par le formulaire public.
|
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
|
||||||
|
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`,
|
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||||||
@@ -121,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
|
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
|
`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
|
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
|
- **`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`
|
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
|
(peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
|
||||||
@@ -145,6 +174,17 @@ les tables sauf les tables d'association.
|
|||||||
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
|
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`)
|
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
|
||||||
qui en a besoin.
|
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`,
|
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
|
||||||
`volunteer_id` (nullable, unique).
|
`volunteer_id` (nullable, unique).
|
||||||
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
||||||
@@ -161,33 +201,88 @@ les tables sauf les tables d'association.
|
|||||||
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
|
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
|
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
|
||||||
déformation, `preserveAspectRatio=True`) à ce même format.
|
déformation, `preserveAspectRatio=True`) à ce même format.
|
||||||
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈
|
- **`BadgeField`** : un champ positionné sur le **recto ou le verso**
|
||||||
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`,
|
(`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
|
||||||
au plus un par édition —
|
`CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
|
||||||
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/
|
édition — unique (`edition_id`, `face`, `field_type`), 422
|
||||||
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
|
`badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
|
||||||
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer)
|
remplace tout, les deux faces dans la même liste), coordonnées `x_mm`/
|
||||||
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`),
|
`y_mm`/`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du
|
||||||
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel
|
badge, `font_size`, `align`, `bold`, `color`, `bg_color` (fond plein de la
|
||||||
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`).
|
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
|
||||||
`POSITIONS` résout **une ligne par créneau affecté**, triée
|
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
|
||||||
chronologiquement : nom du poste + horaire (`HH:MM–HH:MM`), suivi du
|
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
|
||||||
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a
|
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`),
|
||||||
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes,
|
`z_order` (ordre d'empilement sur la face : les champs sont dessinés du
|
||||||
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
|
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
|
||||||
|
reste sur le jour où elle a commencé) : le jour en en-tête sur toute la
|
||||||
|
largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire
|
||||||
|
(`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste,
|
||||||
|
repliée si besoin), dans l'ordre chronologique. Pas de référent sur le
|
||||||
|
badge (choix de la coordination). Une ligne qui ne tient plus dans la
|
||||||
|
hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
|
||||||
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
|
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
|
||||||
`FREE_TEXT` est `Volunteer.badge_text` (vide ⇒ rien) ; `PHOTO` imprime
|
`FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
|
||||||
|
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
|
||||||
|
autres types), sinon rien ; `PHOTO` imprime
|
||||||
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
|
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
|
||||||
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
|
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
|
à 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
|
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
|
photo illisible, **rien n'est imprimé** (cadre vide, choix de la
|
||||||
planche.
|
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
|
||||||
|
(`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?}` —
|
`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
|
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 à
|
par planche, de la page de verso correspondante (image de verso puis
|
||||||
chaque emplacement) — impression recto-verso classique.
|
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`) :
|
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
|
||||||
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
|
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
|
||||||
`Volunteer` existants. Complément des badges (un badge identifie un
|
`Volunteer` existants. Complément des badges (un badge identifie un
|
||||||
@@ -196,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
|
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.
|
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
|
||||||
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
|
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
|
||||||
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
|
`{position_ids?}` — absent = tous). Mise en page compacte, pour tenir
|
||||||
affectation n'apparaît pas (rien à imprimer).
|
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,
|
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
|
||||||
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
|
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
|
||||||
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
|
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
|
||||||
@@ -214,7 +325,13 @@ les tables sauf les tables d'association.
|
|||||||
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
|
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
|
||||||
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
|
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
|
||||||
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
|
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
|
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
|
||||||
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
||||||
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
|
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
|
||||||
@@ -469,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,
|
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
|
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
|
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
|
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
|
||||||
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
|
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
|
||||||
@@ -495,6 +631,22 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
|||||||
alias validé.
|
alias validé.
|
||||||
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
|
- 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.
|
<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
|
- **Mise en forme** : chaque email part en `multipart/alternative` — texte
|
||||||
brut (source de vérité, utilisé par les tests) **et** une version HTML au
|
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
|
thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie
|
||||||
@@ -516,11 +668,20 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
|||||||
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
|
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
|
||||||
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
|
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
|
||||||
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
|
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
|
||||||
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est
|
`{creneaux}`, `{lien_connexion}` (lien de connexion **permanent** du
|
||||||
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles
|
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`,
|
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,
|
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`
|
- **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
|
est effectivement créé et a un email — que ce soit tout de suite (pas de
|
||||||
paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
|
paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
|
||||||
@@ -697,10 +858,18 @@ bug. Largeur adaptative par jour (`columnWidthPx`,
|
|||||||
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
|
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
|
||||||
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
|
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
|
||||||
étroites par défaut.
|
étroites par défaut.
|
||||||
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle,
|
La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des
|
||||||
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`)
|
temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par
|
||||||
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus
|
jour) : chaque affectation est une barre étirée sur toute la durée de son
|
||||||
adapté à son tableau bénévole × créneau.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -730,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)
|
### 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
|
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 à
|
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.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -933,7 +1103,31 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
|
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
|
||||||
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
|
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
|
||||||
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
|
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
|
||||||
|
| 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
|
**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/`).
|
connexion en place (voir `frontend/`).
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError
|
|||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlmodel import Session, select
|
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):
|
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"}
|
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||||
) from exc
|
) from exc
|
||||||
return content, content_type
|
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
|
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)
|
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_account(
|
def delete_account(
|
||||||
account_id: uuid.UUID,
|
account_id: uuid.UUID,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.api.schemas import (
|
|||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
|
AttendanceUpdate,
|
||||||
)
|
)
|
||||||
from app.auth.deps import (
|
from app.auth.deps import (
|
||||||
current_account_in_edition,
|
current_account_in_edition,
|
||||||
@@ -24,7 +25,7 @@ from app.auth.deps import (
|
|||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
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 import has_blocking, validate
|
||||||
from app.validation.loader import build_validation_input
|
from app.validation.loader import build_validation_input
|
||||||
|
|
||||||
@@ -206,6 +207,30 @@ def update_assignment(
|
|||||||
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
|
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)
|
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_assignment(
|
def delete_assignment(
|
||||||
assignment_id: uuid.UUID,
|
assignment_id: uuid.UUID,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from app.api.schemas import (
|
|||||||
from app.auth.deps import get_current_account
|
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.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.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.rate_limit import login_limiter, magic_link_limiter
|
||||||
from app.auth.tokens import create_access_token, hash_token
|
from app.auth.tokens import create_access_token, hash_token
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
@@ -184,6 +186,16 @@ def verify_magic_link(
|
|||||||
payload: VerifyRequest,
|
payload: VerifyRequest,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> TokenResponse:
|
) -> 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(
|
token = session.exec(
|
||||||
select(MagicLinkToken).where(
|
select(MagicLinkToken).where(
|
||||||
MagicLinkToken.token_hash == hash_token(payload.token.strip())
|
MagicLinkToken.token_hash == hash_token(payload.token.strip())
|
||||||
|
|||||||
+107
-56
@@ -1,21 +1,22 @@
|
|||||||
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
||||||
et génération du PDF.
|
et génération du PDF.
|
||||||
|
|
||||||
Le verso est une image fixe, la même pour tous les bénévoles. Le recto
|
Chaque face (recto, verso) combine une image de fond (la même pour tous les
|
||||||
combine une image de fond et des champs positionnés (nom, poste(s) affectés,
|
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
|
||||||
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
|
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
|
||||||
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||||
`BadgeField`) pour le modèle.
|
`BadgeField`) pour le modèle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
@@ -32,6 +33,8 @@ from app.badges.pdf import (
|
|||||||
DEFAULT_BADGE_WIDTH_MM,
|
DEFAULT_BADGE_WIDTH_MM,
|
||||||
BadgeFieldSpec,
|
BadgeFieldSpec,
|
||||||
BadgeSpec,
|
BadgeSpec,
|
||||||
|
BadgeTable,
|
||||||
|
BadgeTableSection,
|
||||||
generate_badge_sheets_pdf,
|
generate_badge_sheets_pdf,
|
||||||
image_size_mm,
|
image_size_mm,
|
||||||
)
|
)
|
||||||
@@ -40,20 +43,26 @@ from app.i18n import t
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
Assignment,
|
Assignment,
|
||||||
|
BadgeFace,
|
||||||
BadgeField,
|
BadgeField,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
BadgeTemplate,
|
BadgeTemplate,
|
||||||
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
Position,
|
Position,
|
||||||
Role,
|
Role,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
)
|
)
|
||||||
|
from app.validation.engine import day_boundary, festival_day
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
||||||
|
|
||||||
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||||
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
|
_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}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -76,7 +85,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
|
|||||||
session.exec(
|
session.exec(
|
||||||
select(BadgeField)
|
select(BadgeField)
|
||||||
.where(BadgeField.edition_id == edition_id)
|
.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,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -202,9 +218,11 @@ def set_fields(
|
|||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[BadgeField]:
|
) -> 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:
|
for f in payload.fields:
|
||||||
if f.field_type in seen:
|
if (f.face, f.field_type) in seen:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={
|
detail={
|
||||||
@@ -212,7 +230,12 @@ def set_fields(
|
|||||||
"params": {"field_type": f.field_type},
|
"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))
|
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
|
||||||
for f in payload.fields:
|
for f in payload.fields:
|
||||||
@@ -246,7 +269,7 @@ def set_size(
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Génération du PDF #
|
# Génération du PDF #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent)
|
AssignmentRow = tuple[Position, Shift]
|
||||||
|
|
||||||
|
|
||||||
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
|
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
|
||||||
@@ -259,16 +282,29 @@ def _category_label(category) -> str:
|
|||||||
return t(f"position.category.{category.value.lower()}")
|
return t(f"position.category.{category.value.lower()}")
|
||||||
|
|
||||||
|
|
||||||
def _position_line(
|
def _day_label(day: dt.date) -> str:
|
||||||
position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID
|
"""En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
|
||||||
) -> str:
|
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
|
||||||
"""Une ligne par créneau affecté : poste + horaire, et le référent du
|
|
||||||
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
|
|
||||||
référent (pas besoin de se l'indiquer à soi-même)."""
|
def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
|
||||||
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}"
|
"""Créneaux affectés regroupés par jour de festival (une soirée qui
|
||||||
if referent is not None and referent.id != volunteer_id:
|
passe minuit reste sur le jour où elle a commencé — `festival_day`) :
|
||||||
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})"
|
le jour en en-tête, puis une ligne horaire / poste par créneau, dans
|
||||||
return line
|
l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
|
||||||
|
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
|
||||||
|
for position, shift in assignments:
|
||||||
|
by_day[festival_day(shift.starts_at, boundary)].append(
|
||||||
|
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
|
||||||
|
)
|
||||||
|
if not by_day:
|
||||||
|
return None
|
||||||
|
return BadgeTable(
|
||||||
|
sections=tuple(
|
||||||
|
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
|
||||||
|
for day, rows in sorted(by_day.items())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _field_text(
|
def _field_text(
|
||||||
@@ -277,19 +313,22 @@ def _field_text(
|
|||||||
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
|
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
|
||||||
lead_volunteer_ids: set[uuid.UUID],
|
lead_volunteer_ids: set[uuid.UUID],
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
|
||||||
|
(`_positions_table`)."""
|
||||||
if field.field_type == BadgeFieldType.FULL_NAME:
|
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:
|
if field.field_type == BadgeFieldType.FREE_TEXT:
|
||||||
return volunteer.badge_text or ""
|
# 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 or field.text or ""
|
||||||
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
||||||
if field.field_type == BadgeFieldType.POSITIONS:
|
|
||||||
return "\n".join(
|
|
||||||
_position_line(position, shift, referent, volunteer.id)
|
|
||||||
for position, shift, referent in assignments
|
|
||||||
)
|
|
||||||
if field.field_type == BadgeFieldType.CATEGORY:
|
if field.field_type == BadgeFieldType.CATEGORY:
|
||||||
categories = sorted(
|
categories = sorted(
|
||||||
{_category_label(position.category) for position, _shift, _r in assignments}
|
{_category_label(position.category) for position, _shift in assignments}
|
||||||
)
|
)
|
||||||
return ", ".join(categories)
|
return ", ".join(categories)
|
||||||
if field.field_type == BadgeFieldType.ROLE:
|
if field.field_type == BadgeFieldType.ROLE:
|
||||||
@@ -317,20 +356,20 @@ def generate_badges(
|
|||||||
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
|
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
|
||||||
|
|
||||||
volunteer_ids = [v.id for v in volunteers]
|
volunteer_ids = [v.id for v in volunteers]
|
||||||
Referent = aliased(Volunteer)
|
|
||||||
rows = session.exec(
|
rows = session.exec(
|
||||||
select(Assignment.volunteer_id, Position, Shift, Referent)
|
select(Assignment.volunteer_id, Position, Shift)
|
||||||
.join(Shift, Shift.id == Assignment.shift_id)
|
.join(Shift, Shift.id == Assignment.shift_id)
|
||||||
.join(Position, Position.id == Shift.position_id)
|
.join(Position, Position.id == Shift.position_id)
|
||||||
.join(Account, Account.id == Position.lead_account_id, isouter=True)
|
|
||||||
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
|
|
||||||
.where(Assignment.volunteer_id.in_(volunteer_ids))
|
.where(Assignment.volunteer_id.in_(volunteer_ids))
|
||||||
.order_by(Shift.starts_at)
|
.order_by(Shift.starts_at)
|
||||||
).all()
|
).all()
|
||||||
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
|
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
|
||||||
for volunteer_id, position, shift, referent in rows:
|
for volunteer_id, position, shift in rows:
|
||||||
assignments_by_volunteer[volunteer_id].append((position, shift, referent))
|
assignments_by_volunteer[volunteer_id].append((position, shift))
|
||||||
|
|
||||||
|
boundary = day_boundary(
|
||||||
|
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||||
|
)
|
||||||
lead_volunteer_ids = {
|
lead_volunteer_ids = {
|
||||||
account.volunteer_id
|
account.volunteer_id
|
||||||
for account in session.exec(
|
for account in session.exec(
|
||||||
@@ -343,30 +382,42 @@ def generate_badges(
|
|||||||
if account.volunteer_id is not None
|
if account.volunteer_id is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
badges = [
|
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
|
||||||
BadgeSpec(
|
return BadgeFieldSpec(
|
||||||
fields=tuple(
|
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
|
||||||
BadgeFieldSpec(
|
x_mm=field.x_mm,
|
||||||
text=_field_text(
|
y_mm=field.y_mm,
|
||||||
field, volunteer, assignments_by_volunteer, lead_volunteer_ids
|
width_mm=field.width_mm,
|
||||||
),
|
height_mm=field.height_mm,
|
||||||
x_mm=field.x_mm,
|
font_size=field.font_size,
|
||||||
y_mm=field.y_mm,
|
align=field.align.value,
|
||||||
width_mm=field.width_mm,
|
bold=field.bold,
|
||||||
height_mm=field.height_mm,
|
color=field.color,
|
||||||
font_size=field.font_size,
|
is_photo=field.field_type == BadgeFieldType.PHOTO,
|
||||||
align=field.align.value,
|
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
|
||||||
bold=field.bold,
|
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
|
||||||
color=field.color,
|
if field.field_type == BadgeFieldType.POSITIONS
|
||||||
is_photo=field.field_type == BadgeFieldType.PHOTO,
|
else None,
|
||||||
image=volunteer.photo
|
bg_color=field.bg_color,
|
||||||
if field.field_type == BadgeFieldType.PHOTO
|
auto_fit=field.auto_fit,
|
||||||
else None,
|
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 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(
|
pdf_bytes = generate_badge_sheets_pdf(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.api._common import set_message
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import EditionRead, EditionUpdate
|
from app.api.schemas import EditionRead, EditionUpdate
|
||||||
from app.auth.deps import current_account_in_edition, require_coordinator
|
from app.auth.deps import current_account_in_edition, require_coordinator
|
||||||
@@ -27,7 +28,10 @@ def update_edition(
|
|||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Edition:
|
) -> 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)
|
setattr(edition, field, value)
|
||||||
if edition.end_date < edition.start_date:
|
if edition.end_date < edition.start_date:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
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 sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PlanningNotificationResult,
|
PlanningNotificationResult,
|
||||||
|
TestEmailRequest,
|
||||||
|
TestEmailResult,
|
||||||
)
|
)
|
||||||
from app.auth.deps import require_coordinator
|
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.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.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")
|
logger = logging.getLogger("app.notifications")
|
||||||
|
|
||||||
@@ -29,16 +40,244 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}"
|
|||||||
|
|
||||||
|
|
||||||
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
|
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(
|
account = session.exec(
|
||||||
select(Account)
|
select(Account)
|
||||||
.where(Account.edition_id == edition.id)
|
.where(Account.edition_id == edition.id)
|
||||||
.where(Account.volunteer_id == volunteer.id)
|
.where(Account.volunteer_id == volunteer.id)
|
||||||
.where(Account.password_hash.is_(None))
|
|
||||||
).first()
|
).first()
|
||||||
if account is None:
|
if account is None and volunteer.email:
|
||||||
return build_magic_link_url("").split("?")[0]
|
email = volunteer.email.strip()
|
||||||
return build_magic_link_url(issue_magic_link_token(session, account))
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
# 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)
|
@router.post("/planning", response_model=PlanningNotificationResult)
|
||||||
@@ -60,70 +299,51 @@ def send_planning_notification(
|
|||||||
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
|
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
|
||||||
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
|
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
|
||||||
|
|
||||||
positions = {
|
ctx = _load_context(session, edition)
|
||||||
p.id: p
|
logo = edition_logo(session, edition.id)
|
||||||
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)
|
|
||||||
|
|
||||||
items: list[tuple[str, str, str]] = []
|
messages: list[EmailMessage] = []
|
||||||
recipients: list[uuid.UUID] = []
|
recipients: list[uuid.UUID] = []
|
||||||
skipped_no_email = 0
|
skipped_no_email = 0
|
||||||
for volunteer in volunteers:
|
for volunteer in volunteers:
|
||||||
if not volunteer.email:
|
if not volunteer.email:
|
||||||
skipped_no_email += 1
|
skipped_no_email += 1
|
||||||
continue
|
continue
|
||||||
shifts = sorted(
|
composed = _planning_email(
|
||||||
shifts_by_volunteer.get(volunteer.id, []),
|
ctx=ctx,
|
||||||
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(
|
|
||||||
subject_template=subject_template,
|
subject_template=subject_template,
|
||||||
body_template=body_template,
|
body_template=body_template,
|
||||||
first_name=volunteer.first_name,
|
first_name=volunteer.first_name,
|
||||||
last_name=volunteer.last_name,
|
last_name=volunteer.last_name,
|
||||||
shift_count=len(shifts),
|
shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
|
||||||
hours=round(minutes / 60, 1),
|
|
||||||
shifts_text=shifts_text,
|
|
||||||
login_link=(
|
login_link=(
|
||||||
_login_link(session, edition, volunteer)
|
_login_link(session, edition, volunteer)
|
||||||
if wants_login_link
|
if wants_login_link
|
||||||
else ""
|
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)
|
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(
|
logger.info(
|
||||||
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
|
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
|
||||||
edition.id,
|
edition.id,
|
||||||
len(items),
|
len(messages),
|
||||||
skipped_no_email,
|
skipped_no_email,
|
||||||
)
|
)
|
||||||
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
|
background.add_task(send_messages, messages)
|
||||||
return PlanningNotificationResult(
|
return PlanningNotificationResult(
|
||||||
queued=len(items),
|
queued=len(messages),
|
||||||
skipped_no_email=skipped_no_email,
|
skipped_no_email=skipped_no_email,
|
||||||
recipients=recipients,
|
recipients=recipients,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ from app.api.deps import get_edition
|
|||||||
from app.api.schemas import PositionSheetsRequest
|
from app.api.schemas import PositionSheetsRequest
|
||||||
from app.auth.deps import require_coordinator
|
from app.auth.deps import require_coordinator
|
||||||
from app.db import get_session
|
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.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
|
||||||
from app.sheets.pdf import (
|
from app.sheets.pdf import (
|
||||||
PositionDaySheet,
|
PositionDaySheet,
|
||||||
SheetArtist,
|
SheetArtist,
|
||||||
|
SheetFooter,
|
||||||
SheetShift,
|
SheetShift,
|
||||||
SheetVolunteer,
|
SheetVolunteer,
|
||||||
generate_position_sheets_pdf,
|
generate_position_sheets_pdf,
|
||||||
@@ -151,7 +153,14 @@ def generate_position_sheets(
|
|||||||
status_code=422, detail={"code": "position_sheets.no_shifts"}
|
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(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlmodel import Session, select
|
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._shiftgen import generate_missing_shifts
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
@@ -103,6 +103,8 @@ def update_position(
|
|||||||
ensure_can_write_position(account, position)
|
ensure_can_write_position(account, position)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
_check_lead_account(session, edition, changes.get("lead_account_id"))
|
_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():
|
for field, value in changes.items():
|
||||||
setattr(position, field, value)
|
setattr(position, field, value)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
+106
-4
@@ -7,8 +7,11 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
FeePaymentStatus,
|
FeePaymentStatus,
|
||||||
FormFieldType,
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -33,23 +36,34 @@ class EditionRead(ApiModel):
|
|||||||
start_date: dt.date
|
start_date: dt.date
|
||||||
end_date: dt.date
|
end_date: dt.date
|
||||||
retention_days: int | None
|
retention_days: int | None
|
||||||
|
volunteer_message: str | None
|
||||||
|
volunteer_message_updated_at: dt.datetime | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_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):
|
class EditionUpdate(ApiModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
year: int | None = None
|
year: int | None = None
|
||||||
start_date: dt.date | None = None
|
start_date: dt.date | None = None
|
||||||
end_date: dt.date | None = None
|
end_date: dt.date | None = None
|
||||||
retention_days: int | 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 #
|
# Volunteer #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class VolunteerCreate(ApiModel):
|
class VolunteerCreate(ApiModel):
|
||||||
first_name: str
|
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||||||
last_name: str
|
# tard) ; le formulaire public et l'import les exigent toujours.
|
||||||
|
first_name: str = ""
|
||||||
|
last_name: str = ""
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
@@ -76,6 +90,9 @@ class VolunteerUpdate(ApiModel):
|
|||||||
companion_wish_note: str | None = None
|
companion_wish_note: str | None = None
|
||||||
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
||||||
badge_text: str | None = None
|
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
|
self_declared_available: bool | None = None
|
||||||
willing_to_pay_fee: bool | None = None
|
willing_to_pay_fee: bool | None = None
|
||||||
|
|
||||||
@@ -94,6 +111,7 @@ class VolunteerRead(ApiModel):
|
|||||||
has_photo: bool
|
has_photo: bool
|
||||||
companion_wish_note: str | None
|
companion_wish_note: str | None
|
||||||
badge_text: str | None
|
badge_text: str | None
|
||||||
|
badge_count: int
|
||||||
self_declared_available: bool
|
self_declared_available: bool
|
||||||
willing_to_pay_fee: bool
|
willing_to_pay_fee: bool
|
||||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
# 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).
|
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
|
||||||
referent_name: str | None
|
referent_name: str | None
|
||||||
referent_phone: 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
|
location: str | None = None
|
||||||
category: PositionCategory | None = None
|
category: PositionCategory | None = None
|
||||||
lead_account_id: uuid.UUID | 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):
|
class PositionRead(ApiModel):
|
||||||
@@ -190,6 +213,8 @@ class PositionRead(ApiModel):
|
|||||||
location: str | None
|
location: str | None
|
||||||
category: PositionCategory
|
category: PositionCategory
|
||||||
lead_account_id: uuid.UUID | None
|
lead_account_id: uuid.UUID | None
|
||||||
|
message: str | None
|
||||||
|
message_updated_at: dt.datetime | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -283,10 +308,17 @@ class AssignmentRead(ApiModel):
|
|||||||
shift_id: uuid.UUID
|
shift_id: uuid.UUID
|
||||||
source: AssignmentSource
|
source: AssignmentSource
|
||||||
locked: bool
|
locked: bool
|
||||||
|
attendance: AttendanceStatus | None
|
||||||
|
attendance_marked_at: dt.datetime | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AttendanceUpdate(ApiModel):
|
||||||
|
# `None` = annuler le pointage.
|
||||||
|
attendance: AttendanceStatus | None
|
||||||
|
|
||||||
|
|
||||||
class AssignmentWriteResult(ApiModel):
|
class AssignmentWriteResult(ApiModel):
|
||||||
assignment: AssignmentRead
|
assignment: AssignmentRead
|
||||||
warnings: list[Violation] = Field(default_factory=list)
|
warnings: list[Violation] = Field(default_factory=list)
|
||||||
@@ -346,6 +378,7 @@ class MatrixResponse(ApiModel):
|
|||||||
# Badges (planches PDF) #
|
# Badges (planches PDF) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class BadgeFieldIn(ApiModel):
|
class BadgeFieldIn(ApiModel):
|
||||||
|
face: BadgeFace = BadgeFace.FRONT
|
||||||
field_type: BadgeFieldType
|
field_type: BadgeFieldType
|
||||||
x_mm: float
|
x_mm: float
|
||||||
y_mm: float
|
y_mm: float
|
||||||
@@ -355,11 +388,21 @@ class BadgeFieldIn(ApiModel):
|
|||||||
align: BadgeAlign = BadgeAlign.LEFT
|
align: BadgeAlign = BadgeAlign.LEFT
|
||||||
bold: bool = False
|
bold: bool = False
|
||||||
color: str = "#000000"
|
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):
|
class BadgeFieldsSet(ApiModel):
|
||||||
"""Remplace l'intégralité des champs du recto — au plus un par
|
"""Remplace l'intégralité des champs du recto et du verso — au plus un
|
||||||
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
|
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
|
||||||
|
ligne)."""
|
||||||
|
|
||||||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -473,6 +516,33 @@ class PlanningNotificationResult(ApiModel):
|
|||||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
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 #
|
# Formulaire public d'inscription #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -725,9 +795,41 @@ class StatsNeeds(ApiModel):
|
|||||||
volunteers_with_assignment: int
|
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):
|
class EditionStats(ApiModel):
|
||||||
registrations: StatsRegistrations
|
registrations: StatsRegistrations
|
||||||
answers: StatsAnswers
|
answers: StatsAnswers
|
||||||
payments: StatsPayments
|
payments: StatsPayments
|
||||||
artist_wishes: list[StatsArtistWish]
|
artist_wishes: list[StatsArtistWish]
|
||||||
needs: StatsNeeds
|
needs: StatsNeeds
|
||||||
|
attendance: StatsAttendance
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ from app.api.schemas import (
|
|||||||
EditionStats,
|
EditionStats,
|
||||||
StatsAnswers,
|
StatsAnswers,
|
||||||
StatsArtistWish,
|
StatsArtistWish,
|
||||||
|
StatsAttendance,
|
||||||
|
StatsAttendanceCounts,
|
||||||
|
StatsAttendancePosition,
|
||||||
StatsNeedDay,
|
StatsNeedDay,
|
||||||
StatsNeedPosition,
|
StatsNeedPosition,
|
||||||
StatsNeeds,
|
StatsNeeds,
|
||||||
@@ -30,6 +33,7 @@ from app.models import (
|
|||||||
Account,
|
Account,
|
||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
|
AttendanceStatus,
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
FeePaymentStatus,
|
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)
|
@router.get("", response_model=EditionStats)
|
||||||
def get_stats(
|
def get_stats(
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
@@ -256,4 +313,5 @@ def get_stats(
|
|||||||
payments=_payments(volunteers, pending_unfinished),
|
payments=_payments(volunteers, pending_unfinished),
|
||||||
artist_wishes=artist_stats,
|
artist_wishes=artist_stats,
|
||||||
needs=needs,
|
needs=needs,
|
||||||
|
attendance=_attendance(positions, shifts, assignments),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ def get_volunteer_planning(
|
|||||||
position_location=p.location,
|
position_location=p.location,
|
||||||
referent_name=f"{r.first_name} {r.last_name}" if r else None,
|
referent_name=f"{r.first_name} {r.last_name}" if r else None,
|
||||||
referent_phone=r.phone 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
|
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
|
||||||
+386
-51
@@ -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,
|
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
|
`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 à
|
taille standard des badges/cartes d'événement) servent de repère à
|
||||||
l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique
|
l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
|
||||||
pour tous les bénévoles : la planche imprime toutes les pages de recto
|
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
|
||||||
(personnalisées) puis, pour chacune, la page de verso correspondante (même
|
plus les champs du verso de chaque bénévole) — impression recto-verso
|
||||||
disposition, même image répétée à chaque emplacement rempli) — impression
|
classique, retournement sur le bord long : sur la page de verso, chaque
|
||||||
recto-verso classique, page de verso n directement après la page de recto n.
|
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
|
import io
|
||||||
@@ -23,13 +24,27 @@ from reportlab.lib.colors import HexColor
|
|||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.lib.utils import ImageReader
|
from reportlab.lib.utils import ImageReader
|
||||||
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
DEFAULT_BADGE_WIDTH_MM = 85.6
|
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||||
DEFAULT_BADGE_HEIGHT_MM = 54.0
|
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
|
_MARGIN_MM = 10.0
|
||||||
_GUTTER_MM = 4.0
|
_GUTTER_MM = 4.0
|
||||||
_LINE_HEIGHT_FACTOR = 1.25
|
_LINE_HEIGHT_FACTOR = 1.25
|
||||||
|
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
|
||||||
|
# des filets.
|
||||||
|
_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
|
# 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
|
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
|
||||||
@@ -47,15 +62,40 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
|
|||||||
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
|
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BadgeTableSection:
|
||||||
|
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
|
||||||
|
jour), puis ses lignes (horaire, poste)."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
rows: tuple[tuple[str, ...], ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BadgeTable:
|
||||||
|
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
|
||||||
|
créneaux du bénévole regroupés par jour."""
|
||||||
|
|
||||||
|
sections: tuple[BadgeTableSection, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BadgeFieldSpec:
|
class BadgeFieldSpec:
|
||||||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
"""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
|
possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
|
||||||
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).
|
du badge (comme dans l'éditeur visuel du front-end).
|
||||||
|
|
||||||
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
|
`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
|
bruts tels que stockés) plutôt qu'un texte, découpée selon
|
||||||
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné."""
|
`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`.
|
||||||
|
|
||||||
|
`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
|
text: str
|
||||||
x_mm: float
|
x_mm: float
|
||||||
@@ -68,13 +108,20 @@ class BadgeFieldSpec:
|
|||||||
color: str # "#rrggbb"
|
color: str # "#rrggbb"
|
||||||
is_photo: bool = False
|
is_photo: bool = False
|
||||||
image: bytes | None = None
|
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)
|
@dataclass(frozen=True)
|
||||||
class BadgeSpec:
|
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, ...]
|
fields: tuple[BadgeFieldSpec, ...]
|
||||||
|
back_fields: tuple[BadgeFieldSpec, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
|
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
|
||||||
@@ -85,14 +132,45 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
|
|||||||
return cols, rows
|
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(
|
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]:
|
) -> tuple[float, float]:
|
||||||
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
|
"""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
|
col, row = index % cols, index // cols
|
||||||
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
|
if mirrored:
|
||||||
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
|
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
|
return x0, y0
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +213,9 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with Image.open(io.BytesIO(image)) as img:
|
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)
|
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
|
||||||
except (UnidentifiedImageError, OSError):
|
except (UnidentifiedImageError, OSError):
|
||||||
return None
|
return None
|
||||||
@@ -143,23 +224,291 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
|||||||
return out.getvalue()
|
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(
|
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
|
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||||
) -> None:
|
) -> None:
|
||||||
if not f.image:
|
if not f.bg_color:
|
||||||
return
|
return
|
||||||
photo = _fit_photo(f.image, f.width_mm, f.height_mm)
|
c.saveState()
|
||||||
if photo is None:
|
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]:
|
||||||
|
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
|
||||||
|
du texte restent des retours à la ligne, puis chaque ligne trop longue
|
||||||
|
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
|
||||||
|
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
|
||||||
|
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
|
||||||
|
|
||||||
|
def fits(candidate: str) -> bool:
|
||||||
|
return stringWidth(candidate, font_name, font_size) <= max_width_pt
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
for paragraph in text.split("\n"):
|
||||||
|
current = ""
|
||||||
|
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
|
||||||
|
# ex. entre le nom du poste et son horaire.
|
||||||
|
for word in paragraph.split(" "):
|
||||||
|
candidate = f"{current} {word}" if current else word
|
||||||
|
if fits(candidate):
|
||||||
|
current = candidate
|
||||||
|
continue
|
||||||
|
if current:
|
||||||
|
lines.append(current.rstrip())
|
||||||
|
current = word
|
||||||
|
while current and not fits(current):
|
||||||
|
cut = len(current) - 1
|
||||||
|
while cut > 1 and not fits(current[:cut]):
|
||||||
|
cut -= 1
|
||||||
|
lines.append(current[:cut])
|
||||||
|
current = current[cut:]
|
||||||
|
lines.append(current.rstrip())
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _column_widths(natural: list[float], available: float) -> list[float]:
|
||||||
|
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
|
||||||
|
courte (jour, horaire) garde sa largeur naturelle, les plus longues
|
||||||
|
(poste, référent) se partagent le reste à parts égales et passent à la
|
||||||
|
ligne. S'il reste de la place, la dernière colonne la prend."""
|
||||||
|
widths = [0.0] * len(natural)
|
||||||
|
pending = sorted(range(len(natural)), key=lambda i: natural[i])
|
||||||
|
remaining = available
|
||||||
|
while pending:
|
||||||
|
share = remaining / len(pending)
|
||||||
|
if natural[pending[0]] > share:
|
||||||
|
for i in pending:
|
||||||
|
widths[i] = share
|
||||||
|
return widths
|
||||||
|
i = pending.pop(0)
|
||||||
|
widths[i] = natural[i]
|
||||||
|
remaining -= natural[i]
|
||||||
|
widths[-1] += remaining
|
||||||
|
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:
|
||||||
|
"""Tableau dans le cadre du champ : pour chaque section, une ligne
|
||||||
|
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
|
||||||
|
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 — 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
|
return
|
||||||
c.drawImage(
|
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||||
ImageReader(io.BytesIO(photo)),
|
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
|
||||||
(x0_mm + f.x_mm) * mm,
|
font_size = f.font_size
|
||||||
# `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab
|
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||||
# place l'image par son coin bas-gauche, depuis le BAS de la page.
|
if f.auto_fit:
|
||||||
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
while (
|
||||||
width=f.width_mm * mm,
|
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
|
||||||
height=f.height_mm * mm,
|
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)
|
||||||
|
|
||||||
|
left = (x0_mm + f.x_mm) * mm
|
||||||
|
top = (y0_mm + badge_height_mm - f.y_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 wrapped, font, is_title, row_height in lines_to_draw:
|
||||||
|
cell_widths = [total_width] if is_title else widths
|
||||||
|
if y - row_height < bottom_limit - 0.01:
|
||||||
|
break # la ligne déborderait du cadre du champ
|
||||||
|
c.setFont(font, font_size)
|
||||||
|
x = left
|
||||||
|
for col, lines in enumerate(wrapped):
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
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:
|
||||||
|
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
|
||||||
|
x = left
|
||||||
|
for width in widths[:-1]:
|
||||||
|
x += width
|
||||||
|
c.line(x, y, x, y - row_height)
|
||||||
|
y -= row_height
|
||||||
|
c.line(left, y, left + total_width, y)
|
||||||
|
|
||||||
|
if y == top:
|
||||||
|
return # pas même le premier en-tête ne tient
|
||||||
|
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
||||||
|
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
|
||||||
|
# `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 = 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
|
||||||
|
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 f.align == "CENTER":
|
||||||
|
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
||||||
|
elif f.align == "RIGHT":
|
||||||
|
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
||||||
|
else:
|
||||||
|
c.drawString(left_x * mm, baseline_y * mm, line)
|
||||||
|
|
||||||
|
|
||||||
def _draw_fields(
|
def _draw_fields(
|
||||||
@@ -168,33 +517,17 @@ def _draw_fields(
|
|||||||
x0_mm: float,
|
x0_mm: float,
|
||||||
y0_mm: float,
|
y0_mm: float,
|
||||||
badge_height_mm: float,
|
badge_height_mm: float,
|
||||||
|
photos: PhotoCache,
|
||||||
) -> None:
|
) -> None:
|
||||||
for f in fields:
|
for f in fields:
|
||||||
if f.is_photo:
|
if f.is_photo:
|
||||||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
|
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
|
||||||
continue
|
continue
|
||||||
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
|
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||||
c.setFillColor(HexColor(f.color))
|
if f.table is not None:
|
||||||
left_x = x0_mm + f.x_mm
|
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||||
# `f.font_size` est en points (unité reportlab) ; le reste du calcul
|
else:
|
||||||
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
|
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||||
# ici le facteur de conversion pt/mm de reportlab).
|
|
||||||
font_size_mm = f.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
|
|
||||||
for i, line in enumerate(f.text.split("\n")):
|
|
||||||
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":
|
|
||||||
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
|
||||||
else:
|
|
||||||
c.drawString(left_x * mm, baseline_y * mm, line)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_badge_sheets_pdf(
|
def generate_badge_sheets_pdf(
|
||||||
@@ -212,16 +545,18 @@ def generate_badge_sheets_pdf(
|
|||||||
|
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
c = canvas.Canvas(buffer, pagesize=A4)
|
c = canvas.Canvas(buffer, pagesize=A4)
|
||||||
|
photos: PhotoCache = {}
|
||||||
for page_badges in pages:
|
for page_badges in pages:
|
||||||
for i, badge in enumerate(page_badges):
|
for i, badge in enumerate(page_badges):
|
||||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
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_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()
|
c.showPage()
|
||||||
|
|
||||||
for i in range(len(page_badges)):
|
for i, badge in enumerate(page_badges):
|
||||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
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_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.showPage()
|
||||||
c.save()
|
c.save()
|
||||||
return buffer.getvalue()
|
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 »."""
|
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
|
||||||
|
|
||||||
import html as _html
|
import html as _html
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
_INK = "#14110E"
|
_INK = "#14110E"
|
||||||
_CREAM = "#F5EFE1"
|
_CREAM = "#F5EFE1"
|
||||||
@@ -26,21 +28,72 @@ _FONT_MONO = "'Courier New', Courier, monospace"
|
|||||||
LOGO_CID = "edition-logo"
|
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
|
"""`\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`)."""
|
ligne — même convention que les modèles texte (`app/email/templates.py`).
|
||||||
blocks = body.strip().split("\n\n")
|
`blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
|
||||||
return "".join(
|
inséré tel quel à la place du repère, hors de tout paragraphe."""
|
||||||
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>'
|
out = []
|
||||||
for block in blocks
|
for block in body.strip().split("\n\n"):
|
||||||
if block.strip()
|
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
|
"""`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
|
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:
|
if has_logo:
|
||||||
header = (
|
header = (
|
||||||
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
|
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};
|
<td style="background:{_PAPER};border:3px solid {_INK};
|
||||||
border-top:none;padding:22px;font-size:13px;
|
border-top:none;padding:22px;font-size:13px;
|
||||||
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
|
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
|
||||||
{_paragraphs(body)}
|
{_paragraphs(body, blocks or {})}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
|
|||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
@@ -44,8 +44,15 @@ def build_message(
|
|||||||
*,
|
*,
|
||||||
logo: tuple[bytes, str] | None = None,
|
logo: tuple[bytes, str] | None = None,
|
||||||
attachments: Sequence[Attachment] = (),
|
attachments: Sequence[Attachment] = (),
|
||||||
|
html_body: str | None = None,
|
||||||
|
html_blocks: Mapping[str, str] | None = None,
|
||||||
) -> EmailMessage:
|
) -> 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
|
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
|
(`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
|
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["Subject"] = subject
|
||||||
message.set_content(body)
|
message.set_content(body)
|
||||||
message.add_alternative(
|
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",
|
subtype="html",
|
||||||
)
|
)
|
||||||
if logo is not None:
|
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))
|
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(
|
def send(
|
||||||
to: str,
|
to: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
@@ -156,6 +201,12 @@ def send(
|
|||||||
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
|
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(
|
def send_many(
|
||||||
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
|
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ _HEADER = [
|
|||||||
"Mécénat — pays",
|
"Mécénat — pays",
|
||||||
"Commentaire",
|
"Commentaire",
|
||||||
"Texte du badge",
|
"Texte du badge",
|
||||||
|
"Nombre de badges",
|
||||||
"Inscrit le",
|
"Inscrit le",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ def generate_volunteers_xlsx(
|
|||||||
_safe_text(v.mecenat_country or ""),
|
_safe_text(v.mecenat_country or ""),
|
||||||
_safe_text(v.comment or ""),
|
_safe_text(v.comment or ""),
|
||||||
_safe_text(v.badge_text or ""),
|
_safe_text(v.badge_text or ""),
|
||||||
|
v.badge_count,
|
||||||
_fmt_dt(v.created_at),
|
_fmt_dt(v.created_at),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,18 +53,36 @@
|
|||||||
"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.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_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.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.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_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||||
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
||||||
"badge.empty_image": "L'image est vide.",
|
"badge.empty_image": "L'image est vide.",
|
||||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
"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.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.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.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||||
"badge.role.referent": "Référent",
|
"badge.role.referent": "Référent",
|
||||||
"badge.role.volunteer": "Bénévole",
|
"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",
|
||||||
|
"badge.weekday.2": "Mercredi",
|
||||||
|
"badge.weekday.3": "Jeudi",
|
||||||
|
"badge.weekday.4": "Vendredi",
|
||||||
|
"badge.weekday.5": "Samedi",
|
||||||
|
"badge.weekday.6": "Dimanche",
|
||||||
"position.category.bar": "Bar",
|
"position.category.bar": "Bar",
|
||||||
"position.category.ticketing": "Billetterie",
|
"position.category.ticketing": "Billetterie",
|
||||||
"position.category.welcome": "Accueil",
|
"position.category.welcome": "Accueil",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Texte commun du champ libre des badges
|
||||||
|
|
||||||
|
Revision ID: 0025_badge_field_text
|
||||||
|
Revises: 0024_volunteer_badge_text
|
||||||
|
Create Date: 2026-10-27
|
||||||
|
|
||||||
|
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
|
||||||
|
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
|
||||||
|
`volunteer.badge_text`, qui le remplace pour ce bénévole.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0025_badge_field_text"
|
||||||
|
down_revision: str | None = "0024_volunteer_badge_text"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("badge_field", "text")
|
||||||
@@ -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 (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
FeePaymentStatus,
|
FeePaymentStatus,
|
||||||
FormFieldType,
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -40,9 +43,12 @@ __all__ = [
|
|||||||
"Artist",
|
"Artist",
|
||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentSource",
|
"AssignmentSource",
|
||||||
|
"AttendanceStatus",
|
||||||
"BadgeAlign",
|
"BadgeAlign",
|
||||||
|
"BadgeFace",
|
||||||
"BadgeField",
|
"BadgeField",
|
||||||
"BadgeFieldType",
|
"BadgeFieldType",
|
||||||
|
"BadgePhotoShape",
|
||||||
"BadgeTemplate",
|
"BadgeTemplate",
|
||||||
"DayPart",
|
"DayPart",
|
||||||
"Edition",
|
"Edition",
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
|||||||
from app.models.base import TimestampMixin
|
from app.models.base import TimestampMixin
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
FeePaymentStatus,
|
FeePaymentStatus,
|
||||||
FormFieldType,
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -90,6 +93,11 @@ class Edition(TimestampMixin, table=True):
|
|||||||
end_date: dt.date
|
end_date: dt.date
|
||||||
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
||||||
retention_days: int | None = None
|
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(
|
day_parts: list["DayPart"] = Relationship(
|
||||||
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
||||||
@@ -160,6 +168,10 @@ class Artist(TimestampMixin, table=True):
|
|||||||
|
|
||||||
|
|
||||||
class Volunteer(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)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
edition_id: uuid.UUID = Field(
|
edition_id: uuid.UUID = Field(
|
||||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
@@ -183,9 +195,15 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||||
companion_wish_note: str | None = None
|
companion_wish_note: str | None = None
|
||||||
# Texte libre imprimé sur le badge (champ `BadgeFieldType.FREE_TEXT`),
|
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
|
||||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ».
|
# 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
|
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
|
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||||
# logique n'y est attachée (pas de workflow de paiement).
|
# logique n'y est attachée (pas de workflow de paiement).
|
||||||
self_declared_available: bool = Field(
|
self_declared_available: bool = Field(
|
||||||
@@ -276,6 +294,12 @@ class Account(TimestampMixin, table=True):
|
|||||||
nullable=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
|
@property
|
||||||
def auth_method(self) -> str:
|
def auth_method(self) -> str:
|
||||||
return "password" if self.password_hash else "magic_link"
|
return "password" if self.password_hash else "magic_link"
|
||||||
@@ -314,6 +338,10 @@ class Position(TimestampMixin, table=True):
|
|||||||
ondelete="SET NULL",
|
ondelete="SET NULL",
|
||||||
nullable=True,
|
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(
|
shifts: list["Shift"] = Relationship(
|
||||||
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
||||||
@@ -393,6 +421,14 @@ class Assignment(TimestampMixin, table=True):
|
|||||||
default=False,
|
default=False,
|
||||||
sa_column=Column(Boolean, nullable=False, server_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")
|
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||||
shift: Shift = Relationship(back_populates="assignments")
|
shift: Shift = Relationship(back_populates="assignments")
|
||||||
@@ -428,16 +464,17 @@ class BadgeTemplate(TimestampMixin, table=True):
|
|||||||
|
|
||||||
|
|
||||||
class BadgeField(TimestampMixin, table=True):
|
class BadgeField(TimestampMixin, table=True):
|
||||||
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie,
|
"""Un champ positionné sur une face du badge (`face` : recto ou verso —
|
||||||
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
|
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
|
||||||
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
|
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
|
||||||
Au plus un champ par `field_type` et par édition : décocher un champ dans
|
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`)."""
|
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
||||||
|
|
||||||
__tablename__ = "badge_field"
|
__tablename__ = "badge_field"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"edition_id", "field_type", name="uq_badge_field_edition_type"
|
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -445,6 +482,14 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
edition_id: uuid.UUID = Field(
|
edition_id: uuid.UUID = Field(
|
||||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
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(
|
field_type: BadgeFieldType = Field(
|
||||||
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
||||||
)
|
)
|
||||||
@@ -466,6 +511,33 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
)
|
)
|
||||||
color: str = Field(default="#000000")
|
color: str = Field(default="#000000")
|
||||||
|
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
|
||||||
|
# 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"
|
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):
|
class PositionCategory(enum.StrEnum):
|
||||||
BAR = "BAR"
|
BAR = "BAR"
|
||||||
TICKETING = "TICKETING"
|
TICKETING = "TICKETING"
|
||||||
@@ -31,7 +38,7 @@ class PositionCategory(enum.StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class BadgeFieldType(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"
|
FULL_NAME = "FULL_NAME"
|
||||||
POSITIONS = "POSITIONS"
|
POSITIONS = "POSITIONS"
|
||||||
@@ -39,11 +46,28 @@ class BadgeFieldType(enum.StrEnum):
|
|||||||
ROLE = "ROLE"
|
ROLE = "ROLE"
|
||||||
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
|
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
|
||||||
PHOTO = "PHOTO"
|
PHOTO = "PHOTO"
|
||||||
# Texte libre propre à chaque bénévole, saisi par la coordination sur sa
|
# Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
|
||||||
# fiche (`Volunteer.badge_text`).
|
# commun à tous les badges), remplacé pour un bénévole par le « Texte du
|
||||||
|
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
|
||||||
FREE_TEXT = "FREE_TEXT"
|
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):
|
class BadgeAlign(enum.StrEnum):
|
||||||
LEFT = "LEFT"
|
LEFT = "LEFT"
|
||||||
CENTER = "CENTER"
|
CENTER = "CENTER"
|
||||||
|
|||||||
+158
-33
@@ -12,12 +12,14 @@ bénévole, une feuille identifie un poste pour la journée.
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import io
|
import io
|
||||||
from dataclasses import dataclass
|
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 import colors
|
||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.lib.utils import ImageReader
|
|
||||||
from reportlab.platypus import (
|
from reportlab.platypus import (
|
||||||
Image,
|
Image,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
@@ -28,7 +30,12 @@ from reportlab.platypus import (
|
|||||||
TableStyle,
|
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"]
|
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
||||||
_MONTHS_FR = [
|
_MONTHS_FR = [
|
||||||
@@ -90,69 +97,187 @@ def _styles() -> dict[str, ParagraphStyle]:
|
|||||||
base = getSampleStyleSheet()
|
base = getSampleStyleSheet()
|
||||||
return {
|
return {
|
||||||
"title": ParagraphStyle(
|
"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(
|
"subtitle": ParagraphStyle(
|
||||||
"SheetSubtitle", parent=base["Normal"],
|
"SheetSubtitle", parent=base["Normal"],
|
||||||
fontSize=12, textColor=colors.grey, spaceAfter=4,
|
fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
|
||||||
),
|
),
|
||||||
"referent": ParagraphStyle(
|
"referent": ParagraphStyle(
|
||||||
"SheetReferent", parent=base["Normal"],
|
"SheetReferent", parent=base["Normal"],
|
||||||
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6,
|
fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
|
||||||
),
|
),
|
||||||
"artists": ParagraphStyle(
|
"artists": ParagraphStyle(
|
||||||
"SheetArtists", parent=base["Normal"],
|
"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": 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": ParagraphStyle(
|
||||||
"ShiftHeaderWarning", parent=base["Heading3"],
|
"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"],
|
"normal": base["Normal"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _photo_thumbnail(photo: bytes) -> Image:
|
@dataclass(frozen=True)
|
||||||
"""Vignette carrée (14 mm de côté max), proportions préservées."""
|
class SheetFooter:
|
||||||
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize()
|
"""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)
|
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:
|
def _shift_table(
|
||||||
rows = [["", "Bénévole", "Téléphone"]] + [
|
shift: SheetShift,
|
||||||
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
|
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
|
for v in shift.volunteers
|
||||||
]
|
]
|
||||||
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm])
|
rows = []
|
||||||
table.setStyle(
|
for i in range(0, len(cells), _PER_ROW):
|
||||||
TableStyle(
|
chunk = cells[i : i + _PER_ROW]
|
||||||
[
|
chunk += [["", ""]] * (_PER_ROW - len(chunk))
|
||||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
rows.append([cell for pair in chunk for cell in pair])
|
||||||
("FONTSIZE", (0, 0), (-1, -1), 10.5),
|
table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
style = [
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||||||
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black),
|
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
||||||
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
|
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||||||
]
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
)
|
("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
|
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()
|
styles = _styles()
|
||||||
buffer = io.BytesIO()
|
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(
|
doc = SimpleDocTemplate(
|
||||||
buffer, pagesize=A4,
|
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 = []
|
story = []
|
||||||
|
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
|
||||||
for i, sheet in enumerate(sheets):
|
for i, sheet in enumerate(sheets):
|
||||||
if i > 0:
|
if i > 0:
|
||||||
story.append(PageBreak())
|
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"]
|
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
|
||||||
story.append(Paragraph(header, style))
|
story.append(Paragraph(header, style))
|
||||||
if shift.volunteers:
|
if shift.volunteers:
|
||||||
story.append(_shift_table(shift))
|
story.append(_shift_table(shift, thumbnails, styles, width))
|
||||||
else:
|
else:
|
||||||
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
||||||
story.append(Spacer(1, 0))
|
story.append(Spacer(1, 0))
|
||||||
|
|
||||||
doc.build(story)
|
doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
|
||||||
return buffer.getvalue()
|
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}"}
|
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "/connexion?token=" in plain_text(outbox[0])
|
assert "/connexion?token=pl." in plain_text(outbox[0])
|
||||||
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
# Lien permanent : rien de stocké, pas de jeton à usage unique.
|
||||||
|
assert session.exec(select(MagicLinkToken)).all() == []
|
||||||
|
|
||||||
|
|
||||||
def test_no_magic_link_token_without_placeholder(
|
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")
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
resp = as_account(lead).post(_url(edition), json={})
|
resp = as_account(lead).post(_url(edition), json={})
|
||||||
assert resp.status_code == 403
|
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["assigned"] == 3
|
||||||
assert position["filled"] == 2
|
assert position["filled"] == 2
|
||||||
assert position["ideal_slots"] == 4
|
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.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
|
||||||
assert lead.delete(url).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
|
||||||
|
|||||||
@@ -221,12 +221,15 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
|
|||||||
{
|
{
|
||||||
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
|
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
|
||||||
"width_mm": 50, "height_mm": 8, "font_size": 9,
|
"width_mm": 50, "height_mm": 8, "font_size": 9,
|
||||||
|
"text": "Staff 2026",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"}
|
by_type = {f["field_type"]: f for f in resp.json()}
|
||||||
|
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
|
||||||
|
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
|
||||||
|
|
||||||
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
with_photo.photo = _PNG
|
with_photo.photo = _PNG
|
||||||
@@ -244,3 +247,196 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
|
|||||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.content.startswith(b"%PDF")
|
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"]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.api.badges import _field_text, _position_line
|
from app.api.badges import _field_text, _positions_table
|
||||||
from app.models import (
|
from app.models import (
|
||||||
BadgeField,
|
BadgeField,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
@@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_position_line_shows_the_schedule():
|
_BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02:00
|
||||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
|
||||||
position = _position(name="Bar central")
|
|
||||||
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
|
|
||||||
|
|
||||||
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
|
|
||||||
|
|
||||||
assert line == "Bar central 19:00–22:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_position_line_shows_the_referent_when_there_is_one():
|
def test_positions_table_groups_shifts_under_their_day():
|
||||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
accueil = (
|
||||||
position = _position(name="Bar central")
|
|
||||||
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
|
|
||||||
referent = _volunteer(first_name="Sam", last_name="Nuit")
|
|
||||||
|
|
||||||
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
|
|
||||||
|
|
||||||
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
|
|
||||||
|
|
||||||
|
|
||||||
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
|
|
||||||
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
|
|
||||||
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
|
|
||||||
|
|
||||||
line = _position_line(
|
|
||||||
_position(name="Bar central"),
|
|
||||||
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
|
||||||
referent=volunteer,
|
|
||||||
volunteer_id=volunteer.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "Référent" not in line
|
|
||||||
|
|
||||||
|
|
||||||
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
|
|
||||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
|
||||||
field = BadgeField(
|
|
||||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
|
|
||||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
|
||||||
)
|
|
||||||
morning = (
|
|
||||||
_position(name="Accueil"),
|
_position(name="Accueil"),
|
||||||
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)),
|
_shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
evening = (
|
bar = (
|
||||||
_position(name="Bar central"),
|
_position(name="Bar central"),
|
||||||
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
_shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)),
|
||||||
None,
|
)
|
||||||
|
demontage = (
|
||||||
|
_position(name="Démontage"),
|
||||||
|
_shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
text = _field_text(
|
table = _positions_table([accueil, bar, demontage], _BOUNDARY)
|
||||||
field, volunteer,
|
|
||||||
assignments_by_volunteer={volunteer.id: [morning, evening]},
|
assert [(s.title, s.rows) for s in table.sections] == [
|
||||||
lead_volunteer_ids=set(),
|
("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))),
|
||||||
|
("Dimanche 12", (("14:00–18:00", "Démontage"),)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_table_keeps_a_night_shift_on_the_day_it_started():
|
||||||
|
night = (
|
||||||
|
_position(name="Bar central"),
|
||||||
|
_shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00"
|
table = _positions_table([night], _BOUNDARY)
|
||||||
|
|
||||||
|
assert table.sections[0].title == "Samedi 11"
|
||||||
|
assert table.sections[0].rows == (("00:00–01:00", "Bar central"),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_table_is_empty_without_any_shift():
|
||||||
|
assert _positions_table([], _BOUNDARY) is None
|
||||||
|
|
||||||
|
|
||||||
def test_field_text_role_is_referent_only_for_lead_accounts():
|
def test_field_text_role_is_referent_only_for_lead_accounts():
|
||||||
@@ -111,3 +91,35 @@ def test_field_text_free_text_is_the_volunteer_badge_text():
|
|||||||
|
|
||||||
assert _field_text(field, with_text, {}, set()) == "Accès backstage"
|
assert _field_text(field, with_text, {}, set()) == "Accès backstage"
|
||||||
assert _field_text(field, without, {}, set()) == ""
|
assert _field_text(field, without, {}, set()) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_text_free_text_falls_back_to_the_common_editor_text():
|
||||||
|
field = BadgeField(
|
||||||
|
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
|
||||||
|
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
|
||||||
|
text="Staff 2026",
|
||||||
|
)
|
||||||
|
own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
|
||||||
|
other = _volunteer(first_name="Sam", last_name="Nuit")
|
||||||
|
|
||||||
|
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,7 +2,10 @@
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from reportlab.lib.units import mm
|
||||||
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||||
|
|
||||||
from app.badges.pdf import (
|
from app.badges.pdf import (
|
||||||
ASSUMED_DPI,
|
ASSUMED_DPI,
|
||||||
@@ -10,11 +13,16 @@ from app.badges.pdf import (
|
|||||||
DEFAULT_BADGE_WIDTH_MM,
|
DEFAULT_BADGE_WIDTH_MM,
|
||||||
BadgeFieldSpec,
|
BadgeFieldSpec,
|
||||||
BadgeSpec,
|
BadgeSpec,
|
||||||
|
BadgeTable,
|
||||||
|
BadgeTableSection,
|
||||||
_cell_origin,
|
_cell_origin,
|
||||||
|
_column_widths,
|
||||||
_fit_photo,
|
_fit_photo,
|
||||||
_grid_size,
|
_grid_size,
|
||||||
|
_spacing,
|
||||||
generate_badge_sheets_pdf,
|
generate_badge_sheets_pdf,
|
||||||
image_size_mm,
|
image_size_mm,
|
||||||
|
wrap_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,3 +159,278 @@ def test_unreadable_photo_does_not_crash_generation():
|
|||||||
[_photo_badge(b"pas une image")], front_image=None, back_image=None
|
[_photo_badge(b"pas une image")], front_image=None, back_image=None
|
||||||
)
|
)
|
||||||
assert pdf.startswith(b"%PDF")
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_text_keeps_a_short_text_on_one_line():
|
||||||
|
assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_text_breaks_between_words_to_fit_the_width():
|
||||||
|
# Assez large pour « Marie-Christine » seul, trop étroit pour le tout.
|
||||||
|
width = 40 * mm
|
||||||
|
lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width)
|
||||||
|
|
||||||
|
assert len(lines) > 1
|
||||||
|
assert " ".join(lines) == "Marie-Christine de La Fontaine"
|
||||||
|
assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line():
|
||||||
|
text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00"
|
||||||
|
width = 40 * mm
|
||||||
|
lines = wrap_text(text, "Helvetica", 8, width)
|
||||||
|
|
||||||
|
assert lines[0].startswith("Bar central 19:00–22:00")
|
||||||
|
assert "Accueil 09:00–13:00" in lines
|
||||||
|
assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_text_cuts_a_single_word_too_long_for_the_frame():
|
||||||
|
width = 10 * mm
|
||||||
|
lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width)
|
||||||
|
|
||||||
|
assert "".join(lines) == "Anticonstitutionnellement"
|
||||||
|
assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec:
|
||||||
|
return BadgeSpec(
|
||||||
|
fields=(
|
||||||
|
BadgeFieldSpec(
|
||||||
|
text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm,
|
||||||
|
font_size=7, align="LEFT", bold=False, color="#000000",
|
||||||
|
table=BadgeTable(
|
||||||
|
sections=(
|
||||||
|
BadgeTableSection(
|
||||||
|
title="Samedi 11",
|
||||||
|
rows=tuple(
|
||||||
|
("19:00–22:00", "Bar central très très long")
|
||||||
|
for _ in range(rows)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_column_widths_keep_short_columns_and_share_the_rest():
|
||||||
|
widths = _column_widths([20, 30, 200, 100], available=200)
|
||||||
|
|
||||||
|
assert widths[:2] == [20, 30]
|
||||||
|
assert widths[2] == widths[3] == 75
|
||||||
|
assert sum(widths) == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_column_widths_give_the_spare_room_to_the_last_column():
|
||||||
|
assert _column_widths([20, 30, 40], available=150) == [20, 30, 100]
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_field_generates_a_valid_pdf():
|
||||||
|
pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None)
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_rows_beyond_the_frame_are_not_drawn():
|
||||||
|
# Cadre trop petit pour 50 lignes : celles qui débordent sont omises,
|
||||||
|
# la planche fait la même taille qu'avec quelques lignes seulement.
|
||||||
|
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()
|
text = reader.pages[0].extract_text()
|
||||||
assert "jeudi 9 juillet" in text.lower()
|
assert "jeudi 9 juillet" in text.lower()
|
||||||
assert "01:00" in text
|
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 base64
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import io
|
||||||
|
|
||||||
from app.sheets.pdf import (
|
from app.sheets.pdf import (
|
||||||
PositionDaySheet,
|
PositionDaySheet,
|
||||||
@@ -142,3 +143,86 @@ def test_generates_a_valid_pdf_without_a_lineup():
|
|||||||
assert sheet.artists == ()
|
assert sheet.artists == ()
|
||||||
pdf = generate_position_sheets_pdf([sheet])
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
assert pdf.startswith(b"%PDF")
|
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`);
|
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 {
|
try {
|
||||||
checkDayBoundary();
|
checkDayBoundary();
|
||||||
@@ -484,7 +542,7 @@ if (coordinator) {
|
|||||||
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
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('/inscriptions', coordinator, createElement(FormBuilderPage));
|
||||||
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
|
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
|
||||||
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
|
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import type {
|
|||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerUpdate,
|
VolunteerUpdate,
|
||||||
|
AssignmentRead,
|
||||||
|
AttendanceStatus,
|
||||||
|
TestEmailRequest,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
const edition = EDITION_ID;
|
const edition = EDITION_ID;
|
||||||
@@ -157,8 +160,14 @@ export function useMatrix() {
|
|||||||
|
|
||||||
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
|
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
|
||||||
* recalculées côté serveur à chaque appel. */
|
* 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() {
|
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). */
|
/** 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() {
|
export function useDeleteAccount() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
return useMutation({
|
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() {
|
export function useDeleteAssignment() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
return useMutation({
|
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() {
|
export function useSendPlanningNotification() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: PlanningNotificationRequest) =>
|
mutationFn: (body: PlanningNotificationRequest) =>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import type {
|
|||||||
VerifyRequest,
|
VerifyRequest,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerUpdate,
|
VolunteerUpdate,
|
||||||
|
AttendanceUpdate,
|
||||||
|
TestEmailRequest,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
import { getAuthToken, notifyUnauthorized } from './session';
|
import { getAuthToken, notifyUnauthorized } from './session';
|
||||||
import { ApiError, type PlanningApi } from './types';
|
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) }),
|
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
||||||
|
resetLoginLink: (editionId, id) =>
|
||||||
|
request(`${base(editionId)}/accounts/${id}/reset-login-link`, { method: 'POST' }),
|
||||||
},
|
},
|
||||||
imports: {
|
imports: {
|
||||||
analyze: (editionId, file: File) => {
|
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) }),
|
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
|
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) =>
|
validate: (editionId, body: AssignmentValidateRequest) =>
|
||||||
request(`${base(editionId)}/assignments/validate`, {
|
request(`${base(editionId)}/assignments/validate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -372,6 +381,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
sendTest: (editionId, body: TestEmailRequest) =>
|
||||||
|
request(`${base(editionId)}/notifications/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
login: (editionId, body: LoginRequest) =>
|
login: (editionId, body: LoginRequest) =>
|
||||||
|
|||||||
@@ -195,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string {
|
|||||||
return `${n(firstName)}.${n(lastName)}@mail.fr`;
|
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 {
|
export function buildMockState(): MockState {
|
||||||
const rnd = mulberry32(20260715);
|
const rnd = mulberry32(20260715);
|
||||||
const edition_id = MOCK_EDITION_ID;
|
const edition_id = MOCK_EDITION_ID;
|
||||||
@@ -206,6 +211,9 @@ export function buildMockState(): MockState {
|
|||||||
location: p.location,
|
location: p.location,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
lead_account_id: null,
|
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,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
}));
|
}));
|
||||||
@@ -290,6 +298,7 @@ export function buildMockState(): MockState {
|
|||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
self_declared_available: false,
|
self_declared_available: false,
|
||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
@@ -373,6 +382,8 @@ export function buildMockState(): MockState {
|
|||||||
shift_id: shift.id,
|
shift_id: shift.id,
|
||||||
source: 'SOLVER',
|
source: 'SOLVER',
|
||||||
locked: false,
|
locked: false,
|
||||||
|
attendance: null,
|
||||||
|
attendance_marked_at: null,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -402,6 +413,8 @@ export function buildMockState(): MockState {
|
|||||||
shift_id: shift.id,
|
shift_id: shift.id,
|
||||||
source: 'MANUAL',
|
source: 'MANUAL',
|
||||||
locked: false,
|
locked: false,
|
||||||
|
attendance: null,
|
||||||
|
attendance_marked_at: null,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -465,6 +478,9 @@ export function buildMockState(): MockState {
|
|||||||
start_date: DAY_DATES[0],
|
start_date: DAY_DATES[0],
|
||||||
end_date: DAY_DATES[DAY_DATES.length - 1],
|
end_date: DAY_DATES[DAY_DATES.length - 1],
|
||||||
retention_days: 365,
|
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,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountRead,
|
AccountRead,
|
||||||
@@ -44,6 +44,7 @@ import type {
|
|||||||
VolunteerRead,
|
VolunteerRead,
|
||||||
EditionStats,
|
EditionStats,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
|
import { MESSAGE_MAX_LENGTH } from '../schema';
|
||||||
import { getAuthToken } from '../session';
|
import { getAuthToken } from '../session';
|
||||||
import { ApiError, type PlanningApi } from '../types';
|
import { ApiError, type PlanningApi } from '../types';
|
||||||
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
|
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
|
||||||
@@ -235,6 +236,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
|||||||
has_photo: pending.photo !== null,
|
has_photo: pending.photo !== null,
|
||||||
companion_wish_note: pending.companionWishNote,
|
companion_wish_note: pending.companionWishNote,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
self_declared_available: pending.available,
|
self_declared_available: pending.available,
|
||||||
willing_to_pay_fee: true,
|
willing_to_pay_fee: true,
|
||||||
fee_payment_label: pending.paymentLabel,
|
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,
|
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 {
|
export function createMockApi(): PlanningApi {
|
||||||
return {
|
return {
|
||||||
editions: {
|
editions: {
|
||||||
@@ -846,6 +903,13 @@ export function createMockApi(): PlanningApi {
|
|||||||
if (body.start_date) next.start_date = body.start_date;
|
if (body.start_date) next.start_date = body.start_date;
|
||||||
if (body.end_date) next.end_date = body.end_date;
|
if (body.end_date) next.end_date = body.end_date;
|
||||||
if ('retention_days' in body) next.retention_days = body.retention_days ?? null;
|
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) {
|
if (next.end_date < next.start_date) {
|
||||||
throw new ApiError(422, 'edition.dates_inconsistent');
|
throw new ApiError(422, 'edition.dates_inconsistent');
|
||||||
}
|
}
|
||||||
@@ -997,6 +1061,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
self_declared_available: false,
|
self_declared_available: false,
|
||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
@@ -1042,6 +1107,14 @@ export function createMockApi(): PlanningApi {
|
|||||||
? { companion_wish_note: body.companion_wish_note ?? null }
|
? { companion_wish_note: body.companion_wish_note ?? null }
|
||||||
: {}),
|
: {}),
|
||||||
...('badge_text' in body ? { badge_text: body.badge_text ?? 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' in body && body.self_declared_available != null
|
||||||
? { self_declared_available: body.self_declared_available }
|
? { self_declared_available: body.self_declared_available }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1114,6 +1187,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
position_location: position?.location ?? null,
|
position_location: position?.location ?? null,
|
||||||
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
|
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
|
||||||
referent_phone: referent?.phone ?? 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));
|
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||||
@@ -1165,6 +1240,9 @@ export function createMockApi(): PlanningApi {
|
|||||||
account.updated_at = touch();
|
account.updated_at = touch();
|
||||||
return delay(account);
|
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) => {
|
remove: (_edition, id) => {
|
||||||
state.accounts = state.accounts.filter((a) => a.id !== id);
|
state.accounts = state.accounts.filter((a) => a.id !== id);
|
||||||
return delay(undefined);
|
return delay(undefined);
|
||||||
@@ -1321,6 +1399,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
self_declared_available: false,
|
self_declared_available: false,
|
||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
@@ -1373,6 +1452,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
location: body.location ?? null,
|
location: body.location ?? null,
|
||||||
category: body.category,
|
category: body.category,
|
||||||
lead_account_id: body.lead_account_id ?? null,
|
lead_account_id: body.lead_account_id ?? null,
|
||||||
|
message: null,
|
||||||
|
message_updated_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1384,6 +1465,13 @@ export function createMockApi(): PlanningApi {
|
|||||||
update: (_edition, id, body) => {
|
update: (_edition, id, body) => {
|
||||||
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
||||||
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
|
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, {
|
Object.assign(p, {
|
||||||
...('name' in body ? { name: body.name } : {}),
|
...('name' in body ? { name: body.name } : {}),
|
||||||
...('location' in body ? { location: body.location ?? null } : {}),
|
...('location' in body ? { location: body.location ?? null } : {}),
|
||||||
@@ -1498,6 +1586,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
shift_id: body.shift_id,
|
shift_id: body.shift_id,
|
||||||
source: body.source ?? 'MANUAL',
|
source: body.source ?? 'MANUAL',
|
||||||
locked: body.locked ?? false,
|
locked: body.locked ?? false,
|
||||||
|
attendance: null,
|
||||||
|
attendance_marked_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1513,6 +1603,13 @@ export function createMockApi(): PlanningApi {
|
|||||||
});
|
});
|
||||||
return delay({ assignment: a, warnings: [] as Violation[] });
|
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) => {
|
remove: (_edition, id) => {
|
||||||
state.assignments = state.assignments.filter((x) => x.id !== id);
|
state.assignments = state.assignments.filter((x) => x.id !== id);
|
||||||
return delay(undefined);
|
return delay(undefined);
|
||||||
@@ -1577,13 +1674,25 @@ export function createMockApi(): PlanningApi {
|
|||||||
return (await fetch(state.badgeTemplate.backImage)).blob();
|
return (await fetch(state.badgeTemplate.backImage)).blob();
|
||||||
},
|
},
|
||||||
setFields: (_edition, body: BadgeFieldsSet) => {
|
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 seen = new Set<string>();
|
||||||
|
const hexColor = /^#[0-9a-fA-F]{6}$/;
|
||||||
for (const f of body.fields) {
|
for (const f of body.fields) {
|
||||||
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
|
const key = `${f.face ?? 'FRONT'}:${f.field_type}`;
|
||||||
seen.add(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) => ({
|
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
||||||
...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++}`,
|
id: `bf${state.seq++}`,
|
||||||
edition_id: state.edition.id,
|
edition_id: state.edition.id,
|
||||||
}));
|
}));
|
||||||
@@ -1792,6 +1901,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
has_photo: photo !== null,
|
has_photo: photo !== null,
|
||||||
companion_wish_note: companionWishNote,
|
companion_wish_note: companionWishNote,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
@@ -1945,6 +2055,25 @@ export function createMockApi(): PlanningApi {
|
|||||||
},
|
},
|
||||||
|
|
||||||
notifications: {
|
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) => {
|
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
||||||
const ids = body.volunteer_ids ?? null;
|
const ids = body.volunteer_ids ?? null;
|
||||||
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
|
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
|
||||||
|
|||||||
@@ -39,16 +39,25 @@ export interface EditionRead {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
retention_days: number | null;
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Longueur maximale d'un message (organisation, poste) — comme l'API. */
|
||||||
|
export const MESSAGE_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
export interface EditionUpdate {
|
export interface EditionUpdate {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
year?: number;
|
year?: number;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
retention_days?: number | null;
|
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
|
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
|
||||||
* coordination. */
|
* coordination. */
|
||||||
badge_text: string | null;
|
badge_text: string | null;
|
||||||
|
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
|
||||||
|
badge_count: number;
|
||||||
self_declared_available: boolean;
|
self_declared_available: boolean;
|
||||||
willing_to_pay_fee: boolean;
|
willing_to_pay_fee: boolean;
|
||||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||||
@@ -161,6 +172,8 @@ export interface VolunteerUpdate {
|
|||||||
wished_companion_ids?: string[] | null;
|
wished_companion_ids?: string[] | null;
|
||||||
companion_wish_note?: string | null;
|
companion_wish_note?: string | null;
|
||||||
badge_text?: string | null;
|
badge_text?: string | null;
|
||||||
|
/** 1 à 100. */
|
||||||
|
badge_count?: number | null;
|
||||||
self_declared_available?: boolean | null;
|
self_declared_available?: boolean | null;
|
||||||
willing_to_pay_fee?: 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). */
|
* rattaché à une fiche bénévole (pas de téléphone à donner alors). */
|
||||||
referent_name: string | null;
|
referent_name: string | null;
|
||||||
referent_phone: 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;
|
location: string | null;
|
||||||
category: PositionCategory;
|
category: PositionCategory;
|
||||||
lead_account_id: string | null;
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -302,6 +322,8 @@ export interface PositionUpdate {
|
|||||||
location?: string | null;
|
location?: string | null;
|
||||||
category?: PositionCategory;
|
category?: PositionCategory;
|
||||||
lead_account_id?: string | null;
|
lead_account_id?: string | null;
|
||||||
|
/** Vide = plus de message. */
|
||||||
|
message?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
@@ -348,10 +370,20 @@ export interface AssignmentRead {
|
|||||||
shift_id: string;
|
shift_id: string;
|
||||||
source: AssignmentSource;
|
source: AssignmentSource;
|
||||||
locked: boolean;
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
|
||||||
|
|
||||||
|
export interface AttendanceUpdate {
|
||||||
|
/** `null` = annuler le pointage. */
|
||||||
|
attendance: AttendanceStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssignmentCreate {
|
export interface AssignmentCreate {
|
||||||
volunteer_id: string;
|
volunteer_id: string;
|
||||||
shift_id: string;
|
shift_id: string;
|
||||||
@@ -433,9 +465,16 @@ export type BadgeFieldType =
|
|||||||
| 'PHOTO'
|
| 'PHOTO'
|
||||||
| 'FREE_TEXT';
|
| 'FREE_TEXT';
|
||||||
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
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 {
|
export interface BadgeFieldIn {
|
||||||
field_type: BadgeFieldType;
|
field_type: BadgeFieldType;
|
||||||
|
/** Défaut `FRONT`. */
|
||||||
|
face: BadgeFace;
|
||||||
x_mm: number;
|
x_mm: number;
|
||||||
y_mm: number;
|
y_mm: number;
|
||||||
width_mm: number;
|
width_mm: number;
|
||||||
@@ -444,6 +483,19 @@ export interface BadgeFieldIn {
|
|||||||
align: BadgeAlign;
|
align: BadgeAlign;
|
||||||
bold: boolean;
|
bold: boolean;
|
||||||
color: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BadgeFieldsSet {
|
export interface BadgeFieldsSet {
|
||||||
@@ -552,6 +604,28 @@ export interface PlanningNotificationResult {
|
|||||||
recipients: string[];
|
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 //
|
// Formulaire public d'inscription //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
@@ -789,4 +863,30 @@ export interface EditionStats {
|
|||||||
by_position: StatsNeedPosition[];
|
by_position: StatsNeedPosition[];
|
||||||
volunteers_with_assignment: number;
|
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,
|
VolunteerRead,
|
||||||
VolunteerUpdate,
|
VolunteerUpdate,
|
||||||
EditionStats,
|
EditionStats,
|
||||||
|
AttendanceUpdate,
|
||||||
|
TestEmailRequest,
|
||||||
|
TestEmailResult,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +111,9 @@ export interface PlanningApi {
|
|||||||
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
|
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
|
||||||
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
|
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
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: {
|
imports: {
|
||||||
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
||||||
@@ -140,6 +146,7 @@ export interface PlanningApi {
|
|||||||
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
|
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
|
||||||
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
|
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
|
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
|
||||||
validate(
|
validate(
|
||||||
editionId: string,
|
editionId: string,
|
||||||
body: AssignmentValidateRequest,
|
body: AssignmentValidateRequest,
|
||||||
@@ -222,6 +229,8 @@ export interface PlanningApi {
|
|||||||
editionId: string,
|
editionId: string,
|
||||||
body: PlanningNotificationRequest,
|
body: PlanningNotificationRequest,
|
||||||
): Promise<PlanningNotificationResult>;
|
): Promise<PlanningNotificationResult>;
|
||||||
|
/** Email de test, envoyé tout de suite (COORDINATION). */
|
||||||
|
sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
|
||||||
};
|
};
|
||||||
auth: {
|
auth: {
|
||||||
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
|
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.remove": "Retirer",
|
||||||
"action.add": "Ajouter",
|
"action.add": "Ajouter",
|
||||||
"app.loading": "Chargement…",
|
"app.loading": "Chargement…",
|
||||||
"common.now": "MAINTENANT",
|
|
||||||
"app.error.generic": "Une erreur est survenue",
|
"app.error.generic": "Une erreur est survenue",
|
||||||
"app.error.reference": "Référence à communiquer : {id}",
|
"app.error.reference": "Référence à communiquer : {id}",
|
||||||
"error.generic": "Erreur inattendue",
|
"error.generic": "Erreur inattendue",
|
||||||
@@ -107,28 +106,89 @@
|
|||||||
"volunteers.filter.all": "TOUS",
|
"volunteers.filter.all": "TOUS",
|
||||||
"volunteers.filter.unassigned": "SANS AFFECTATION",
|
"volunteers.filter.unassigned": "SANS AFFECTATION",
|
||||||
"volunteers.legend.assigned": "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.count": "{count} BÉNÉVOLES",
|
||||||
"volunteers.totalHours": "TOTAL H",
|
"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.name": "NOM",
|
||||||
"volunteers.cumulative": "CUMUL",
|
"volunteers.cumulative": "CUMUL",
|
||||||
"volunteers.badge.unassigned": "○ OUBLIÉ",
|
"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.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.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
|
||||||
"volunteers.footnote": "Oublié = aucune affectation.",
|
"volunteers.footnote": "Oublié = aucune affectation.",
|
||||||
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
|
"mobileVol.greeting": "Salut {name}",
|
||||||
"mobileVol.greeting": "SALUT {name}",
|
"mobileVol.summary.one": "{count} créneau · {duration} sur {days} jour",
|
||||||
"mobileVol.slots": "{count} CRÉNEAUX",
|
"mobileVol.summary.other": "{count} créneaux · {duration} sur {days} jour(s)",
|
||||||
"mobileVol.hours": "{count} HEURES",
|
"mobileVol.summary.none": "Pas encore de créneau.",
|
||||||
"mobileVol.location": "📍 {location}",
|
"mobileVol.featured.now": "● EN COURS",
|
||||||
"mobileVol.referent": "👤 Responsable : {name}",
|
"mobileVol.featured.next": "PROCHAIN CRÉNEAU",
|
||||||
"mobileVol.phone": "📞 {phone}",
|
"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.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.",
|
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
|
||||||
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
|
"mobileLead.kicker": "Ton équipe",
|
||||||
"mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT",
|
"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.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
|
||||||
"mobileLead.noShift": "Aucun créneau sur ce poste.",
|
"mobileLead.noShift": "Aucun créneau sur ce poste.",
|
||||||
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
|
|
||||||
"mobileLead.badgePresent": "✓ PRÉSENT",
|
"mobileLead.badgePresent": "✓ PRÉSENT",
|
||||||
"mobileLead.badgeAbsent": "✕ ABSENT",
|
"mobileLead.badgeAbsent": "✕ ABSENT",
|
||||||
"mobileLead.markPresent": "PRÉSENT",
|
"mobileLead.markPresent": "PRÉSENT",
|
||||||
@@ -138,6 +198,31 @@
|
|||||||
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
||||||
"config.tab.grid": "POSTES & CRÉNEAUX",
|
"config.tab.grid": "POSTES & CRÉNEAUX",
|
||||||
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
"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.mail": "MESSAGES",
|
||||||
"config.tab.rules": "RÈGLES & ÉDITION",
|
"config.tab.rules": "RÈGLES & ÉDITION",
|
||||||
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
||||||
@@ -178,7 +263,7 @@
|
|||||||
"config.mail.subject": "OBJET",
|
"config.mail.subject": "OBJET",
|
||||||
"config.mail.body": "CORPS DU MESSAGE",
|
"config.mail.body": "CORPS DU MESSAGE",
|
||||||
"config.mail.variables": "VARIABLES DISPONIBLES",
|
"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.send": "ENVOYER LE RÉCAPITULATIF",
|
||||||
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
|
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
|
||||||
"config.mail.sending": "ENVOI EN COURS…",
|
"config.mail.sending": "ENVOI EN COURS…",
|
||||||
@@ -231,7 +316,9 @@
|
|||||||
"config.vol.field.phone": "Téléphone",
|
"config.vol.field.phone": "Téléphone",
|
||||||
"config.vol.field.comment": "Commentaire",
|
"config.vol.field.comment": "Commentaire",
|
||||||
"config.vol.field.badgeText": "Texte du badge",
|
"config.vol.field.badgeText": "Texte du badge",
|
||||||
"config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
|
"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.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.",
|
"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.",
|
||||||
"config.vol.photo.none": "Aucune photo",
|
"config.vol.photo.none": "Aucune photo",
|
||||||
@@ -240,11 +327,15 @@
|
|||||||
"config.vol.photo.remove": "Retirer",
|
"config.vol.photo.remove": "Retirer",
|
||||||
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
|
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
|
||||||
"config.vol.access.title": "Accès de connexion",
|
"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.grant": "Donner un accès de connexion",
|
||||||
"config.vol.access.active": "Accès actif : {email}",
|
"config.vol.access.active": "Accès actif : {email}",
|
||||||
"config.vol.access.revoke": "Révoquer",
|
"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.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.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
|
||||||
"config.vol.field.referentPositions": "Référent des postes",
|
"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.",
|
"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.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.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.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.title": "IMPORTER UN FICHIER",
|
||||||
"config.vol.wiz.step1": "1 · FICHIER",
|
"config.vol.wiz.step1": "1 · FICHIER",
|
||||||
"config.vol.wiz.step2": "2 · COLONNES",
|
"config.vol.wiz.step2": "2 · COLONNES",
|
||||||
@@ -355,8 +445,12 @@
|
|||||||
"badges.fields": "Champs du recto",
|
"badges.fields": "Champs du recto",
|
||||||
"badges.field.fullName": "Prénom + nom",
|
"badges.field.fullName": "Prénom + nom",
|
||||||
"badges.field.fullName.placeholder": "Prénom Nom",
|
"badges.field.fullName.placeholder": "Prénom Nom",
|
||||||
"badges.field.positions": "Poste(s) affecté(s)",
|
"badges.field.positions": "Créneaux affectés (tableau)",
|
||||||
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||||
|
"badges.field.positions.sample1.day": "Vendredi 10",
|
||||||
|
"badges.field.positions.sample1.rows": "18:00–22:00|Bar central",
|
||||||
|
"badges.field.positions.sample2.day": "Samedi 11",
|
||||||
|
"badges.field.positions.sample2.rows": "09:00–13:00|Accueil\n23:00–01:00|Bar central",
|
||||||
"badges.field.category": "Catégorie",
|
"badges.field.category": "Catégorie",
|
||||||
"badges.field.category.placeholder": "Bar",
|
"badges.field.category.placeholder": "Bar",
|
||||||
"badges.field.role": "Rôle (référent / bénévole)",
|
"badges.field.role": "Rôle (référent / bénévole)",
|
||||||
@@ -366,7 +460,8 @@
|
|||||||
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
|
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
|
||||||
"badges.field.freeText": "Texte libre",
|
"badges.field.freeText": "Texte libre",
|
||||||
"badges.field.freeText.placeholder": "Accès backstage",
|
"badges.field.freeText.placeholder": "Accès backstage",
|
||||||
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
|
"badges.field.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
|
||||||
|
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
|
||||||
"badges.field.size": "Taille",
|
"badges.field.size": "Taille",
|
||||||
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
||||||
"badges.size.width": "Largeur du badge (mm)",
|
"badges.size.width": "Largeur du badge (mm)",
|
||||||
@@ -379,13 +474,129 @@
|
|||||||
"badges.saved": "Enregistré ✓",
|
"badges.saved": "Enregistré ✓",
|
||||||
"badges.generate.title": "Génération de la planche PDF",
|
"badges.generate.title": "Génération de la planche PDF",
|
||||||
"badges.generate.all": "Tous les bénévoles ({count})",
|
"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.pick": "Choisir ({count} sélectionné(s))",
|
||||||
"badges.generate.action": "Générer le PDF",
|
"badges.generate.action": "Générer le PDF",
|
||||||
"badges.generating": "Génération…",
|
"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.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||||
"badge.empty_image": "L'image est vide.",
|
"badge.empty_image": "L'image est vide.",
|
||||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
"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.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_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.",
|
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||||
@@ -396,6 +607,22 @@
|
|||||||
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
||||||
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
||||||
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
|
"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.empty": "Aucun bénévole inscrit pour l'instant.",
|
||||||
"stats.showTable": "Voir le tableau",
|
"stats.showTable": "Voir le tableau",
|
||||||
"stats.unit.hours": "h",
|
"stats.unit.hours": "h",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type {
|
|||||||
ArtistRead,
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
MatrixResponse,
|
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
Violation,
|
Violation,
|
||||||
@@ -108,22 +107,6 @@ export function candidateChanceScore(params: {
|
|||||||
return score;
|
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
|
/** 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ù
|
* 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
|
* le dernier de la journée se termine) — même règle que
|
||||||
@@ -169,68 +152,6 @@ export function dayBoundaryFromDayParts(
|
|||||||
return clockLabel(bestStart);
|
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
|
* 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,
|
* 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, //
|
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
|
||||||
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
|
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
|
||||||
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. //
|
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
|
||||||
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », //
|
// pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
|
||||||
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
const TIME_GRID_SLOT_MINUTES = 30;
|
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,589 +1,44 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import { useGenerateBadges, useVolunteers } from '@/api/hooks';
|
||||||
useBadgeImageUrl,
|
|
||||||
useBadgeTemplate,
|
|
||||||
useGenerateBadges,
|
|
||||||
useSetBadgeFields,
|
|
||||||
useSetBadgeSize,
|
|
||||||
useUploadBadgeBackImage,
|
|
||||||
useUploadBadgeFrontImage,
|
|
||||||
useVolunteers,
|
|
||||||
} from '@/api/hooks';
|
|
||||||
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
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 { 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
|
import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
|
||||||
// `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;
|
|
||||||
|
|
||||||
/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un
|
// L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
|
||||||
* badge à pleine taille (téléphone, tablette en portrait). Les coordonnées
|
// données, contrôle avant génération) vit dans `./badges/`.
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BadgesPage() {
|
export function BadgesPage() {
|
||||||
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
||||||
|
|
||||||
|
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')}
|
||||||
|
</Chip>
|
||||||
|
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
|
||||||
|
{t('badges.tab.generate')}
|
||||||
|
</Chip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<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]">
|
{tab === 'customize' ? (
|
||||||
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
|
<BadgeCustomizeTab tabBar={tabBar} />
|
||||||
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
|
|
||||||
{t('badges.tab.customize')}
|
|
||||||
</Chip>
|
|
||||||
<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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
|
||||||
}, [template.data]);
|
|
||||||
|
|
||||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
|
||||||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
|
||||||
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]: { ...prev[type], ...patch } } : prev));
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
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, badgeW - field.width_mm),
|
|
||||||
y_mm: clamp(d.startYMm + dyMm, 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;
|
|
||||||
onChange({
|
|
||||||
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
|
||||||
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<span
|
<>
|
||||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
<div className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
|
||||||
style={{
|
{tabBar}
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
|
<BadgeGenerateTab />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -646,6 +101,18 @@ function BadgeGenerateTab() {
|
|||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</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' ? (
|
{mode === 'pick' ? (
|
||||||
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
||||||
{sorted.map((v) => (
|
{sorted.map((v) => (
|
||||||
@@ -659,7 +126,10 @@ function BadgeGenerateTab() {
|
|||||||
onChange={() => toggle(v.id)}
|
onChange={() => toggle(v.id)}
|
||||||
className="h-[14px] w-[14px] accent-ink"
|
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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import { Chip } from '@/components/ui';
|
|||||||
import { ConfigArtists } from '@/pages/config/ConfigArtists';
|
import { ConfigArtists } from '@/pages/config/ConfigArtists';
|
||||||
import { ConfigGrid } from '@/pages/config/ConfigGrid';
|
import { ConfigGrid } from '@/pages/config/ConfigGrid';
|
||||||
import { ConfigMail } from '@/pages/config/ConfigMail';
|
import { ConfigMail } from '@/pages/config/ConfigMail';
|
||||||
|
import { ConfigMessage } from '@/pages/config/ConfigMessage';
|
||||||
import { ConfigRules } from '@/pages/config/ConfigRules';
|
import { ConfigRules } from '@/pages/config/ConfigRules';
|
||||||
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
|
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
|
||||||
import { t } from '@/i18n';
|
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 }> = [
|
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
|
||||||
{ id: 'grid', labelKey: 'config.tab.grid' },
|
{ id: 'grid', labelKey: 'config.tab.grid' },
|
||||||
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
|
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
|
||||||
{ id: 'artists', labelKey: 'config.tab.artists' },
|
{ id: 'artists', labelKey: 'config.tab.artists' },
|
||||||
|
{ id: 'message', labelKey: 'config.tab.message' },
|
||||||
{ id: 'mail', labelKey: 'config.tab.mail' },
|
{ id: 'mail', labelKey: 'config.tab.mail' },
|
||||||
{ id: 'rules', labelKey: 'config.tab.rules' },
|
{ id: 'rules', labelKey: 'config.tab.rules' },
|
||||||
];
|
];
|
||||||
@@ -38,6 +40,7 @@ export function ConfigPage() {
|
|||||||
{tab === 'grid' ? <ConfigGrid /> : null}
|
{tab === 'grid' ? <ConfigGrid /> : null}
|
||||||
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
|
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
|
||||||
{tab === 'artists' ? <ConfigArtists /> : null}
|
{tab === 'artists' ? <ConfigArtists /> : null}
|
||||||
|
{tab === 'message' ? <ConfigMessage /> : null}
|
||||||
{tab === 'mail' ? <ConfigMail /> : null}
|
{tab === 'mail' ? <ConfigMail /> : null}
|
||||||
{tab === 'rules' ? <ConfigRules /> : null}
|
{tab === 'rules' ? <ConfigRules /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,61 @@
|
|||||||
import { Fragment, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { ShiftRead } from '@/api/schema';
|
import type { PositionRead, ShiftRead } from '@/api/schema';
|
||||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
import {
|
||||||
|
useAssignments,
|
||||||
|
useDayParts,
|
||||||
|
usePositions,
|
||||||
|
useSetAttendance,
|
||||||
|
useShifts,
|
||||||
|
useUpdatePosition,
|
||||||
|
useVolunteer,
|
||||||
|
useVolunteerPlanning,
|
||||||
|
useVolunteers,
|
||||||
|
} from '@/api/hooks';
|
||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
|
import { MessageEditor } from '@/components/MessageEditor';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { NowLine } from '@/components/NowLine';
|
|
||||||
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
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 { useNowMinutes } from '@/lib/useNowMinutes';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
|
||||||
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
|
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.
|
// 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 à
|
// `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole
|
||||||
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être
|
// mobile.dc.html` : son poste, ses créneaux à choisir (celui en cours ou le
|
||||||
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa
|
// prochain par défaut), les compteurs présents / attendus / absents, puis
|
||||||
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec
|
// l'équipe du créneau à pointer. Pensée pour le téléphone ; sur un écran
|
||||||
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier
|
// plus large, la même colonne, centrée. Un compte peut être référent de
|
||||||
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait
|
// plusieurs postes (`Position.lead_account_id`) : on passe de l'un à l'autre
|
||||||
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à
|
// par des onglets. Aucun repli vers « le premier poste de l'édition » si le
|
||||||
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu
|
// compte n'est référent d'aucun poste — ce serait montrer un poste qui n'est
|
||||||
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un
|
// pas le sien ; s'il est par ailleurs rattaché à une fiche bénévole (cas
|
||||||
// écran référent vide (`VolunteerHomeView`).
|
// 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
|
// Un référent peut aussi être affecté comme bénévole, y compris la même
|
||||||
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
|
// 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() {
|
export function MobileLeadPage() {
|
||||||
const { account } = useAuth();
|
const { account } = useAuth();
|
||||||
@@ -35,26 +63,25 @@ export function MobileLeadPage() {
|
|||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
const assignments = useAssignments();
|
const assignments = useAssignments();
|
||||||
const nowMinutes = useNowMinutes();
|
const dayParts = useDayParts();
|
||||||
const [presence, setPresence] = useState<Presence>({});
|
const me = useVolunteer(account?.volunteer_id);
|
||||||
|
const myPlanning = useVolunteerPlanning(account?.volunteer_id);
|
||||||
const myPositions = (positions.data ?? []).filter(
|
const [view, setView] = useState<View | null>(null);
|
||||||
(p) => account && p.lead_account_id === account.id,
|
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 shiftsByPosition = useMemo(() => {
|
||||||
const map = new Map<string, ShiftRead[]>();
|
const map = new Map<string, ShiftRead[]>();
|
||||||
for (const s of shifts.data ?? []) {
|
for (const s of shifts.data ?? []) map.set(s.position_id, [...(map.get(s.position_id) ?? []), s]);
|
||||||
const list = map.get(s.position_id) ?? [];
|
|
||||||
list.push(s);
|
|
||||||
map.set(s.position_id, list);
|
|
||||||
}
|
|
||||||
for (const list of map.values()) {
|
for (const list of map.values()) {
|
||||||
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
|
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [shifts.data]);
|
}, [shifts.data]);
|
||||||
|
|
||||||
const volunteersById = useMemo(
|
const volunteersById = useMemo(
|
||||||
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
|
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
|
||||||
[volunteers.data],
|
[volunteers.data],
|
||||||
@@ -64,6 +91,10 @@ export function MobileLeadPage() {
|
|||||||
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
|
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
|
||||||
if (error) return <ErrorNote error={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 (myPositions.length === 0) {
|
||||||
if (account?.volunteer_id) {
|
if (account?.volunteer_id) {
|
||||||
return (
|
return (
|
||||||
@@ -82,149 +113,356 @@ export function MobileLeadPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Par défaut : le poste dont un créneau est en cours ou arrive le plus tôt.
|
||||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
const nextEnd = (p: PositionRead) =>
|
||||||
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
|
(shiftsByPosition.get(p.id) ?? []).find((s) => now < naiveMinutes(s.ends_at));
|
||||||
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
|
const position =
|
||||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
myPositions.find((p) => p.id === positionId) ??
|
||||||
{t('mobileLead.positionsCount', { count: myPositions.length })}
|
[...myPositions].sort((a, b) => {
|
||||||
</div>
|
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) ?? [];
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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 ? volunteerName(volunteer) : a.volunteer_id,
|
||||||
|
initials: volunteer ? volunteerInitials(volunteer) : '?',
|
||||||
|
hasPhoto: volunteer?.has_photo ?? false,
|
||||||
|
phone: volunteer?.phone ?? '',
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6">
|
return (
|
||||||
{myPositions.map((position) => {
|
<div className="flex flex-1 flex-col">
|
||||||
const positionShifts = shiftsByPosition.get(position.id) ?? [];
|
{tabs}
|
||||||
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||||
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex;
|
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
|
||||||
|
{myPositions.length > 1 ? (
|
||||||
return (
|
<div className="mb-3 flex flex-wrap gap-[6px]">
|
||||||
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
{myPositions.map((p) => (
|
||||||
<div className="bg-ink px-[14px] pb-[11px] pt-[11px] text-cream">
|
<button
|
||||||
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
|
key={p.id}
|
||||||
{position.name.toUpperCase()}
|
type="button"
|
||||||
</div>
|
onClick={() => {
|
||||||
{position.location ? (
|
setPositionId(p.id);
|
||||||
<div className="font-display text-[13px] font-semibold opacity-80">
|
setShiftId(null);
|
||||||
{position.location}
|
}}
|
||||||
</div>
|
className={cx(
|
||||||
) : null}
|
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
|
||||||
</div>
|
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
|
>
|
||||||
{positionShifts.length === 0 ? (
|
{p.name}
|
||||||
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
</button>
|
||||||
{t('mobileLead.noShift')}
|
))}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{positionShifts.map((shift, i) => {
|
|
||||||
const team = (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]}`
|
|
||||||
: '?',
|
|
||||||
hasPhoto: volunteer?.has_photo ?? false,
|
|
||||||
phone: volunteer?.phone ?? '',
|
|
||||||
present: presence[a.id] === 'present',
|
|
||||||
absent: presence[a.id] === 'absent',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const presentCount = team.filter((m) => m.present).length;
|
|
||||||
|
|
||||||
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>
|
|
||||||
<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 })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-[11px] p-[11px]">
|
|
||||||
{team.map((member) => (
|
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
|
||||||
<VolunteerAvatar
|
|
||||||
volunteerId={member.volunteerId}
|
|
||||||
hasPhoto={member.hasPhoto}
|
|
||||||
initials={member.initials}
|
|
||||||
className="h-[38px] w-[38px] text-[13px]"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block font-display text-[17px] 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')}
|
|
||||||
</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>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setPresence((p) => ({ ...p, [member.id]: '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',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('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"
|
|
||||||
>
|
|
||||||
{t('mobileLead.markAbsent')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{team.length === 0 ? (
|
|
||||||
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
|
||||||
{t('mobileLead.emptyTeam')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
|
|
||||||
<NowLine />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
) : 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>
|
||||||
|
{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={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-[10px] py-[9px]">
|
||||||
|
<VolunteerAvatar
|
||||||
|
volunteerId={member.volunteerId}
|
||||||
|
hasPhoto={member.hasPhoto}
|
||||||
|
initials={member.initials}
|
||||||
|
className="h-12 w-12 !border-2 text-[15px]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block break-words font-display text-[16px] font-extrabold leading-tight">
|
||||||
|
{member.name}
|
||||||
|
{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}
|
||||||
|
</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={() => mark(member, 'present')}
|
||||||
|
aria-pressed={member.state === 'present'}
|
||||||
|
className={cx(
|
||||||
|
'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(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{team.length === 0 ? (
|
||||||
|
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||||
|
{t('mobileLead.emptyTeam')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : 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>
|
||||||
</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 { useStats } from '@/api/hooks';
|
||||||
import type { EditionStats } from '@/api/schema';
|
import type { EditionStats, StatsAttendanceCounts } from '@/api/schema';
|
||||||
import {
|
import {
|
||||||
BarList,
|
BarList,
|
||||||
CumulativeLineChart,
|
CumulativeLineChart,
|
||||||
@@ -15,7 +15,8 @@ import { ErrorNote, Loading } from '@/components/state';
|
|||||||
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
|
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { foldAccents } from '@/lib/planning';
|
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) =>
|
const fr = (n: number, digits = 0) =>
|
||||||
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
||||||
@@ -62,6 +63,9 @@ export function StatsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{s.registrations.total === 0 ? (
|
||||||
<Panel>
|
<Panel>
|
||||||
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
|
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
|
||||||
@@ -76,6 +80,7 @@ export function StatsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<NeedsSection stats={s} />
|
<NeedsSection stats={s} />
|
||||||
|
{s.attendance.live_shifts === 0 ? <AttendanceSection stats={s} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -309,3 +314,97 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
|
|||||||
</Section>
|
</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 { ErrorNote, Loading } from '@/components/state';
|
||||||
import { NowLine } from '@/components/NowLine';
|
|
||||||
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
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 { useNowMinutes } from '@/lib/useNowMinutes';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
import { volunteerInitials } from '@/lib/volunteer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu,
|
* Contenu de l'écran bénévole (maquette `design/Planning bénévole
|
||||||
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de
|
* mobile.dc.html`) : le créneau en cours ou le prochain mis en avant (compte
|
||||||
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT
|
* à rebours, lieu, référent à appeler), puis le planning par journée de
|
||||||
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce
|
* festival (§ 3 — une nuit reste sur le jour où elle a commencé), chacune
|
||||||
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent
|
* avec sa frise horaire et ses créneaux (terminé / en cours / à venir,
|
||||||
* vide (voir `MobileLeadPage.tsx`).
|
* 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({
|
export function VolunteerHomeView({
|
||||||
volunteerId,
|
volunteerId,
|
||||||
@@ -26,13 +42,20 @@ export function VolunteerHomeView({
|
|||||||
}) {
|
}) {
|
||||||
const meVolunteer = useVolunteer(volunteerId);
|
const meVolunteer = useVolunteer(volunteerId);
|
||||||
const planning = useVolunteerPlanning(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.
|
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
|
||||||
const slots = useMemo(
|
const slots = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...(planning.data ?? [])].sort(
|
(planning.data ?? [])
|
||||||
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
|
.map((entry) => ({
|
||||||
),
|
entry,
|
||||||
|
start: naiveMinutes(entry.starts_at),
|
||||||
|
end: naiveMinutes(entry.ends_at),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.start - b.start),
|
||||||
[planning.data],
|
[planning.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -41,91 +64,368 @@ export function VolunteerHomeView({
|
|||||||
|
|
||||||
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
||||||
const initials = meVolunteer.data
|
const initials = meVolunteer.data
|
||||||
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}`
|
? volunteerInitials(meVolunteer.data)
|
||||||
: (firstName[0] ?? '?');
|
: (firstName[0] ?? '?');
|
||||||
|
|
||||||
const totalHours =
|
const days = new Map<string, Slot[]>();
|
||||||
Math.round(
|
for (const slot of slots) {
|
||||||
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
|
const day = festivalDay(slot.entry.starts_at, boundary);
|
||||||
10,
|
days.set(day, [...(days.get(day) ?? []), slot]);
|
||||||
) / 10;
|
}
|
||||||
|
const totalMinutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
|
||||||
// Le repère « maintenant » se place juste avant le premier créneau pas
|
const featured = slots.find((s) => now < s.end) ?? null;
|
||||||
// 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
<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-[14px] bg-ink p-4 text-cream sm:px-6 sm:py-6">
|
||||||
<div className="flex items-center gap-[12px]">
|
<VolunteerAvatar
|
||||||
<VolunteerAvatar
|
volunteerId={volunteerId}
|
||||||
volunteerId={volunteerId}
|
hasPhoto={meVolunteer.data?.has_photo ?? false}
|
||||||
hasPhoto={meVolunteer.data?.has_photo ?? false}
|
initials={initials}
|
||||||
initials={initials}
|
className="h-[76px] w-[76px] !border-yellow !bg-orange text-[24px]"
|
||||||
className="h-[52px] w-[52px] text-[18px]"
|
/>
|
||||||
/>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0">
|
<div className="font-display text-[30px] font-black leading-none tracking-tight">
|
||||||
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
{t('mobileVol.greeting', { name: firstName })}
|
||||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
|
||||||
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-[6px] font-display text-[14px] font-semibold leading-snug text-sand-dark">
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
{slots.length
|
||||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
? t(slots.length > 1 ? 'mobileVol.summary.other' : 'mobileVol.summary.one', {
|
||||||
{t('mobileVol.slots', { count: slots.length })}
|
count: slots.length,
|
||||||
</div>
|
duration: durationLabel(totalMinutes),
|
||||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
days: days.size,
|
||||||
{t('mobileVol.hours', { count: totalHours })}
|
})
|
||||||
|
: t('mobileVol.summary.none')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
|
{slots.length ? (
|
||||||
{slots.map((entry, i) => (
|
<div className="px-[14px] pt-4 sm:px-6">
|
||||||
<Fragment key={entry.assignment_id}>
|
{featured ? (
|
||||||
{i === nowInsertAt ? <NowLine /> : null}
|
<FeaturedSlot slot={featured} all={slots} now={now} boundary={boundary} />
|
||||||
<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">
|
<div className="border-[3px] border-ink bg-green p-4 font-display text-[19px] font-black leading-snug text-white shadow-brutal-lg">
|
||||||
<span className="font-display text-[15px] font-black">
|
{t('mobileVol.allDone', { name: firstName })}
|
||||||
{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>
|
|
||||||
</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>
|
</div>
|
||||||
</Fragment>
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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 ? (
|
{slots.length === 0 ? (
|
||||||
<div className="border-[3px] border-dashed border-ink p-4 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('mobileVol.noSlots')}
|
{t('mobileVol.noSlots')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
</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 { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
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 { Chip, HelpTip } from '@/components/ui';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning';
|
import {
|
||||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
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 { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
import { volunteerName } from '@/lib/volunteer';
|
||||||
|
|
||||||
type Filter = 'all' | 'unassigned';
|
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() {
|
export function VolunteerViewPage() {
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
const assignments = useAssignments();
|
const assignments = useAssignments();
|
||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const dayParts = useDayParts();
|
const dayParts = useDayParts();
|
||||||
|
const now = useNowMinutes();
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
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(
|
const shiftsById = useMemo(
|
||||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||||
[shifts.data],
|
[shifts.data],
|
||||||
@@ -27,56 +81,90 @@ export function VolunteerViewPage() {
|
|||||||
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
||||||
[positions.data],
|
[positions.data],
|
||||||
);
|
);
|
||||||
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
|
||||||
const lattice = useMemo(
|
|
||||||
() => buildLattice(shifts.data ?? [], dayBoundary),
|
|
||||||
[shifts.data, dayBoundary],
|
|
||||||
);
|
|
||||||
const hours = useMemo(
|
const hours = useMemo(
|
||||||
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
||||||
[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 rows = useMemo(() => {
|
||||||
const all = assignments.data ?? [];
|
const byVolunteer = new Map<string, Array<{ id: string; shiftId: string }>>();
|
||||||
const byVolunteer = new Map<string, AssignmentRead[]>();
|
for (const a of assignments.data ?? []) {
|
||||||
for (const a of all) {
|
byVolunteer.set(a.volunteer_id, [...(byVolunteer.get(a.volunteer_id) ?? []), { id: a.id, shiftId: a.shift_id }]);
|
||||||
const list = byVolunteer.get(a.volunteer_id) ?? [];
|
|
||||||
list.push(a);
|
|
||||||
byVolunteer.set(a.volunteer_id, list);
|
|
||||||
}
|
}
|
||||||
return (volunteers.data ?? [])
|
const layoutByDay = new Map(axis.layout.map((l) => [l.day.date, l]));
|
||||||
.filter((v) => {
|
const built = (volunteers.data ?? [])
|
||||||
const own = byVolunteer.get(v.id) ?? [];
|
.filter((v) => filter === 'all' || !(byVolunteer.get(v.id)?.length))
|
||||||
if (filter === 'unassigned') return own.length === 0;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
const own = byVolunteer.get(v.id) ?? [];
|
const items = (byVolunteer.get(v.id) ?? []).flatMap(({ id, shiftId }) => {
|
||||||
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>();
|
const shift = shiftsById.get(shiftId);
|
||||||
for (const a of own) {
|
return shift ? [{ id, shift, starts_at: shift.starts_at, ends_at: shift.ends_at }] : [];
|
||||||
const shift = shiftsById.get(a.shift_id);
|
});
|
||||||
if (shift)
|
const bars: Bar[] = [];
|
||||||
cellByKey.set(
|
for (const [day, placements] of placeInTimeGrid(axis.days, items, dayBoundary)) {
|
||||||
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
|
const l = layoutByDay.get(day);
|
||||||
{ ...a, shift },
|
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 {
|
return {
|
||||||
volunteer: v,
|
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,
|
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 (
|
// Position (px) de l'heure actuelle dans la piste, `null` hors grille.
|
||||||
volunteers.isLoading ||
|
const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
|
||||||
shifts.isLoading ||
|
|
||||||
assignments.isLoading ||
|
if (volunteers.isLoading || shifts.isLoading || assignments.isLoading || positions.isLoading)
|
||||||
positions.isLoading
|
|
||||||
)
|
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
|
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
|
||||||
if (error) return <ErrorNote error={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 (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<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="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]">
|
<div className="flex items-center gap-[6px]">
|
||||||
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
|
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
|
||||||
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
|
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||||
@@ -104,66 +199,123 @@ export function VolunteerViewPage() {
|
|||||||
{t('volunteers.filter.unassigned')}
|
{t('volunteers.filter.unassigned')}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</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>
|
<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-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>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||||
{rows.length === 0 ? (
|
{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="w-max border-b-[3px] border-ink">
|
||||||
<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">
|
{/* En-tête : jours, puis tranches de 30 min. */}
|
||||||
{t('volunteers.count', { count: rows.length })}
|
<div className="sticky top-0 z-[7] flex h-[27px]">
|
||||||
<HelpTip text={t('volunteers.footnote')} side="bottom" />
|
<div
|
||||||
</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"
|
||||||
{lattice.days.map((day) => (
|
style={{ width: NAME_W }}
|
||||||
<div
|
>
|
||||||
key={day.date}
|
{t('volunteers.count', { count: rows.length })}
|
||||||
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"
|
<HelpTip text={t('volunteers.footnote')} side="bottom" />
|
||||||
style={{ gridColumn: `span ${day.columns.length}` }}
|
</div>
|
||||||
>
|
{axis.layout.map(({ day, width }) => (
|
||||||
<span className="font-display text-[12px] font-black">
|
<div
|
||||||
{shortDayLabel(`${day.date}T12:00`)}
|
key={day.date}
|
||||||
</span>
|
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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
))}
|
<div className="sticky top-[27px] z-[6] flex">
|
||||||
<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">
|
<div
|
||||||
{t('volunteers.totalHours')}
|
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]"
|
||||||
</div>
|
style={{ width: NAME_W }}
|
||||||
|
>
|
||||||
<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]">
|
{t('volunteers.name')}
|
||||||
{t('volunteers.name')}
|
</div>
|
||||||
</div>
|
<div className="relative flex flex-none border-b-[3px] border-ink bg-sand" style={{ width: axis.width }}>
|
||||||
{lattice.columns.map((column) => (
|
{axis.layout.map(({ day, slotW }) =>
|
||||||
<div
|
day.slotLabels.map((label, i) => (
|
||||||
key={column.key}
|
<div
|
||||||
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(
|
||||||
{column.time}
|
'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 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
{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>
|
</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]">
|
|
||||||
{t('volunteers.cumulative')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<VolunteerRow
|
<VolunteerRow key={row.volunteer.id} row={row} layout={axis.layout} width={axis.width} nowX={nowX} />
|
||||||
key={row.volunteer.id}
|
))}
|
||||||
row={row}
|
</div>
|
||||||
columns={lattice.columns}
|
|
||||||
abbr={abbr}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
<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({
|
function VolunteerRow({
|
||||||
row,
|
row,
|
||||||
columns,
|
layout,
|
||||||
abbr,
|
width,
|
||||||
|
nowX,
|
||||||
}: {
|
}: {
|
||||||
row: Row;
|
row: { volunteer: VolunteerRead; bars: Bar[]; lanes: number; total: number; empty: boolean };
|
||||||
columns: { key: string }[];
|
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>;
|
||||||
abbr: (positionId: string) => string;
|
width: number;
|
||||||
|
nowX: number | null;
|
||||||
}) {
|
}) {
|
||||||
|
const height = row.lanes * LANE_H + ROW_PAD * 2;
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex" style={{ height }}>
|
||||||
<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="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">
|
<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>
|
</span>
|
||||||
{row.empty ? (
|
{row.empty ? (
|
||||||
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{columns.map((column) => {
|
|
||||||
const cell = row.cellByKey.get(column.key);
|
<div className="relative flex-none border-b border-ink bg-sand" style={{ width }}>
|
||||||
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />;
|
{/* Tranches de 30 min, filet épais entre deux jours. */}
|
||||||
return (
|
{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
|
<div
|
||||||
key={column.key}
|
key={bar.assignmentId}
|
||||||
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
|
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>
|
||||||
);
|
))}
|
||||||
})}
|
{nowX !== null ? (
|
||||||
<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">
|
<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}
|
{row.total}
|
||||||
</div>
|
</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 { 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 { ErrorNote } from '@/components/state';
|
||||||
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
import { volunteerName } from '@/lib/volunteer';
|
||||||
|
|
||||||
const MAIL_VARIABLES = [
|
const MAIL_VARIABLES = [
|
||||||
'{prenom}',
|
'{prenom}',
|
||||||
@@ -100,6 +103,8 @@ export function ConfigMail() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<TestEmailPanel subject={subject} body={body} />
|
||||||
|
|
||||||
<Panel shadow="lg">
|
<Panel shadow="lg">
|
||||||
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
||||||
<div className="p-[13px]">
|
<div className="p-[13px]">
|
||||||
@@ -116,3 +121,102 @@ export function ConfigMail() {
|
|||||||
</div>
|
</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,
|
useCreateAccount,
|
||||||
useCreateVolunteer,
|
useCreateVolunteer,
|
||||||
useDeleteAccount,
|
useDeleteAccount,
|
||||||
|
useResetLoginLink,
|
||||||
useDeleteVolunteer,
|
useDeleteVolunteer,
|
||||||
useExecuteImport,
|
useExecuteImport,
|
||||||
useExportMecenatReceipts,
|
useExportMecenatReceipts,
|
||||||
@@ -41,6 +42,7 @@ import { foldAccents } from '@/lib/planning';
|
|||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
|
||||||
|
|
||||||
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
||||||
{ key: 'first_name', required: true },
|
{ key: 'first_name', required: true },
|
||||||
@@ -111,6 +113,7 @@ function VolunteerAccessSection({
|
|||||||
const accounts = useAccounts();
|
const accounts = useAccounts();
|
||||||
const createAccount = useCreateAccount();
|
const createAccount = useCreateAccount();
|
||||||
const deleteAccount = useDeleteAccount();
|
const deleteAccount = useDeleteAccount();
|
||||||
|
const resetLoginLink = useResetLoginLink();
|
||||||
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
|
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,8 +159,29 @@ function VolunteerAccessSection({
|
|||||||
{t('config.vol.access.needEmail')}
|
{t('config.vol.access.needEmail')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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}
|
{createAccount.isError ? <ErrorNote error={createAccount.error} /> : null}
|
||||||
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
|
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
|
||||||
|
{resetLoginLink.isError ? <ErrorNote error={resetLoginLink.error} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,6 +193,43 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
|
|||||||
EXPIRED: 'config.vol.feeStatus.expired',
|
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) :
|
* 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
|
* 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 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">
|
<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]}
|
{volunteerInitials(volunteer)}
|
||||||
{volunteer.last_name[0]}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-display text-[12.5px] font-bold">
|
<span className="truncate font-display text-[12.5px] font-bold">
|
||||||
{volunteer.first_name} {volunteer.last_name}
|
{volunteerName(volunteer)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
|
<span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
|
||||||
@@ -913,11 +973,10 @@ export function ConfigVolunteers() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-[7px] flex items-center gap-[9px]">
|
<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">
|
<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]}
|
{volunteerInitials(selected)}
|
||||||
{selected.last_name[0]}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-[18px] font-black tracking-tight">
|
<span className="font-display text-[18px] font-black tracking-tight">
|
||||||
{selected.first_name} {selected.last_name}
|
{volunteerName(selected)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -960,6 +1019,11 @@ export function ConfigVolunteers() {
|
|||||||
onChange={(v) => patch({ badge_text: v || null })}
|
onChange={(v) => patch({ badge_text: v || null })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<BadgeCountInput
|
||||||
|
key={`${selected.id}-${selected.badge_count}`}
|
||||||
|
value={selected.badge_count}
|
||||||
|
onChange={(badge_count) => patch({ badge_count })}
|
||||||
|
/>
|
||||||
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
|
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
|
||||||
|
|
||||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||||
@@ -1040,10 +1104,7 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
if (!form.first_name.trim() || !form.last_name.trim()) {
|
// Aucun champ obligatoire : la fiche peut être complétée plus tard.
|
||||||
setLocalError(t('config.vol.requiredNames'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await createVolunteer.mutateAsync({
|
await createVolunteer.mutateAsync({
|
||||||
first_name: form.first_name.trim(),
|
first_name: form.first_name.trim(),
|
||||||
@@ -1085,11 +1146,11 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label className="block">
|
<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)} />
|
<TextInput value={form.first_name} onChange={(v) => set('first_name', v)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<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)} />
|
<TextInput value={form.last_name} onChange={(v) => set('last_name', v)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from '@/lib/planning';
|
} from '@/lib/planning';
|
||||||
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
positionId: string;
|
positionId: string;
|
||||||
@@ -250,7 +251,7 @@ export function AssignmentPanel({
|
|||||||
{initials(volunteer)}
|
{initials(volunteer)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
|
<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">
|
<span className="block font-mono text-[9px] opacity-60">
|
||||||
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
|
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
|
||||||
</span>
|
</span>
|
||||||
@@ -348,7 +349,7 @@ function CandidateRow({
|
|||||||
{initials(volunteer)}
|
{initials(volunteer)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
||||||
{volunteer.first_name} {volunteer.last_name}
|
{volunteerName(volunteer)}
|
||||||
{suggested ? (
|
{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">
|
<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')}
|
{t('grille.panel.suggested')}
|
||||||
@@ -436,7 +437,7 @@ function CandidateRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initials(v: VolunteerRead): string {
|
function initials(v: VolunteerRead): string {
|
||||||
return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`;
|
return volunteerInitials(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function round(n: number): number {
|
function round(n: number): number {
|
||||||
|
|||||||
@@ -12,12 +12,17 @@ export default {
|
|||||||
sand: '#EFE8D6', // barres secondaires
|
sand: '#EFE8D6', // barres secondaires
|
||||||
'sand-line': '#E0D8C4', // séparateurs sur paper
|
'sand-line': '#E0D8C4', // séparateurs sur paper
|
||||||
'sand-dark': '#E7E0CE',
|
'sand-dark': '#E7E0CE',
|
||||||
|
'sand-mid': '#CFC7B4', // frise horaire : graduations, créneau terminé
|
||||||
yellow: '#FFD23F',
|
yellow: '#FFD23F',
|
||||||
'yellow-soft': '#FFE68A', // état « incomplet »
|
'yellow-soft': '#FFE68A', // état « incomplet »
|
||||||
'yellow-hover': '#FFDF77',
|
'yellow-hover': '#FFDF77',
|
||||||
orange: '#FF5C39',
|
orange: '#FF5C39',
|
||||||
'orange-dark': '#E0421F',
|
'orange-dark': '#E0421F',
|
||||||
green: '#0E9F6E',
|
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: '#A8C4FF',
|
||||||
'blue-strong': '#2A6BFF', // « non publié »
|
'blue-strong': '#2A6BFF', // « non publié »
|
||||||
mauve: '#CBA6E3', // état « sureffectif »
|
mauve: '#CBA6E3', // état « sureffectif »
|
||||||
|
|||||||
Reference in New Issue
Block a user