From 6b08dcc953f97455782847f1c0eeaf88bc615c08 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Thu, 10 Sep 2026 08:13:28 +0200 Subject: [PATCH] =?UTF-8?q?Frontend=20:=20=C3=A9cran=20Messages=20branch?= =?UTF-8?q?=C3=A9=20sur=20l'envoi=20d'emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api : PlanningNotificationRequest/Result dans schema.ts, méthode notifications.sendPlanning (http + mock), hook useSendPlanningNotification - mock : compte les destinataires avec/sans email, émet des liens magiques quand {lien_connexion} est présent - ConfigMail : plus une maquette. Éditeur objet/corps, variables cliquables ({prenom} {nom} {nb_creneaux} {nb_heures} {creneaux} {lien_connexion}), boutons « Envoyer le récapitulatif » et « Envoyer avec lien de connexion », retour d'envoi (queued / sans adresse) et ErrorNote - i18n config.mail.* mis à jour ; onglet « MESSAGES » (plus « (MAQUETTE) ») typecheck + lint + build + smoke : OK Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- frontend/README.md | 2 +- frontend/src/api/hooks.ts | 8 +++ frontend/src/api/http-client.ts | 8 +++ frontend/src/api/mock/mock-client.ts | 31 +++++++++ frontend/src/api/schema.ts | 21 ++++++ frontend/src/api/types.ts | 9 +++ frontend/src/i18n/locales/fr.json | 18 +++-- frontend/src/pages/config/ConfigMail.tsx | 89 +++++++++++++++++------- 8 files changed, 151 insertions(+), 35 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 227ba10..ee8026b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -56,7 +56,7 @@ d'écran « Confirmations » (ancien onglet 03). | 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) | | 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | lecture¹ | | 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) | -| 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. | +| 06 | `/configuration` | `positions` · `shifts` · `volunteers` | CRUD postes / créneaux (min/idéal) / bénévoles (dont suppression RGPD) ; onglet Messages → `POST /notifications/planning` (récapitulatif + lien de connexion) | ¹ En mode `http`, l'API n'expose pas encore aux bénévoles le détail des créneaux : l'écran dégrade proprement (créneaux sans détail, message d'info). diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 6ab1ae6..a28d4a1 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -10,6 +10,7 @@ import type { AssignmentCreate, AssignmentUpdate, AssignmentValidateResult, + PlanningNotificationRequest, PositionCreate, PositionUpdate, ShiftCreate, @@ -228,3 +229,10 @@ export function useDeleteAssignment() { onSuccess: invalidate.planning, }); } + +export function useSendPlanningNotification() { + return useMutation({ + mutationFn: (body: PlanningNotificationRequest) => + api.notifications.sendPlanning(edition, body), + }); +} diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 7af0f39..1d99113 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -4,6 +4,7 @@ import type { AssignmentValidateRequest, LoginRequest, MagicLinkRequest, + PlanningNotificationRequest, PositionCreate, PositionUpdate, ShiftCreate, @@ -119,6 +120,13 @@ export function createHttpApi(baseUrl: string): PlanningApi { matrix: { get: (editionId) => request(`${base(editionId)}/matrix`), }, + notifications: { + sendPlanning: (editionId, body: PlanningNotificationRequest) => + request(`${base(editionId)}/notifications/planning`, { + method: 'POST', + body: JSON.stringify(body), + }), + }, auth: { login: (editionId, body: LoginRequest) => request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }), diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index d19a37f..cfdf76c 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -5,6 +5,7 @@ import type { AssignmentRead, AssignmentValidateResult, MatrixResponse, + PlanningNotificationRequest, ShiftRead, TokenResponse, Violation, @@ -425,6 +426,36 @@ export function createMockApi(): PlanningApi { get: () => delay(buildMatrix()), }, + notifications: { + sendPlanning: (_edition, body: PlanningNotificationRequest) => { + const ids = body.volunteer_ids ?? null; + const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id)); + const withEmail = targets.filter((v) => v.email); + const wantsLink = + (body.subject ?? '').includes('{lien_connexion}') || + (body.body ?? '').includes('{lien_connexion}'); + for (const volunteer of withEmail) { + if (!wantsLink) continue; + const account = state.accounts.find( + (a) => a.volunteer_id === volunteer.id && a.auth_method === 'magic_link', + ); + if (!account) continue; + const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; + magicTokens.set(raw, { + accountId: account.id, + expiresAt: Date.now() + MAGIC_LINK_TTL_MS, + used: false, + }); + lastMagicLink.set(account.email.toLowerCase(), raw); + } + return delay({ + queued: withEmail.length, + skipped_no_email: targets.length - withEmail.length, + recipients: withEmail.map((v) => v.id), + }); + }, + }, + auth: { login: (_edition, { email, password }) => { const account = accountByEmail(email); diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 76ac328..cbe7453 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -239,3 +239,24 @@ export interface MagicLinkRequest { export interface VerifyRequest { token: string; } + +// --------------------------------------------------------------------------- // +// Notifications par email // +// --------------------------------------------------------------------------- // +/** + * `subject` / `body` sont des modèles. Variables : {prenom} {nom} + * {nb_creneaux} {nb_heures} {creneaux} {lien_connexion} (ce dernier déclenche + * l'émission d'un lien magique personnel). + */ +export interface PlanningNotificationRequest { + subject?: string | null; + body?: string | null; + /** null / absent = tous les bénévoles de l'édition qui ont un email. */ + volunteer_ids?: string[] | null; +} + +export interface PlanningNotificationResult { + queued: number; + skipped_no_email: number; + recipients: string[]; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index aa909fc..39aba5c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -9,6 +9,8 @@ import type { LoginRequest, MagicLinkRequest, MatrixResponse, + PlanningNotificationRequest, + PlanningNotificationResult, PositionCreate, PositionRead, PositionUpdate, @@ -66,6 +68,13 @@ export interface PlanningApi { matrix: { get(editionId: string): Promise; }; + notifications: { + /** Récapitulatif de planning par email (COORDINATION). */ + sendPlanning( + editionId: string, + body: PlanningNotificationRequest, + ): Promise; + }; auth: { login(editionId: string, body: LoginRequest): Promise; /** 202 sans divulgation : ne dit pas si le compte existe. */ diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 3272573..b354a62 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -151,7 +151,7 @@ "config.reserved": "RÉSERVÉ À LA COORDINATION", "config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.volunteers": "BÉNÉVOLES & IMPORT", - "config.tab.mail": "MESSAGES (MAQUETTE)", + "config.tab.mail": "MESSAGES", "config.tab.rules": "RÈGLES & ÉDITION", "config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX", @@ -187,16 +187,20 @@ "config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?", "config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade (RGPD).", - "config.mail.draftBanner": "Écran maquette : l'envoi d'emails / SMS est hors périmètre. Ces réglages ne sont pas enregistrés.", - "config.mail.templateTitle": "MODÈLE DE MESSAGE DE PUBLICATION", - "config.mail.from": "EXPÉDITEUR", - "config.mail.replyTo": "RÉPONDRE À", + "config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.", + "config.mail.templateTitle": "MODÈLE DE MESSAGE", + "config.mail.senderNote": "Expéditeur défini côté serveur (SMTP_FROM).", "config.mail.subject": "OBJET", "config.mail.body": "CORPS DU MESSAGE", "config.mail.variables": "VARIABLES DISPONIBLES", + "config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère un lien de connexion personnel.", + "config.mail.send": "ENVOYER LE RÉCAPITULATIF", + "config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION", + "config.mail.sending": "ENVOI EN COURS…", + "config.mail.sent": "Envoi lancé : {queued} email(s), {skipped} bénévole(s) sans adresse.", + "config.mail.loginLinkLine": "Accède à ton planning : {lien_connexion}", "config.mail.previewTitle": "APERÇU", - "config.mail.previewFrom": "DE : {from}", - "config.mail.cta": "VOIR MON PLANNING →", + "config.mail.previewSender": "DE : expéditeur configuré côté serveur", "config.rules.editionTitle": "ÉDITION & CONNEXION", "config.rules.editionId": "Identifiant d'édition", diff --git a/frontend/src/pages/config/ConfigMail.tsx b/frontend/src/pages/config/ConfigMail.tsx index 3a1806b..9ff9c0f 100644 --- a/frontend/src/pages/config/ConfigMail.tsx +++ b/frontend/src/pages/config/ConfigMail.tsx @@ -1,37 +1,47 @@ import { useState } from 'react'; -import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui'; +import { useSendPlanningNotification } from '@/api/hooks'; +import { ErrorNote } from '@/components/state'; +import { Btn, FieldLabel, Panel, PanelHeader } from '@/components/ui'; import { t } from '@/i18n'; -const MAIL_VARIABLES = ['{prenom}', '{nom}', '{nb_creneaux}', '{nb_heures}', '{poste}', '{referent}', '{lien_magique}']; +const MAIL_VARIABLES = [ + '{prenom}', + '{nom}', + '{nb_creneaux}', + '{nb_heures}', + '{creneaux}', + '{lien_connexion}', +]; + +const DEFAULT_SUBJECT = 'Ton planning bénévole'; +const DEFAULT_BODY = + 'Salut {prenom},\n\nVoici ton planning : {nb_creneaux} créneau(x), soit {nb_heures} h.\n\n{creneaux}\n\nÀ très vite sur le festival !'; + +function withLoginLink(body: string): string { + if (body.includes('{lien_connexion}')) return body; + return `${body}\n\n${t('config.mail.loginLinkLine')}`; +} -// Écran maquette : l'envoi d'emails et la publication versionnée sont hors -// périmètre (CLAUDE.md §4). Les réglages ci-dessous ne sont pas persistés. export function ConfigMail() { - const [from, setFrom] = useState('coordination@lessonsdautomne.fr'); - const [replyTo, setReplyTo] = useState('marie.bertin@lessonsdautomne.fr'); - const [subject, setSubject] = useState('Ton planning bénévole a été mis à jour'); - const [body, setBody] = useState( - "Salut {prenom},\n\nTon planning vient d'être mis à jour. Tu as {nb_creneaux} créneaux, soit {nb_heures} heures.\n\nRetrouve le détail sur ton planning personnel.", - ); + const [subject, setSubject] = useState(DEFAULT_SUBJECT); + const [body, setBody] = useState(DEFAULT_BODY); + const send = useSendPlanningNotification(); + + const submit = (payloadBody: string) => send.mutate({ subject, body: payloadBody }); return (
- {t('config.mail.draftBanner')} + {t('config.mail.smtpBanner')}
{t('config.mail.templateTitle')}
- - +
+ {t('config.mail.senderNote')} +