feat: masse licence validation

This commit is contained in:
Thibaut Valentin 2025-08-13 15:32:06 +02:00
parent 7bd5e7baa5
commit 09f6cd7463
4 changed files with 360 additions and 11 deletions

View File

@ -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<List<LicenceModel>> getLicence(long id, Consumer<MembreModel> 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<Long> ids) {
Uni<String> 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<LicenceModel> 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<MembreModel, Uni<?>> 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));
}

View File

@ -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<Long> ids) {
return licenceService.valideLicences(ids);
}
@DELETE
@Path("{id}")
@RolesAllowed("federation_admin")

View File

@ -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 <>
<h2>Validation des licences</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
&laquo; retour
</button>
<div>
<div className="row">
<div className="col-lg-9">
<SearchBar search={search}/>
{data
? <MakeCentralPanel data={data} visibleMember={memberData} navigate={navigate}
page={page} source={source} selectedMembers={selectedMembers} setSelectedMembers={setSelectedMembers}/>
: error
? <AxiosError error={error}/>
: <Def/>
}
</div>
<div className="col-lg-3">
{source !== "club" &&
<div className="card mb-4">
<div className="card-header">Filtre</div>
<div className="card-body">
<FiltreBar data={data} clubFilter={clubFilter} setClubFilter={setClubFilter} source={source}
stateFilter={stateFilter} setStateFilter={setStateFilter}/>
</div>
</div>}
<div className="mb-4">
{source === "admin" && <>
<button className="btn btn-primary" data-bs-toggle="modal" data-bs-target="#confirm-validation">Valider
les {selectedMembers.length} licences sélectionnée
</button>
<ConfirmDialog title="Validation des licences" message={"Êtes-vous sûr de vouloir valider les "+selectedMembers.length+" licences ?"}
onConfirm={handleValidation} id="confirm-validation"/>
</>}
</div>
</div>
</div>
</div>
</>
}
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(<li key={i} className={"page-item " + ((page === i) ? "active" : "")}>
<span className="page-link" onClick={() => navigate("#" + i)}>{i}</span>
</li>);
}
return <>
<div className="mb-4">
<small>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})</small>
<ul className="list-group">
{visibleMember.map(member => (
<MakeRow key={member.id} member={member} navigate={navigate} source={source} isChecked={selectedMembers.includes(member.id)}
onCheckboxClick={handleCheckboxClick} onRowClick={handleRowClick}/>))}
</ul>
</div>
<div className="mb-4">
<nav aria-label="Page navigation">
<ul className="pagination justify-content-center">
<li className={"page-item" + ((page <= 1) ? " disabled" : "")}>
<span className="page-link" onClick={() => navigate("#" + (page - 1))}>&laquo;</span></li>
{pages}
<li className={"page-item" + ((page >= data.page_count) ? " disabled" : "")}>
<span className="page-link" onClick={() => navigate("#" + (page + 1))}>&raquo;</span></li>
</ul>
</nav>
</div>
</>
}
function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) {
const rowContent = <>
<div className="row">
<div className="col-auto">
<input className="form-check-input me-1" type="checkbox" checked={isChecked || false} onChange={() => {
}}
onClick={(e) => onCheckboxClick(e, member.id)}/>
<span>{member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------"}</span>
</div>
<div className="ms-2 col-auto">
<div className="fw-bold">{member.fname} {member.lname}</div>
</div>
</div>
{source === "club" ?
<small>{member.categorie}</small>
: <small>{member.club?.name || "Sans club"}</small>}
</>
if (member.licence != null) {
return <li
className={"list-group-item d-flex justify-content-between align-items-start list-group-item-action list-group-item-"
+ (member.licence.validate ? "success" : (member.licence.certificate.length > 1 ? "warning" : "danger"))}
onClick={(e) => onRowClick(e, member.id)}>
{rowContent}
</li>
} else {
return <li className="list-group-item d-flex justify-content-between align-items-start list-group-item-action"
onClick={(e) => onRowClick(e, member.id)}>
{rowContent}
</li>
}
}
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 <div>
{source !== "club" && <ClubSelectFilter clubFilter={clubFilter} setClubFilter={setClubFilter}/>}
<div className="mb-3">
<select className="form-select" value={stateFilter} onChange={event => setStateFilter(Number(event.target.value))}>
<option value={2}>Demande en cours</option>
<option value={5}>Demande complet</option>
<option value={6}>Demande incomplet</option>
</select>
</div>
</div>
}
function ClubSelectFilter({clubFilter, setClubFilter}) {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/club/no_detail`, setLoading, 1)
return <>
{data
? <div className="mb-3">
<select className="form-select" value={clubFilter} onChange={event => setClubFilter(event.target.value)}>
<option value="">--- tout les clubs ---</option>
<option value="null">--- sans club ---</option>
{data.map(club => (<option key={club.id} value={club.name}>{club.name}</option>))}
</select>
</div>
: error
? <AxiosError error={error}/>
: <Def/>
}
</>
}
function Def() {
return <div className="list-group">
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
</div>
}

View File

@ -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: <NewMemberPage/>
},
{
path: 'member/validate',
element: <ValidateList source="admin"/>
},
{
path: 'club',
element: <ClubList/>
@ -60,4 +64,4 @@ export function getAdminChildren() {
element: <StatsPage/>
}
]
}
}