From 09f6cd7463ead65cf45dcddc0177647ba24146dd Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 13 Aug 2025 15:32:06 +0200 Subject: [PATCH] feat: masse licence validation --- .../ffsaf/domain/service/LicenceService.java | 44 ++- .../ffsaf/rest/LicenceEndpoints.java | 17 + src/main/webapp/src/pages/ValidateList.jsx | 302 ++++++++++++++++++ src/main/webapp/src/pages/admin/AdminRoot.jsx | 8 +- 4 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 src/main/webapp/src/pages/ValidateList.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index dd925e3..a8db060 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -20,6 +20,7 @@ import org.hibernate.reactive.mutiny.Mutiny; import java.util.List; import java.util.function.Consumer; +import java.util.function.Function; @WithSession @ApplicationScoped @@ -34,6 +35,9 @@ public class LicenceService { @Inject SequenceRepository sequenceRepository; + @Inject + KeycloakService keycloakService; + public Uni> getLicence(long id, Consumer checkPerm) { return combRepository.findById(id).invoke(checkPerm) .chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); @@ -48,6 +52,23 @@ public class LicenceService { .chain(membres -> repository.find("saison = ?1 AND membre IN ?2", Utils.getSaison(), membres).list()); } + public Uni valideLicences(List ids) { + Uni uni = Uni.createFrom().nullItem(); + + for (Long id : ids) { + uni = uni.chain(__ -> repository.find("membre.id = ?1 AND saison = ?2", id, Utils.getSaison()).firstResult() + .chain(model -> { + model.setValidate(true); + return Panache.withTransaction(() -> repository.persist(model) + .call(m -> Mutiny.fetch(m.getMembre()) + .call(genLicenceNumberAndAccountIfNeed()) + )); + })) + .map(__ -> "OK"); + } + return uni; + } + public Uni setLicence(long id, LicenceForm form) { if (form.getId() == -1) { return combRepository.findById(id).chain(membreModel -> { @@ -58,10 +79,8 @@ public class LicenceService { model.setCertificate(form.getCertificate()); model.setValidate(form.isValidate()); return Panache.withTransaction(() -> repository.persist(model) - .call(m -> (m.isValidate() && membreModel.getLicence() <= 0) ? - sequenceRepository.getNextValueInTransaction(SequenceType.Licence) - .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) - .chain(() -> combRepository.persist(membreModel)) + .call(m -> m.isValidate() ? Uni.createFrom().item(membreModel) + .call(genLicenceNumberAndAccountIfNeed()) : Uni.createFrom().nullItem() )); }); @@ -71,17 +90,24 @@ public class LicenceService { model.setValidate(form.isValidate()); return Panache.withTransaction(() -> repository.persist(model) .call(m -> m.isValidate() ? Mutiny.fetch(m.getMembre()) - .call(membreModel -> (membreModel.getLicence() <= 0) ? - sequenceRepository.getNextValueInTransaction(SequenceType.Licence) - .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) - .chain(() -> combRepository.persist(membreModel)) - : Uni.createFrom().nullItem()) + .call(genLicenceNumberAndAccountIfNeed()) : Uni.createFrom().nullItem() )); }); } } + private Function> genLicenceNumberAndAccountIfNeed() { + return membreModel -> ((membreModel.getLicence() <= 0) ? + sequenceRepository.getNextValueInTransaction(SequenceType.Licence) + .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) + .chain(() -> combRepository.persist(membreModel)) + : Uni.createFrom().nullItem()) + .call(__ -> (membreModel.getUserId() == null) ? + keycloakService.initCompte(membreModel.getId()) + : Uni.createFrom().nullItem()); + } + public Uni deleteLicence(long id) { return Panache.withTransaction(() -> repository.deleteById(id)); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 1a045d8..448995b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -13,6 +13,7 @@ import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; @@ -97,6 +98,22 @@ public class LicenceEndpoints { return licenceService.setLicence(id, form).map(SimpleLicence::fromModel); } + + @POST + @Path("validate") + @RolesAllowed("federation_admin") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Validation licence", description = "Valide en masse les licence de l'année en cours (pour les administrateurs)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les licences ont été mise à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni valideLicences(@Parameter(description = "Id des membre a valider") List ids) { + return licenceService.valideLicences(ids); + } + @DELETE @Path("{id}") @RolesAllowed("federation_admin") diff --git a/src/main/webapp/src/pages/ValidateList.jsx b/src/main/webapp/src/pages/ValidateList.jsx new file mode 100644 index 0000000..70f54b0 --- /dev/null +++ b/src/main/webapp/src/pages/ValidateList.jsx @@ -0,0 +1,302 @@ +import {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, useRef, useState} from "react"; +import {useLocation, useNavigate} from "react-router-dom"; +import {apiAxios, errFormater} from "../utils/Tools.js"; +import {toast} from "react-toastify"; +import {SearchBar} from "../components/SearchBar.jsx"; +import {ConfirmDialog} from "../components/ConfirmDialog.jsx"; + +export function ValidateList({source}) { + const {hash} = useLocation(); + const navigate = useNavigate(); + let page = Number(hash.substring(1)); + page = (page > 0) ? page : 1; + + const [memberData, setMemberData] = useState([]); + const [licenceData, setLicenceData] = useState([]); + const [clubFilter, setClubFilter] = useState(""); + const [stateFilter, setStateFilter] = useState(2) + const [lastSearch, setLastSearch] = useState(""); + + const [selectedMembers, setSelectedMembers] = useState([]); + + const setLoading = useLoadingSwitcher() + const {data, error, refresh} = useFetch(`/member/find/${source}?page=${page}&licenceRequest=${stateFilter}`, setLoading, 1) + + + useEffect(() => { + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}`); + }, [hash, clubFilter, stateFilter]); + + useEffect(() => { + if (!data) + return; + const data2 = []; + for (const e of data.result) { + data2.push({ + id: e.id, + fname: e.fname, + lname: e.lname, + club: e.club, + categorie: e.categorie, + licence_number: e.licence, + licence: licenceData.find(licence => licence.membre === e.id) + }) + } + setMemberData(data2); + }, [data, licenceData]); + + useEffect(() => { + toast.promise( + apiAxios.get(`/licence/current/${source}`), + { + pending: "Chargement des licences...", + success: "Licences chargées", + error: { + render({data}) { + return errFormater(data, "Impossible de charger les licences") + } + } + }) + .then(data => { + setLicenceData(data.data); + }); + }, []); + + const search = (search) => { + if (search === lastSearch) + return; + setLastSearch(search); + refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}&licenceRequest=${stateFilter}`); + } + + const handleValidation = () => { + if (selectedMembers.length === 0) { + toast.error("Aucun membre sélectionné"); + return; + } + + toast.promise( + apiAxios.post(`/licence/validate`, selectedMembers), + { + pending: "Validation des licences en cours...", + success: "Licences validées avec succès 🎉", + error: { + render({data}) { + return errFormater(data, "Échec de la validation des licences") + } + } + } + ).then(() => { + setSelectedMembers([]); + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}`); + }); + } + + return <> +

Validation des licences

+ +
+
+
+ + {data + ? + : error + ? + : + } +
+
+ {source !== "club" && +
+
Filtre
+
+ +
+
} + +
+ {source === "admin" && <> + + + } +
+ +
+
+
+ +} + +function MakeCentralPanel({data, visibleMember, navigate, page, source, selectedMembers, setSelectedMembers}) { + const lastCheckedRef = useRef(null); + + function handleCheckbox(e, memberId) { + const isShiftKeyPressed = e.shiftKey; + const isChecked = !selectedMembers.includes(memberId); // Inverse l'état actuel + + if (isShiftKeyPressed && lastCheckedRef.current !== null) { + // Sélection multiple avec Shift + const startIndex = visibleMember.findIndex(m => m.id === lastCheckedRef.current); + const endIndex = visibleMember.findIndex(m => m.id === memberId); + const [start, end] = [Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)]; + + const newSelected = [...selectedMembers]; + for (let i = start; i <= end; i++) { + const member = visibleMember[i]; + if (isChecked && !newSelected.includes(member.id)) { + newSelected.push(member.id); + } else if (!isChecked) { + const index = newSelected.indexOf(member.id); + if (index !== -1) newSelected.splice(index, 1); + } + } + setSelectedMembers(newSelected); + } else { + // Sélection normale (sans Shift) + setSelectedMembers(prev => + isChecked + ? [...prev, memberId] + : prev.filter(id => id !== memberId) + ); + } + + lastCheckedRef.current = memberId; // Met à jour le dernier membre cliqué + } + + const handleCheckboxClick = (e, memberId) => { + handleCheckbox(e, memberId); + }; + + const handleRowClick = (e, memberId) => { + // Si le clic est sur la checkbox, on laisse le gestionnaire de la checkbox gérer l'événement + if (e.target.type === 'checkbox') return; + handleCheckbox(e, memberId); + }; + + const pages = [] + for (let i = 1; i <= data.page_count; i++) { + pages.push(
  • + navigate("#" + i)}>{i} +
  • ); + } + + return <> +
    + Ligne {((page - 1) * data.page_size) + 1} à { + (page * data.page_size > data.result_count) ? data.result_count : (page * data.page_size)} (page {page} sur {data.page_count}) +
      + {visibleMember.map(member => ( + ))} +
    +
    +
    + +
    + +} + +function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) { + const rowContent = <> +
    +
    + { + }} + onClick={(e) => onCheckboxClick(e, member.id)}/> + {member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------"} +
    +
    +
    {member.fname} {member.lname}
    +
    +
    + {source === "club" ? + {member.categorie} + : {member.club?.name || "Sans club"}} + + + if (member.licence != null) { + return
  • 1 ? "warning" : "danger"))} + onClick={(e) => onRowClick(e, member.id)}> + {rowContent} +
  • + } else { + return
  • onRowClick(e, member.id)}> + {rowContent} +
  • + } +} + +let allClub = [] + +function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setStateFilter}) { + useEffect(() => { + if (!data) + return; + allClub.push(...data.result.map((e) => e.club?.name)) + allClub = allClub.filter((value, index, self) => self.indexOf(value) === index).filter(value => value != null).sort() + }, [data]); + + return
    + {source !== "club" && } +
    + +
    +
    +} + +function ClubSelectFilter({clubFilter, setClubFilter}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/no_detail`, setLoading, 1) + + return <> + {data + ?
    + +
    + : error + ? + : + } + +} + +function Def() { + return
    +
  • +
  • +
  • +
  • +
  • +
    +} diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 948503b..74ab595 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -9,8 +9,8 @@ import {AffiliationReqPage} from "./affiliation/AffiliationReqPage.jsx"; import {NewClubPage} from "./club/NewClubPage.jsx"; import {ClubPage} from "./club/ClubPage.jsx"; import {AffiliationReqList} from "./affiliation/AffiliationReqList.jsx"; -import {Scale} from "leaflet/src/control/Control.Scale.js"; import {StatsPage} from "./StatsPage.jsx"; +import {ValidateList} from "../ValidateList.jsx"; export function AdminRoot() { return <> @@ -35,6 +35,10 @@ export function getAdminChildren() { path: 'member/new', element: }, + { + path: 'member/validate', + element: + }, { path: 'club', element: @@ -60,4 +64,4 @@ export function getAdminChildren() { element: } ] -} \ No newline at end of file +}