From 461742eb785e0efc2f75dccd1ba501a80cd03401 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 28 Oct 2026 19:34:24 +0100 Subject: [PATCH] Email de test et ajout manuel sans champ obligatoire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 --- CLAUDE.md | 17 +++- backend/app/api/notifications.py | 36 +++++++- backend/app/api/schemas.py | 21 ++++- backend/app/email/sender.py | 33 ++++++++ backend/app/i18n/locales/fr.json | 3 + backend/tests/test_api_test_email.py | 83 +++++++++++++++++++ backend/tests/test_api_volunteers.py | 9 ++ frontend/src/api/hooks.ts | 4 + frontend/src/api/http-client.ts | 5 ++ frontend/src/api/mock/mock-client.ts | 7 ++ frontend/src/api/schema.ts | 9 ++ frontend/src/api/types.ts | 3 + frontend/src/i18n/locales/fr.json | 15 +++- frontend/src/lib/volunteer.ts | 17 ++++ frontend/src/pages/BadgesPage.tsx | 3 +- frontend/src/pages/MobileLeadPage.tsx | 7 +- frontend/src/pages/VolunteerHomeView.tsx | 3 +- frontend/src/pages/VolunteerViewPage.tsx | 3 +- .../src/pages/badges/BadgeCustomizeTab.tsx | 3 +- frontend/src/pages/config/ConfigMail.tsx | 66 ++++++++++++++- .../src/pages/config/ConfigVolunteers.tsx | 20 ++--- frontend/src/pages/grille/AssignmentPanel.tsx | 7 +- 22 files changed, 346 insertions(+), 28 deletions(-) create mode 100644 backend/tests/test_api_test_email.py create mode 100644 frontend/src/lib/volunteer.ts diff --git a/CLAUDE.md b/CLAUDE.md index 50f1d12..1efb329 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,11 @@ les tables sauf les tables d'association. - **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles peuvent partager une même adresse, ex. un couple ou une famille ; c'est le couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul), - `first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance, + `first_name`, `last_name`, `phone`, `comment`. **Aucun champ obligatoire + à l'ajout manuel** par la coordination (`POST .../volunteers`, prénom et + nom vides par défaut — fiche complétée plus tard) ; le formulaire public + et l'import exigent toujours prénom et nom. Un bénévole sans nom + s'affiche « Sans nom », initiales « ? » (`lib/volunteer.ts`). Pas de date de naissance, taille de t-shirt, régime, ni compétences. Porte trois listes de vœux, exposées par la fiche (`artist_ids`, `wished_position_ids`, `wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider). @@ -601,6 +605,15 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, alias validé. - Diagnostic : `docker compose run --rm backend uv run python -m app.email.check ` — 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 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 @@ -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 | | 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 | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 6c1814c..1aa5f0a 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -4,18 +4,22 @@ import logging import uuid from collections import defaultdict -from fastapi import APIRouter, BackgroundTasks, Depends +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlmodel import Session, select from app.api.deps import get_edition from app.api.schemas import ( PlanningNotificationRequest, PlanningNotificationResult, + TestEmailRequest, + TestEmailResult, ) from app.auth.deps import require_coordinator from app.auth.magic_link import build_magic_link_url, issue_magic_link_token +from app.config import get_settings from app.db import get_session 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.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)) +@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) def send_planning_notification( payload: PlanningNotificationRequest, diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index b4706b8..1796966 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -60,8 +60,10 @@ class EditionUpdate(ApiModel): # Volunteer # # --------------------------------------------------------------------------- # class VolunteerCreate(ApiModel): - first_name: str - last_name: str + # Facultatifs à l'ajout manuel par la coordination (fiche complétée plus + # tard) ; le formulaire public et l'import les exigent toujours. + first_name: str = "" + last_name: str = "" email: str | None = None phone: str | None = None comment: str | None = None @@ -514,6 +516,21 @@ class PlanningNotificationResult(ApiModel): 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 # # --------------------------------------------------------------------------- # diff --git a/backend/app/email/sender.py b/backend/app/email/sender.py index 64096d1..23e4b5f 100644 --- a/backend/app/email/sender.py +++ b/backend/app/email/sender.py @@ -145,6 +145,39 @@ def deliver_many(messages: list[EmailMessage]) -> None: logger.info("Emails remis : %d/%d", sent, len(messages)) +def deliver_test(message: EmailMessage) -> tuple[str, str | None]: + """Envoi d'un email de test, **synchrone** et sans « best effort » : + renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la + coordination voie tout de suite ce qui ne va pas (`POST + .../notifications/test`). Statuts : `sent`, `disabled` + (`EMAIL_ENABLED=false`), `connection_failed` (connexion ou + authentification), `rejected` (le serveur a refusé le message). Détail : + message du serveur ou type d'erreur — jamais l'adresse du destinataire + (`_smtp_error_summary`).""" + settings = get_settings() + if not settings.email_enabled: + logger.info("Email de test non envoyé (EMAIL_ENABLED=false)") + return "disabled", None + try: + smtp = open_smtp(settings) + except smtplib.SMTPException as exc: + logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc)) + return "connection_failed", _smtp_error_summary(exc) + except OSError as exc: + logger.error("Email de test : SMTP injoignable : %s", exc) + return "connection_failed", f"{exc.__class__.__name__} : {exc}" + with smtp: + try: + smtp.send_message(message) + except smtplib.SMTPException as exc: + logger.error( + "Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc) + ) + return "rejected", _smtp_error_summary(exc) + logger.info("Email de test remis") + return "sent", None + + def send( to: str, subject: str, diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 3604488..fceff4e 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.planning.default_subject": "Ton planning bénévole", "email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !", + "email.test.subject": "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.shift_line": "- {position} : {start} → {end}", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", diff --git a/backend/tests/test_api_test_email.py b/backend/tests/test_api_test_email.py new file mode 100644 index 0000000..5746720 --- /dev/null +++ b/backend/tests/test_api_test_email.py @@ -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 diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index b7da5c6..67a6e78 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -656,3 +656,12 @@ def test_only_the_coordination_can_change_a_photo( assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403 assert lead.delete(url).status_code == 403 + + +def test_manual_add_requires_no_field(client, edition): + resp = client.post(_url(edition), json={}) + assert resp.status_code == 201 + body = resp.json() + assert body["first_name"] == "" + assert body["last_name"] == "" + assert client.get(_url(edition)).status_code == 200 diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 787e1e0..16eb525 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -586,6 +586,10 @@ export function useGeneratePositionSheets() { }); } +export function useSendTestEmail() { + return useMutation({ mutationFn: (email: string) => api.notifications.sendTest(edition, email) }); +} + export function useSendPlanningNotification() { return useMutation({ mutationFn: (body: PlanningNotificationRequest) => diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 15f777f..0100ef9 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -378,6 +378,11 @@ export function createHttpApi(baseUrl: string): PlanningApi { method: 'POST', body: JSON.stringify(body), }), + sendTest: (editionId, email: string) => + request(`${base(editionId)}/notifications/test`, { + method: 'POST', + body: JSON.stringify({ email }), + }), }, auth: { login: (editionId, body: LoginRequest) => diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 2c2cbb3..caba23f 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -2052,6 +2052,13 @@ export function createMockApi(): PlanningApi { }, notifications: { + // Pas de serveur SMTP en démo : rien n'est envoyé, comme + // `EMAIL_ENABLED=false` côté serveur. + sendTest: (_edition, 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) => { const ids = body.volunteer_ids ?? null; const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id)); diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 1dc43db..1b13ae8 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -604,6 +604,15 @@ export interface PlanningNotificationResult { recipients: string[]; } +/** Résultat de l'email de test (`POST .../notifications/test`). */ +export interface TestEmailResult { + status: 'sent' | 'disabled' | 'connection_failed' | 'rejected'; + /** Message du serveur SMTP en cas d'échec. */ + detail: string | null; + smtp_host: string; + smtp_from: string; +} + // --------------------------------------------------------------------------- // // Formulaire public d'inscription // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7115f17..c8716a2 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -58,6 +58,7 @@ import type { VolunteerUpdate, EditionStats, AttendanceUpdate, + TestEmailResult, } from './schema'; /** @@ -224,6 +225,8 @@ export interface PlanningApi { editionId: string, body: PlanningNotificationRequest, ): Promise; + /** Email de test, envoyé tout de suite (COORDINATION). */ + sendTest(editionId: string, email: string): Promise; }; auth: { login(editionId: string, body: LoginRequest): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index f7cf5b3..2b192c5 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -113,6 +113,7 @@ "volunteers.search.placeholder": "Rechercher un bénévole ou un poste…", "volunteers.search.label": "Rechercher un bénévole ou un poste", "volunteers.search.empty": "Aucun bénévole ni poste ne correspond à « {query} ».", + "volunteer.unnamed": "Sans nom", "volunteers.count": "{count} BÉNÉVOLES", "volunteers.totalHours": "TOTAL H", "volunteers.sort.label": "TRIER PAR", @@ -197,6 +198,19 @@ "config.reserved": "RÉSERVÉ À LA COORDINATION", "config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.volunteers": "BÉNÉVOLES & IMPORT", + "config.mail.test.title": "Tester l'envoi d'emails", + "config.mail.test.help": "Envoie tout de suite 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.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.", @@ -321,7 +335,6 @@ "config.vol.addTitle": "NOUVEAU BÉNÉVOLE", "config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.", "config.vol.create": "CRÉER LE BÉNÉVOLE", - "config.vol.requiredNames": "Le prénom et le nom sont obligatoires.", "config.vol.wiz.title": "IMPORTER UN FICHIER", "config.vol.wiz.step1": "1 · FICHIER", "config.vol.wiz.step2": "2 · COLONNES", diff --git a/frontend/src/lib/volunteer.ts b/frontend/src/lib/volunteer.ts new file mode 100644 index 0000000..41aecbf --- /dev/null +++ b/frontend/src/lib/volunteer.ts @@ -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() || '?'; +} diff --git a/frontend/src/pages/BadgesPage.tsx b/frontend/src/pages/BadgesPage.tsx index cf5124a..3f9fa49 100644 --- a/frontend/src/pages/BadgesPage.tsx +++ b/frontend/src/pages/BadgesPage.tsx @@ -5,6 +5,7 @@ import { ApiError } from '@/api'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { Btn, Chip, Panel, PanelHeader } from '@/components/ui'; import { t } from '@/i18n'; +import { volunteerName } from '@/lib/volunteer'; import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab'; @@ -125,7 +126,7 @@ function BadgeGenerateTab() { onChange={() => toggle(v.id)} className="h-[14px] w-[14px] accent-ink" /> - {v.first_name} {v.last_name} + {volunteerName(v)} {v.badge_count > 1 ? ( × {v.badge_count} ) : null} diff --git a/frontend/src/pages/MobileLeadPage.tsx b/frontend/src/pages/MobileLeadPage.tsx index e7ea827..2dff040 100644 --- a/frontend/src/pages/MobileLeadPage.tsx +++ b/frontend/src/pages/MobileLeadPage.tsx @@ -23,6 +23,7 @@ import { longDayLabel, naiveMinutes } from '@/lib/time'; import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; import { VolunteerHomeView } from '@/pages/VolunteerHomeView'; +import { volunteerInitials, volunteerName } from '@/lib/volunteer'; // Page réellement consultée par le référent (accueil de son rôle LEAD, cf. // `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole @@ -139,8 +140,8 @@ export function MobileLeadPage() { return { id: a.id, volunteerId: a.volunteer_id, - name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id, - initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?', + name: volunteer ? volunteerName(volunteer) : a.volunteer_id, + initials: volunteer ? volunteerInitials(volunteer) : '?', hasPhoto: volunteer?.has_photo ?? false, phone: volunteer?.phone ?? '', state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null, @@ -228,7 +229,7 @@ export function MobileLeadPage() { ) : null} diff --git a/frontend/src/pages/VolunteerHomeView.tsx b/frontend/src/pages/VolunteerHomeView.tsx index 867804c..7cf5c51 100644 --- a/frontend/src/pages/VolunteerHomeView.tsx +++ b/frontend/src/pages/VolunteerHomeView.tsx @@ -19,6 +19,7 @@ import { import { longDayLabel, naiveMinutes } from '@/lib/time'; import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; +import { volunteerInitials } from '@/lib/volunteer'; /** * Contenu de l'écran bénévole (maquette `design/Planning bénévole @@ -63,7 +64,7 @@ export function VolunteerHomeView({ const firstName = meVolunteer.data?.first_name ?? fallbackName ?? ''; const initials = meVolunteer.data - ? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}` + ? volunteerInitials(meVolunteer.data) : (firstName[0] ?? '?'); const days = new Map(); diff --git a/frontend/src/pages/VolunteerViewPage.tsx b/frontend/src/pages/VolunteerViewPage.tsx index fc2fe2a..4e51fd4 100644 --- a/frontend/src/pages/VolunteerViewPage.tsx +++ b/frontend/src/pages/VolunteerViewPage.tsx @@ -19,6 +19,7 @@ import { minutesOfDay, naiveMinutes, shortDayLabel, timePart } from '@/lib/time' import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; +import { volunteerName } from '@/lib/volunteer'; type Filter = 'all' | 'unassigned'; /** Ordre des lignes : celui de la liste des bénévoles (par nom), ou par @@ -343,7 +344,7 @@ function VolunteerRow({ style={{ width: NAME_W }} > - {row.volunteer.first_name} {row.volunteer.last_name} + {volunteerName(row.volunteer)} {row.empty ? ( diff --git a/frontend/src/pages/badges/BadgeCustomizeTab.tsx b/frontend/src/pages/badges/BadgeCustomizeTab.tsx index ae49435..9108de6 100644 --- a/frontend/src/pages/badges/BadgeCustomizeTab.tsx +++ b/frontend/src/pages/badges/BadgeCustomizeTab.tsx @@ -36,6 +36,7 @@ import { Btn, FieldLabel } from '@/components/ui'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; import { dayBoundaryFromDayParts } from '@/lib/planning'; +import { volunteerName } from '@/lib/volunteer'; import { BadgeCanvas } from './BadgeCanvas'; import { BadgeInspector } from './BadgeInspector'; @@ -878,7 +879,7 @@ function PreviewToolbar({
- {volunteer.first_name} {volunteer.last_name} + {volunteerName(volunteer)}
{total diff --git a/frontend/src/pages/config/ConfigMail.tsx b/frontend/src/pages/config/ConfigMail.tsx index 5b164f5..e6787d3 100644 --- a/frontend/src/pages/config/ConfigMail.tsx +++ b/frontend/src/pages/config/ConfigMail.tsx @@ -1,9 +1,11 @@ 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 { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; const MAIL_VARIABLES = [ '{prenom}', @@ -100,6 +102,8 @@ export function ConfigMail() {
+ + {t('config.mail.previewTitle')}
@@ -116,3 +120,63 @@ export function ConfigMail() {
); } + +const RESULT_TONE: Record = { + 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 ( + + {t('config.mail.test.title')} +
{ + e.preventDefault(); + if (email.trim()) test.mutate(email.trim()); + }} + className="flex flex-col gap-3 p-[13px]" + > +

{t('config.mail.test.help')}

+ + + {test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')} + + {result && !test.isPending ? ( +
+
+ {t(`config.mail.test.result.${result.status}`)} +
+ {result.detail ? ( +
+ {t('config.mail.test.serverSaid', { detail: result.detail })} +
+ ) : null} + {result.status !== 'sent' && result.status !== 'disabled' ? ( +
+ {t('config.mail.test.config', { host: result.smtp_host, from: result.smtp_from })} +
+ ) : null} +
+ ) : null} + {test.isError ? : null} + +
+ ); +} diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index 079041e..8fafa2c 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -41,6 +41,7 @@ import { foldAccents } from '@/lib/planning'; import { t } from '@/i18n'; import { datePart, shortDayLabel, timePart } from '@/lib/time'; import { cx } from '@/lib/cx'; +import { volunteerInitials, volunteerName } from '@/lib/volunteer'; const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [ { key: 'first_name', required: true }, @@ -888,11 +889,10 @@ export function ConfigVolunteers() { > - {volunteer.first_name[0]} - {volunteer.last_name[0]} + {volunteerInitials(volunteer)} - {volunteer.first_name} {volunteer.last_name} + {volunteerName(volunteer)} @@ -950,11 +950,10 @@ export function ConfigVolunteers() {
- {selected.first_name[0]} - {selected.last_name[0]} + {volunteerInitials(selected)} - {selected.first_name} {selected.last_name} + {volunteerName(selected)}
@@ -1082,10 +1081,7 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) { const submit = async () => { setLocalError(null); - if (!form.first_name.trim() || !form.last_name.trim()) { - setLocalError(t('config.vol.requiredNames')); - return; - } + // Aucun champ obligatoire : la fiche peut être complétée plus tard. try { await createVolunteer.mutateAsync({ first_name: form.first_name.trim(), @@ -1127,11 +1123,11 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) { >
diff --git a/frontend/src/pages/grille/AssignmentPanel.tsx b/frontend/src/pages/grille/AssignmentPanel.tsx index 413dd0c..849df46 100644 --- a/frontend/src/pages/grille/AssignmentPanel.tsx +++ b/frontend/src/pages/grille/AssignmentPanel.tsx @@ -27,6 +27,7 @@ import { } from '@/lib/planning'; import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time'; import { t } from '@/i18n'; +import { volunteerInitials, volunteerName } from '@/lib/volunteer'; interface Props { positionId: string; @@ -250,7 +251,7 @@ export function AssignmentPanel({ {initials(volunteer)}
- {volunteer.first_name} {volunteer.last_name} + {volunteerName(volunteer)} {t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })} @@ -348,7 +349,7 @@ function CandidateRow({ {initials(volunteer)} - {volunteer.first_name} {volunteer.last_name} + {volunteerName(volunteer)} {suggested ? ( {t('grille.panel.suggested')} @@ -436,7 +437,7 @@ function CandidateRow({ } function initials(v: VolunteerRead): string { - return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`; + return volunteerInitials(v); } function round(n: number): number {