From 11dca5630c05a87a2cedde91e54e1cb6e00caa41 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 20 Aug 2025 16:48:11 +0200 Subject: [PATCH 1/4] feat: register for club and free mode wip: helloasso --- .../ffsaf/data/model/CompetitionModel.java | 3 + .../ffsaf/data/model/RegisterModel.java | 3 + .../ffsaf/domain/service/ClubService.java | 18 +- .../domain/service/CompetitionService.java | 213 +++++++--- .../titionfire/ffsaf/rest/ClubEndpoints.java | 17 + .../ffsaf/rest/CompetitionEndpoints.java | 20 +- .../ffsaf/rest/data/RegisterRequestData.java | 1 + .../ffsaf/rest/data/SimpleRegisterComb.java | 4 +- .../ffsaf/rest/data/VerySimpleMembre.java | 18 + .../src/pages/competition/CompetitionList.jsx | 2 +- .../competition/CompetitionRegisterAdmin.css | 36 ++ .../competition/CompetitionRegisterAdmin.jsx | 373 +++++++++++++----- .../src/pages/competition/CompetitionRoot.jsx | 8 +- .../src/pages/competition/CompetitionView.jsx | 118 +++++- 14 files changed, 653 insertions(+), 181 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/VerySimpleMembre.java create mode 100644 src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.css diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java index c5701d7..77b4b94 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java @@ -9,6 +9,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -54,6 +55,8 @@ public class CompetitionModel { @OneToMany(mappedBy = "competition", fetch = FetchType.LAZY, cascade = CascadeType.ALL) List insc; + List banMembre = new ArrayList<>(); + String owner; String data1; diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/RegisterModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/RegisterModel.java index 57407c2..19f84ab 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/RegisterModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/RegisterModel.java @@ -40,6 +40,9 @@ public class RegisterModel { @JoinColumn(name = "club") ClubModel club = null; + @Column(nullable = false, columnDefinition = "boolean default false") + boolean lockEdit = false; + public RegisterModel(CompetitionModel competition, MembreModel membre, Integer weight, int overCategory, Categorie categorie, ClubModel club) { this.id = new RegisterId(competition.getId(), membre.getId()); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 82ff6d9..40a6155 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -10,10 +10,7 @@ import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.net2.request.SReqClub; -import fr.titionfire.ffsaf.rest.data.ClubMapData; -import fr.titionfire.ffsaf.rest.data.DeskMember; -import fr.titionfire.ffsaf.rest.data.RenewAffData; -import fr.titionfire.ffsaf.rest.data.SimpleClubList; +import fr.titionfire.ffsaf.rest.data.*; import fr.titionfire.ffsaf.rest.exception.DBadRequestException; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.exception.DNotFoundException; @@ -144,6 +141,19 @@ public class ClubService { .toList()); } + public Uni> getMembers(SecurityCtx securityCtx) { + return combRepository.find("userId = ?1", securityCtx.getSubject()).firstResult() + .invoke(Unchecked.consumer(m -> { + if (m == null || m.getClub() == null) + throw new DNotFoundException("Club non trouvé"); + if (!securityCtx.isInClubGroup(m.getClub().getId())) + throw new DForbiddenException(); + })) + .chain(m -> combRepository.list("club = ?1", m.getClub())) + .map(membreModels -> membreModels.stream() + .map(m -> new VerySimpleMembre(m.getLname(), m.getFname(), m.getLicence())).toList()); + } + public Uni updateOfUser(SecurityCtx securityCtx, PartClubForm form) { TypeReference> typeRef = new TypeReference<>() { }; 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 f1f6751..f01590a 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java @@ -59,6 +59,9 @@ public class CompetitionService { @Inject ServerCustom serverCustom; + @Inject + MembreService membreService; + @Inject CompetPermService permService; @@ -196,6 +199,9 @@ public class CompetitionService { } private void copyData(CompetitionData data, CompetitionModel model) { + if (model.getBanMembre() == null) + model.setBanMembre(new ArrayList<>()); + model.setName(data.getName()); model.setAdresse(data.getAdresse()); model.setDescription(data.getDescription()); @@ -211,55 +217,120 @@ public class CompetitionService { model.setData4(data.getData4()); } - public Uni> getRegister(SecurityCtx securityCtx, Long id) { - return permService.hasEditPerm(securityCtx, id) - .chain(c -> Mutiny.fetch(c.getInsc())) - .onItem().transformToMulti(Multi.createFrom()::iterable) - .onItem().call(combModel -> Mutiny.fetch(combModel.getMembre().getLicences())) - .map(combModel -> SimpleRegisterComb.fromModel(combModel, combModel.getMembre().getLicences())) - .collect().asList(); + public Uni> getRegister(SecurityCtx securityCtx, Long id, String source) { + if ("admin".equals(source)) + return permService.hasEditPerm(securityCtx, id) + .chain(c -> Mutiny.fetch(c.getInsc())) + .onItem().transformToMulti(Multi.createFrom()::iterable) + .onItem().call(combModel -> Mutiny.fetch(combModel.getMembre().getLicences())) + .map(combModel -> SimpleRegisterComb.fromModel(combModel, combModel.getMembre().getLicences())) + .collect().asList(); + if ("club".equals(source)) + return Uni.createFrom().nullItem() + .invoke(Unchecked.consumer(__ -> { + if (!securityCtx.isClubAdmin()) + throw new DForbiddenException(); + })) + .chain(__ -> membreService.getByAccountId(securityCtx.getSubject())) + .chain(model -> registerRepository.list("competition.id = ?1 AND membre.club = ?2", id, + model.getClub())) + .onItem().transformToMulti(Multi.createFrom()::iterable) + .onItem().call(combModel -> Mutiny.fetch(combModel.getMembre().getLicences())) + .map(combModel -> SimpleRegisterComb.fromModel(combModel, combModel.getMembre().getLicences())) + .collect().asList(); + + return membreService.getByAccountId(securityCtx.getSubject()) + .chain(model -> registerRepository.find("competition.id = ?1 AND membre = ?2", id, model).firstResult() + .map(rm -> rm == null ? List.of() : List.of(SimpleRegisterComb.fromModel(rm, List.of())))); } - public Uni addRegisterComb(SecurityCtx securityCtx, Long id, RegisterRequestData data) { - return permService.hasEditPerm(securityCtx, id) - .chain(c -> findComb(data.getLicence(), data.getFname(), data.getLname()) - .chain(combModel -> Mutiny.fetch(c.getInsc()) - .chain(Unchecked.function(insc -> { - Optional opt = insc.stream() - .filter(m -> m.getMembre().equals(combModel)).findAny(); + public Uni addRegisterComb(SecurityCtx securityCtx, Long id, RegisterRequestData data, + String source) { + if ("admin".equals(source)) + return permService.hasEditPerm(securityCtx, id) + .chain(c -> findComb(data.getLicence(), data.getFname(), data.getLname()) + .chain(combModel -> updateRegister(id, data, c, combModel, true))) + .chain(r -> Mutiny.fetch(r.getMembre().getLicences()) + .map(licences -> SimpleRegisterComb.fromModel(r, licences))); + if ("club".equals(source)) + return repository.findById(id) + .invoke(Unchecked.consumer(cm -> { + if (!(cm.getRegisterMode() == RegisterMode.CLUB_ADMIN || cm.getRegisterMode() == RegisterMode.FREE) + || !securityCtx.isClubAdmin()) + throw new DForbiddenException(); + if (new Date().before(cm.getStartRegister()) || new Date().after(cm.getEndRegister())) + throw new DBadRequestException("Inscription fermée"); + })) + .chain(c -> findComb(data.getLicence(), data.getFname(), data.getLname()) + .invoke(Unchecked.consumer(model -> { + if (!securityCtx.isInClubGroup(model.getClub().getId())) + throw new DForbiddenException(); + if (c.getBanMembre().contains(model.getId())) + throw new DForbiddenException( + "Vous n'avez pas le droit d'inscrire ce membre (par décision de l'administrateur de la compétition)"); + })) + .chain(combModel -> updateRegister(id, data, c, combModel, false))) + .chain(r -> Mutiny.fetch(r.getMembre().getLicences()) + .map(licences -> SimpleRegisterComb.fromModel(r, licences))); - RegisterModel r; - if (opt.isPresent()) { - r = opt.get(); - r.setWeight(data.getWeight()); - r.setOverCategory(data.getOverCategory()); - r.setCategorie( - (combModel.getBirth_date() == null) ? combModel.getCategorie() : - Utils.getCategoryFormBirthDate(combModel.getBirth_date(), - c.getDate())); - int days = Utils.getDaysBeforeCompetition(c.getDate()); - if (days > -7) { - r.setClub(combModel.getClub()); - } - } else { - r = new RegisterModel(c, combModel, data.getWeight(), data.getOverCategory(), - (combModel.getBirth_date() == null) ? combModel.getCategorie() : - Utils.getCategoryFormBirthDate(combModel.getBirth_date(), - c.getDate()), - (combModel.getClub() == null) ? null : combModel.getClub()); - insc.add(r); - } + return repository.findById(id) + .invoke(Unchecked.consumer(cm -> { + if (cm.getRegisterMode() != RegisterMode.FREE) + throw new DForbiddenException(); + if (new Date().before(cm.getStartRegister()) || new Date().after(cm.getEndRegister())) + throw new DBadRequestException("Inscription fermée"); + })) + .chain(c -> membreService.getByAccountId(securityCtx.getSubject()) + .invoke(Unchecked.consumer(model -> { + if (c.getBanMembre().contains(model.getId())) + throw new DForbiddenException( + "Vous n'avez pas le droit de vous inscrire (par décision de l'administrateur de la compétition)"); + })) + .chain(combModel -> updateRegister(id, data, c, combModel, false))) + .map(r -> SimpleRegisterComb.fromModel(r, List.of())); + } - if (c.getSystem() == CompetitionSystem.SAFCA) { - SReqRegister.sendIfNeed(serverCustom.clients, - new CompetitionData.SimpleRegister(r.getMembre().getId(), - r.getOverCategory(), r.getWeight(), r.getCategorie(), - (r.getClub() == null) ? null : r.getClub().getId()), c.getId()); - } - return Panache.withTransaction(() -> repository.persist(c)).map(__ -> r); - })))) - .chain(r -> Mutiny.fetch(r.getMembre().getLicences()) - .map(licences -> SimpleRegisterComb.fromModel(r, licences))); + private Uni updateRegister(Long id, RegisterRequestData data, CompetitionModel c, + MembreModel combModel, boolean admin) { + return registerRepository.find("competition.id = ?1 AND membre = ?2", id, combModel).firstResult() + .onFailure().recoverWithNull() + .map(Unchecked.function(r -> { + if (r != null) { + if (!admin && r.isLockEdit()) + throw new DForbiddenException( + "Modification bloquée par l'administrateur de la compétition"); + r.setWeight(data.getWeight()); + r.setOverCategory(data.getOverCategory()); + r.setCategorie( + (combModel.getBirth_date() == null) ? combModel.getCategorie() : + Utils.getCategoryFormBirthDate(combModel.getBirth_date(), + c.getDate())); + int days = Utils.getDaysBeforeCompetition(c.getDate()); + if (days > -7) + r.setClub(combModel.getClub()); + if (admin) + r.setLockEdit(data.isLockEdit()); + } else { + r = new RegisterModel(c, combModel, data.getWeight(), data.getOverCategory(), + (combModel.getBirth_date() == null) ? combModel.getCategorie() : + Utils.getCategoryFormBirthDate(combModel.getBirth_date(), + c.getDate()), + (combModel.getClub() == null) ? null : combModel.getClub()); + if (admin) + r.setLockEdit(data.isLockEdit()); + else + r.setLockEdit(false); + } + + if (c.getSystem() == CompetitionSystem.SAFCA) { + SReqRegister.sendIfNeed(serverCustom.clients, + new CompetitionData.SimpleRegister(r.getMembre().getId(), + r.getOverCategory(), r.getWeight(), r.getCategorie(), + (r.getClub() == null) ? null : r.getClub().getId()), c.getId()); + } + return r; + })) + .chain(r -> Panache.withTransaction(() -> registerRepository.persist(r))); } private Uni findComb(Long licence, String fname, String lname) { @@ -282,19 +353,45 @@ public class CompetitionService { } } - public Uni removeRegisterComb(SecurityCtx securityCtx, Long id, Long combId) { - return permService.hasEditPerm(securityCtx, id) - .chain(c -> registerRepository.delete("competition = ?1 AND membre.id = ?2", c, combId) - .invoke(Unchecked.consumer(l -> { - if (l != 0) { - if (c.getSystem() == CompetitionSystem.SAFCA) { - SReqRegister.sendRmIfNeed(serverCustom.clients, combId, id); - } - } else { - throw new DBadRequestException("Combattant non inscrit"); - } - })) - ).replaceWithVoid(); + public Uni removeRegisterComb(SecurityCtx securityCtx, Long id, Long combId, String source) { + if ("admin".equals(source)) + return permService.hasEditPerm(securityCtx, id) + .chain(c -> deleteRegister(combId, c, true)); + if ("club".equals(source)) + return repository.findById(id) + .invoke(Unchecked.consumer(cm -> { + if (!(cm.getRegisterMode() == RegisterMode.CLUB_ADMIN || cm.getRegisterMode() == RegisterMode.FREE) + || !securityCtx.isClubAdmin()) + throw new DForbiddenException(); + if (new Date().before(cm.getStartRegister()) || new Date().after(cm.getEndRegister())) + throw new DBadRequestException("Inscription fermée"); + })) + .call(cm -> membreService.getByAccountId(securityCtx.getSubject()) + .invoke(Unchecked.consumer(model -> { + if (!securityCtx.isInClubGroup(model.getClub().getId())) + throw new DForbiddenException(); + }))) + .chain(c -> deleteRegister(combId, c, false)); + + return repository.findById(id) + .invoke(Unchecked.consumer(cm -> { + if (cm.getRegisterMode() != RegisterMode.FREE) + throw new DForbiddenException(); + if (new Date().before(cm.getStartRegister()) || new Date().after(cm.getEndRegister())) + throw new DBadRequestException("Inscription fermée"); + })) + .chain(c -> deleteRegister(combId, c, false)); + } + + private Uni deleteRegister(Long combId, CompetitionModel c, boolean admin) { + return registerRepository.find("competition = ?1 AND membre.id = ?2", c, combId).firstResult() + .onFailure().transform(t -> new DBadRequestException("Combattant non inscrit")) + .call(Unchecked.function(registerModel -> { + if (!admin && registerModel.isLockEdit()) + throw new DForbiddenException("Modification bloquée par l'administrateur de la compétition"); + return Panache.withTransaction(() -> registerRepository.delete(registerModel)); + })) + .replaceWithVoid(); } public Uni delete(SecurityCtx securityCtx, Long id) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 1b3a2c2..d4dcf09 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -275,6 +275,23 @@ public class ClubEndpoints { return pdfService.getAffiliationPdf(securityCtx.getSubject()); } + + @GET + @Path("/members") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra", "club_tresorier"}) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Revoie tout les membres de votre club") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "List des membres"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "L'utilisateur n'est pas membre d'un club"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni> getMembers() { + return clubService.getMembers(securityCtx); + } + + @GET @Path("/renew/{id}") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java index 3d2398d..fe74f04 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java @@ -36,29 +36,31 @@ public class CompetitionEndpoints { } @GET - @Path("{id}/register") + @Path("{id}/register/{source}") @Authenticated @Produces(MediaType.APPLICATION_JSON) - public Uni> getRegister(@PathParam("id") Long id) { - return service.getRegister(securityCtx, id); + public Uni> getRegister(@PathParam("id") Long id, @PathParam("source") String source) { + return service.getRegister(securityCtx, id, source); } @POST - @Path("{id}/register") + @Path("{id}/register/{source}") @Authenticated @Produces(MediaType.APPLICATION_JSON) @Operation(hidden = true) - public Uni addRegisterComb(@PathParam("id") Long id, RegisterRequestData data) { - return service.addRegisterComb(securityCtx, id, data); + public Uni addRegisterComb(@PathParam("id") Long id, @PathParam("source") String source, + RegisterRequestData data) { + return service.addRegisterComb(securityCtx, id, data, source); } @DELETE - @Path("{id}/register/{comb_id}") + @Path("{id}/register/{comb_id}/{source}") @Authenticated @Produces(MediaType.APPLICATION_JSON) @Operation(hidden = true) - public Uni removeRegisterComb(@PathParam("id") Long id, @PathParam("comb_id") Long combId) { - return service.removeRegisterComb(securityCtx, id, combId); + public Uni removeRegisterComb(@PathParam("id") Long id, @PathParam("comb_id") Long combId, + @PathParam("source") String source) { + return service.removeRegisterComb(securityCtx, id, combId, source); } @GET 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 74811d8..eb9c50b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java @@ -12,4 +12,5 @@ public class RegisterRequestData { private Integer weight; private int overCategory; + private boolean lockEdit = false; } 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 7026f65..2075f68 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleRegisterComb.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleRegisterComb.java @@ -24,6 +24,7 @@ public class SimpleRegisterComb { private Integer weight; private int overCategory; private boolean hasLicenceActive; + private boolean lockEdit; public static SimpleRegisterComb fromModel(RegisterModel register, List licences) { MembreModel membreModel = register.getMembre(); @@ -31,6 +32,7 @@ public class SimpleRegisterComb { (register.getCategorie() == null) ? "Catégorie inconnue" : register.getCategorie().getName(), SimpleClubModel.fromModel(register.getClub()), membreModel.getLicence(), register.getWeight(), register.getOverCategory(), - licences.stream().anyMatch(l -> l.isValidate() && l.getSaison() == Utils.getSaison())); + licences.stream().anyMatch(l -> l.isValidate() && l.getSaison() == Utils.getSaison()), + register.isLockEdit()); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/VerySimpleMembre.java b/src/main/java/fr/titionfire/ffsaf/rest/data/VerySimpleMembre.java new file mode 100644 index 0000000..cbf8931 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/VerySimpleMembre.java @@ -0,0 +1,18 @@ +package fr.titionfire.ffsaf.rest.data; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class VerySimpleMembre { + @Schema(description = "Le nom du membre.", example = "Dupont") + private String lname = ""; + @Schema(description = "Le prénom du membre.", example = "Jean") + private String fname = ""; + @Schema(description = "Le numéro de licence du membre.", example = "12345") + private Integer licence; +} diff --git a/src/main/webapp/src/pages/competition/CompetitionList.jsx b/src/main/webapp/src/pages/competition/CompetitionList.jsx index 0541147..dad9d94 100644 --- a/src/main/webapp/src/pages/competition/CompetitionList.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionList.jsx @@ -58,7 +58,7 @@ const inscText = (type) => { function MakeRow({data, navigate}) { return
data.canEdit ? navigate("" + data.id) : navigate("view/" + data.id)}> + onClick={() => data.canEdit ? navigate("" + data.id) : navigate("" + data.id + "/view")}>
{data.name} par {data.clubName}
diff --git a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.css b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.css new file mode 100644 index 0000000..4ecee85 --- /dev/null +++ b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.css @@ -0,0 +1,36 @@ +.autocomplete-wrapper { + position: relative; + width: 100%; + max-width: 500px; +} + +.suggestions-list2 { + position: absolute; + width: 100%; + z-index: 1000; + max-height: 200px; + overflow-y: auto; + margin: 0; + padding: 0; + border: 1px solid #ced4da; + border-top: none; + border-radius: 0 0 4px 4px; +} + +.suggestions-list { + position: absolute; + z-index: 1000; + margin: 0; + padding: 0; + border: 1px solid #ced4da; + border-top: none; + border-radius: 0 0 4px 4px; +} + +.suggestions-list .list-group-item { + cursor: pointer; +} + +.suggestions-list .list-group-item:hover { + background-color: #f8f9fa; +} diff --git a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx index 74f515a..25106bb 100644 --- a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx @@ -1,16 +1,17 @@ import {useNavigate, useParams} from "react-router-dom"; -import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; +import {LoadingProvider, useLoadingSwitcher} from "../../hooks/useLoading.jsx"; import {useFetch} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; import {ThreeDots} from "react-loader-spinner"; -import {useEffect, useReducer, useState} from "react"; +import {useEffect, useReducer, useRef, useState} from "react"; import {apiAxios} from "../../utils/Tools.js"; import {toast} from "react-toastify"; import {SimpleReducer} from "../../utils/SimpleReducer.jsx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faTrashCan} from "@fortawesome/free-solid-svg-icons"; +import {faAdd, faTrashCan} from "@fortawesome/free-solid-svg-icons"; +import "./CompetitionRegisterAdmin.css" -export function CompetitionRegisterAdmin() { +export function CompetitionRegisterAdmin({source}) { const {id} = useParams() const navigate = useNavigate() const [state, dispatch] = useReducer(SimpleReducer, []) @@ -19,32 +20,24 @@ export function CompetitionRegisterAdmin() { const [modalState, setModalState] = useState({}) const setLoading = useLoadingSwitcher() - const {data, error} = useFetch(`/competition/${id}/register`, setLoading, 1) + const {data, error} = useFetch(`/competition/${id}/register/${source}`, setLoading, 1) const sortName = (a, b) => { - if (a.data.fname === b.data.fname) - return a.data.lname.localeCompare(b.data.lname); + if (a.data.fname === b.data.fname) return a.data.lname.localeCompare(b.data.lname); return a.data.fname.localeCompare(b.data.fname); } useEffect(() => { - if (!data) - return; - data.forEach((d, index) => { - dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}}) + if (!data) return; + data.forEach((d) => { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: d}}) }) dispatch({type: 'SORT', payload: sortName}) }, [data, clubFilter, catFilter]); - const sendRegister = (event, new_state) => { - event.preventDefault(); - - console.log(new_state) - - toast.promise(apiAxios.post(`/competition/${id}/register`, new_state), { - pending: "Recherche en cours", - success: "Combattant trouvé et ajouté/mis à jour", - error: { + const sendRegister = (new_state) => { + toast.promise(apiAxios.post(`/competition/${id}/register/${source}`, new_state), { + pending: "Recherche en cours", success: "Combattant trouvé et ajouté/mis à jour", error: { render({data}) { return data.response.data || "Combattant non trouvé" } @@ -53,17 +46,7 @@ export function CompetitionRegisterAdmin() { if (response.data.error) { return } - - let maxId = 0; - if (new_state.id) { - maxId = new_state.id - 1 - } else { - state.forEach((d) => { - if (d.id > maxId) - maxId = d.id; - }) - } - dispatch({type: 'UPDATE_OR_ADD', payload: {id: maxId + 1, data: response.data}}) + dispatch({type: 'UPDATE_OR_ADD', payload: {id: response.data.id, data: response.data}}) dispatch({type: 'SORT', payload: sortName}) document.getElementById("closeModal").click(); }) @@ -71,22 +54,18 @@ export function CompetitionRegisterAdmin() { return

Combattants inscrits

-
- {data - ?
- (clubFilter.length === 0 || s.data.club.name === clubFilter) && (catFilter.length === 0 || s.data.categorie === catFilter))} - dispatch={dispatch} id={id} setModalState={setModalState}/> -
- : error - ? - : - } + {data ?
+ (clubFilter.length === 0 || s.data.club.name === clubFilter) && (catFilter.length === 0 || s.data.categorie === catFilter))} + dispatch={dispatch} id={id} setModalState={setModalState} source={source}/> +
: error ? : }
@@ -94,27 +73,213 @@ export function CompetitionRegisterAdmin() { onClick={() => setModalState({})}>Ajouter un combattant
+
Filtre
+ setCatFilter={setCatFilter} source={source}/>
- +
} -function Modal({sendRegister, modalState, setModalState}) { +function QuickAdd({sendRegister, source}) { + + const handleAdd = (licence) => { + console.log("Quick add licence: " + licence) + + sendRegister({ + licence: licence, fname: "", lname: "", weight: "", overCategory: 0, lockEdit: false, id: null + }) + } + + return
+
Ajout rapide
+
+
+ N° de licence +
+
+ { + if (e.key === "Enter") { + const licence = e.target.value.trim() + if (licence.length === 0) return; + e.target.value = "" + handleAdd(licence) + } + }}/> + + + {source === "club" && + + } +
+
+
+} + +function SearchMember({sendRegister}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/members`, setLoading, 1) + const [suggestions, setSuggestions] = useState([]) + + const handleAdd = (name) => { + const member = data.find(m => `${m.fname} ${m.lname}`.trim() === name); + console.log("Quick add licence:", member) + + if (!member) { + toast.error("Combattant non trouvé"); + return; + } + + sendRegister({ + licence: member.licence, fname: member.fname, lname: member.lname, weight: "", overCategory: 0, lockEdit: false, id: null + }) + } + + useEffect(() => { + if (!data) return; + + const names = data.map(member => `${member.fname} ${member.lname}`.trim()); + names.sort((a, b) => a.localeCompare(b)); + setSuggestions(names); + }, []); + + return <> + {data ?
+ Prénom et nom + +
: error ? : } + +} + +const AutoCompleteInput = ({suggestions = [], handleAdd}) => { + const [inputValue, setInputValue] = useState(''); + const [filteredSuggestions, setFilteredSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [activeSuggestion, setActiveSuggestion] = useState(0); + const wrapperRef = useRef(null); + + // Filtre les suggestions + useEffect(() => { + if (inputValue.trim() === '') { + setFilteredSuggestions([]); + setShowSuggestions(false); + } else { + const filtered = suggestions.filter(suggestion => suggestion.toLowerCase().includes(inputValue.toLowerCase())); + setFilteredSuggestions(filtered); + setShowSuggestions(true); + setActiveSuggestion(0); // Réinitialise la sélection active + } + }, [inputValue, suggestions]); + + // Ferme les suggestions si clic à l'extérieur + useEffect(() => { + const handleClickOutside = (event) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { + setShowSuggestions(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Gestion du clic sur une suggestion + const handleSuggestionClick = (suggestion) => { + setInputValue(suggestion); + setShowSuggestions(false); // Ferme automatiquement après sélection + }; + + // Navigation clavier + const handleKeyDown = (e) => { + // Touches directionnelles + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveSuggestion(prev => prev < filteredSuggestions.length - 1 ? prev + 1 : prev); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveSuggestion(prev => (prev > 0 ? prev - 1 : 0)); + } + // Validation avec Entrée + else if (e.key === 'Enter' && filteredSuggestions.length > 0) { + e.preventDefault(); + if (inputValue === filteredSuggestions[activeSuggestion]) { + handleAdd(inputValue); + setInputValue(''); + } else { + setInputValue(filteredSuggestions[activeSuggestion]); + } + setShowSuggestions(false); + } + // Fermeture avec Échap + else if (e.key === 'Escape') { + setShowSuggestions(false); + } + }; + + return (
+
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => inputValue && setShowSuggestions(true)} + placeholder="Rechercher..." + aria-autocomplete="list" + aria-expanded={showSuggestions} + aria-controls="suggestions-list" + /> + + +
+ {showSuggestions && filteredSuggestions.length > 0 && (
    + {filteredSuggestions.map((suggestion, index) => (
  • handleSuggestionClick(suggestion)} + role="option" + aria-selected={index === activeSuggestion} + > + {suggestion} +
  • ))} +
)} +
); +}; + +function Modal({sendRegister, modalState, setModalState, source}) { const [licence, setLicence] = useState("") const [fname, setFname] = useState("") const [lname, setLname] = useState("") const [weight, setWeight] = useState("") const [cat, setCat] = useState(0) const [editMode, setEditMode] = useState(false) + const [lockEdit, setLockEdit] = useState(false) useEffect(() => { if (!modalState) { @@ -124,6 +289,7 @@ function Modal({sendRegister, modalState, setModalState}) { setWeight("") setCat(0) setEditMode(false) + setLockEdit(false) } else { setLicence(modalState.licence ? modalState.licence : "") setFname(modalState.fname ? modalState.fname : "") @@ -131,6 +297,7 @@ function Modal({sendRegister, modalState, setModalState}) { setWeight(modalState.weight ? modalState.weight : "") setCat(modalState.overCategory ? modalState.overCategory : 0) setEditMode(modalState.licence || (modalState.fname && modalState.lname)) + setLockEdit(modalState.lockEdit) } }, [modalState]); @@ -139,21 +306,16 @@ function Modal({sendRegister, modalState, setModalState}) {
{ + e.preventDefault() const new_state = { - licence: licence, - fname: fname, - lname: lname, - weight: weight, - overCategory: cat, - id: modalState.id + licence: licence, fname: fname, lname: lname, weight: weight, overCategory: cat, lockEdit: lockEdit, id: modalState.id } setModalState(new_state) - sendRegister(e, new_state) + sendRegister(new_state) }}>
-

Ajouter un combattant

- +

{editMode ? "Modification d'" : "Ajouter "}un combattant

+
@@ -194,9 +356,16 @@ function Modal({sendRegister, modalState, setModalState}) {
+ + {editMode && source === "admin" &&
+ setLockEdit(e.target.checked)}/> + +
}
- +
@@ -208,10 +377,9 @@ function Modal({sendRegister, modalState, setModalState}) { let allClub = [] let allCat = [] -function FiltreBar({data, clubFilter, setClubFilter, catFilter, setCatFilter}) { +function FiltreBar({data, clubFilter, setClubFilter, catFilter, setCatFilter, source}) { useEffect(() => { - if (!data) - return; + if (!data) return; allClub.push(...data.map((e) => e.club?.name)) allClub = allClub.filter((value, index, self) => self.indexOf(value) === index).filter(value => value != null).sort() allCat.push(...data.map((e) => e.categorie)) @@ -219,71 +387,72 @@ function FiltreBar({data, clubFilter, setClubFilter, catFilter, setCatFilter}) { }, [data]); return
-
+ {source === "admin" &&
-
+
}
} -function MakeCentralPanel({data, dispatch, id, setModalState}) { +function MakeCentralPanel({data, dispatch, id, setModalState, source}) { return <>
- {data.map((req, index) => ( -
setModalState({...req.data, id: req.id})}> -
- {req.data.licence ? String(req.data.licence).padStart(5, '0') : "-------"} -
-
{req.data.fname} {req.data.lname}
- {req.data.club?.name || "Sans club"} + {data.map((req, index) => (
+
+
setModalState({...req.data, id: req.id})}> +
+ {req.data.licence ? String(req.data.licence).padStart(5, '0') : "-------"} +
+
{req.data.fname} {req.data.lname}
+ {req.data.club?.name || "Sans club"} +
+
+
+
+ {req.data.categorie + (req.data.overCategory === 0 ? "" : (" avec " + req.data.overCategory + " de surclassement"))}
+ {req.data.weight ? req.data.weight : "---"} kg +
+
-
-
- {req.data.categorie + (req.data.overCategory === 0 ? "" : (" avec " + req.data.overCategory + " de surclassement"))}
- {req.data.weight ? req.data.weight : "---"} kg -
-
-
- -
+ } + }).finally(() => { + dispatch({type: 'REMOVE', payload: req.id}) + }) + }}> + +
- ))} +
))}
@@ -297,4 +466,4 @@ function Def() {
  • -} \ No newline at end of file +} diff --git a/src/main/webapp/src/pages/competition/CompetitionRoot.jsx b/src/main/webapp/src/pages/competition/CompetitionRoot.jsx index bf02c63..6daa71c 100644 --- a/src/main/webapp/src/pages/competition/CompetitionRoot.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionRoot.jsx @@ -25,12 +25,16 @@ export function getCompetitionChildren() { element: }, { - path: 'view/:id', + path: ':id/view', element: }, { path: ':id/register', - element: + element: + }, + { + path: ':id/club/register', + element: } ] } diff --git a/src/main/webapp/src/pages/competition/CompetitionView.jsx b/src/main/webapp/src/pages/competition/CompetitionView.jsx index cf2a6e2..c179f73 100644 --- a/src/main/webapp/src/pages/competition/CompetitionView.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionView.jsx @@ -3,7 +3,10 @@ import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; import {useFetch} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; import {useAuth} from "../../hooks/useAuth.jsx"; -import {isClubAdmin} from "../../utils/Tools.js"; +import {apiAxios, isClubAdmin} from "../../utils/Tools.js"; +import {ThreeDots} from "react-loader-spinner"; +import {useEffect, useState} from "react"; +import {toast} from "react-toastify"; export function CompetitionView() { @@ -43,6 +46,7 @@ const inscText = (type) => { function MakeContent({data}) { const {userinfo} = useAuth() + const navigate = useNavigate() return
    @@ -57,10 +61,18 @@ function MakeContent({data}) {

    Organisateur : {data.clubName}

    Type d'inscription : {inscText(data.registerMode)}

    {(data.registerMode === "FREE" || data.registerMode === "CLUB_ADMIN") && -

    Date d'inscription : Du {new Date(data.startRegister.split('+')[0]).toLocaleString()} au {new Date(data.endRegister.split('+')[0]).toLocaleString()}

    +

    Date d'inscription + : Du {new Date(data.startRegister.split('+')[0]).toLocaleString()} au {new Date(data.endRegister.split('+')[0]).toLocaleString()} +

    } - {(data.registerMode === "CLUB_ADMIN" && isClubAdmin(userinfo)) || data.registerMode === "FREE" && - + + {(data.registerMode === "FREE" || data.registerMode === "CLUB_ADMIN") && isClubAdmin(userinfo) && + + } + {data.registerMode === "FREE" && !isClubAdmin(userinfo) && + } {data.registerMode === "HELLOASSO" &&

    Billetterie :

    } + +function SelfRegister({data2}) { + const {id} = useParams() + const setLoading = useLoadingSwitcher() + const {data, refresh, error} = useFetch(`/competition/${id}/register/user`, setLoading, 1) + + const [weight, setWeight] = useState("") + const [cat, setCat] = useState(0) + + useEffect(() => { + if (data && data.length > 0) { + setWeight(data[0].weight || "") + setCat(data[0].overCategory || 0) + } + }, [data]); + + const disabled = new Date() < new Date(data2.startRegister.split('+')[0]) || new Date() > new Date(data2.endRegister.split('+')[0]) + + const handleUnregister = () => { + if (window.confirm("Êtes-vous sûr de vouloir vous désinscrire ?")) { + toast.promise(apiAxios.delete(`/competition/${id}/register/${data[0].id}/user`), { + pending: "Désinscription en cours", + success: "Désinscription réalisée", + error: { + render({data}) { + return data.response.data || "Erreur" + } + } + }).finally(() => { + refresh(`/competition/${id}/register/user`) + }) + } + } + + const sendSubmit = (new_state) => { + toast.promise(apiAxios.post(`/competition/${id}/register/user`, new_state), { + pending: "Enregistrement en cours", + success: "Inscription réalisée", + error: { + render({data}) { + return data.response.data || "Combattant non trouvé" + } + } + }).finally(() => { + refresh(`/competition/${id}/register/user`) + }) + } + + const handleSubmit = (e) => { + sendSubmit({ + licence: 0, fname: "", lname: "", weight: weight, overCategory: cat, lockEdit: false, id: null + }) + } + + return <> + {data + ? data.length > 0 + ?
    +

    Mon inscription

    +
    + Poids (en kg) + setWeight(e.target.value)}/> +
    + +
    Catégorie normalisée: {data[0].categorie}
    +
    + Surclassement + +
    + +
    + + +
    +
    + : + : error + ? + : + } + +} + +function Def() { + return
    +
  • +
    +} From 2a1bdfbdcbee1eda3961cb476c4a2d8bbf0b65e2 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 20 Aug 2025 21:59:26 +0200 Subject: [PATCH 2/4] feat: helloasso competition register --- .../data/model/HelloAssoRegisterModel.java | 41 ++++++ .../HelloAssoRegisterRepository.java | 9 ++ .../domain/service/CompetitionService.java | 121 +++++++++++++++++- .../ffsaf/domain/service/WebhookService.java | 18 ++- .../rest/client/dto/NotificationData.java | 35 +++++ .../ffsaf/rest/data/RegisterRequestData.java | 4 + 6 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/HelloAssoRegisterModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/HelloAssoRegisterRepository.java diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/HelloAssoRegisterModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/HelloAssoRegisterModel.java new file mode 100644 index 0000000..da0515f --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/HelloAssoRegisterModel.java @@ -0,0 +1,41 @@ +package fr.titionfire.ffsaf.data.model; + +import fr.titionfire.ffsaf.data.id.RegisterId; +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "helloasso_register") +public class HelloAssoRegisterModel { + @EmbeddedId + RegisterId id; + + @MapsId("competitionId") + @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) + @JoinColumn(name = "id_competition") + CompetitionModel competition; + + @MapsId("membreId") + @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) + @JoinColumn(name = "id_membre") + MembreModel membre; + + Integer orderId; + + public HelloAssoRegisterModel(CompetitionModel competition, MembreModel membre, Integer orderId) { + this.id = new RegisterId(competition.getId(), membre.getId()); + this.competition = competition; + this.membre = membre; + this.orderId = orderId; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/HelloAssoRegisterRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/HelloAssoRegisterRepository.java new file mode 100644 index 0000000..355275d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/HelloAssoRegisterRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.HelloAssoRegisterModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class HelloAssoRegisterRepository implements PanacheRepositoryBase { +} 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 f01590a..f1350ab 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java @@ -1,6 +1,7 @@ package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.CompetitionModel; +import fr.titionfire.ffsaf.data.model.HelloAssoRegisterModel; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.model.RegisterModel; import fr.titionfire.ffsaf.data.repository.*; @@ -8,6 +9,7 @@ import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleCompet; import fr.titionfire.ffsaf.net2.request.SReqCompet; import fr.titionfire.ffsaf.net2.request.SReqRegister; +import fr.titionfire.ffsaf.rest.client.dto.NotificationData; import fr.titionfire.ffsaf.rest.data.CompetitionData; import fr.titionfire.ffsaf.rest.data.RegisterRequestData; import fr.titionfire.ffsaf.rest.data.SimpleCompetData; @@ -22,13 +24,18 @@ import io.quarkus.cache.Cache; import io.quarkus.cache.CacheName; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.reactive.ReactiveMailer; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import io.vertx.mutiny.core.Vertx; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; import org.hibernate.reactive.mutiny.Mutiny; +import org.jboss.logging.Logger; import org.keycloak.representations.idm.UserRepresentation; import java.util.*; @@ -37,6 +44,7 @@ import java.util.stream.Stream; @WithSession @ApplicationScoped public class CompetitionService { + private static final Logger LOGGER = Logger.getLogger(CompetitionService.class); @Inject CompetitionRepository repository; @@ -65,6 +73,13 @@ public class CompetitionService { @Inject CompetPermService permService; + @Inject + HelloAssoRegisterRepository helloAssoRepository; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + ReactiveMailer reactiveMailer; + @Inject Vertx vertx; @@ -249,7 +264,7 @@ public class CompetitionService { if ("admin".equals(source)) return permService.hasEditPerm(securityCtx, id) .chain(c -> findComb(data.getLicence(), data.getFname(), data.getLname()) - .chain(combModel -> updateRegister(id, data, c, combModel, true))) + .chain(combModel -> updateRegister(data, c, combModel, true))) .chain(r -> Mutiny.fetch(r.getMembre().getLicences()) .map(licences -> SimpleRegisterComb.fromModel(r, licences))); if ("club".equals(source)) @@ -269,7 +284,7 @@ public class CompetitionService { throw new DForbiddenException( "Vous n'avez pas le droit d'inscrire ce membre (par décision de l'administrateur de la compétition)"); })) - .chain(combModel -> updateRegister(id, data, c, combModel, false))) + .chain(combModel -> updateRegister(data, c, combModel, false))) .chain(r -> Mutiny.fetch(r.getMembre().getLicences()) .map(licences -> SimpleRegisterComb.fromModel(r, licences))); @@ -286,13 +301,13 @@ public class CompetitionService { throw new DForbiddenException( "Vous n'avez pas le droit de vous inscrire (par décision de l'administrateur de la compétition)"); })) - .chain(combModel -> updateRegister(id, data, c, combModel, false))) + .chain(combModel -> updateRegister(data, c, combModel, false))) .map(r -> SimpleRegisterComb.fromModel(r, List.of())); } - private Uni updateRegister(Long id, RegisterRequestData data, CompetitionModel c, + private Uni updateRegister(RegisterRequestData data, CompetitionModel c, MembreModel combModel, boolean admin) { - return registerRepository.find("competition.id = ?1 AND membre = ?2", id, combModel).firstResult() + return registerRepository.find("competition = ?1 AND membre = ?2", c, combModel).firstResult() .onFailure().recoverWithNull() .map(Unchecked.function(r -> { if (r != null) { @@ -480,4 +495,100 @@ public class CompetitionService { })) .call(__ -> cache.invalidate(data.getId())); } + + public Uni unregisterHelloAsso(NotificationData data) { + if (!data.getState().equals("Refunded")) + return Uni.createFrom().item(Response.ok().build()); + + return helloAssoRepository.list("orderId = ?1", data.getOrder().getId()) + .chain(regs -> { + Uni uni = Uni.createFrom().nullItem(); + + for (HelloAssoRegisterModel reg : regs) { + if (reg.getCompetition().getRegisterMode() != RegisterMode.HELLOASSO) + continue; + if (!data.getOrder().getOrganizationSlug().equalsIgnoreCase(reg.getCompetition().getData1())) + continue; + + uni = uni.call(__ -> Panache.withTransaction( + () -> registerRepository.delete("competition = ?1 AND membre = ?2", + reg.getCompetition(), reg.getMembre()))); + } + + return uni; + }) + .onFailure().invoke(Throwable::printStackTrace) + .map(__ -> Response.ok().build()); + } + + public Uni registerHelloAsso(NotificationData data) { + String organizationSlug = data.getOrganizationSlug(); + String formSlug = data.getFormSlug(); + RegisterRequestData req = new RegisterRequestData(null, "", "", null, 0, false); + + return repository.find("data1 = ?1 AND data2 = ?2", organizationSlug, formSlug).firstResult() + .onFailure().recoverWithNull() + .chain(cm -> { + Uni uni = Uni.createFrom().nullItem(); + if (cm == null || cm.getRegisterMode() != RegisterMode.HELLOASSO) + return uni; + + List place = List.of(cm.getData3().toLowerCase().split(";")); + List fail = new ArrayList<>(); + + for (NotificationData.Item item : data.getItems()) { + if (!place.contains(item.getName().toLowerCase())) + continue; + if (item.getCustomFields() == null || item.getCustomFields().isEmpty()) { + fail.add("%s %s - licence n°???".formatted(item.getUser().getLastName(), + item.getUser().getFirstName())); + continue; + } + + Optional optional = item.getCustomFields().stream() + .filter(cf -> cf.getName().equalsIgnoreCase("Numéro de licence")).findAny().map( + NotificationData.CustomField::getAnswer).map(Long::valueOf); + + if (optional.isPresent()) { + uni = uni.call(__ -> membreService.getByLicence(optional.get()) + .invoke(Unchecked.consumer(m -> { + if (m == null) + throw new NotFoundException(); + })) + .call(m -> Panache.withTransaction(() -> + helloAssoRepository.persist( + new HelloAssoRegisterModel(cm, m, data.getId())))) + .chain(m -> updateRegister(req, cm, m, true))) + .onFailure().recoverWithItem(throwable -> { + fail.add("%s %s - licence n°%d".formatted(item.getUser().getLastName(), + item.getUser().getFirstName(), optional.get())); + return null; + }) + .replaceWithVoid(); + } else { + fail.add("%s %s - licence n°???".formatted(item.getUser().getLastName(), + item.getUser().getFirstName())); + } + } + + return uni.call(__ -> fail.isEmpty() ? Uni.createFrom().nullItem() : + reactiveMailer.send( + Mail.withText(cm.getData4(), + "FFSAF - Compétition - Erreur HelloAsso", + String.format( + """ + Bonjour, + + Une erreur a été rencontrée lors de l'enregistrement d'une inscription à votre compétition %s pour les combattants suivants: + %s + + Cordialement, + L'intranet de la FFSAF + """, cm.getName(), String.join("\r\n", fail)) + ).setFrom("FFSAF ").setReplyTo("support@ffsaf.fr") + ).onFailure().invoke(e -> LOGGER.error("Fail to send email", e))); + }) + .onFailure().invoke(Throwable::printStackTrace) + .map(__ -> Response.ok().build()); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java index 9e011bc..d675609 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java @@ -13,13 +13,25 @@ public class WebhookService { @Inject CheckoutService checkoutService; + @Inject + CompetitionService competitionService; + @ConfigProperty(name = "helloasso.organizationSlug") String organizationSlug; public Uni helloAssoNotification(HelloassoNotification notification) { - if (notification.getEventType().equals("Payment")){ - if (notification.getData().getOrder().getOrganizationSlug().equalsIgnoreCase(organizationSlug)){ - return checkoutService.paymentStatusChange(notification.getData().getState(), notification.getMetadata()); + if (notification.getEventType().equals("Payment")) { + if (notification.getData().getOrder().getFormType().equals("Checkout")) { + if (notification.getData().getOrder().getOrganizationSlug().equalsIgnoreCase(organizationSlug)) { + return checkoutService.paymentStatusChange(notification.getData().getState(), + notification.getMetadata()); + } + } else if (notification.getData().getOrder().getFormType().equals("Event")) { + return competitionService.unregisterHelloAsso(notification.getData()); + } + }else if (notification.getEventType().equals("Order")){ + if (notification.getData().getFormType().equals("Event")) { + return competitionService.registerHelloAsso(notification.getData()); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java index 8d79e6e..b364b87 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java @@ -5,6 +5,8 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @NoArgsConstructor @AllArgsConstructor @@ -12,11 +14,14 @@ import lombok.NoArgsConstructor; public class NotificationData { private Order order; private Integer id; + private String formSlug; + private String formType; private String organizationSlug; private String checkoutIntentId; private String oldSlugOrganization; // Pour les changements de nom d'association private String newSlugOrganization; private String state; // Pour les formulaires + private List items; @Data @@ -26,5 +31,35 @@ public class NotificationData { public static class Order { private Integer id; private String organizationSlug; + private String formSlug; + private String formType; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @RegisterForReflection + public static class Item { + private String name; + private User user; + private List customFields; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @RegisterForReflection + public static class User { + private String firstName; + private String lastName; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @RegisterForReflection + public static class CustomField { + private String name; + private String answer; } } 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 eb9c50b..11fa9aa 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/RegisterRequestData.java @@ -1,9 +1,13 @@ package fr.titionfire.ffsaf.rest.data; import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@AllArgsConstructor +@NoArgsConstructor @RegisterForReflection public class RegisterRequestData { private Long licence; From 0fc871bd46867b547a12e86144ad1baa43dc3fd1 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 21 Aug 2025 11:11:04 +0200 Subject: [PATCH 3/4] feat: ban competition + competition list sort --- .../domain/service/CompetitionService.java | 19 +++++++- .../ffsaf/rest/CompetitionEndpoints.java | 4 +- .../src/pages/competition/CompetitionEdit.jsx | 18 ++++---- .../src/pages/competition/CompetitionList.jsx | 19 ++++++-- .../competition/CompetitionRegisterAdmin.jsx | 43 +++++++++++++++++-- .../src/pages/competition/CompetitionRoot.jsx | 2 +- 6 files changed, 85 insertions(+), 20 deletions(-) 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 f1350ab..52c0db5 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java @@ -264,6 +264,12 @@ public class CompetitionService { if ("admin".equals(source)) return permService.hasEditPerm(securityCtx, id) .chain(c -> findComb(data.getLicence(), data.getFname(), data.getLname()) + .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))) .chain(r -> Mutiny.fetch(r.getMembre().getLicences()) .map(licences -> SimpleRegisterComb.fromModel(r, licences))); @@ -368,9 +374,20 @@ public class CompetitionService { } } - public Uni removeRegisterComb(SecurityCtx securityCtx, Long id, Long combId, String source) { + public Uni removeRegisterComb(SecurityCtx securityCtx, Long id, Long combId, String source, boolean ban) { if ("admin".equals(source)) return permService.hasEditPerm(securityCtx, id) + .chain(cm -> { + if (cm.getBanMembre() == null) + cm.setBanMembre(new ArrayList<>()); + if (ban) { + if (!cm.getBanMembre().contains(combId)) + cm.getBanMembre().add(combId); + } else { + cm.getBanMembre().remove(combId); + } + return Panache.withTransaction(() -> repository.persist(cm)); + }) .chain(c -> deleteRegister(combId, c, true)); if ("club".equals(source)) return repository.findById(id) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java index fe74f04..82fccc0 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java @@ -59,8 +59,8 @@ public class CompetitionEndpoints { @Produces(MediaType.APPLICATION_JSON) @Operation(hidden = true) public Uni removeRegisterComb(@PathParam("id") Long id, @PathParam("comb_id") Long combId, - @PathParam("source") String source) { - return service.removeRegisterComb(securityCtx, id, combId, source); + @PathParam("source") String source, @QueryParam("ban") boolean ban) { + return service.removeRegisterComb(securityCtx, id, combId, source, ban); } @GET diff --git a/src/main/webapp/src/pages/competition/CompetitionEdit.jsx b/src/main/webapp/src/pages/competition/CompetitionEdit.jsx index ffed104..88289e9 100644 --- a/src/main/webapp/src/pages/competition/CompetitionEdit.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionEdit.jsx @@ -23,11 +23,11 @@ export function CompetitionEdit() { toast.promise( apiAxios.delete(`/competition/${id}`), { - pending: "Suppression de la competition en cours...", - success: "Competition supprimé avec succès 🎉", + pending: "Suppression de la compétition en cours...", + success: "Compétition supprimé avec succès 🎉", error: { render({data}) { - return errFormater(data, "Échec de la suppression de la competition") + return errFormater(data, "Échec de la suppression de la compétition") } }, } @@ -47,18 +47,18 @@ export function CompetitionEdit() { {data.id !== null && } + onClick={_ => navigate(`/competition/${data.id}/register?type=${data.registerMode}`)}>Voir/Modifier les participants} {data.id !== null && data.system === "SAFCA" && } {data.id !== null && <>
    - }
    @@ -301,7 +301,7 @@ function Content({data}) { return
    -
    {data.id ? "Edition competition" : "Création competition"}
    +
    {data.id ? "Edition compétition" : "Création compétition"}
    @@ -330,7 +330,7 @@ function Content({data}) {

    diff --git a/src/main/webapp/src/pages/competition/CompetitionList.jsx b/src/main/webapp/src/pages/competition/CompetitionList.jsx index dad9d94..3c00b92 100644 --- a/src/main/webapp/src/pages/competition/CompetitionList.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionList.jsx @@ -31,12 +31,23 @@ function MakeCentralPanel({data, navigate}) { return <> {userinfo?.roles?.includes("create_compet") && -
    - -
    } +
    + +
    }
    +

    Compétition future

    - {data.map(req => ())} + {data.filter(req => new Date(req.toDate.split('T')[0]) >= new Date()).sort((a, b) => { + return new Date(a.date.split('T')[0]) - new Date(b.date.split(')T')[0]) + }).map(req => ())} +
    +
    +
    +

    Compétition passée

    +
    + {data.filter(req => new Date(req.toDate.split('T')[0]) < new Date()).sort((a, b) => { + return new Date(b.date.split('T')[0]) - new Date(a.date.split(')T')[0]) + }).map(req => ())}
    diff --git a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx index 25106bb..c514db1 100644 --- a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx @@ -1,4 +1,4 @@ -import {useNavigate, useParams} from "react-router-dom"; +import {useNavigate, useParams, useSearchParams} from "react-router-dom"; import {LoadingProvider, useLoadingSwitcher} from "../../hooks/useLoading.jsx"; import {useFetch} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; @@ -8,7 +8,7 @@ import {apiAxios} from "../../utils/Tools.js"; import {toast} from "react-toastify"; import {SimpleReducer} from "../../utils/SimpleReducer.jsx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faAdd, faTrashCan} from "@fortawesome/free-solid-svg-icons"; +import {faAdd, faGavel, faTrashCan} from "@fortawesome/free-solid-svg-icons"; import "./CompetitionRegisterAdmin.css" export function CompetitionRegisterAdmin({source}) { @@ -407,7 +407,15 @@ function FiltreBar({data, clubFilter, setClubFilter, catFilter, setCatFilter, so } function MakeCentralPanel({data, dispatch, id, setModalState, source}) { + const [searchParams] = useSearchParams(); + const registerType = searchParams.get("type") || "FREE"; + return <> + {(registerType === "FREE" || registerType === "CLUB_ADMIN") && source === "admin" && + Tips 1: Il est possible de bannir un combattant, ce qui l'empêchera d'être réinscrit par un autre moyen que par un administrateur de cette compétition. + Pour cela, cliquez sur la petite à côté de son nom.
    + Tips 2: Il est aussi possible de verrouiller les modifications de son inscription depuis sa fiche, ce qui l'empêchera d'être modifié/supprimé par lui-même et/ou un responsable de club. +
    }
    {data.map((req, index) => (
    @@ -432,13 +440,42 @@ function MakeCentralPanel({data, dispatch, id, setModalState, source}) {
    + {(registerType === "FREE" || registerType === "CLUB_ADMIN") && source === "admin" && + }
    ); - } diff --git a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx index c514db1..ff92de2 100644 --- a/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx +++ b/src/main/webapp/src/pages/competition/CompetitionRegisterAdmin.jsx @@ -4,12 +4,14 @@ import {useFetch} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; import {ThreeDots} from "react-loader-spinner"; import {useEffect, useReducer, useRef, useState} from "react"; -import {apiAxios} from "../../utils/Tools.js"; +import {apiAxios, errFormater} from "../../utils/Tools.js"; import {toast} from "react-toastify"; import {SimpleReducer} from "../../utils/SimpleReducer.jsx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faAdd, faGavel, faTrashCan} from "@fortawesome/free-solid-svg-icons"; import "./CompetitionRegisterAdmin.css" +import * as XLSX from "xlsx-js-style"; +import * as Tools from "../../utils/Tools.js"; export function CompetitionRegisterAdmin({source}) { const {id} = useParams() @@ -81,6 +83,7 @@ export function CompetitionRegisterAdmin({source}) { setCatFilter={setCatFilter} source={source}/>
    + {source === "admin" && }
    @@ -427,7 +430,7 @@ function MakeCentralPanel({data, dispatch, id, setModalState, source}) {
    {req.data.licence ? String(req.data.licence).padStart(5, '0') : "-------"}
    -
    {req.data.fname} {req.data.lname}
    +
    {req.data.fname} {req.data.lname} {req.data.genre}
    {req.data.club?.name || "Sans club"}
    @@ -495,6 +498,40 @@ function MakeCentralPanel({data, dispatch, id, setModalState, source}) { } +function FileOutput({data}) { + const handleFileDownload = () => { + const dataOut = [] + for (const e of data) { + const tmp = { + licence: e.licence, + nom: e.lname, + prenom: e.fname, + genre: e.genre, + weight: e.weight, + categorie: e.categorie, + overCategory: e.overCategory, + club: e.club ? e.club.name : '', + } + dataOut.push(tmp) + } + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.json_to_sheet(dataOut); + XLSX.utils.sheet_add_aoa(ws, [["Licence", "Nom", "Prénom", "Genre", "Poids", "Catégorie normalizer", "Surclassement", "Club"]], {origin: 'A1'}); + + ws["!cols"] = [{wch: 7}, {wch: 16}, {wch: 16}, {wch: 6}, {wch: 6}, {wch: 10}, {wch: 10}, {wch: 60}] + + XLSX.utils.book_append_sheet(wb, ws, "Feuille 1"); + XLSX.writeFile(wb, "output.xlsx"); + }; + + return ( +
    + +
    + ); +} + function Def() { return