feat: masse licence validation
This commit is contained in:
parent
7bd5e7baa5
commit
09f6cd7463
@ -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));
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
302
src/main/webapp/src/pages/ValidateList.jsx
Normal file
302
src/main/webapp/src/pages/ValidateList.jsx
Normal 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")}>
|
||||
« 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))}>«</span></li>
|
||||
{pages}
|
||||
<li className={"page-item" + ((page >= data.page_count) ? " disabled" : "")}>
|
||||
<span className="page-link" onClick={() => navigate("#" + (page + 1))}>»</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>
|
||||
}
|
||||
@ -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/>
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user