Email de test et ajout manuel sans champ obligatoire
É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:
co-authored by
Claude Opus 5.5
parent
5c2cc0a6c3
commit
461742eb78
@@ -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
|
||||
<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
|
||||
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 +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() || '?';
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user