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
+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() {
return useMutation({
mutationFn: (body: PlanningNotificationRequest) =>
+5
View File
@@ -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) =>
+7
View File
@@ -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));
+9
View File
@@ -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 //
// --------------------------------------------------------------------------- //
+3
View File
@@ -58,6 +58,7 @@ import type {
VolunteerUpdate,
EditionStats,
AttendanceUpdate,
TestEmailResult,
} from './schema';
/**
@@ -224,6 +225,8 @@ export interface PlanningApi {
editionId: string,
body: PlanningNotificationRequest,
): Promise<PlanningNotificationResult>;
/** Email de test, envoyé tout de suite (COORDINATION). */
sendTest(editionId: string, email: string): Promise<TestEmailResult>;
};
auth: {
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.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",
+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 { 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 ? (
<span className="ml-auto opacity-60">× {v.badge_count}</span>
) : null}
+4 -3
View File
@@ -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() {
<VolunteerAvatar
volunteerId={me.data.id}
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]"
/>
) : null}
+2 -1
View File
@@ -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<string, Slot[]>();
+2 -1
View File
@@ -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 }}
>
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.first_name} {row.volunteer.last_name}
{volunteerName(row.volunteer)}
</span>
{row.empty ? (
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
@@ -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({
</button>
<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">
{volunteer.first_name} {volunteer.last_name}
{volunteerName(volunteer)}
</div>
<div className="font-mono text-[9px] uppercase leading-snug opacity-60">
{total
+65 -1
View File
@@ -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() {
</div>
</Panel>
<TestEmailPanel />
<Panel shadow="lg">
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
<div className="p-[13px]">
@@ -116,3 +120,63 @@ export function ConfigMail() {
</div>
);
}
const RESULT_TONE: Record<TestEmailResult['status'], string> = {
sent: 'bg-green text-white',
disabled: 'bg-yellow-soft',
connection_failed: 'bg-orange',
rejected: 'bg-orange',
};
/** Envoie 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 { 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() {
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
{volunteer.first_name[0]}
{volunteer.last_name[0]}
{volunteerInitials(volunteer)}
</span>
<span className="truncate font-display text-[12.5px] font-bold">
{volunteer.first_name} {volunteer.last_name}
{volunteerName(volunteer)}
</span>
</span>
<span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
@@ -950,11 +950,10 @@ export function ConfigVolunteers() {
</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.first_name[0]}
{selected.last_name[0]}
{volunteerInitials(selected)}
</span>
<span className="font-display text-[18px] font-black tracking-tight">
{selected.first_name} {selected.last_name}
{volunteerName(selected)}
</span>
</div>
</div>
@@ -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 }) {
>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<FieldLabel>{t('config.vol.field.firstName')} *</FieldLabel>
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
<TextInput value={form.first_name} onChange={(v) => set('first_name', v)} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.lastName')} *</FieldLabel>
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
<TextInput value={form.last_name} onChange={(v) => set('last_name', v)} />
</label>
</div>
@@ -27,6 +27,7 @@ import {
} from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
interface Props {
positionId: string;
@@ -250,7 +251,7 @@ export function AssignmentPanel({
{initials(volunteer)}
</span>
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
{volunteer.first_name} {volunteer.last_name}
{volunteerName(volunteer)}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
</span>
@@ -348,7 +349,7 @@ function CandidateRow({
{initials(volunteer)}
</span>
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
{volunteer.first_name} {volunteer.last_name}
{volunteerName(volunteer)}
{suggested ? (
<span className="ml-[6px] inline-block border-[1.5px] border-ink bg-blue px-[5px] py-[1px] align-middle font-display text-[8px] font-extrabold uppercase tracking-wide">
{t('grille.panel.suggested')}
@@ -436,7 +437,7 @@ function CandidateRow({
}
function initials(v: VolunteerRead): string {
return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`;
return volunteerInitials(v);
}
function round(n: number): number {