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
+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>