feat: categorie on CM

This commit is contained in:
2025-11-24 15:56:15 +01:00
parent b78b3f005b
commit b1bcf75e56
28 changed files with 1343 additions and 43 deletions

View File

@@ -1,4 +1,6 @@
import {createContext, useContext, useEffect, useId, useReducer, useRef, useState} from "react";
import {apiAxios} from "../utils/Tools.js";
import {toast} from "react-toastify";
function uuidv4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
@@ -45,6 +47,11 @@ export function WSProvider({url, onmessage, children}) {
const ws = useRef(null)
const listenersRef = useRef([])
const callbackRef = useRef({})
const isReadyRef = useRef(isReady)
useEffect(() => {
isReadyRef.current = isReady
}, [isReady])
useEffect(() => {
listenersRef.current = state.listener
@@ -129,7 +136,7 @@ export function WSProvider({url, onmessage, children}) {
const send = (uuid, code, type, data, resolve = () => {
}, reject = () => {
}) => {
if (!isReady) {
if (!isReadyRef.current) {
reject("WebSocket is not connected");
return;
}
@@ -150,6 +157,8 @@ export function WSProvider({url, onmessage, children}) {
}
}
console.log("WSProvider: sending message", {uuid, code, type, data});
ws.current?.send(JSON.stringify({
uuid: uuid,
code: code,
@@ -191,3 +200,37 @@ export function useWS() {
send,
}
}
export function useRequestWS(code, payload, setLoading = null, loadingLevel = 1) {
const [data, setData] = useState(null)
const [error, setErrors] = useState(null)
const {isReady, sendRequest} = useWS()
const refresh = (code, payload) => {
if (setLoading)
setLoading(loadingLevel)
sendRequest(code, payload).then((data) => {
setData(data);
}).catch((err) => {
setErrors(err);
}).finally(() => {
if (setLoading)
setLoading(0)
})
}
useEffect(() => {
if (isReady)
refresh(code, payload)
else{
if (setLoading)
setLoading(loadingLevel)
setTimeout(() => refresh(code, payload), 1000)
}
}, []);
return {
data, error, refresh, setData
}
}

View File

@@ -0,0 +1,445 @@
import {useEffect, useReducer, useRef, useState} from "react";
import {useRequestWS, useWS} from "../../../hooks/useWS.jsx";
import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {CheckField, TextField} from "../../../components/MemberCustomFiels.jsx";
import {toast} from "react-toastify";
import {build_tree, from_sendTree, resize_tree, TreeNode} from "../../../utils/TreeUtils.js"
import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx";
import {SimpleReducer} from "../../../utils/SimpleReducer.jsx";
import {MarchReducer} from "../../../utils/MatchReducer.jsx";
export function CMAdmin() {
const [catId, setCatId] = useState(null);
const [cat, setCat] = useState(null);
const [combs, setCombs] = useState([])
// const [cats, setCats] = useState([])
const {dispatch} = useWS();
useEffect(() => {
const categoryListener = ({data}) => {
if (!cat || data.id !== cat.id)
return
setCat({
...cat,
name: data.name,
liceName: data.liceName,
type: data.type
})
}
dispatch({type: 'addListener', payload: {callback: categoryListener, code: 'sendCategory'}})
return () => dispatch({type: 'removeListener', payload: categoryListener})
}, []);
/*useEffect(() => {
toast.promise(sendRequest("getAllCategory", {}),
{
pending: 'Chargement des catégories...',
success: 'Catégories chargées !',
error: 'Erreur lors du chargement des catégories'
}
).then((data) => {
setCats(data);
})
}, []);*/
return <>
<div className="card">
<div className='card-header'>
<CategoryHeader cat={cat} setCatId={setCatId}/>
</div>
<div className="card-body">
<LoadingProvider>
<div className="row">
<CategoryContent cat={cat} catId={catId} setCat={setCat}/>
</div>
</LoadingProvider>
</div>
</div>
</>
}
function CategoryHeader({cat, setCatId}) {
const setLoading = useLoadingSwitcher()
const bthRef = useRef();
const confirmRef = useRef();
const [modal, setModal] = useState({})
const [confirm, setConfirm] = useState({})
const {data: cats, setData: setCats} = useRequestWS('getAllCategory', {}, setLoading);
const {dispatch} = useWS();
useEffect(() => {
const categoryListener = ({data}) => {
setCats([
...cats.filter(c => c.id !== data.id),
data
])
}
dispatch({type: 'addListener', payload: {callback: categoryListener, code: 'sendCategory'}})
return () => dispatch({type: 'removeListener', payload: categoryListener})
}, [cats]);
useEffect(() => {
if (cats && cats.length > 0 && !cat) {
setCatId(cats[0].id);
}
}, [cats]);
const handleCatChange = (e) => {
const selectedCatId = e.target.value;
if (selectedCatId !== "-1") {
setCatId(selectedCatId);
} else { // New category
setModal({});
bthRef.current.click();
console.log(cat);
e.target.value = cat?.id;
}
}
return <div className="row">
<div className="col-auto">
<div className="input-group">
<h5 style={{margin: "auto 0.5em auto 0"}}>Edition de la catégorie</h5>
<select className="form-select" onChange={handleCatChange}>
{cats && cats.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>))}
{cats && <option value={-1}>Nouvelle...</option>}
</select>
</div>
</div>
<div className="col">
</div>
<div className="col">
<button className="btn btn-primary float-end" onClick={() => {
setModal(cat);
bthRef.current.click();
}} disabled={cat === null}>Modifier
</button>
</div>
<button ref={bthRef} data-bs-toggle="modal" data-bs-target="#CategorieModal" style={{display: "none"}}>open</button>
<div className="modal fade" id="CategorieModal" tabIndex="-1" aria-labelledby="CategorieModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<ModalContent state={modal} setCatId={setCatId} setConfirm={setConfirm} confirmRef={confirmRef}/>
</div>
</div>
</div>
<button ref={confirmRef} data-bs-toggle="modal" data-bs-target="#confirm-dialog" style={{display: "none"}}>open</button>
<ConfirmDialog id="confirm-dialog" onConfirm={confirm.confirm ? confirm.confirm : () => {
}} onCancel={confirm.cancel ? confirm.cancel : () => {
}} title={confirm ? confirm.title : ""} message={confirm ? confirm.message : ""}/>
</div>
}
function ModalContent({state, setCatId, setConfirm, confirmRef}) {
const [name, setName] = useState("")
const [lice, setLice] = useState("1")
const [poule, setPoule] = useState(true)
const [tournoi, setTournoi] = useState(false)
const [size, setSize] = useState(4)
const [loserMatch, setLoserMatch] = useState(1)
const {sendRequest} = useWS();
useEffect(() => {
setName(state.name || "");
setLice(state.liceName || "1");
setPoule(((state.type || 1) & 1) !== 0);
setTournoi((state.type & 2) !== 0);
if (state?.trees && state.trees.length >= 1) {
const tree = state.trees[0];
setSize(tree.getMaxChildrenAtDepth(tree.death() - 1) * 2);
if (state.trees.length === 1) {
setLoserMatch(0);
} else if (state.trees.length === 2) {
setLoserMatch(1);
} else {
setLoserMatch(-1);
}
} else {
setSize(4);
setLoserMatch(1);
}
}, [state])
const handleSubmit = (e) => {
e.preventDefault();
const regex = /^([^;]+;)*[^;]+$/;
if (regex.test(lice.trim()) === false) {
toast.error("Le format du nom des lices est invalide. Veuillez séparer les noms par des ';'.");
return;
}
const nType = (poule ? 1 : 0) + (tournoi ? 2 : 0);
if (nType === 0) {
toast.error("Au moins un type (poule ou tournoi) doit être sélectionné.");
return;
}
if (state?.id) {
const applyChanges = () => {
const newData = {
id: state.id,
name: name.trim(),
liceName: lice.trim(),
type: nType
}
let nbMatch = -1;
let oldSubTree = -1;
const oldTrees = state?.trees || [];
if (oldTrees.length >= 1) {
const tree = state.trees[0];
nbMatch = tree.getMaxChildrenAtDepth(tree.death() - 1);
if (state.trees.length === 1)
oldSubTree = 0
else if (state.trees.length === 2)
oldSubTree = 1
}
console.log(tournoi, size, nbMatch, loserMatch, oldSubTree);
if (tournoi && (size !== nbMatch * 2 || loserMatch !== oldSubTree)) {
setConfirm({
title: "Changement de l'arbre du tournoi",
message: `Voulez-vous vraiment changer la taille de l'arbre du tournoi ou les matchs pour les perdants ? Cela va modifier les matchs existants (incluant des possibles suppressions)!`,
confirm: () => {
const trees2 = build_tree(size, loserMatch)
const newTrees = []
let i = 0;
for (; i < oldTrees.length && i < trees2.length; i++) {
newTrees.push(oldTrees[i]);
resize_tree(newTrees.at(i), trees2.at(i));
}
for (; i < trees2.length; i++) {
newTrees.push(trees2.at(i));
}
toast.promise(sendRequest('updateTrees', {categoryId: state.id, trees: newTrees}),
{
pending: 'Mise à jour des arbres du tournoi...',
success: 'Arbres mis à jour !',
error: 'Erreur lors de la mise à jour des arbres'
}
).then(__ => {
toast.promise(sendRequest('updateCategory', newData),
{
pending: 'Mise à jour de la catégorie...',
success: 'Catégorie mise à jour !',
error: 'Erreur lors de la mise à jour de la catégorie'
}
)
})
}
})
confirmRef.current.click();
} else {
toast.promise(sendRequest('updateCategory', newData),
{
pending: 'Mise à jour de la catégorie...',
success: 'Catégorie mise à jour !',
error: 'Erreur lors de la mise à jour de la catégorie'
}
)
}
}
if (nType !== state.type) {
let typeStr = "";
if ((state.type & 1) !== 0 && (nType & 1) === 0)
typeStr += "poule ";
if ((state.type & 2) !== 0 && (nType & 2) === 0)
typeStr += "tournoi ";
setConfirm({
title: "Changement de type de catégorie",
message: `Voulez-vous vraiment enlever la partie ${typeStr} de la catégorie. Cela va supprimer les matchs contenus dans cette partie !`,
confirm: () => {
applyChanges();
}
})
confirmRef.current.click();
} else {
applyChanges();
}
} else {
toast.promise(sendRequest('createCategory', {name: name.trim(), liceName: lice.trim(), type: nType}),
{
pending: 'Création de la catégorie...',
success: 'Catégorie créée !',
error: 'Erreur lors de la création de la catégorie'
}
).then(id => {
setCatId(id);
if (tournoi) {
const trees = build_tree(size, loserMatch)
console.log("Creating trees for new category:", trees);
toast.promise(sendRequest('updateTrees', {categoryId: id, trees: trees}),
{
pending: 'Création des arbres du tournoi...',
success: 'Arbres créés !',
error: 'Erreur lors de la création des arbres'
}
)
}
})
}
const data = {
name: name.trim(),
liceName: lice.trim(),
type: poule + (tournoi << 1),
size: size,
loserMatch: loserMatch
}
console.log("Submitting category data:", data);
}
return <form onSubmit={handleSubmit}>
<div className="modal-header">
<h1 className="modal-title fs-5" id="CategorieModalLabel">Ajouter une catégorie</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="mb-3">
<label htmlFor="nameInput1" className="form-label">Nom</label>
<input type="text" className="form-control" id="nameInput1" placeholder="Epée bouclier" name="name" value={name}
onChange={e => setName(e.target.value)}/>
</div>
<div className="mb-3">
<label htmlFor="liceInput1" className="form-label">Nom des lices <small>(séparée par des ';')</small></label>
<input type="text" className="form-control" id="liceInput1" placeholder="1;2" name="lice" value={lice}
onChange={e => setLice(e.target.value)}/>
</div>
<div className="mb-3">
<label className="form-label">Type</label>
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch" id="switchCheckDefault" name="poule" checked={poule}
onChange={e => setPoule(e.target.checked)}/>
<label className="form-check-label" htmlFor="switchCheckDefault">Poule</label>
</div>
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch" id="switchCheckDefault2" name="trournoi" checked={tournoi}
onChange={e => setTournoi(e.target.checked)}/>
<label className="form-check-label" htmlFor="switchCheckDefault2">Tournoi</label>
</div>
</div>
<div className="mb-3">
<label htmlFor="sizeInput1" className="form-label">Nombre de combattants</label>
<input type="number" className="form-control" id="sizeInput1" placeholder="4" name="size" disabled={!tournoi} value={size}
onChange={e => setSize(Number.parseInt(e.target.value))}/>
</div>
<div className="mb-3">
<span>Match pour les perdants du tournoi:</span>
<div className="form-check">
<input className="form-check-input" type="radio" name="radioDefault" id="radioDefault1" disabled={!tournoi}
checked={loserMatch === -1} onChange={e => {
if (e.target.checked) setLoserMatch(-1)
}}/>
<label className="form-check-label" htmlFor="radioDefault1">
Tous les matchs
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="radio" name="radioDefault" id="radioDefault2" disabled={!tournoi}
checked={loserMatch === 1} onChange={e => {
if (e.target.checked) setLoserMatch(1)
}}/>
<label className="form-check-label" htmlFor="radioDefault2">
Demi-finales et finales
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="radio" name="radioDefault" id="radioDefault3" disabled={!tournoi}
checked={loserMatch === 0} onChange={e => {
if (e.target.checked) setLoserMatch(0)
}}/>
<label className="form-check-label" htmlFor="radioDefault3">
Finales uniquement
</label>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Enregistrer</button>
</div>
</form>
}
function CategoryContent({cat, catId, setCat}) {
const setLoading = useLoadingSwitcher()
const {sendRequest, dispatch} = useWS();
const [matches, reducer] = useReducer(MarchReducer, []);
useEffect(() => {
const treeListener = ({data}) => {
if (!cat || data.length < 1 || data[0].categorie !== cat.id)
return
setCat({
...cat,
trees: data.map(d => from_sendTree(d, true))
})
let matches2 = [];
data.flatMap(d => from_sendTree(d, false).flat()).forEach((data_) => matches2.push({...data_}));
reducer({type: 'REPLACE_TREE', payload: matches2});
}
dispatch({type: 'addListener', payload: {callback: treeListener, code: 'sendTreeCategory'}})
return () => dispatch({type: 'removeListener', payload: treeListener})
}, [cat]);
useEffect(() => {
if (!catId)
return;
setLoading(1);
sendRequest('getFullCategory', catId)
.then((data) => {
setCat({
id: data.id,
name: data.name,
liceName: data.liceName,
type: data.type,
trees: data.trees.map(d => from_sendTree(d, true))
})
let matches2 = [];
data.trees.flatMap(d => from_sendTree(d, false).flat()).forEach((data_) => matches2.push({...data_}));
data.matches.forEach((data_) => matches2.push({...data_}));
reducer({type: 'REPLACE_ALL', payload: matches2});
}).finally(() => setLoading(0))
}, [catId]);
console.log("Matches in category content:", matches);
return <>
<div className="col">
<div className="vr"></div>
</div>
<div className="col">
</div>
</>
}

View File

@@ -3,6 +3,7 @@ import {LoadingProvider} from "../../../hooks/useLoading.jsx";
import {useEffect, useState} from "react";
import {useWS, WSProvider} from "../../../hooks/useWS.jsx";
import {ColoredCircle} from "../../../components/ColoredCircle.jsx";
import {CMAdmin} from "./CMAdmin.jsx";
const vite_url = import.meta.env.VITE_URL;
@@ -36,10 +37,13 @@ function HomeComp() {
return <WSProvider url={`${vite_url.replace('http', 'ws')}/api/ws/competition/${compUuid}`} onmessage={messageHandler}>
<WSStatus setPerm={setPerm}/>
<Routes>
<Route path="/" element={<Home2 perm={perm}/>}/>
<Route path="/test" element={<Test2/>}/>
</Routes>
<LoadingProvider>
<Routes>
<Route path="/" element={<Home2 perm={perm}/>}/>
<Route path="/admin" element={<CMAdmin/>}/>
<Route path="/test" element={<Test2/>}/>
</Routes>
</LoadingProvider>
</WSProvider>
}
@@ -79,8 +83,8 @@ function Home2({perm}) {
<h4 className="col-auto" style={{margin: "auto 0"}}>Sélectionne les modes d'affichage</h4>
<div className="col">
{perm === "admin" && <>
<button className="btn btn-primary" onClick={() => nav("admin")}>Administration</button>
<button className="btn btn-primary ms-3" onClick={() => nav("table")}>Table de marque</button>
<button className="btn btn-primary" onClick={() => nav("table")}>Table de marque</button>
<button className="btn btn-primary ms-3" onClick={() => nav("admin")}>Administration</button>
</>}
{perm === "table" && <>
<button className="btn btn-primary" onClick={() => nav("table")}>Table de marque</button>

View File

@@ -0,0 +1,44 @@
export function MarchReducer(datas, action) {
switch (action.type) {
case 'ADD':
return [
...datas,
action.payload
]
case 'ADD_ALL':
return [
...datas,
...action.payload
]
case 'REPLACE_ALL':
return [
...action.payload
]
case 'REPLACE_TREE':
return [
...datas.filter(data => data.categorie_ord !== -42),
...action.payload
]
case 'CLEAR':
return []
case 'REMOVE':
return datas.filter(data => data.id !== action.payload)
case 'REMOVE_TREE':
return datas.filter(data => data.categorie_ord !== -42)
case 'UPDATE_OR_ADD':
const index = datas.findIndex(data => data.id === action.payload.id)
if (index === -1) {
return [
...datas,
action.payload
]
} else {
datas[index] = action.payload
return [...datas]
}
case 'SORT':
return datas.sort(action.payload)
default:
throw new Error()
}
}

View File

@@ -0,0 +1,129 @@
export function TreeNode(data, left = null, right = null) {
this.data = data;
this.left = left;
this.right = right;
}
TreeNode.prototype = {
getMaxChildrenAtDepth: function (death, current = 0) {
if (current === death)
return 1;
let tmp = 0;
if (this.right != null)
tmp += this.right.getMaxChildrenAtDepth(death, current + 1);
if (this.left != null)
tmp += this.left.getMaxChildrenAtDepth(death, current + 1);
return tmp;
},
death: function () {
let dg = 0;
let dd = 0;
if (this.right != null)
dg = this.right.death();
if (this.left != null)
dg = this.left.death();
return 1 + Math.max(dg, dd);
},
isEnd: function (data) {
if (this.data === data) {
return this.right == null && this.left == null;
} else {
return (this.right != null && this.right.isEnd(data)) || (this.left != null && this.left.isEnd(data));
}
},
flat: function () {
let out = []
this.__flat(out)
return out;
},
__flat: function (out) {
out.push(this.data)
if (this.left != null)
this.left.__flat(out)
if (this.right != null)
this.right.__flat(out)
}
}
export function build_tree(nb_comb, sub_tree_level = 0) {
const nb_match = Math.ceil(nb_comb / 2)
const level = Math.ceil(Math.log(nb_match) / Math.log(2))
const treeNodes = []
for (let i = level; i >= 0; i--) {
const treeNode = new TreeNode(null)
build_tree_(treeNode, 0, i, 0, nb_match)
treeNodes.push(treeNode)
if (sub_tree_level !== -1) {
if (sub_tree_level === 0)
break
if (i > sub_tree_level)
i = sub_tree_level
}
}
return treeNodes
}
export function build_tree_(treeNode, death, death_target, leave, leave_target) {
if (death === death_target) {
return (leave < leave_target) ? 1 : 0;
}
if (leave + 1 === leave_target) {
return 1;
} else {
let to_add = 0;
if (leave < leave_target) {
treeNode.left = new TreeNode(null)
to_add += build_tree_(treeNode.left, death + 1, death_target, leave, leave_target)
}
if (leave + to_add < leave_target) {
treeNode.right = new TreeNode(null)
to_add += build_tree_(treeNode.right, death + 1, death_target, leave + to_add, leave_target)
}
return to_add;
}
}
export function resize_tree(src, to) {
if (src === null || to === null)
return null;
if (src.left === null && to.left != null) {
src.left = to.left;
} else if (src.left != null && to.left === null) {
src.left = null;
} else {
resize_tree(src.left, to.left);
}
if (src.right === null && to.right != null) {
src.right = to.right;
} else if (src.right != null && to.right === null) {
src.right = null;
} else {
resize_tree(src.right, to.right);
}
}
export function from_sendTree(tree, matchId = true) {
if (tree == null)
return null;
const node = new TreeNode(matchId ? tree.match?.id : tree.match);
node.left = from_sendTree(tree.left, matchId);
node.right = from_sendTree(tree.right, matchId);
return node;
}