From 197ee0d5b19495a449dd606001898005525deaec Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 22 Jan 2026 22:01:03 +0100 Subject: [PATCH] feat: register import --- .../domain/service/CompetitionService.java | 120 ++++++++- .../ffsaf/rest/CompetitionEndpoints.java | 10 + .../ffsaf/rest/data/RegisterRequestData.java | 2 +- .../ffsaf/rest/data/SimpleRegisterComb.java | 2 + src/main/webapp/public/locales/en/common.json | 81 +++++- src/main/webapp/public/locales/fr/common.json | 85 +++++- src/main/webapp/src/components/FileImport.jsx | 241 ++++++++++++++++++ .../competition/CompetitionRegisterAdmin.jsx | 142 ++++++++++- src/main/webapp/src/utils/Tools.js | 29 +++ 9 files changed, 695 insertions(+), 17 deletions(-) create mode 100644 src/main/webapp/src/components/FileImport.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java index 7282864..110482e 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java @@ -367,7 +367,7 @@ public class CompetitionService { c.getBanMembre().remove(combModel.getId()); return Panache.withTransaction(() -> repository.persist(c)); }) - .chain(combModel -> updateRegister(data, c, combModel, true))) + .chain(combModel -> updateRegister(data, c, combModel, true, false))) .map(r -> SimpleRegisterComb.fromModel(r, r.getMembre().getLicences()) .setCategorieInscrite(r.getCategoriesInscrites())); } else { @@ -390,9 +390,9 @@ public class CompetitionService { model.setClub(data.getClub()); model.setCountry(data.getCountry()); model.setWeightReal(data.getWeightReal()); + model.setCategorie(data.getCategorie()); if (model.getCompetition().getRequiredWeight().contains(model.getCategorie())) model.setWeight(data.getWeight()); - model.setCategorie(data.getCategorie()); }) .call(g -> Mutiny.fetch(g.getCategoriesInscrites())) .call(g -> catPresetRepository.list("competition = ?1 AND id IN ?2", g.getCompetition(), @@ -427,7 +427,7 @@ public class CompetitionService { if (c.getBanMembre().contains(model.getId())) throw new DForbiddenException(trad.t("insc.err1")); })) - .chain(combModel -> updateRegister(data, c, combModel, false))) + .chain(combModel -> updateRegister(data, c, combModel, false, false))) .map(r -> SimpleRegisterComb.fromModel(r, r.getMembre().getLicences()) .setCategorieInscrite(r.getCategoriesInscrites())); @@ -443,19 +443,101 @@ public class CompetitionService { if (c.getBanMembre().contains(model.getId())) throw new DForbiddenException(trad.t("insc.err2")); })) - .chain(combModel -> updateRegister(data, c, combModel, false))) + .chain(combModel -> updateRegister(data, c, combModel, false, false))) .map(r -> SimpleRegisterComb.fromModel(r, List.of()).setCategorieInscrite(r.getCategoriesInscrites())); } + public Uni> addRegistersComb(SecurityCtx securityCtx, Long id, + List datas, + String source) { + if (!"admin".equals(source)) + return Uni.createFrom().failure(new DForbiddenException()); + + return Multi.createFrom().iterable(datas).onItem().transformToUni(data -> + makeImportUpdate(securityCtx, id, data).onFailure().recoverWithItem(t -> { + SimpleRegisterComb errorComb = new SimpleRegisterComb(); + errorComb.setLicence(-42); + errorComb.setFname("ERROR"); + errorComb.setLname(t.getMessage()); + return errorComb; + })).concatenate().collect().asList(); + } + + private Uni makeImportUpdate(SecurityCtx securityCtx, Long id, RegisterRequestData data) { + if (data.getLicence() == null || data.getLicence() != -1) { // not a guest + return permService.hasEditPerm(securityCtx, id) + .chain(c -> findComb(data.getLicence(), data.getFname(), data.getLname()) + .call(combModel -> Mutiny.fetch(combModel.getLicences())) + .call(combModel -> { + if (c.getBanMembre() == null) + c.setBanMembre(new ArrayList<>()); + c.getBanMembre().remove(combModel.getId()); + return Panache.withTransaction(() -> repository.persist(c)); + }) + .chain(combModel -> updateRegister(data, c, combModel, true, true))) + .map(r -> SimpleRegisterComb.fromModel(r, r.getMembre().getLicences()) + .setCategorieInscrite(r.getCategoriesInscrites())); + } else { + return permService.hasEditPerm(securityCtx, id) + .chain(c -> findGuestOrInit(data.getFname(), data.getLname(), c)) + .invoke(Unchecked.consumer(model -> { + if (data.getCategorie() == null) + throw new DBadRequestException(trad.t("categorie.requise")); + model.setCategorie(data.getCategorie()); + + if (data.getGenre() == null) { + if (model.getGenre() == null) + data.setGenre(Genre.NA); + } else + model.setGenre(data.getGenre()); + + if (data.getClub() == null) { + if (model.getClub() == null) + data.setClub(""); + } else + model.setClub(data.getClub()); + + if (data.getCountry() == null) { + if (model.getCountry() == null) + data.setCountry("FR"); + } else + model.setCountry(data.getCountry()); + + if (model.getCompetition().getRequiredWeight().contains(model.getCategorie())) { + if (data.getCountry() != null) + model.setWeight(data.getWeight()); + } + })) + .call(g -> Mutiny.fetch(g.getCategoriesInscrites())) + .call(g -> catPresetRepository.list("competition = ?1 AND id IN ?2", g.getCompetition(), + data.getCategoriesInscrites()) + .invoke(cats -> { + g.getCategoriesInscrites().clear(); + g.getCategoriesInscrites().addAll(cats); + g.getCategoriesInscrites().removeIf(cat -> cat.getCategories().stream() + .noneMatch(e -> e.getCategorie().equals(g.getCategorie()))); + })) + .chain(model -> Panache.withTransaction(() -> competitionGuestRepository.persist(model)) + .call(r -> model.getCompetition().getSystem() == CompetitionSystem.INTERNAL ? + sRegister.sendRegister(model.getCompetition().getUuid(), + r) : Uni.createFrom().voidItem())) + .map(g -> SimpleRegisterComb.fromModel(g).setCategorieInscrite(g.getCategoriesInscrites())); + } + } + private Uni updateRegister(RegisterRequestData data, CompetitionModel c, - MembreModel combModel, boolean admin) { + MembreModel combModel, boolean admin, boolean append) { return registerRepository.find("competition = ?1 AND membre = ?2", c, combModel).firstResult() .onFailure().recoverWithNull() .map(Unchecked.function(r -> { if (r != null) { if (!admin && r.isLockEdit()) throw new DForbiddenException(trad.t("insc.err3")); - r.setOverCategory(data.getOverCategory()); + if (data.getOverCategory() != null || !append) + if (data.getOverCategory() == null) + r.setOverCategory(0); + else + r.setOverCategory(data.getOverCategory()); r.setCategorie( (combModel.getBirth_date() == null) ? combModel.getCategorie() : Utils.getCategoryFormBirthDate(combModel.getBirth_date(), @@ -464,7 +546,8 @@ public class CompetitionService { if (days > -7) r.setClub(combModel.getClub()); if (c.getRequiredWeight().contains(r.getCategorie2())) - r.setWeight(data.getWeight()); + if (data.getCountry() != null || !append) + r.setWeight(data.getWeight()); if (admin) { r.setWeightReal(data.getWeightReal()); r.setLockEdit(data.isLockEdit()); @@ -510,6 +593,27 @@ public class CompetitionService { sRegister.sendRegister(c.getUuid(), r) : Uni.createFrom().voidItem()); } + private Uni findGuestOrInit(String fname, String lname, CompetitionModel competition) { + if (fname == null || lname == null) + return Uni.createFrom().failure(new DBadRequestException(trad.t("nom.et.prenom.requis"))); + return competitionGuestRepository.find( + "unaccent(lname) ILIKE unaccent(?1) AND unaccent(fname) ILIKE unaccent(?2) AND competition = ?3", + lname, fname, competition).firstResult() + .map(guestModel -> { + if (guestModel == null) { + CompetitionGuestModel model = new CompetitionGuestModel(); + model.setFname(fname); + if (lname.equals("__team")) + model.setLname("_team"); + else + model.setLname(lname); + model.setCompetition(competition); + return model; + } + return guestModel; + }); + } + private Uni findComb(Long licence, String fname, String lname) { if (licence != null && licence > 0) { return combRepository.find("licence = ?1", licence).firstResult() @@ -821,7 +925,7 @@ public class CompetitionService { .call(m -> Panache.withTransaction(() -> helloAssoRepository.persist( new HelloAssoRegisterModel(cm, m, data.getId())))) - .chain(m -> updateRegister(req, cm, m, true))) + .chain(m -> updateRegister(req, cm, m, true, true))) .onFailure().recoverWithItem(throwable -> { fail.add("%s %s - licence n°%d".formatted(item.getUser().getLastName(), item.getUser().getFirstName(), optional.get())); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java index c2785af..5a21ab2 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java @@ -50,6 +50,16 @@ public class CompetitionEndpoints { return service.addRegisterComb(securityCtx, id, data, source); } + @POST + @Path("{id}/registers/{source}") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + @Operation(hidden = true) + public Uni> addRegistersComb(@PathParam("id") Long id, @PathParam("source") String source, + List data) { + return service.addRegistersComb(securityCtx, id, data, source); + } + @DELETE @Path("{id}/register/{comb_id}/{source}") @Authenticated diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java index 0665be5..93d20e5 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java @@ -20,7 +20,7 @@ public class RegisterRequestData { private Integer weight; private Integer weightReal; - private int overCategory; + private Integer overCategory; private boolean lockEdit = false; private List categoriesInscrites; diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleRegisterComb.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleRegisterComb.java index b4b3b21..0dd5799 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleRegisterComb.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleRegisterComb.java @@ -8,12 +8,14 @@ import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @AllArgsConstructor +@NoArgsConstructor @RegisterForReflection public class SimpleRegisterComb { private long id; diff --git a/src/main/webapp/public/locales/en/common.json b/src/main/webapp/public/locales/en/common.json index 7e137bd..f7f7f12 100644 --- a/src/main/webapp/public/locales/en/common.json +++ b/src/main/webapp/public/locales/en/common.json @@ -124,6 +124,7 @@ "catégorie": "Category", "catégorieàAjouter": "Category to add", "certificatMédical": "Medical certificate", + "champAttendu": "Expected field", "chargement...": "Loading...", "chargerLexcel": "Load Excel", "chargerLexcel.msg": "Please use the file above as a template; do not rename the columns or modify the license numbers.", @@ -154,6 +155,7 @@ "club_one": "Club", "club_other": "Clubs", "club_zero": "No club", + "colonneDansLeFichier": "Column in the file", "combattant": "fighter", "comp.aff.blason": "Display the club's coat of arms on screens", "comp.aff.flag": "Display the fighter's country on screens", @@ -231,6 +233,8 @@ "comp.toast.register.add.error": "Fighter not found", "comp.toast.register.add.pending": "Search in progress", "comp.toast.register.add.success": "Fighter found and added/updated", + "comp.toast.register.addMultiple.success_one": "Successful import for 1 fighter", + "comp.toast.register.addMultiple.success_other": "Successful import for {{count}} fighters", "comp.toast.register.ban.error": "Error", "comp.toast.register.ban.pending": "Unregistration in progress", "comp.toast.register.ban.success": "Fighter unregistered and banned", @@ -300,6 +304,7 @@ "erreurDePaiement": "Payment error😕", "erreurDePaiement.detail": "Error message:", "erreurDePaiement.msg": "An error occurred while processing your payment. Please try again later.", + "erreurPourLinscription": "Registration error", "espaceAdministration": "Administration space", "f": "F", "faitPar": "Done by", @@ -322,6 +327,9 @@ }, "homme": "Male", "horairesD'entraînements": "Training schedules", + "importationDuFichier": "Importing the file", + "importerDesCombattants": "Import fighters", + "importerDesInvités": "Import guests", "information": "Information", "invité": "guest", "keepEmpty": "Leave blank to make no changes.", @@ -330,6 +338,8 @@ "licenceNo": "License no. {{no}}", "lieu": "Place", "lieuxDentraînements": "Training locations", + "ligneIgnorée1": "Line ignored: missing name, first name or category.", + "ligneIgnorée2": "Line ignored: missing first name or license.", "loading": "Loading...", "me": { "result": { @@ -458,6 +468,8 @@ "nouveauClub": "New club", "nouveauMembre": "New member", "nouvelEmail": "New email", + "numéroDeLaLigneDentête": "Header line number", + "numéroDeLigne": "Line number", "ou": "or", "oui": "Yes", "outdated_session": { @@ -493,6 +505,7 @@ "peutSinscrire": "Can register?", "photos": "Photos", "plastron": "Breastplate", + "poids": "Weight", "poidsDemandéPour": "Weight required for", "prenom": "First name", "protectionDeBras": "Arm protection", @@ -577,9 +590,75 @@ "validerLicence_other": "Validate the {{count}} selected licenses", "validerLicence_zero": "$t(validerLicence_other)", "validée": "Validated", + "veuillezAssocierChaqueChampàUneColonneDuFichier": "Please associate each field with a column in the file", + "veuillezIndiqueràQuelle": "Please indicate on which line the headers are located in the file", + "veuillezMapperLesColonnesSuivantes": "Please map the following columns", "voir/modifierLesParticipants": "View/Edit participants", "voirLesStatues": "View statues", "vousNêtesPasEncoreInscrit": "You are not yet registered or your registration has not yet been entered on the intranet", "à": "at", - "étatDeLaDemande": "Request status" + "étatDeLaDemande": "Request status", + "fileImport.variants": { + "licence": [ + "license", + "licence", + "license number", + "license ID", + "ID license", + "licence no" + ], + "pays": [ + "country", + "pays", + "country of residence", + "origin country" + ], + "nom": [ + "last name", + "nom", + "family name", + "surname", + "lastname" + ], + "prenom": [ + "first name", + "prénom", + "given name", + "first given name" + ], + "genre": [ + "gender", + "genre", + "sex", + "civility" + ], + "weight": [ + "weight", + "poids", + "weight (kg)", + "actual weight", + "mass" + ], + "categorie": [ + "category", + "catégorie", + "weight category", + "age category" + ], + "overCategory": [ + "over category", + "surclassement", + "category override", + "over classification" + ], + "club": [ + "club", + "club name", + "association", + "association name" + ] + }, + "comp.toast.registers.addMultiple.error": "Import failed", + "comp.toast.registers.addMultiple.pending": "Import in progress", + "comp.toast.registers.addMultiple.success": "Import completed successfully 🎉" } diff --git a/src/main/webapp/public/locales/fr/common.json b/src/main/webapp/public/locales/fr/common.json index 5355b57..8a11036 100644 --- a/src/main/webapp/public/locales/fr/common.json +++ b/src/main/webapp/public/locales/fr/common.json @@ -124,6 +124,7 @@ "catégorie": "Catégorie", "catégorieàAjouter": "Catégorie à ajouter", "certificatMédical": "Certificat médical", + "champAttendu": "Champ attendu", "chargement...": "Chargement...", "chargerLexcel": "Charger l'Excel", "chargerLexcel.msg": "Merci d'utiliser le fichier ci-dessus comme base, ne pas renommer les colonnes ni modifier les n° de licences.", @@ -154,6 +155,7 @@ "club_one": "Club", "club_other": "Clubs", "club_zero": "Sans club", + "colonneDansLeFichier": "Colonne dans le fichier", "combattant": "combattant", "comp.aff.blason": "Afficher le blason du club sur les écrans", "comp.aff.flag": "Afficher le pays du combattant sur les écrans", @@ -231,6 +233,8 @@ "comp.toast.register.add.error": "Combattant non trouvé", "comp.toast.register.add.pending": "Recherche en cours", "comp.toast.register.add.success": "Combattant trouvé et ajouté/mis à jour", + "comp.toast.register.addMultiple.success_one": "Importation réussie pour 1 combattant", + "comp.toast.register.addMultiple.success_other": "Importation réussie pour {{count}} combattants", "comp.toast.register.ban.error": "Erreur", "comp.toast.register.ban.pending": "Désinscription en cours", "comp.toast.register.ban.success": "Combattant désinscrit et bannie", @@ -300,6 +304,7 @@ "erreurDePaiement": "Erreur de paiement😕", "erreurDePaiement.detail": "Message d'erreur :", "erreurDePaiement.msg": "Une erreur est survenue lors du traitement de votre paiement. Veuillez réessayer plus tard.", + "erreurPourLinscription": "Erreur pour l'inscription", "espaceAdministration": "Espace administration", "f": "F", "faitPar": "Fait par", @@ -322,6 +327,9 @@ }, "homme": "Homme", "horairesD'entraînements": "Horaires d'entraînements", + "importationDuFichier": "Importation du fichier", + "importerDesCombattants": "Importer des combattants", + "importerDesInvités": "Importer des invités", "information": "Information", "invité": "invité", "keepEmpty": "Laissez vide pour ne rien changer.", @@ -330,6 +338,8 @@ "licenceNo": "Licence n°{{no}}", "lieu": "Lieu", "lieuxDentraînements": "Lieux d'entraînements", + "ligneIgnorée1": "Ligne ignorée : nom, prénom ou catégorie manquante.", + "ligneIgnorée2": "Ligne ignorée : nom prénom ou licence manquante.", "loading": "Chargement...", "me": { "result": { @@ -458,6 +468,8 @@ "nouveauClub": "Nouveau club", "nouveauMembre": "Nouveau membre", "nouvelEmail": "Nouvel email", + "numéroDeLaLigneDentête": "Numéro de la ligne d'en-tête", + "numéroDeLigne": "Numéro de ligne", "ou": "Ou", "oui": "Oui", "outdated_session": { @@ -493,6 +505,7 @@ "peutSinscrire": "Peut s'inscrire?", "photos": "Photos", "plastron": "Plastron", + "poids": "Poids", "poidsDemandéPour": "Poids demandé pour", "prenom": "Prénom", "protectionDeBras": "Protection de bras", @@ -577,9 +590,79 @@ "validerLicence_other": "Valider les {{count}} licences sélectionnées", "validerLicence_zero": "$t(validerLicence_other)", "validée": "Validée", + "veuillezAssocierChaqueChampàUneColonneDuFichier": "Veuillez associer chaque champ à une colonne du fichier", + "veuillezIndiqueràQuelle": "Veuillez indiquer à quelle ligne se trouvent les en-têtes dans le fichier", + "veuillezMapperLesColonnesSuivantes": "Veuillez mapper les colonnes suivantes", "voir/modifierLesParticipants": "Voir/Modifier les participants", "voirLesStatues": "Voir les statues", "vousNêtesPasEncoreInscrit": "Vous n'êtes pas encore inscrit ou votre inscription n'a pas encore été rentrée sur l'intranet", "à": "à", - "étatDeLaDemande": "État de la demande" + "étatDeLaDemande": "État de la demande", + "fileImport.variants": { + "licence": [ + "licence", + "n° licence", + "num licence", + "id licence", + "license", + "licence id" + ], + "pays": [ + "pays", + "country", + "pays de résidence", + "pays d'origine" + ], + "nom": [ + "nom", + "nom de famille", + "lastname", + "family name", + "nom complet" + ], + "prenom": [ + "prénom", + "prenom", + "first name", + "given name", + "prénom usuel" + ], + "genre": [ + "genre", + "sexe", + "gender", + "sex", + "civilité" + ], + "weight": [ + "poids", + "weight", + "poids (kg)", + "poids réel", + "masse" + ], + "categorie": [ + "catégorie", + "category", + "catégorie de poids", + "weight category", + "catégorie d'âge" + ], + "overCategory": [ + "surclassement", + "over category", + "surcatégorie", + "surclassement de catégorie" + ], + "club": [ + "club", + "nom du club", + "club name", + "association", + "nom de l'association" + ] + }, + "comp.toast.registers.addMultiple.error": "Erreur lors de l'importation des combattants", + "comp.toast.registers.addMultiple.pending": "Importation des combattants en cours...", + "comp.toast.registers.addMultiple.success": "Importation des combattants réussie 🎉" } diff --git a/src/main/webapp/src/components/FileImport.jsx b/src/main/webapp/src/components/FileImport.jsx new file mode 100644 index 0000000..24f3552 --- /dev/null +++ b/src/main/webapp/src/components/FileImport.jsx @@ -0,0 +1,241 @@ +import React, {useId, useRef, useState} from "react"; +import {toast} from "react-toastify"; +import * as XLSX from "xlsx"; +import {useTranslation} from "react-i18next"; + +const parseValue = (value, type) => { + if (value === undefined || value === null) + return null; + + switch (type) { + case 'Integer': + if (value === '') + return null; + const parsedInt = parseInt(value, 10); + return isNaN(parsedInt) ? null : parsedInt; + case 'Boolean': + if (typeof value === 'boolean') + return value; + if (typeof value === 'string') { + const lowerValue = value.toLowerCase().trim(); + if (lowerValue === 'oui' || lowerValue === 'true' || lowerValue === '1' || lowerValue === 'x') { + return true; + } else if (lowerValue === 'non' || lowerValue === 'false' || lowerValue === '0' || lowerValue === '') { + return false; + } + } + return null; + case 'Date': + if (value === '') + return null; + if (typeof value === 'string') { + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; + } + return null; + case 'String': + default: + return String(value); + } +}; + +export function FileImport({onDataMapped, expectedFields, textButton}) { + const id = useId(); + const [headerLineNumber, setHeaderLineNumber] = useState(1); + const [fileData, setFileData] = useState([]); + const [fileHeaders, setFileHeaders] = useState([]); + const [fileName, setFileName] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [columnMappings, setColumnMappings] = useState({}); + const fileChooser = useRef(null); + const openMappingModal = useRef(null); + const closeMappingModal = useRef(null); + const openHeaderLineModal = useRef(null); + const {t} = useTranslation(); + + + // Fonction pour trouver la meilleure correspondance + const findBestMatch = (fileHeaders, expectedField) => { + const fieldLabel = expectedField.label.toLowerCase(); + const fieldKey = expectedField.key.toLowerCase(); + + // Variantes possibles pour chaque champ (ex: "Nom" peut être "nom", "Nom de famille", etc.) + const variants = { + licence: t('fileImport.variants.licence', {returnObjects: true}), + pays: t('fileImport.variants.pays', {returnObjects: true}), + nom: t('fileImport.variants.nom', {returnObjects: true}), + prenom: t('fileImport.variants.prenom', {returnObjects: true}), + genre: t('fileImport.variants.genre', {returnObjects: true}), + weight: t('fileImport.variants.weight', {returnObjects: true}), + categorie: t('fileImport.variants.categorie', {returnObjects: true}), + overCategory: t('fileImport.variants.overCategory', {returnObjects: true}), + club: t('fileImport.variants.club', {returnObjects: true}), + }; + + // Recherche de la meilleure correspondance + for (const header of fileHeaders) { + const lowerHeader = header.toLowerCase(); + if (lowerHeader === fieldLabel || lowerHeader === fieldKey || (variants[fieldKey] && variants[fieldKey].includes(lowerHeader))) { + return header; + } + } + + // Aucune correspondance trouvée + return null; + }; + + // Gestion du fichier sélectionné + const handleFileChange = (e) => { + const file = e.target.files[0]; + if (!file) return; + + setSelectedFile(file); + setFileName(file.name); + openHeaderLineModal.current.click(); + }; + + // Valider le numéro de la ligne d'en-tête et lire le fichier + const handleHeaderLineSubmit = () => { + if (!selectedFile) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const data = event.target.result; + const workbook = XLSX.read(data, {type: 'binary'}); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(sheet, {header: 1}); + + // Extraire les en-têtes et les données en fonction du numéro de ligne + const headers = jsonData[headerLineNumber - 1]; + const rows = jsonData.slice(headerLineNumber); + setFileHeaders(headers); + setFileData(rows); + + // Initialiser le mapping avec pré-remplissage intelligent + const initialMappings = {}; + expectedFields.forEach(field => { + const bestMatch = findBestMatch(headers, field); + initialMappings[field.key] = bestMatch || ''; + }); + setColumnMappings(initialMappings); + + openMappingModal.current.click(); + fileChooser.current.value = ''; + }; + reader.readAsBinaryString(selectedFile); + }; + + // Mettre à jour le mapping d'une colonne + const handleMappingChange = (fieldKey, header) => { + setColumnMappings({ + ...columnMappings, + [fieldKey]: header, + }); + }; + + // Valider le mapping et envoyer les données + const handleSubmit = () => { + // Vérifier que tous les champs requis sont mappés + const missingMappings = expectedFields + .filter(field => field.mandatory && !columnMappings[field.key]) + .map(field => field.label); + + if (missingMappings.length > 0) { + toast.error(`${t('veuillezMapperLesColonnesSuivantes')} : ${missingMappings.join(', ')}`); + return; + } + + // Préparer les données mappées et parsées + const mappedData = fileData.map(row => { + const mappedRow = {}; + expectedFields.forEach(field => { + const headerIndex = fileHeaders.indexOf(columnMappings[field.key]); + const rawValue = headerIndex !== -1 ? row[headerIndex] : ''; + mappedRow[field.key] = parseValue(rawValue, field.type); + }); + return mappedRow; + }); + + // Envoyer les données au parent ou au backend + onDataMapped(mappedData); + closeMappingModal.current.click(); + }; + + const handleFileChooser = () => { + fileChooser.current.click(); + } + + return
+ + + + + + + + + +
+} diff --git a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx index 5a37561..93c8969 100644 --- a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx @@ -4,7 +4,7 @@ import {useFetch} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; import {ThreeDots} from "react-loader-spinner"; import React, {useEffect, useId, useReducer, useRef, useState} from "react"; -import {apiAxios, applyOverCategory, CatList, getCatName, getToastMessage} from "../../utils/Tools.js"; +import {apiAxios, applyOverCategory, CatList, getCatFromName, getCatName, getToastMessage} from "../../utils/Tools.js"; import {toast} from "react-toastify"; import {SimpleReducer} from "../../utils/SimpleReducer.jsx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; @@ -14,6 +14,7 @@ import * as XLSX from "xlsx-js-style"; import {useCountries} from "../../hooks/useCountries.jsx"; import {Trans, useTranslation} from "react-i18next"; import {Checkbox} from "../../components/MemberCustomFiels.jsx"; +import {FileImport} from "../../components/FileImport.jsx"; export function CompetitionRegisterAdmin({source}) { const {id} = useParams() @@ -55,6 +56,26 @@ export function CompetitionRegisterAdmin({source}) { return response.data }) } + const sendRegisters = (new_state) => { + toast.promise(apiAxios.post(`/competition/${id}/registers/${source}`, new_state), getToastMessage("comp.toast.registers.addMultiple") + ).then((response) => { + if (response.data.error) + return; + + let i = 0; + response.data.forEach((d) => { + if (d.licence === -42) { + toast.warn(t('erreurPourLinscription') + " :" + d.lname, {autoClose: false}); + } else { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: d}}) + i++; + } + }) + if (i > 0) + toast.success(t('comp.toast.register.addMultiple.success', {count: i})) + dispatch({type: 'SORT', payload: sortName}) + }) + } return

{t('comp.combattantsInscrits')}

@@ -98,12 +119,14 @@ export function CompetitionRegisterAdmin({source}) { filterNotWeight={filterNotWeight} setFilterNotWeight={setFilterNotWeight} source={source}/>
- {source === "admin" && } + {source === "admin" &&
} + {source === "admin" &&
} + {source === "admin" &&
} - + } @@ -346,7 +369,7 @@ function CategoriesList({error2, availableCats, fistCatInput, categories, setCat } -function Modal({data2, data3, error2, sendRegister, modalState, setModalState, source}) { +function Modal_({data2, data3, error2, sendRegister, modalState, setModalState, source}) { const country = useCountries('fr') const {t} = useTranslation(); const closeBtn = useRef(null); @@ -739,7 +762,7 @@ function FileOutput({data, data2}) { const dataOut = [] for (const e of data) { const tmp = { - licence: e.licence, + licence: e.id <= 0 ? -1 : e.licence, pays: e.country, nom: e.lname, prenom: e.fname, @@ -805,6 +828,113 @@ function FileOutput({data, data2}) { ); } +function FileImportGuest({data2, sendRegisters}) { + const {t} = useTranslation(); + + const expectedFields = [ + {key: 'nom', label: t('nom'), mandatory: true, type: 'String'}, + {key: 'prenom', label: t('prenom'), mandatory: true, type: 'String'}, + {key: 'pays', label: t('pays'), mandatory: false, type: 'String'}, + {key: 'genre', label: t('genre'), mandatory: false, type: 'String'}, + {key: 'weight', label: t('poids'), mandatory: false, type: 'Integer'}, + {key: 'categorie', label: t('catégorie'), mandatory: true, type: 'String'}, + {key: 'club', label: t('club', {count: 1}), mandatory: false, type: 'String'}, + ]; + + if (data2) + data2.forEach(row => { + expectedFields.push({key: "__" + row.id, label: row.name, mandatory: false, type: 'Boolean'}) + }) + + + const onDataMapped = (mappedData) => { + const out = [] + mappedData.forEach(row => { + if (!row.nom || !row.prenom || !row.categorie) { + toast.warn(t('ligneIgnorée1')) + return; + } + + const categoriesInscrites = [] + data2.forEach(cat => { + if (row["__" + cat.id]) { + categoriesInscrites.push(cat.id) + } + delete row["__" + cat.id] + }) + out.push({ + id: 0, + licence: -1, + fname: row.prenom.trim(), + lname: row.nom.trim(), + country: row.pays ? row.pays.trim() : "FR", + genre: row.genre ? row.genre.trim() : "NA", + categorie: getCatFromName(row.categorie.trim()), + club: row.club ? row.club.trim() : "", + weight: row.weight, + overCategory: 0, + lockEdit: false, + categoriesInscrites: categoriesInscrites + }) + }) + + sendRegisters(out) + } + + return +} + +function FileImportComb({data2, sendRegisters}) { + const {t} = useTranslation(); + + const expectedFields = [ + {key: 'licence', label: t('licence'), mandatory: true, type: 'Integer'}, + {key: 'nom', label: t('nom'), mandatory: true, type: 'String'}, + {key: 'prenom', label: t('prenom'), mandatory: true, type: 'String'}, + {key: 'weight', label: t('poids'), mandatory: false, type: 'Integer'}, + {key: 'overCategory', label: t('comp.modal.surclassement'), mandatory: false, type: 'Integer'}, + ]; + + if (data2) + data2.forEach(row => { + expectedFields.push({key: "__" + row.id, label: row.name, mandatory: false, type: 'Boolean'}) + }) + + const onDataMapped = (mappedData) => { + const out = [] + mappedData.forEach(row => { + if (row.licence && row.licence <= 0) + return; + if (!(row.licence || (row.nom && row.prenom))) { + toast.warn(t('ligneIgnorée2')) + return; + } + + const categoriesInscrites = [] + data2.forEach(cat => { + if (row["__" + cat.id]) { + categoriesInscrites.push(cat.id) + } + delete row["__" + cat.id] + }) + out.push({ + id: 0, + licence: row.licence, + fname: row.prenom.trim(), + lname: row.nom.trim(), + weight: row.weight, + overCategory: row.overCategory, + lockEdit: false, + categoriesInscrites: categoriesInscrites + }) + }) + + sendRegisters(out) + } + + return +} + function Def() { return
  • diff --git a/src/main/webapp/src/utils/Tools.js b/src/main/webapp/src/utils/Tools.js index 19434e0..af3c5cf 100644 --- a/src/main/webapp/src/utils/Tools.js +++ b/src/main/webapp/src/utils/Tools.js @@ -137,6 +137,35 @@ export function getCatName(cat) { } } +export function getCatFromName(name) { + switch (name.toLowerCase()) { + case i18n.t('cat.superMini').toLowerCase(): + return "SUPER_MINI"; + case i18n.t('cat.miniPoussin').toLowerCase(): + return "MINI_POUSSIN"; + case i18n.t('cat.poussin').toLowerCase(): + return "POUSSIN"; + case i18n.t('cat.benjamin').toLowerCase(): + return "BENJAMIN"; + case i18n.t('cat.minime').toLowerCase(): + return "MINIME"; + case i18n.t('cat.cadet').toLowerCase(): + return "CADET"; + case i18n.t('cat.junior').toLowerCase(): + return "JUNIOR"; + case i18n.t('cat.senior1').toLowerCase(): + return "SENIOR1"; + case i18n.t('cat.senior2').toLowerCase(): + return "SENIOR2"; + case i18n.t('cat.vétéran1').toLowerCase(): + return "VETERAN1"; + case i18n.t('cat.vétéran2').toLowerCase(): + return "VETERAN2"; + default: + return name; + } +} + export const SwordList = [ "NONE", "ONE_HAND",