Bénévoles : ajout manuel, import de fichier guidé et gestion complète
É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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
df8d6733db
commit
ccf3438eef
@@ -4,10 +4,8 @@
|
|||||||
"action.close": "Fermer",
|
"action.close": "Fermer",
|
||||||
"action.remove": "Retirer",
|
"action.remove": "Retirer",
|
||||||
"action.add": "Ajouter",
|
"action.add": "Ajouter",
|
||||||
|
|
||||||
"app.loading": "Chargement…",
|
"app.loading": "Chargement…",
|
||||||
"app.error.generic": "Une erreur est survenue",
|
"app.error.generic": "Une erreur est survenue",
|
||||||
|
|
||||||
"error.generic": "Erreur inattendue",
|
"error.generic": "Erreur inattendue",
|
||||||
"error.not_found": "Ressource introuvable",
|
"error.not_found": "Ressource introuvable",
|
||||||
"error.forbidden": "Accès refusé",
|
"error.forbidden": "Accès refusé",
|
||||||
@@ -20,11 +18,9 @@
|
|||||||
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
|
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
|
||||||
"auth.invalid_credentials": "Email ou mot de passe incorrect",
|
"auth.invalid_credentials": "Email ou mot de passe incorrect",
|
||||||
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
|
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
|
||||||
|
|
||||||
"role.coordinator": "Coordination",
|
"role.coordinator": "Coordination",
|
||||||
"role.lead": "Référent",
|
"role.lead": "Référent",
|
||||||
"role.volunteer": "Bénévole",
|
"role.volunteer": "Bénévole",
|
||||||
|
|
||||||
"auth.logout": "Déconnexion",
|
"auth.logout": "Déconnexion",
|
||||||
"auth.email": "Email",
|
"auth.email": "Email",
|
||||||
"auth.password": "Mot de passe",
|
"auth.password": "Mot de passe",
|
||||||
@@ -46,17 +42,14 @@
|
|||||||
"auth.magic.verifyTitle": "Connexion en cours",
|
"auth.magic.verifyTitle": "Connexion en cours",
|
||||||
"auth.magic.verifying": "Vérification du lien…",
|
"auth.magic.verifying": "Vérification du lien…",
|
||||||
"auth.magic.retry": "Demander un nouveau lien",
|
"auth.magic.retry": "Demander un nouveau lien",
|
||||||
|
|
||||||
"header.title": "GESTION DES BÉNÉVOLES",
|
"header.title": "GESTION DES BÉNÉVOLES",
|
||||||
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||||
"header.mockMode": "DONNÉES DE DÉMO",
|
"header.mockMode": "DONNÉES DE DÉMO",
|
||||||
|
|
||||||
"tab.grille": "Grille des besoins",
|
"tab.grille": "Grille des besoins",
|
||||||
"tab.volunteers": "Vue par bénévole",
|
"tab.volunteers": "Vue par bénévole",
|
||||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||||
"tab.mobileLead": "Référent · mobile",
|
"tab.mobileLead": "Référent · mobile",
|
||||||
"tab.config": "Configuration",
|
"tab.config": "Configuration",
|
||||||
|
|
||||||
"position.category.bar": "Bar",
|
"position.category.bar": "Bar",
|
||||||
"position.category.ticketing": "Billetterie",
|
"position.category.ticketing": "Billetterie",
|
||||||
"position.category.welcome": "Accueil",
|
"position.category.welcome": "Accueil",
|
||||||
@@ -66,7 +59,6 @@
|
|||||||
"position.category.setup": "Montage",
|
"position.category.setup": "Montage",
|
||||||
"position.category.stage": "Régie",
|
"position.category.stage": "Régie",
|
||||||
"position.category.other": "Autre",
|
"position.category.other": "Autre",
|
||||||
|
|
||||||
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
|
"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.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.",
|
"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.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": "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.",
|
"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.day": "JOUR",
|
||||||
"grille.filter.position": "POSTE",
|
"grille.filter.position": "POSTE",
|
||||||
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
|
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
|
||||||
@@ -107,7 +98,6 @@
|
|||||||
"grille.panel.blocked": "BLOQUÉ",
|
"grille.panel.blocked": "BLOQUÉ",
|
||||||
"grille.panel.blockingPrefix": "BLOQUANT — {reason}",
|
"grille.panel.blockingPrefix": "BLOQUANT — {reason}",
|
||||||
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
|
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
|
||||||
|
|
||||||
"volunteers.filter.show": "AFFICHER",
|
"volunteers.filter.show": "AFFICHER",
|
||||||
"volunteers.filter.all": "TOUS",
|
"volunteers.filter.all": "TOUS",
|
||||||
"volunteers.filter.overloaded": "SURCHARGÉS",
|
"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.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.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.",
|
"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",
|
"design.noteTitle": "CHOIX DE CONCEPTION",
|
||||||
|
|
||||||
"mobileVol.statusTime": "9:41",
|
"mobileVol.statusTime": "9:41",
|
||||||
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
|
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
|
||||||
"mobileVol.greeting": "SALUT {name}",
|
"mobileVol.greeting": "SALUT {name}",
|
||||||
@@ -136,7 +124,6 @@
|
|||||||
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
|
"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.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.",
|
"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.statusTime": "18:07",
|
||||||
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
|
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
|
||||||
"mobileLead.inProgress": "CRÉNEAU · {hours}",
|
"mobileLead.inProgress": "CRÉNEAU · {hours}",
|
||||||
@@ -150,14 +137,12 @@
|
|||||||
"mobileLead.markAbsent": "ABSENT",
|
"mobileLead.markAbsent": "ABSENT",
|
||||||
"mobileLead.emptyTeam": "Aucun bénévole affecté à ce créneau.",
|
"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).",
|
"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.admin": "ADMINISTRATION",
|
||||||
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
||||||
"config.tab.grid": "POSTES & CRÉNEAUX",
|
"config.tab.grid": "POSTES & CRÉNEAUX",
|
||||||
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
||||||
"config.tab.mail": "MESSAGES",
|
"config.tab.mail": "MESSAGES",
|
||||||
"config.tab.rules": "RÈGLES & ÉDITION",
|
"config.tab.rules": "RÈGLES & ÉDITION",
|
||||||
|
|
||||||
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
||||||
"config.grid.slotsCount": "{count} créneaux",
|
"config.grid.slotsCount": "{count} créneaux",
|
||||||
"config.grid.addPosition": "+ AJOUTER UN POSTE",
|
"config.grid.addPosition": "+ AJOUTER UN POSTE",
|
||||||
@@ -172,10 +157,8 @@
|
|||||||
"config.grid.generateShifts": "GÉNÉRER LES CRÉNEAUX",
|
"config.grid.generateShifts": "GÉNÉRER LES CRÉNEAUX",
|
||||||
"config.grid.generated": "{count} créneau(x) ajouté(s).",
|
"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.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 BÉNÉVOLES",
|
||||||
"config.vol.importTitle": "IMPORTER DES INSCRIPTIONS",
|
"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.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.profiles": "PROFILS ENREGISTRÉS",
|
"config.vol.profiles": "PROFILS ENREGISTRÉS",
|
||||||
"config.vol.colName": "NOM",
|
"config.vol.colName": "NOM",
|
||||||
"config.vol.colPhone": "TÉLÉPHONE",
|
"config.vol.colPhone": "TÉLÉPHONE",
|
||||||
@@ -193,7 +176,6 @@
|
|||||||
"config.vol.delete": "SUPPRIMER (RGPD — EFFACEMENT COMPLET)",
|
"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.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.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.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.templateTitle": "MODÈLE DE MESSAGE",
|
||||||
"config.mail.senderNote": "Expéditeur défini côté serveur (SMTP_FROM).",
|
"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.loginLinkLine": "Accède à ton planning : {lien_connexion}",
|
||||||
"config.mail.previewTitle": "APERÇU",
|
"config.mail.previewTitle": "APERÇU",
|
||||||
"config.mail.previewSender": "DE : expéditeur configuré côté serveur",
|
"config.mail.previewSender": "DE : expéditeur configuré côté serveur",
|
||||||
|
|
||||||
"config.rules.editionTitle": "ÉDITION",
|
"config.rules.editionTitle": "ÉDITION",
|
||||||
"config.rules.editionName": "Nom",
|
"config.rules.editionName": "Nom",
|
||||||
"config.rules.editionStart": "Premier jour",
|
"config.rules.editionStart": "Premier jour",
|
||||||
@@ -238,5 +219,78 @@
|
|||||||
"config.rules.roleLead": "Référent de poste",
|
"config.rules.roleLead": "Référent de poste",
|
||||||
"config.rules.roleLeadDesc": "Lecture sur l'édition, écriture sur ses postes",
|
"config.rules.roleLeadDesc": "Lecture sur l'édition, écriture sur ses postes",
|
||||||
"config.rules.roleVolunteer": "Bénévole",
|
"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} »)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,159 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiError } from '@/api';
|
||||||
import {
|
import {
|
||||||
|
useAnalyzeImport,
|
||||||
useCreateVolunteer,
|
useCreateVolunteer,
|
||||||
useDeleteVolunteer,
|
useDeleteVolunteer,
|
||||||
|
useExecuteImport,
|
||||||
useUpdateVolunteer,
|
useUpdateVolunteer,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} 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 { FieldLabel, TextInput } from '@/components/ui';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
import { shortDayLabel } from '@/lib/time';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
|
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
|
||||||
const IMPORT_SAMPLE: Array<[string, string]> = [
|
|
||||||
['Océane', 'Thibault'],
|
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
||||||
['Samir', 'Amrani'],
|
{ key: 'first_name', required: true },
|
||||||
['Lucie', 'Charpentier'],
|
{ key: 'last_name', required: true },
|
||||||
['Noé', 'Delaunay'],
|
{ 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<ImportFieldKey, string> = {
|
||||||
|
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<string, string | number> {
|
||||||
|
const out: Record<string, string | number> = {};
|
||||||
|
for (const [k, v] of Object.entries(issue.params)) {
|
||||||
|
if (v != null) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function ConfigVolunteers() {
|
export function ConfigVolunteers() {
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
const updateVolunteer = useUpdateVolunteer();
|
const updateVolunteer = useUpdateVolunteer();
|
||||||
const deleteVolunteer = useDeleteVolunteer();
|
const deleteVolunteer = useDeleteVolunteer();
|
||||||
const createVolunteer = useCreateVolunteer();
|
|
||||||
|
const [mode, setMode] = useState<Mode>('list');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [skillDraft, setSkillDraft] = 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 selected = useMemo(() => {
|
||||||
const list = volunteers.data ?? [];
|
const list = volunteers.data ?? [];
|
||||||
return list.find((v) => v.id === selectedId) ?? list[0];
|
return list.find((v) => v.id === selectedId) ?? null;
|
||||||
}, [volunteers.data, selectedId]);
|
}, [volunteers.data, selectedId]);
|
||||||
|
|
||||||
if (volunteers.isLoading) return <Loading />;
|
if (volunteers.isLoading) return <Loading />;
|
||||||
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
|
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
|
||||||
|
|
||||||
const rows = (volunteers.data ?? []).slice(0, 40);
|
const total = volunteers.data?.length ?? 0;
|
||||||
|
const rows = filtered.slice(0, 60);
|
||||||
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
|
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
|
||||||
selected && updateVolunteer.mutate({ id: selected.id, body });
|
selected && updateVolunteer.mutate({ id: selected.id, body });
|
||||||
|
|
||||||
const runImport = () => {
|
if (mode === 'add') {
|
||||||
for (const [first_name, last_name] of IMPORT_SAMPLE) {
|
return <AddVolunteerForm onClose={() => setMode('list')} />;
|
||||||
createVolunteer.mutate({ first_name, last_name });
|
}
|
||||||
|
if (mode === 'import') {
|
||||||
|
return <ImportWizard onClose={() => setMode('list')} />;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 items-stretch">
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
||||||
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
|
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
|
||||||
<div className="flex flex-1 items-center gap-[14px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
<div className="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
||||||
<span className="flex h-10 w-10 flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[16px] font-extrabold">
|
<div>
|
||||||
↓
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block font-display text-[15px] font-black">
|
<span className="block font-display text-[15px] font-black">
|
||||||
{t('config.vol.importTitle')}
|
{t('config.vol.importTitle')}
|
||||||
</span>
|
</span>
|
||||||
<span className="block font-mono text-[10px] leading-relaxed opacity-70">
|
<span className="block font-mono text-[10px] leading-relaxed opacity-70">
|
||||||
{t('config.vol.importHint')}
|
{t('config.vol.importHint')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-[10px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runImport}
|
onClick={() => setMode('add')}
|
||||||
disabled={createVolunteer.isPending}
|
className="cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
|
||||||
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
|
|
||||||
>
|
>
|
||||||
{t('config.vol.importButton')}
|
+ {t('config.vol.addButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('import')}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-yellow"
|
||||||
|
>
|
||||||
|
↓ {t('config.vol.importButton2')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
||||||
<div className="font-mono text-[24px] font-bold">{volunteers.data?.length ?? 0}</div>
|
<div className="font-mono text-[24px] font-bold">{total}</div>
|
||||||
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
|
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
|
||||||
{t('config.vol.profiles')}
|
{t('config.vol.profiles')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-[10px]">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('config.vol.search')}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total === 0 ? (
|
||||||
|
<div className="border-[3px] border-dashed border-ink bg-paper p-[22px] font-display text-[13px] font-extrabold">
|
||||||
|
{t('config.vol.emptyList')}
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="border-[3px] border-dashed border-ink bg-paper p-[22px] font-display text-[13px] font-extrabold">
|
||||||
|
{t('config.vol.noMatch')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
<div
|
<div
|
||||||
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
|
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
|
||||||
@@ -111,7 +186,9 @@ export function ConfigVolunteers() {
|
|||||||
{volunteer.first_name} {volunteer.last_name}
|
{volunteer.first_name} {volunteer.last_name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-mono text-[11px] opacity-80">{volunteer.phone ?? '—'}</span>
|
<span className="truncate font-mono text-[11px] opacity-80">
|
||||||
|
{volunteer.phone ?? '—'}
|
||||||
|
</span>
|
||||||
<span className="truncate font-mono text-[10.5px] opacity-70">
|
<span className="truncate font-mono text-[10.5px] opacity-70">
|
||||||
{volunteer.email ?? '—'}
|
{volunteer.email ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
@@ -121,13 +198,28 @@ export function ConfigVolunteers() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{filtered.length > rows.length ? (
|
||||||
|
<div className="px-[10px] py-[7px] font-mono text-[10px] opacity-60">
|
||||||
|
{t('config.vol.count', { count: filtered.length })}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||||
<div className="bg-ink px-[13px] py-[11px] text-cream">
|
<div className="bg-ink px-[13px] py-[11px] text-cream">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
|
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(null)}
|
||||||
|
className="cursor-pointer font-mono text-[11px] text-cream/70 hover:text-cream"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="mt-[7px] flex items-center gap-[9px]">
|
<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">
|
<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.first_name[0]}
|
||||||
@@ -142,24 +234,24 @@ export function ConfigVolunteers() {
|
|||||||
<div className="flex flex-col gap-3 p-[13px]">
|
<div className="flex flex-col gap-3 p-[13px]">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.firstName')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
|
||||||
<TextInput value={selected.first_name} onChange={(v) => patch({ first_name: v })} />
|
<TextInput value={selected.first_name} onChange={(v) => patch({ first_name: v })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.lastName')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
|
||||||
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
|
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.phone')}</FieldLabel>
|
||||||
<TextInput mono value={selected.phone ?? ''} onChange={(v) => patch({ phone: v || null })} />
|
<TextInput mono value={selected.phone ?? ''} onChange={(v) => patch({ phone: v || null })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.email')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.email')}</FieldLabel>
|
||||||
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
|
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.tshirt')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.tshirt')}</FieldLabel>
|
||||||
<select
|
<select
|
||||||
value={selected.tshirt_size ?? ''}
|
value={selected.tshirt_size ?? ''}
|
||||||
onChange={(e) => patch({ tshirt_size: (e.target.value || null) as TshirtSize | null })}
|
onChange={(e) => patch({ tshirt_size: (e.target.value || null) as TshirtSize | null })}
|
||||||
@@ -173,9 +265,22 @@ export function ConfigVolunteers() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.diet')}</FieldLabel>
|
||||||
|
<TextInput value={selected.diet ?? ''} onChange={(v) => patch({ diet: v || null })} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={selected.comment ?? ''}
|
||||||
|
onChange={(e) => patch({ comment: e.target.value || null })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>{t('config.vol.skills')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
||||||
<div className="mb-2 flex flex-wrap gap-[6px]">
|
<div className="mb-2 flex flex-wrap gap-[6px]">
|
||||||
{selected.skill_codes.map((code) => (
|
{selected.skill_codes.map((code) => (
|
||||||
<button
|
<button
|
||||||
@@ -240,3 +345,612 @@ export function ConfigVolunteers() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Ajout manuel //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
||||||
|
const createVolunteer = useCreateVolunteer();
|
||||||
|
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
|
||||||
|
const [skills, setSkills] = useState<string[]>([]);
|
||||||
|
const [skillDraft, setSkillDraft] = useState('');
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const set = <K extends keyof VolunteerCreate>(key: K, value: VolunteerCreate[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setLocalError(null);
|
||||||
|
if (!form.first_name.trim() || !form.last_name.trim()) {
|
||||||
|
setLocalError(t('config.vol.requiredNames'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createVolunteer.mutateAsync({
|
||||||
|
first_name: form.first_name.trim(),
|
||||||
|
last_name: form.last_name.trim(),
|
||||||
|
email: form.email?.trim() || null,
|
||||||
|
phone: form.phone?.trim() || null,
|
||||||
|
tshirt_size: form.tshirt_size ?? null,
|
||||||
|
diet: form.diet?.trim() || null,
|
||||||
|
comment: form.comment?.trim() || null,
|
||||||
|
skill_codes: skills,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof ApiError ? t(err.code) : t('error.generic'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[calc(100vh-118px)] min-h-0 flex-1 overflow-auto p-4">
|
||||||
|
<div className="mx-auto max-w-[560px] border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
<div className="flex items-center justify-between bg-ink px-[14px] py-[11px] text-cream">
|
||||||
|
<span className="font-display text-[14px] font-black">{t('config.vol.addTitle')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer font-mono text-[12px] text-cream/70 hover:text-cream"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 p-[14px]"
|
||||||
|
>
|
||||||
|
<div className="font-mono text-[10px] leading-relaxed opacity-65">
|
||||||
|
{t('config.vol.addHint')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="block">
|
||||||
|
<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>
|
||||||
|
<TextInput value={form.last_name} onChange={(v) => set('last_name', v)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.email')}</FieldLabel>
|
||||||
|
<TextInput mono value={form.email ?? ''} onChange={(v) => set('email', v)} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.phone')}</FieldLabel>
|
||||||
|
<TextInput mono value={form.phone ?? ''} onChange={(v) => set('phone', v)} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.tshirt')}</FieldLabel>
|
||||||
|
<select
|
||||||
|
value={form.tshirt_size ?? ''}
|
||||||
|
onChange={(e) => set('tshirt_size', (e.target.value || null) as TshirtSize | null)}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{TSHIRT_SIZES.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.diet')}</FieldLabel>
|
||||||
|
<TextInput value={form.diet ?? ''} onChange={(v) => set('diet', v)} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={form.comment ?? ''}
|
||||||
|
onChange={(e) => set('comment', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-[6px]">
|
||||||
|
{skills.map((code) => (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSkills((s) => s.filter((c) => c !== code))}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
|
||||||
|
>
|
||||||
|
{code} ✕
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={skillDraft}
|
||||||
|
onChange={(e) => setSkillDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const code = skillDraft.trim().toUpperCase();
|
||||||
|
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
|
||||||
|
setSkillDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('config.vol.skillPlaceholder')}
|
||||||
|
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const code = skillDraft.trim().toUpperCase();
|
||||||
|
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
|
||||||
|
setSkillDraft('');
|
||||||
|
}}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
|
||||||
|
>
|
||||||
|
{t('action.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localError ? (
|
||||||
|
<div className="border-2 border-ink bg-orange p-[9px] font-display text-[11px] font-bold">
|
||||||
|
{localError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createVolunteer.isPending}
|
||||||
|
className="flex-1 cursor-pointer border-[3px] border-ink bg-yellow p-[11px] font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{t('config.vol.create')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
|
||||||
|
>
|
||||||
|
{t('config.vol.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Import de fichier (assistant en 3 étapes) //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
type AvailRow = { header: string; date: string; day_part_code: string };
|
||||||
|
|
||||||
|
function ImportWizard({ onClose }: { onClose: () => void }) {
|
||||||
|
const analyze = useAnalyzeImport();
|
||||||
|
const execute = useExecuteImport();
|
||||||
|
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [analysis, setAnalysis] = useState<ImportAnalyzeResponse | null>(null);
|
||||||
|
const [colMap, setColMap] = useState<Partial<Record<ImportFieldKey, string>>>({});
|
||||||
|
const [availRows, setAvailRows] = useState<AvailRow[]>([]);
|
||||||
|
const [report, setReport] = useState<ImportReport | null>(null);
|
||||||
|
const [mapError, setMapError] = useState<string | null>(null);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const step: 1 | 2 | 3 = report ? 3 : analysis ? 2 : 1;
|
||||||
|
|
||||||
|
const pickFile = async (f: File) => {
|
||||||
|
setFile(f);
|
||||||
|
setAnalysis(null);
|
||||||
|
setReport(null);
|
||||||
|
setMapError(null);
|
||||||
|
try {
|
||||||
|
const res = await analyze.mutateAsync(f);
|
||||||
|
setAnalysis(res);
|
||||||
|
setColMap({ ...res.suggested_mapping });
|
||||||
|
setAvailRows([]);
|
||||||
|
} catch {
|
||||||
|
/* l'erreur est portée par analyze.error */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMapping = (dryRun: boolean): ImportMapping => ({
|
||||||
|
dry_run: dryRun,
|
||||||
|
columns: {
|
||||||
|
first_name: colMap.first_name ?? '',
|
||||||
|
last_name: colMap.last_name ?? '',
|
||||||
|
email: colMap.email || null,
|
||||||
|
phone: colMap.phone || null,
|
||||||
|
tshirt_size: colMap.tshirt_size || null,
|
||||||
|
diet: colMap.diet || null,
|
||||||
|
comment: colMap.comment || null,
|
||||||
|
skills: colMap.skills || null,
|
||||||
|
},
|
||||||
|
availability_columns: availRows.filter((r) => r.header && r.date && r.day_part_code),
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (dryRun: boolean) => {
|
||||||
|
if (!file) return;
|
||||||
|
setMapError(null);
|
||||||
|
if (!colMap.first_name || !colMap.last_name) {
|
||||||
|
setMapError(t('config.vol.wiz.mapMissing'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await execute.mutateAsync({ file, mapping: buildMapping(dryRun) });
|
||||||
|
setReport(res);
|
||||||
|
} catch {
|
||||||
|
/* l'erreur est portée par execute.error */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
setReport(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[calc(100vh-118px)] min-h-0 flex-1 overflow-auto p-4">
|
||||||
|
<div className="mx-auto max-w-[820px] border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
<div className="flex items-center justify-between bg-ink px-[14px] py-[11px] text-cream">
|
||||||
|
<span className="font-display text-[14px] font-black">{t('config.vol.wiz.title')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer font-mono text-[12px] text-cream/70 hover:text-cream"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-[6px] border-b-[3px] border-ink bg-sand px-[14px] py-[8px] font-display text-[9.5px] font-extrabold uppercase tracking-wide">
|
||||||
|
{[
|
||||||
|
['config.vol.wiz.step1', 1],
|
||||||
|
['config.vol.wiz.step2', 2],
|
||||||
|
['config.vol.wiz.step3', 3],
|
||||||
|
].map(([key, n]) => (
|
||||||
|
<span
|
||||||
|
key={n as number}
|
||||||
|
className={cx(
|
||||||
|
'border-2 border-ink px-[8px] py-[4px]',
|
||||||
|
step === n ? 'bg-ink text-cream' : 'bg-cream text-ink opacity-70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(key as string)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[14px] p-[14px]">
|
||||||
|
{/* Étape 1 : fichier */}
|
||||||
|
{step === 1 ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv,.xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void pickFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
className="cursor-pointer border-[3px] border-dashed border-ink bg-cream p-[22px] font-display text-[13px] font-black uppercase tracking-wide hover:bg-yellow"
|
||||||
|
>
|
||||||
|
{analyze.isPending ? t('config.vol.wiz.analyzing') : t('config.vol.wiz.pickFile')}
|
||||||
|
</button>
|
||||||
|
{file ? (
|
||||||
|
<div className="font-mono text-[11px] opacity-70">{file.name}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="font-mono text-[10px] leading-relaxed opacity-65">
|
||||||
|
{t('config.vol.wiz.fileHint')}
|
||||||
|
</div>
|
||||||
|
{analyze.error ? <ErrorNote error={analyze.error} /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Étape 2 : colonnes */}
|
||||||
|
{step === 2 && analysis ? (
|
||||||
|
<>
|
||||||
|
<div className="font-mono text-[11px] font-bold">
|
||||||
|
{t('config.vol.wiz.detected', {
|
||||||
|
rows: analysis.row_count,
|
||||||
|
cols: analysis.headers.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.vol.wiz.mapTitle')}</FieldLabel>
|
||||||
|
<div className="mb-1 font-mono text-[10px] leading-relaxed opacity-65">
|
||||||
|
{t('config.vol.wiz.mapHint')}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{MAP_FIELDS.map(({ key, required }) => (
|
||||||
|
<label key={key} className="block">
|
||||||
|
<span className="mb-[4px] block font-display text-[10px] font-bold">
|
||||||
|
{t(FIELD_LABEL[key])}
|
||||||
|
{required ? ' *' : ''}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={colMap[key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setColMap((m) => ({ ...m, [key]: e.target.value || undefined }))
|
||||||
|
}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('config.vol.wiz.ignore')}</option>
|
||||||
|
{analysis.headers.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.vol.wiz.availTitle')}</FieldLabel>
|
||||||
|
<div className="mb-1 font-mono text-[10px] leading-relaxed opacity-65">
|
||||||
|
{t('config.vol.wiz.availHint')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{availRows.map((row, i) => (
|
||||||
|
<div key={i} className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="min-w-[140px] flex-1">
|
||||||
|
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
|
||||||
|
{t('config.vol.wiz.availColumn')}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={row.header}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAvailRows((rs) =>
|
||||||
|
rs.map((r, j) => (j === i ? { ...r, header: e.target.value } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('config.vol.wiz.ignore')}</option>
|
||||||
|
{analysis.headers.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-[130px] flex-1">
|
||||||
|
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
|
||||||
|
{t('config.vol.wiz.availDate')}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={row.date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAvailRows((rs) =>
|
||||||
|
rs.map((r, j) => (j === i ? { ...r, date: e.target.value } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('config.vol.wiz.ignore')}</option>
|
||||||
|
{analysis.edition_dates.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{shortDayLabel(`${d}T12:00`)} ({d})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-[130px] flex-1">
|
||||||
|
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
|
||||||
|
{t('config.vol.wiz.availPart')}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={row.day_part_code}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAvailRows((rs) =>
|
||||||
|
rs.map((r, j) =>
|
||||||
|
j === i ? { ...r, day_part_code: e.target.value } : r,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('config.vol.wiz.ignore')}</option>
|
||||||
|
{analysis.day_parts.map((dp) => (
|
||||||
|
<option key={dp.code} value={dp.code}>
|
||||||
|
{dp.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAvailRows((rs) => rs.filter((_, j) => j !== i))}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-orange"
|
||||||
|
>
|
||||||
|
{t('action.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setAvailRows((rs) => [...rs, { header: '', date: '', day_part_code: '' }])
|
||||||
|
}
|
||||||
|
className="cursor-pointer self-start border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow"
|
||||||
|
>
|
||||||
|
+ {t('config.vol.wiz.availAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border-2 border-ink">
|
||||||
|
<table className="w-full border-collapse font-mono text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-ink text-cream">
|
||||||
|
{analysis.headers.map((h) => (
|
||||||
|
<th key={h} className="border border-cream/30 px-[6px] py-[4px] text-left">
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analysis.preview.map((r) => (
|
||||||
|
<tr key={r.line}>
|
||||||
|
{analysis.headers.map((h) => (
|
||||||
|
<td key={h} className="border border-sand-line px-[6px] py-[3px]">
|
||||||
|
{r.values[h] ?? ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mapError ? (
|
||||||
|
<div className="border-2 border-ink bg-orange p-[9px] font-display text-[11px] font-bold">
|
||||||
|
{mapError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{execute.error ? <ErrorNote error={execute.error} /> : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void run(true)}
|
||||||
|
disabled={execute.isPending}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-[10px] font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{t('config.vol.wiz.simulate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAnalysis(null);
|
||||||
|
setFile(null);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
|
||||||
|
>
|
||||||
|
{t('config.vol.wiz.back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Étape 3 : rapport */}
|
||||||
|
{step === 3 && report ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'border-2 border-ink p-[10px] font-display text-[12px] font-black uppercase tracking-wide',
|
||||||
|
report.dry_run ? 'bg-yellow-soft' : 'bg-green text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{report.dry_run ? t('config.vol.wiz.dryRunTitle') : t('config.vol.wiz.doneTitle')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 font-mono text-[11px]">
|
||||||
|
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
|
||||||
|
{t('config.vol.wiz.created', { count: report.created })}
|
||||||
|
</span>
|
||||||
|
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
|
||||||
|
{t('config.vol.wiz.updated', { count: report.updated })}
|
||||||
|
</span>
|
||||||
|
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
|
||||||
|
{t('config.vol.wiz.skipped', { count: report.skipped })}
|
||||||
|
</span>
|
||||||
|
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
|
||||||
|
{t('config.vol.wiz.availWritten', { count: report.availabilities_written })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.skills_created.length > 0 ? (
|
||||||
|
<div className="font-mono text-[11px]">
|
||||||
|
{t('config.vol.wiz.skillsCreated', { list: report.skills_created.join(', ') })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const flagged = report.rows.filter((r) => r.issues.length > 0);
|
||||||
|
if (flagged.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-[11px] opacity-70">
|
||||||
|
{t('config.vol.wiz.noIssues')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.vol.wiz.rowsWithIssues')}</FieldLabel>
|
||||||
|
<div className="flex max-h-[280px] flex-col gap-[6px] overflow-auto border-2 border-ink p-[8px]">
|
||||||
|
{flagged.map((r) => (
|
||||||
|
<div key={r.line} className="font-mono text-[10.5px]">
|
||||||
|
<span className="font-bold">{t('config.vol.wiz.line', { line: r.line })}</span>{' '}
|
||||||
|
<span className="opacity-60">· {r.action}</span>
|
||||||
|
<ul className="ml-3 list-disc">
|
||||||
|
{r.issues.map((issue, k) => (
|
||||||
|
<li
|
||||||
|
key={k}
|
||||||
|
className={cx(
|
||||||
|
issue.severity === 'ERROR' && 'font-bold text-orange-dark',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(issue.code, issueParams(issue))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{execute.error ? <ErrorNote error={execute.error} /> : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{report.dry_run ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void run(false)}
|
||||||
|
disabled={execute.isPending}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-green px-[14px] py-[10px] font-display text-[12px] font-black uppercase tracking-wide text-white shadow-brutal hover:bg-ink disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{t('config.vol.wiz.runImport')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={restart}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
|
||||||
|
>
|
||||||
|
{t('config.vol.wiz.back')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-[10px] font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
|
||||||
|
>
|
||||||
|
{t('config.vol.wiz.close')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user