Files
ffsaf-site/src/main/webapp/src/pages/PayAndValidateList.jsx
Thibaut Valentin 7c4addd525
All checks were successful
Deploy Production Server / if_merged (pull_request) Successful in 10m27s
feat: add cat filter on mass license validation
2025-12-18 14:46:52 +01:00

490 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, getCatName} from "../utils/Tools.js";
import {toast} from "react-toastify";
import {SearchBar} from "../components/SearchBar.jsx";
import {ConfirmDialog} from "../components/ConfirmDialog.jsx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faCircleInfo, faEuroSign} from "@fortawesome/free-solid-svg-icons";
import "./PayAndValidateList.css";
import * as Tools from "../utils/Tools.js";
export function PayAndValidateList({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 [catFilter, setCatFilter] = useState("");
const [stateFilter, setStateFilter] = useState((source === "club") ? 1 : 2)
const [lastSearch, setLastSearch] = useState("");
const [paymentFilter, setPaymentFilter] = useState((source === "club") ? 0 : 2);
const storedMembers = sessionStorage.getItem("selectedMembers");
const [selectedMembers, setSelectedMembers] = useState(storedMembers ? JSON.parse(storedMembers) : []);
const setLoading = useLoadingSwitcher()
const {
data,
error,
refresh
} = useFetch(`/member/find/${source}?page=${page}&licenceRequest=${stateFilter}&payment=${paymentFilter}&categorie=${catFilter}`, setLoading, 1)
useEffect(() => {
sessionStorage.setItem("selectedMembers", JSON.stringify(selectedMembers));
}, [selectedMembers]);
useEffect(() => {
refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}&categorie=${catFilter}`);
}, [hash, clubFilter, stateFilter, lastSearch, paymentFilter, catFilter]);
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]);
const fetchLicenceData = () => {
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);
});
};
useEffect(() => {
fetchLicenceData();
}, []);
const search = (search) => {
if (search === lastSearch)
return;
setLastSearch(search);
}
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}&payment=${paymentFilter}&categorie=${catFilter}`);
});
}
const handlePayment = () => {
if (selectedMembers.length === 0) {
toast.error("Aucun membre sélectionné");
return;
}
toast.promise(
apiAxios.post(`/licence/validate-pay`, 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([]);
fetchLicenceData();
refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}&categorie=${catFilter}`);
});
}
const handlePay = () => {
if (selectedMembers.length === 0) {
toast.error("Aucun membre sélectionné");
return;
}
toast.promise(
apiAxios.post(`/licence/pay`, selectedMembers),
{
pending: "Création de la commande en cours...",
success: "Commande crée avec succès 🎉",
error: {
render({data}) {
return errFormater(data, "Échec de le création de la commande")
}
}
}
).then((data) => {
window.location.href = data.data;
});
}
return <>
<h2>Validation des licences</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("../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">
<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} paymentFilter={paymentFilter}
setPaymentFilter={setPaymentFilter} setCatFilter={setCatFilter} catFilter={catFilter}/>
</div>
</div>
<div className="mb-4">
{source === "admin" && <>
<button className="btn btn-primary" data-bs-toggle="modal" data-bs-target="#confirm-pay">Valider le payement
des {selectedMembers.length} licences sélectionnée
</button>
<ConfirmDialog title="Payment des licences"
message={"Êtes-vous sûr de vouloir marquer comme payées les " + selectedMembers.length + " licences ?"}
onConfirm={handlePayment} id="confirm-pay"/>
<button className="btn btn-primary" data-bs-toggle="modal" data-bs-target="#confirm-validation" style={{marginTop: "0.5em"}}>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"/>
</>}
{source === "club" && <>
<span>{selectedMembers.length} licences sélectionnée<br/>Total à régler : {selectedMembers.length * 15}</span>
<HaPay onClick={handlePay}/>
<div className="card">
<div className="card-header">
<FontAwesomeIcon icon={faCircleInfo} size="2xl" style={{color: "#74C0FC", marginRight: "0.25em"}}/>
A propos de HelloAsso
</div>
<div className="card-body">
Le modèle solidaire de HelloAsso garantit que 100% de votre paiement sera versé à lassociation choisie. Vous
pouvez soutenir laide quils apportent aux associations en laissant une contribution volontaire à HelloAsso au
moment de votre paiement.
</div>
</div>
</>}
</div>
</div>
</div>
</div>
</>
}
function HaPay({onClick}) {
return <>
<div className="HaPay" style={{marginTop: "0.5em"}}>
<button className="HaPayButton" onClick={onClick}>
<img
src="https://api.helloasso.com/v5/img/logo-ha.svg"
alt=""
className="HaPayButtonLogo"
/>
<div className="HaPayButtonLabel">
<span> Payer avec </span>
<svg
width="73"
height="14"
viewBox="0 0 73 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M72.9992 8.78692C72.9992 11.7371 71.242 13.6283 68.4005 13.6283C65.5964 13.6283 63.8018 11.9073 63.8018 8.74909C63.8018 5.79888 65.559 3.90771 68.4005 3.90771C71.2046 3.90771 72.9992 5.64759 72.9992 8.78692ZM67.2041 8.74909C67.2041 10.5457 67.5779 11.2265 68.4005 11.2265C69.223 11.2265 69.5969 10.5079 69.5969 8.78692C69.5969 6.99031 69.223 6.30949 68.4005 6.30949C67.5779 6.30949 67.1854 7.04705 67.2041 8.74909Z"
/>
<path
d="M62.978 5.08045L61.8003 6.89597C61.1647 6.47991 60.4356 6.25297 59.6692 6.23406C59.1084 6.23406 58.9214 6.40426 58.9214 6.65011C58.9214 6.9527 59.0149 7.08508 60.716 7.61461C62.4172 8.14413 63.3332 8.88169 63.3332 10.527C63.3332 12.3803 61.576 13.6474 59.1084 13.6474C57.5381 13.6474 56.0986 13.0801 55.1826 12.2101L56.7529 10.4514C57.3885 10.962 58.211 11.3402 59.0336 11.3402C59.6131 11.3402 59.9683 11.1511 59.9683 10.7918C59.9683 10.3568 59.7813 10.2622 58.2484 9.78945C56.5847 9.27883 55.65 8.31434 55.65 6.85814C55.65 5.23174 57.0333 3.92684 59.5383 3.92684C60.8656 3.90793 62.1555 4.36181 62.978 5.08045Z"
/>
<path
d="M54.7358 5.08045L53.5581 6.89597C52.9225 6.47991 52.1934 6.25297 51.427 6.23406C50.8662 6.23406 50.6792 6.40426 50.6792 6.65011C50.6792 6.9527 50.7727 7.08508 52.4738 7.61461C54.175 8.14413 55.091 8.88169 55.091 10.527C55.091 12.3803 53.3338 13.6474 50.8662 13.6474C49.2959 13.6474 47.8564 13.0801 46.9404 12.2101L48.5107 10.4514C49.1463 10.962 49.9689 11.3402 50.7914 11.3402C51.3709 11.3402 51.7261 11.1511 51.7261 10.7918C51.7261 10.3568 51.5391 10.2622 50.0062 9.78945C48.3238 9.27883 47.4078 8.31434 47.4078 6.85814C47.4078 5.23174 48.7911 3.92684 51.2961 3.92684C52.6234 3.90793 53.9133 4.36181 54.7358 5.08045Z"
/>
<path
d="M46.7721 11.4156L46.0991 13.5526C44.9401 13.477 44.1923 13.1555 43.6876 12.3045C43.0333 13.3068 42.0051 13.6283 40.9956 13.6283C39.201 13.6283 38.042 12.418 38.042 10.7537C38.042 8.74909 39.5375 7.65222 42.3603 7.65222H42.9959V7.42528C42.9959 6.51752 42.6968 6.27167 41.706 6.27167C40.9209 6.30949 40.1357 6.4797 39.4067 6.74446L38.6963 4.62636C39.8179 4.17248 41.0143 3.94554 42.2294 3.90771C45.0709 3.90771 46.23 5.00459 46.23 7.23616V10.3566C46.23 10.9996 46.3795 11.2643 46.7721 11.4156ZM43.0146 10.7348V9.39209H42.6594C41.7247 9.39209 41.2947 9.71359 41.2947 10.4133C41.2947 10.9239 41.5752 11.2643 42.0238 11.2643C42.4164 11.2643 42.7903 11.0563 43.0146 10.7348Z"
/>
<path
d="M37.5363 8.78692C37.5363 11.7371 35.7791 13.6283 32.9376 13.6283C30.1335 13.6283 28.3389 11.9073 28.3389 8.74909C28.3389 5.79888 30.0961 3.90771 32.9376 3.90771C35.7417 3.90771 37.5363 5.64759 37.5363 8.78692ZM31.7412 8.74909C31.7412 10.5457 32.1151 11.2265 32.9376 11.2265C33.7601 11.2265 34.134 10.5079 34.134 8.78692C34.134 6.99031 33.7601 6.30949 32.9376 6.30949C32.1151 6.30949 31.7225 7.04705 31.7412 8.74909Z"
/>
<path
d="M23.8154 10.6972V0.692948L27.1243 0.352539V10.527C27.1243 10.8296 27.2551 10.9809 27.5355 10.9809C27.6477 10.9809 27.7786 10.962 27.8907 10.9052L28.4889 13.2881C27.8907 13.4961 27.2738 13.6096 26.6569 13.5907C24.8249 13.6285 23.8154 12.5505 23.8154 10.6972Z"
/>
<path
d="M18.8057 10.6972V0.692948L22.1145 0.352539V10.527C22.1145 10.8296 22.2454 10.9809 22.5071 10.9809C22.6192 10.9809 22.7501 10.962 22.8623 10.9052L23.4418 13.2881C22.8436 13.4961 22.2267 13.6096 21.6098 13.5907C19.8151 13.6285 18.8057 12.5505 18.8057 10.6972Z"
/>
<path
d="M17.9071 9.71359H12.4859C12.6728 11.0185 13.3084 11.2454 14.2805 11.2454C14.9161 11.2454 15.533 10.9807 16.2994 10.4511L17.6454 12.2856C16.6172 13.1555 15.3087 13.6283 13.9627 13.6283C10.6912 13.6283 9.13965 11.5858 9.13965 8.78692C9.13965 6.13929 10.6352 3.90771 13.5888 3.90771C16.2247 3.90771 17.9632 5.60976 17.9632 8.63562C17.9819 8.93821 17.9445 9.39209 17.9071 9.71359ZM14.7291 7.70895C14.7105 6.80119 14.5235 6.04473 13.6823 6.04473C12.9719 6.04473 12.6167 6.46079 12.4859 7.84134H14.7291V7.70895Z"
/>
<path
d="M8.24307 6.61229V13.2692H4.93423V7.21746C4.93423 6.49882 4.7286 6.32862 4.4295 6.32862C4.07431 6.32862 3.70043 6.61229 3.30786 7.21746V13.2503H-0.000976562V0.692948L3.30786 0.352539V5.06154C4.07431 4.24834 4.82207 3.90793 5.83154 3.90793C7.32706 3.90793 8.24307 4.89133 8.24307 6.61229Z"
/>
</svg>
</div>
</button>
<div className="HaPaySecured">
<svg
width="9"
height="10"
viewBox="0 0 11 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.875 3V4.5H7.625V3C7.625 1.96875 6.78125 1.125 5.75 1.125C4.69531 1.125 3.875 1.96875 3.875 3ZM2.75 4.5V3C2.75 1.35938 4.08594 0 5.75 0C7.39062 0 8.75 1.35938 8.75 3V4.5H9.5C10.3203 4.5 11 5.17969 11 6V10.5C11 11.3438 10.3203 12 9.5 12H2C1.15625 12 0.5 11.3438 0.5 10.5V6C0.5 5.17969 1.15625 4.5 2 4.5H2.75ZM1.625 6V10.5C1.625 10.7109 1.78906 10.875 2 10.875H9.5C9.6875 10.875 9.875 10.7109 9.875 10.5V6C9.875 5.8125 9.6875 5.625 9.5 5.625H2C1.78906 5.625 1.625 5.8125 1.625 6Z"
/>
</svg>
<span>Paiement sécurisé</span>
<img
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-visa.svg"
alt="Logo Visa"
/>
<img
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-mastercard.svg"
alt="Logo Mastercard"
/>
<img
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-cb.svg"
alt="Logo CB"
/>
<img
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-pci.svg"
alt="Logo PCI"
/>
</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 className="col-auto">{(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "}
{(member.licence != null && member.licence.pay) ? <FontAwesomeIcon icon={faEuroSign}/> : <>&nbsp;&nbsp;</>}</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, paymentFilter, setPaymentFilter, setCatFilter, catFilter}) {
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={catFilter} onChange={event => setCatFilter(event.target.value)}>
<option value="">--- toute les catégories ---</option>
{Tools.CatList.map(cat => (
<option key={cat} value={cat}>{getCatName(cat)}</option>
))}
</select>
</div>
<div className="mb-3">
<select className="form-select" value={stateFilter} onChange={event => setStateFilter(Number(event.target.value))}>
<option value={1}>Avec demande ou licence validée</option>
<option value={2}>Demande en cours</option>
<option value={5}>Demande complet</option>
<option value={6}>Demande incomplet</option>
</select>
</div>
{source !== "club" && <div className="mb-3">
<select className="form-select" value={paymentFilter} onChange={event => setPaymentFilter(Number(event.target.value))}>
<option value={2}>Tout les états de paiement</option>
<option value={0}>Sans paiement</option>
<option value={1}>Avec paiement</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>
}