From ccf3438eef1553ed67b6264d5cd7992949c55c56 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Thu, 10 Sep 2026 21:55:59 +0200 Subject: [PATCH] =?UTF-8?q?B=C3=A9n=C3=A9voles=20:=20ajout=20manuel,=20imp?= =?UTF-8?q?ort=20de=20fichier=20guid=C3=A9=20et=20gestion=20compl=C3=A8te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Écran « Bénévoles & import » remanié autour des trois méthodes d'ajout demandées (la 3e — formulaire d'inscription en ligne — reste à faire) : - Ajout manuel : formulaire complet (prénom, nom, email, téléphone, taille de t-shirt, régime, commentaire, compétences) ; l'erreur 409 `volunteer.duplicate_email` est affichée. - Import de fichier : assistant en 3 étapes (fichier → association des colonnes + colonnes de disponibilité optionnelles → simulation puis import réel) avec aperçu et rapport ligne par ligne. - Gestion : recherche par nom / email, fiche éditable étendue (régime et commentaire ajoutés), suppression RGPD en cascade inchangée. Le bouton « créer 4 exemples » (maquette) est retiré. Traductions `config.vol.*` et miroir des messages `import.*` du back-end. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- frontend/src/i18n/locales/fr.json | 98 +- .../src/pages/config/ConfigVolunteers.tsx | 872 ++++++++++++++++-- 2 files changed, 869 insertions(+), 101 deletions(-) diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 48a4770..afab5d4 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -4,10 +4,8 @@ "action.close": "Fermer", "action.remove": "Retirer", "action.add": "Ajouter", - "app.loading": "Chargement…", "app.error.generic": "Une erreur est survenue", - "error.generic": "Erreur inattendue", "error.not_found": "Ressource introuvable", "error.forbidden": "Accès refusé", @@ -20,11 +18,9 @@ "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", "auth.invalid_credentials": "Email ou mot de passe incorrect", "auth.invalid_token": "Ce lien de connexion est invalide ou expiré", - "role.coordinator": "Coordination", "role.lead": "Référent", "role.volunteer": "Bénévole", - "auth.logout": "Déconnexion", "auth.email": "Email", "auth.password": "Mot de passe", @@ -46,17 +42,14 @@ "auth.magic.verifyTitle": "Connexion en cours", "auth.magic.verifying": "Vérification du lien…", "auth.magic.retry": "Demander un nouveau lien", - "header.title": "GESTION DES BÉNÉVOLES", "header.meta": "BÉNÉVOLAT · {count} INSCRITS", "header.mockMode": "DONNÉES DE DÉMO", - "tab.grille": "Grille des besoins", "tab.volunteers": "Vue par bénévole", "tab.mobileVolunteer": "Bénévole · mobile", "tab.mobileLead": "Référent · mobile", "tab.config": "Configuration", - "position.category.bar": "Bar", "position.category.ticketing": "Billetterie", "position.category.welcome": "Accueil", @@ -66,7 +59,6 @@ "position.category.setup": "Montage", "position.category.stage": "Régie", "position.category.other": "Autre", - "validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau", "validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment", "validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.", @@ -79,7 +71,6 @@ "validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.", "validation.MISSING_REQUIRED_SKILL": "Compétence requise manquante : {skill}", "validation.MISSING_REQUIRED_SKILL.rule": "Le bénévole n'a pas une compétence requise par le créneau.", - "grille.filter.day": "JOUR", "grille.filter.position": "POSTE", "grille.legend.critical": "SOUS-EFFECTIF CRITIQUE", @@ -107,7 +98,6 @@ "grille.panel.blocked": "BLOQUÉ", "grille.panel.blockingPrefix": "BLOQUANT — {reason}", "grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE", - "volunteers.filter.show": "AFFICHER", "volunteers.filter.all": "TOUS", "volunteers.filter.overloaded": "SURCHARGÉS", @@ -122,9 +112,7 @@ "volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.", "volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».", "volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.", - "design.noteTitle": "CHOIX DE CONCEPTION", - "mobileVol.statusTime": "9:41", "mobileVol.kicker": "MON PLANNING BÉNÉVOLE", "mobileVol.greeting": "SALUT {name}", @@ -136,7 +124,6 @@ "mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.", "mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.", "mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.", - "mobileLead.statusTime": "18:07", "mobileLead.kicker": "RÉFÉRENT DE POSTE", "mobileLead.inProgress": "CRÉNEAU · {hours}", @@ -150,14 +137,12 @@ "mobileLead.markAbsent": "ABSENT", "mobileLead.emptyTeam": "Aucun bénévole affecté à ce créneau.", "mobileLead.designNote": "Le référent ne voit que son poste et un créneau. Le pointage est un geste unique par personne. Il est local à cette maquette : le pointage sur site est hors périmètre (CLAUDE.md §4).", - "config.admin": "ADMINISTRATION", "config.reserved": "RÉSERVÉ À LA COORDINATION", "config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.volunteers": "BÉNÉVOLES & IMPORT", "config.tab.mail": "MESSAGES", "config.tab.rules": "RÈGLES & ÉDITION", - "config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX", "config.grid.slotsCount": "{count} créneaux", "config.grid.addPosition": "+ AJOUTER UN POSTE", @@ -172,10 +157,8 @@ "config.grid.generateShifts": "GÉNÉRER LES CRÉNEAUX", "config.grid.generated": "{count} créneau(x) ajouté(s).", "config.grid.note": "Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».", - - "config.vol.importTitle": "IMPORTER DES INSCRIPTIONS", - "config.vol.importHint": "L'import de fichier CSV / Excel se fait via l'endpoint dédié. Ce bouton crée 4 bénévoles d'exemple via l'API.", - "config.vol.importButton": "CRÉER 4 EXEMPLES", + "config.vol.importTitle": "IMPORTER DES BÉNÉVOLES", + "config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.", "config.vol.profiles": "PROFILS ENREGISTRÉS", "config.vol.colName": "NOM", "config.vol.colPhone": "TÉLÉPHONE", @@ -193,7 +176,6 @@ "config.vol.delete": "SUPPRIMER (RGPD — EFFACEMENT COMPLET)", "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.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).", @@ -208,7 +190,6 @@ "config.mail.loginLinkLine": "Accède à ton planning : {lien_connexion}", "config.mail.previewTitle": "APERÇU", "config.mail.previewSender": "DE : expéditeur configuré côté serveur", - "config.rules.editionTitle": "ÉDITION", "config.rules.editionName": "Nom", "config.rules.editionStart": "Premier jour", @@ -238,5 +219,78 @@ "config.rules.roleLead": "Référent de poste", "config.rules.roleLeadDesc": "Lecture sur l'édition, écriture sur ses postes", "config.rules.roleVolunteer": "Bénévole", - "config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email" + "config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email", + "config.vol.addButton": "AJOUTER MANUELLEMENT", + "config.vol.importButton2": "IMPORTER UN FICHIER", + "config.vol.search": "Rechercher un nom, un email…", + "config.vol.emptyList": "Aucun bénévole pour l'instant. Ajoutez-en un ou importez un fichier.", + "config.vol.noMatch": "Aucun bénévole ne correspond à la recherche.", + "config.vol.count": "{count} bénévole(s)", + "config.vol.cancel": "ANNULER", + "config.vol.field.firstName": "Prénom", + "config.vol.field.lastName": "Nom", + "config.vol.field.email": "Email (lien magique)", + "config.vol.field.phone": "Téléphone", + "config.vol.field.tshirt": "Taille de t-shirt", + "config.vol.field.diet": "Régime alimentaire", + "config.vol.field.comment": "Commentaire", + "config.vol.field.skills": "Compétences", + "config.vol.addTitle": "NOUVEAU BÉNÉVOLE", + "config.vol.addHint": "Le bénévole reçoit un accès par lien magique uniquement si un email est renseigné.", + "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", + "config.vol.wiz.step3": "3 · APERÇU & IMPORT", + "config.vol.wiz.pickFile": "CHOISIR UN FICHIER CSV OU EXCEL", + "config.vol.wiz.fileHint": "Une ligne par bénévole. La première ligne contient les intitulés de colonnes. En mode démonstration, seuls les fichiers CSV sont pris en charge.", + "config.vol.wiz.analyzing": "Analyse du fichier…", + "config.vol.wiz.detected": "{rows} ligne(s) · {cols} colonne(s) détectée(s)", + "config.vol.wiz.preview": "APERÇU DES PREMIÈRES LIGNES", + "config.vol.wiz.mapTitle": "ASSOCIER LES COLONNES", + "config.vol.wiz.mapHint": "« Prénom » et « Nom » sont obligatoires. Laissez « — » pour ignorer une colonne.", + "config.vol.wiz.ignore": "—", + "config.vol.wiz.availTitle": "DISPONIBILITÉS (OPTIONNEL)", + "config.vol.wiz.availHint": "Associez une colonne du fichier à une date et une demi-journée pour importer les disponibilités déclarées.", + "config.vol.wiz.availAdd": "AJOUTER UNE COLONNE DE DISPONIBILITÉ", + "config.vol.wiz.availColumn": "Colonne", + "config.vol.wiz.availDate": "Date", + "config.vol.wiz.availPart": "Demi-journée", + "config.vol.wiz.mapMissing": "Associez au moins les colonnes « Prénom » et « Nom ».", + "config.vol.wiz.simulate": "SIMULER", + "config.vol.wiz.runImport": "IMPORTER POUR DE VRAI", + "config.vol.wiz.back": "RETOUR", + "config.vol.wiz.next": "SUIVANT", + "config.vol.wiz.dryRunTitle": "SIMULATION — RIEN N'A ÉTÉ ENREGISTRÉ", + "config.vol.wiz.doneTitle": "IMPORT TERMINÉ", + "config.vol.wiz.created": "{count} créé(s)", + "config.vol.wiz.updated": "{count} mis à jour", + "config.vol.wiz.skipped": "{count} ignoré(s)", + "config.vol.wiz.availWritten": "{count} disponibilité(s)", + "config.vol.wiz.skillsCreated": "Compétences créées : {list}", + "config.vol.wiz.rowsWithIssues": "LIGNES AVEC REMARQUES", + "config.vol.wiz.line": "Ligne {line}", + "config.vol.wiz.noIssues": "Aucune remarque : tout est prêt à être importé.", + "config.vol.wiz.close": "FERMER", + "import.error.empty_file": "Le fichier est vide.", + "import.error.encoding": "L'encodage du fichier n'est pas reconnu.", + "import.error.unreadable_file": "Fichier illisible ou format non pris en charge.", + "import.error.file_too_large": "Le fichier est trop volumineux (5 Mo maximum).", + "import.error.xlsx_demo_only": "En mode démonstration, l'import Excel n'est pas disponible : exportez votre fichier en CSV.", + "import.mapping.invalid": "Le paramétrage des colonnes est invalide.", + "import.mapping.required_field_missing": "Le champ « {field} » doit être associé à une colonne.", + "import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.", + "import.mapping.unknown_day_part": "Demi-journée « {day_part_code} » inconnue (colonne « {column} »).", + "import.mapping.date_outside_edition": "La date {date} de la colonne « {column} » est hors des dates de l’édition.", + "import.mapping.duplicate_availability_column": "La colonne « {column} » est associée plusieurs fois.", + "import.row.missing_required_field": "Champ obligatoire manquant : {field}.", + "import.row.invalid_email": "Adresse email invalide : {value}.", + "import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.", + "import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.", + "import.row.updated_existing": "Bénévole existant mis à jour.", + "import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l’édition.", + "import.row.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.", + "import.row.skill_created": "Nouvelle compétence créée : {label}.", + "import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »)." } diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index 20d78db..460c899 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -1,133 +1,225 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import { ApiError } from '@/api'; import { + useAnalyzeImport, useCreateVolunteer, useDeleteVolunteer, + useExecuteImport, useUpdateVolunteer, useVolunteers, } from '@/api/hooks'; -import type { TshirtSize } from '@/api/schema'; +import type { + ImportAnalyzeResponse, + ImportFieldKey, + ImportIssue, + ImportMapping, + ImportReport, + TshirtSize, + VolunteerCreate, +} from '@/api/schema'; import { FieldLabel, TextInput } from '@/components/ui'; import { ErrorNote, Loading } from '@/components/state'; import { t } from '@/i18n'; +import { shortDayLabel } from '@/lib/time'; import { cx } from '@/lib/cx'; const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']; -const IMPORT_SAMPLE: Array<[string, string]> = [ - ['Océane', 'Thibault'], - ['Samir', 'Amrani'], - ['Lucie', 'Charpentier'], - ['Noé', 'Delaunay'], + +const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [ + { key: 'first_name', required: true }, + { key: 'last_name', required: true }, + { key: 'email', required: false }, + { key: 'phone', required: false }, + { key: 'tshirt_size', required: false }, + { key: 'diet', required: false }, + { key: 'comment', required: false }, + { key: 'skills', required: false }, ]; +const FIELD_LABEL: Record = { + first_name: 'config.vol.field.firstName', + last_name: 'config.vol.field.lastName', + email: 'config.vol.field.email', + phone: 'config.vol.field.phone', + tshirt_size: 'config.vol.field.tshirt', + diet: 'config.vol.field.diet', + comment: 'config.vol.field.comment', + skills: 'config.vol.field.skills', +}; + +type Mode = 'list' | 'add' | 'import'; + +/** Retire les paramètres nuls avant traduction (`t` n'accepte pas `null`). */ +function issueParams(issue: ImportIssue): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(issue.params)) { + if (v != null) out[k] = v; + } + return out; +} + export function ConfigVolunteers() { const volunteers = useVolunteers(); const updateVolunteer = useUpdateVolunteer(); const deleteVolunteer = useDeleteVolunteer(); - const createVolunteer = useCreateVolunteer(); + + const [mode, setMode] = useState('list'); const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(''); const [skillDraft, setSkillDraft] = useState(''); + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase(); + const list = [...(volunteers.data ?? [])]; + if (!needle) return list; + return list.filter((v) => + `${v.first_name} ${v.last_name} ${v.email ?? ''}`.toLowerCase().includes(needle), + ); + }, [volunteers.data, search]); + const selected = useMemo(() => { const list = volunteers.data ?? []; - return list.find((v) => v.id === selectedId) ?? list[0]; + return list.find((v) => v.id === selectedId) ?? null; }, [volunteers.data, selectedId]); if (volunteers.isLoading) return ; if (volunteers.error) return ; - const rows = (volunteers.data ?? []).slice(0, 40); + const total = volunteers.data?.length ?? 0; + const rows = filtered.slice(0, 60); const patch = (body: Parameters[0]['body']) => selected && updateVolunteer.mutate({ id: selected.id, body }); - const runImport = () => { - for (const [first_name, last_name] of IMPORT_SAMPLE) { - createVolunteer.mutate({ first_name, last_name }); - } - }; + if (mode === 'add') { + return setMode('list')} />; + } + if (mode === 'import') { + return setMode('list')} />; + } return (
-
- - ↓ - - +
+
{t('config.vol.importTitle')} {t('config.vol.importHint')} - - +
+
+ + +
-
{volunteers.data?.length ?? 0}
+
{total}
{t('config.vol.profiles')}
-
-
- {t('config.vol.colName')} - {t('config.vol.colPhone')} - {t('config.vol.colEmail')} - {t('config.vol.colSkills')} -
- {rows.map((volunteer) => { - const active = volunteer.id === selected?.id; - return ( - - ); - })} +
+ setSearch(e.target.value)} + placeholder={t('config.vol.search')} + className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]" + />
+ + {total === 0 ? ( +
+ {t('config.vol.emptyList')} +
+ ) : rows.length === 0 ? ( +
+ {t('config.vol.noMatch')} +
+ ) : ( +
+
+ {t('config.vol.colName')} + {t('config.vol.colPhone')} + {t('config.vol.colEmail')} + {t('config.vol.colSkills')} +
+ {rows.map((volunteer) => { + const active = volunteer.id === selected?.id; + return ( + + ); + })} + {filtered.length > rows.length ? ( +
+ {t('config.vol.count', { count: filtered.length })} +
+ ) : null} +
+ )}
{selected ? (