Files
ffsaf-site/src/main/webapp/src/pages/MemberList.jsx

609 lines
26 KiB
JavaScript

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, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {Checkbox} from "../components/MemberCustomFiels.jsx";
import * as Tools from "../utils/Tools.js";
import {apiAxios, errFormater, getCatName} from "../utils/Tools.js";
import {toast} from "react-toastify";
import {SearchBar} from "../components/SearchBar.jsx";
import * as XLSX from "xlsx-js-style";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faEuroSign} from "@fortawesome/free-solid-svg-icons";
let lastRefresh = "";
export function MemberList({source}) {
const {hash} = useLocation();
const navigate = useNavigate();
const [memberData, setMemberData] = useState([]);
const [licenceData, setLicenceData] = useState([]);
const [showLicenceState, setShowLicenceState] = useState(false);
const setFilter = (filter) => {
navigate("#" + encodeURI(JSON.stringify(filter)))
}
const filter = {
page: 1,
search: "",
club: "",
licenceRequest: 4,
payment: 2,
order: "",
categorie: "",
archived: false,
...JSON.parse(decodeURI(hash.substring(1)) || "{}"),
}
const setLoading = useLoadingSwitcher()
const {
data,
error,
refresh
} = useFetch(`/member/find/${source}?page=${filter.page}&search=${filter.search}&club=${filter.club}&licenceRequest=${filter.licenceRequest}&payment=${filter.payment}&order=${filter.order}&categorie=${filter.categorie}&archive=${filter.archived}`, setLoading, 1)
useEffect(() => {
const tmp = `/member/find/${source}?page=${filter.page}&search=${filter.search}&club=${filter.club}&licenceRequest=${filter.licenceRequest}&payment=${filter.payment}&order=${filter.order}&categorie=${filter.categorie}&archive=${filter.archived}`;
if (tmp === lastRefresh)
return;
lastRefresh = tmp
refresh(lastRefresh);
}, [hash]);
useEffect(() => {
if (!data)
return;
if (data.page_count < filter.page) {
setFilter({...filter, page: 1});
}
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: showLicenceState ? licenceData.find(licence => licence.membre === e.id) : null
})
}
setMemberData(data2);
}, [data, licenceData]);
useEffect(() => {
if (!showLicenceState)
return;
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);
});
}, [showLicenceState]);
const search = (search) => {
if (search === filter.search)
return;
setFilter({...filter, search: search});
}
return <>
<div>
<div className="row">
<div className="col-lg-9">
<SearchBar search={search} defaultValue={filter.search}/>
{data
? <MakeCentralPanel data={data} visibleMember={memberData} navigate={navigate} showLicenceState={showLicenceState}
page={filter.page} setPage={e => setFilter({...filter, page: e})} source={source}/>
: error
? <AxiosError error={error}/>
: <Def/>
}
</div>
<div className="col-lg-3">
<div className="mb-4">
<button className="btn btn-primary" onClick={() => navigate("new")}>Ajouter un membre</button>
{source === "admin" &&
<button className="btn btn-primary" onClick={() => navigate("validate")} style={{marginTop: "0.5rem"}}>Valider des
licences</button>}
{source === "club" && false && // TODO: enable when payment is ready
<button className="btn btn-primary" onClick={() => navigate("pay")} style={{marginTop: "0.5rem"}}>Paiement des
licences</button>}
</div>
<div className="card mb-4">
<div className="card-header">Trie</div>
<div className="card-body">
<OrderBar onOrderChange={e => setFilter({...filter, order: e.join(",")})} defaultValues={filter.order} source={source}/>
</div>
</div>
<div className="card mb-4">
<div className="card-header">Filtre</div>
<div className="card-body">
<FiltreBar showLicenceState={showLicenceState}
setShowLicenceState={setShowLicenceState}
showArchived={filter.archived}
setShowArchived={e => setFilter({...filter, archived: e})}
clubFilter={filter.club}
setClubFilter={e => setFilter({...filter, club: e})}
source={source}
stateFilter={filter.licenceRequest}
setStateFilter={e => setFilter({...filter, licenceRequest: e})}
paymentFilter={filter.payment}
setPaymentFilter={e => setFilter({...filter, payment: e})}
catFilter={filter.categorie}
setCatFilter={e => setFilter({...filter, categorie: e})}/>
</div>
</div>
{source === "club" &&
<div className="card mb-4">
<div className="card-header">Gestion groupée</div>
<div className="card-body">
<FileOutput/>
<div style={{marginTop: "1.5em"}}></div>
<FileInput/>
</div>
</div>}
</div>
</div>
</div>
</>
}
function FileOutput() {
function formatColumnDate(worksheet, col) {
const range = XLSX.utils.decode_range(worksheet['!ref'])
// note: range.s.r + 1 skips the header row
for (let row = range.s.r + 1; row <= range.e.r; ++row) {
const ref = XLSX.utils.encode_cell({r: row, c: col})
if (worksheet[ref] && worksheet[ref].t === "n") {
worksheet[ref].v = Math.trunc(worksheet[ref].v)
} else {
worksheet[ref].t = "n"
}
worksheet[ref].z = "dd/mm/yyyy"
}
}
const handleFileDownload = () => {
toast.promise(
apiAxios.get(`/member/club/export`),
{
pending: "Exportation des licences...",
success: "Licences exportées",
error: {
render({data}) {
return errFormater(data, "Impossible d'exporté les licences")
}
}
})
.then(data => {
const dataOut = []
for (const e of data.data) {
const tmp = {
licence: e.licence,
nom: e.nom,
prenom: e.prenom,
email: e.email,
genre: e.genre,
birthdate: new Date(e.birthdate),
licenceCurrent: e.licenceCurrent ? 'X' : '',
certif: e.certif ? e.certif.split("¤")[0] : '',
certifDate: e.certif ? new Date(e.certif.split("¤")[1]) : '',
}
if (isNaN(tmp.certifDate) || tmp.certifDate === 'NaN') {
tmp.certifDate = ''
}
//tmp.birthdate.setMilliseconds(0);
//tmp.birthdate.setSeconds(0);
//tmp.birthdate.setMinutes(0);
//tmp.birthdate.setHours(0);
//
//console.log(tmp.birthdate);
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", "Email", "Genre", "Date de naissance", "Licence en cours", "Nom médecin certificat", "Date certificat"]], {origin: 'A1'});
// XLSX.utils.sheet_add_json(ws, dataOut, {skipHeader: true, origin: 'A2'});
formatColumnDate(ws, 5)
formatColumnDate(ws, 8)
console.log(ws)
//ws["!data"][0][0].z = "yyyy-mm-dd hh:mm:ss"
ws["!cols"] = [{wch: 7}, {wch: 16}, {wch: 16}, {wch: 30}, {wch: 9}, {wch: 12}, {wch: 6}, {wch: 13}, {wch: 12}]
XLSX.utils.book_append_sheet(wb, ws, `Saison ${Tools.getSaison()}-${Tools.getSaison() + 1}`);
XLSX.writeFile(wb, "output.xlsx");
});
};
return (
<div>
<button className="btn btn-primary" onClick={handleFileDownload}>Télécharger l'Excel des membres</button>
<small>À utiliser comme template pour mettre à jour les informations</small>
</div>
);
}
function FileInput() {
const re = /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i;
function excelDateToJSDate(serial) {
const utcDays = Math.floor(serial - 25569);
const utcValue = utcDays * 86400;
return new Date(utcValue * 1000);
}
const handleFileUpload = (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
const workbook = XLSX.read(event.target.result, {type: 'binary'});
const sheet = workbook.Sheets[`Saison ${Tools.getSaison()}-${Tools.getSaison() + 1}`];
const sheetData = XLSX.utils.sheet_to_json(sheet);
const dataOut = []
let error = 0;
let cetifNotFill = 0;
for (let i = 0; i < sheetData.length; i++) {
const line = sheetData[i];
// noinspection NonAsciiCharacters,JSNonASCIINames
const tmp = {
licence: line["Licence"],
nom: line["Nom"],
prenom: line["Prénom"],
email: line["Email"],
genre: line["Genre"],
birthdate: line["Date de naissance"],
licenceCurrent: line["Licence en cours"] === undefined ? false : line["Licence en cours"].toLowerCase() === "x",
certif: "",
}
if (tmp.nom === undefined || tmp.nom === "") {
toast.error("Nom vide à la ligne " + (i + 2))
error++;
}
if (tmp.prenom === undefined || tmp.prenom === "") {
toast.error("Prénom vide à la ligne " + (i + 2))
error++;
}
if (tmp.licenceCurrent) { // need check full data
if (tmp.email === undefined || tmp.email === "") {
toast.error("Email vide à la ligne " + (i + 2))
error++;
}
if (!re.test(tmp.email)) {
toast.error("Email invalide à la ligne " + (i + 2))
error++;
}
// noinspection NonAsciiCharacters,JSNonASCIINames
if (line["Nom médecin certificat"] === undefined || line["Nom médecin certificat"] === "" ||
line["Date certificat"] === undefined || line["Date certificat"] === "") {
cetifNotFill++;
} else {
try {
const date = excelDateToJSDate(line["Date certificat"]);
if (Number.isNaN(date.getFullYear())) {
toast.error("Format de la date de certificat invalide à la ligne " + (i + 2))
error++;
} else {
// noinspection JSNonASCIINames
tmp.certif = line["Nom médecin certificat"] + "¤" + date.getFullYear() + "-" + ("0" + (date.getMonth() + 1)).slice(-2) + "-" + ("0" + date.getDate()).slice(-2);
}
} catch (e) {
toast.error("Format de la date de certificat invalide à la ligne " + (i + 2))
error++;
}
}
if (tmp.birthdate === undefined || tmp.birthdate === "") {
toast.error("Date de naissance vide à la ligne " + (i + 2))
error++;
}
}
if (tmp.birthdate !== undefined && tmp.birthdate !== "") {
console.log(tmp.birthdate);
try {
tmp.birthdate = excelDateToJSDate(tmp.birthdate).toISOString();
} catch (e) {
toast.error("Format de la date de naissance invalide à la ligne " + (i + 2))
error++;
}
}
dataOut.push(tmp)
}
if (error > 0) {
toast.error(`${error} erreur(s) dans le fichier, opération annulée`)
} else {
console.log(dataOut);
toast.promise(
apiAxios.put(`/member/club/import`, dataOut),
{
pending: "Envoie des changement en cours",
success: "Changement envoyé avec succès 🎉",
error: {
render({data}) {
return errFormater(data, "Échec de l'envoie des changements")
}
}
}
).then(_ => {
if (cetifNotFill > 0)
toast.warn(`${cetifNotFill} certificat(s) médical(aux) non rempli(s)`)
})
}
};
reader.readAsBinaryString(file);
};
return (
<div>
<span>Charger l'Excel</span>
<div className="input-group">
<input type="file" className="form-control" id="logo" name="logo" accept=".xls,.xlsx" onChange={handleFileUpload}/>
</div>
<small>Merci d'utiliser le fichier ci-dessus comme base, ne pas renommer les colonnes ni modifier les n° de licences.</small>
</div>
);
}
function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page, setPage, source}) {
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={() => setPage(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>
<div className="list-group">
{visibleMember.map(member => (
<MakeRow key={member.id} member={member} navigate={navigate} showLicenceState={showLicenceState} source={source}/>))}
</div>
</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={() => setPage(page - 1)}>&laquo;</span></li>
{pages}
<li className={"page-item" + ((page >= data.page_count) ? " disabled" : "")}>
<span className="page-link" onClick={() => setPage(page + 1)}>&raquo;</span></li>
</ul>
</nav>
</div>
</>
}
function MakeRow({member, showLicenceState, navigate, source}) {
const rowContent = <>
<div className="row" style={{padding: "0.6em 0"}}>
<span className="col-auto">{(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "}
{(showLicenceState && member.licence != null && member.licence.pay) ? <FontAwesomeIcon icon={faEuroSign}/> : <>&nbsp;&nbsp;</>}</span>
<div className="ms-2 col-auto">
<div className="fw-bold">{member.fname} {member.lname}</div>
</div>
</div>
<div style={{verticalAlign: "center", margin: "auto 0"}}>
{source === "club" ?
<small>{getCatName(member.categorie)}</small>
: <div style={{
textAlign: "right",
fontSize: "small"
}}>{member.club?.name || "Sans club"}<br/><small>{getCatName(member.categorie)}</small></div>}
</div>
</>
if (showLicenceState && member.licence != null) {
return <a 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"))}
style={{padding: "0 1em"}}
onClick={e => {
e.preventDefault();
navigate("" + member.id)
}}
href={"member/" + member.id}>
{rowContent}
</a>
} else {
return <a className="list-group-item d-flex justify-content-between align-items-start list-group-item-action"
style={{padding: "0 1em"}}
onClick={e => {
e.preventDefault();
navigate("" + member.id)
}}
href={"member/" + member.id}>
{rowContent}
</a>
}
}
function OrderBar({onOrderChange, defaultValues = "", source}) {
const [orderCriteria, setOrderCriteria] = useState([...defaultValues.split(",").filter(c => c !== ''), '']);
const handleChange = (index, value) => {
const newCriteria = [...orderCriteria];
newCriteria[index] = value;
// Si le dernier critère est rempli, on en ajoute un nouveau
if (index === orderCriteria.length - 1 && value !== '') {
newCriteria.push('');
}
// Si un critère (sauf le premier) est réinitialisé, on le supprime
else if (value === '' && (index !== 0 || orderCriteria.length > 1)) {
newCriteria.splice(index, 1);
}
setOrderCriteria(newCriteria);
onOrderChange(newCriteria.filter(c => c !== ''));
};
// Liste de toutes les options possibles
const allOptions = [
{value: 'lname n', label: 'Nom ↓', base: 'lname'},
{value: 'lname i', label: 'Nom ↑', base: 'lname'},
{value: 'fname n', label: 'Prénom ↓', base: 'fname'},
{value: 'fname i', label: 'Prénom ↑', base: 'fname'},
{value: 'categorie n', label: 'Catégorie ↓', base: 'categorie'},
{value: 'categorie i', label: 'Catégorie ↑', base: 'categorie'},
{value: 'licence n', label: 'Licence ↓', base: 'licence'},
{value: 'licence i', label: 'Licence ↑', base: 'licence'},
];
if (source === "admin") {
allOptions.push(
{value: 'club.name n', label: 'Club ↓', base: 'club.name'},
{value: 'club.name i', label: 'Club ↑', base: 'club.name'},
);
}
return (
<div className="mb-3">
{orderCriteria.map((criteria, index) => {
// Récupère les bases des critères déjà sélectionnés (sauf le courant)
const usedBases = orderCriteria
.filter((c, i) => c !== '' && i !== index)
.map(c => allOptions.find(o => o.value === c)?.base);
// Filtre les options disponibles
const availableOptions = allOptions.filter(option =>
!usedBases.includes(option.base) || option.value === criteria
);
return (
<select
key={index}
className="form-select mb-2"
value={criteria}
onChange={(e) => handleChange(index, e.target.value)}
>
<option value="">----</option>
{availableOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
})}
</div>
);
}
function FiltreBar({
showLicenceState,
setShowLicenceState,
showArchived,
setShowArchived,
clubFilter,
setClubFilter,
source,
stateFilter,
setStateFilter,
paymentFilter,
setPaymentFilter,
catFilter,
setCatFilter,
}) {
return <div>
<div className="mb-3">
<Checkbox value={showLicenceState} onChange={setShowLicenceState} label="Afficher l'état des licences"/>
</div>
<div className="mb-3">
<Checkbox value={showArchived} onChange={setShowArchived} name="checkbox2" label="Afficher les combattants inactifs"/>
</div>
<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>
{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={4}>Tout les états de licences</option>
<option value={0}>Sans demande ni licence validée</option>
<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>
<option value={3}>Licence validée</option>
</select>
</div>
<div className="mb-3">
<select className="form-select" value={paymentFilter} onChange={event => setPaymentFilter(Number(event.target.value))}
hidden={stateFilter === 0 || stateFilter === 4}>
<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>
}