Email de test et ajout manuel sans champ obligatoire
ci-cd / build-backend (push) Successful in 1m25s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 13s

Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout
de suite un email de test, résultat réel affiché (envoyé, désactivé,
connexion ou message refusé, réponse du serveur SMTP) —
POST .../notifications/test.

Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ;
un bénévole sans nom s'affiche « Sans nom » / « ? » partout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:34:24 +01:00
co-authored by Claude Opus 5.5
parent 5c2cc0a6c3
commit 461742eb78
22 changed files with 346 additions and 28 deletions
+16 -1
View File
@@ -75,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).
@@ -601,6 +605,15 @@ 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) un email de
test, même mise en forme et même logo que les vrais, et 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
@@ -1066,6 +1079,8 @@ Aucune dépendance locale requise hors Docker.
| 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 | | 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 | | 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 | | 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 |
**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, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
+35 -1
View File
@@ -4,18 +4,22 @@ import logging
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
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, issue_magic_link_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, send_many
from app.email.sender import build_message, deliver_test
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, Edition, Position, Shift, Volunteer
@@ -41,6 +45,36 @@ def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str
return build_magic_link_url(issue_magic_link_token(session, account)) return build_magic_link_url(issue_magic_link_token(session, account))
@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 un email de test à l'adresse saisie (même mise
en forme et même logo que les vrais emails) et renvoie le résultat réel
du serveur SMTP — pour vérifier la configuration depuis l'écran
« Messages » sans accès au serveur (`python -m app.email.check`)."""
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"})
message = build_message(
email,
t("email.test.subject"),
t("email.test.body", edition=edition.name or str(edition.year)),
logo=edition_logo(session, edition.id),
)
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,
)
@router.post("/planning", response_model=PlanningNotificationResult) @router.post("/planning", response_model=PlanningNotificationResult)
def send_planning_notification( def send_planning_notification(
payload: PlanningNotificationRequest, payload: PlanningNotificationRequest,
+19 -2
View File
@@ -60,8 +60,10 @@ class EditionUpdate(ApiModel):
# 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
@@ -514,6 +516,21 @@ class PlanningNotificationResult(ApiModel):
recipients: list[uuid.UUID] = Field(default_factory=list) recipients: list[uuid.UUID] = Field(default_factory=list)
class TestEmailRequest(ApiModel):
email: str
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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Formulaire public d'inscription # # Formulaire public d'inscription #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+33
View File
@@ -145,6 +145,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,
+3
View File
@@ -53,6 +53,9 @@
"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": "Email de test — gestion des bénévoles",
"email.test.body": "Bonjour,\n\nCet email de test confirme que l'envoi d'emails fonctionne pour {edition}.\n\nRien à faire de plus.",
"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.shift_line": "- {position} : {start} → {end}",
"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.",
+83
View File
@@ -0,0 +1,83 @@
"""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
+9
View File
@@ -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
+4
View File
@@ -586,6 +586,10 @@ export function useGeneratePositionSheets() {
}); });
} }
export function useSendTestEmail() {
return useMutation({ mutationFn: (email: string) => api.notifications.sendTest(edition, email) });
}
export function useSendPlanningNotification() { export function useSendPlanningNotification() {
return useMutation({ return useMutation({
mutationFn: (body: PlanningNotificationRequest) => mutationFn: (body: PlanningNotificationRequest) =>
+5
View File
@@ -378,6 +378,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
sendTest: (editionId, email: string) =>
request(`${base(editionId)}/notifications/test`, {
method: 'POST',
body: JSON.stringify({ email }),
}),
}, },
auth: { auth: {
login: (editionId, body: LoginRequest) => login: (editionId, body: LoginRequest) =>
+7
View File
@@ -2052,6 +2052,13 @@ 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, email) => {
const address = email.trim();
if (!/^[^\s@]+@[^\s@]+$/.test(address)) throw new ApiError(422, 'notifications.invalid_email');
return delay({ status: 'disabled' as const, detail: null, smtp_host: 'localhost:25', smtp_from: 'noreply@festival.local' });
},
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));
+9
View File
@@ -604,6 +604,15 @@ 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;
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Formulaire public d'inscription // // Formulaire public d'inscription //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+3
View File
@@ -58,6 +58,7 @@ import type {
VolunteerUpdate, VolunteerUpdate,
EditionStats, EditionStats,
AttendanceUpdate, AttendanceUpdate,
TestEmailResult,
} from './schema'; } from './schema';
/** /**
@@ -224,6 +225,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, email: string): Promise<TestEmailResult>;
}; };
auth: { auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>; login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
+14 -1
View File
@@ -113,6 +113,7 @@
"volunteers.search.placeholder": "Rechercher un bénévole ou un 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.label": "Rechercher un bénévole ou un poste",
"volunteers.search.empty": "Aucun bénévole ni poste ne correspond à « {query} ».", "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.label": "TRIER PAR",
@@ -197,6 +198,19 @@
"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 un email de test à l'adresse de votre choix, pour vérifier que les emails partent bien (liens de connexion, confirmations d'inscription, plannings).",
"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.tab.message": "MESSAGE AUX BÉNÉVOLES",
"config.message.orgTitle": "Message de l'organisation", "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.orgHelp": "Le même pour tous les bénévoles, affiché en bas de leur écran planning. Laissez vide pour ne rien afficher.",
@@ -321,7 +335,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",
+17
View File
@@ -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() || '?';
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { ApiError } from '@/api';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { Btn, Chip, Panel, PanelHeader } from '@/components/ui'; import { Btn, Chip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { volunteerName } from '@/lib/volunteer';
import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab'; import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
@@ -125,7 +126,7 @@ 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 ? ( {v.badge_count > 1 ? (
<span className="ml-auto opacity-60">× {v.badge_count}</span> <span className="ml-auto opacity-60">× {v.badge_count}</span>
) : null} ) : null}
+4 -3
View File
@@ -23,6 +23,7 @@ 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 { 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`), d'après la maquette `design/Planning bénévole // `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole
@@ -139,8 +140,8 @@ export function MobileLeadPage() {
return { return {
id: a.id, id: a.id,
volunteerId: a.volunteer_id, volunteerId: a.volunteer_id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id, name: volunteer ? volunteerName(volunteer) : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?', initials: volunteer ? volunteerInitials(volunteer) : '?',
hasPhoto: volunteer?.has_photo ?? false, hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '', phone: volunteer?.phone ?? '',
state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null, state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null,
@@ -228,7 +229,7 @@ export function MobileLeadPage() {
<VolunteerAvatar <VolunteerAvatar
volunteerId={me.data.id} volunteerId={me.data.id}
hasPhoto={me.data.has_photo} hasPhoto={me.data.has_photo}
initials={`${me.data.first_name[0]}${me.data.last_name[0]}`} initials={volunteerInitials(me.data)}
className="h-[60px] w-[60px] !border-blue text-[19px]" className="h-[60px] w-[60px] !border-blue text-[19px]"
/> />
) : null} ) : null}
+2 -1
View File
@@ -19,6 +19,7 @@ import {
import { longDayLabel, naiveMinutes } from '@/lib/time'; 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 (maquette `design/Planning bénévole * Contenu de l'écran bénévole (maquette `design/Planning bénévole
@@ -63,7 +64,7 @@ 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 days = new Map<string, Slot[]>(); const days = new Map<string, Slot[]>();
+2 -1
View File
@@ -19,6 +19,7 @@ import { minutesOfDay, naiveMinutes, shortDayLabel, timePart } 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 { 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 /** Ordre des lignes : celui de la liste des bénévoles (par nom), ou par
@@ -343,7 +344,7 @@ function VolunteerRow({
style={{ width: NAME_W }} 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">
@@ -36,6 +36,7 @@ import { Btn, FieldLabel } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { dayBoundaryFromDayParts } from '@/lib/planning'; import { dayBoundaryFromDayParts } from '@/lib/planning';
import { volunteerName } from '@/lib/volunteer';
import { BadgeCanvas } from './BadgeCanvas'; import { BadgeCanvas } from './BadgeCanvas';
import { BadgeInspector } from './BadgeInspector'; import { BadgeInspector } from './BadgeInspector';
@@ -878,7 +879,7 @@ function PreviewToolbar({
</button> </button>
<div className="min-w-0 px-[10px] py-[6px] sm:min-w-[220px]"> <div className="min-w-0 px-[10px] py-[6px] sm:min-w-[220px]">
<div className="truncate font-display text-[12.5px] font-extrabold leading-tight"> <div className="truncate font-display text-[12.5px] font-extrabold leading-tight">
{volunteer.first_name} {volunteer.last_name} {volunteerName(volunteer)}
</div> </div>
<div className="font-mono text-[9px] uppercase leading-snug opacity-60"> <div className="font-mono text-[9px] uppercase leading-snug opacity-60">
{total {total
+65 -1
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSendPlanningNotification } from '@/api/hooks'; import { useSendPlanningNotification, useSendTestEmail } 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';
const MAIL_VARIABLES = [ const MAIL_VARIABLES = [
'{prenom}', '{prenom}',
@@ -100,6 +102,8 @@ export function ConfigMail() {
</div> </div>
</Panel> </Panel>
<TestEmailPanel />
<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 +120,63 @@ 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 un email de test à l'adresse saisie et affiche le résultat réel
* du serveur SMTP (envoyé, envoi désactivé, connexion ou message refusé). */
function TestEmailPanel() {
const [email, setEmail] = useState('');
const test = useSendTestEmail();
const result = test.data;
return (
<Panel shadow="lg">
<PanelHeader>{t('config.mail.test.title')}</PanelHeader>
<form
onSubmit={(e) => {
e.preventDefault();
if (email.trim()) test.mutate(email.trim());
}}
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>
<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>
{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>
);
}
+8 -12
View File
@@ -41,6 +41,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 },
@@ -888,11 +889,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">
@@ -950,11 +950,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>
@@ -1082,10 +1081,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(),
@@ -1127,11 +1123,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 {