feat: cm admin comb selector

This commit is contained in:
Thibaut Valentin 2025-11-26 12:36:57 +01:00
parent 160c7d59e3
commit c21eda9df7
12 changed files with 932 additions and 136 deletions

View File

@ -16,7 +16,7 @@ import lombok.Setter;
@RegisterForReflection
@Entity
@Table(name = "cardboard")
@Table(name = "competition_guest")
public class CompetitionGuestModel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -37,6 +37,8 @@ public class CompetitionGuestModel {
String country = "fr";
Integer weight = null;
public CompetitionGuestModel(String s) {
this.fname = s.substring(0, s.indexOf(" "));
this.lname = s.substring(s.indexOf(" ") + 1);

View File

@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.domain.entity;
import fr.titionfire.ffsaf.data.model.CompetitionGuestModel;
import fr.titionfire.ffsaf.data.model.MembreModel;
import fr.titionfire.ffsaf.data.model.RegisterModel;
import fr.titionfire.ffsaf.utils.Categorie;
import fr.titionfire.ffsaf.utils.Genre;
import io.quarkus.runtime.annotations.RegisterForReflection;
@ -13,20 +14,24 @@ import lombok.Data;
@RegisterForReflection
public class CombEntity {
private long id;
private String lname = "";
private String fname = "";
Categorie categorie = null;
Long club = null;
String club_str = null;
Genre genre = null;
String country = "fr";
private String lname;
private String fname;
Categorie categorie;
Long club;
String club_str;
Genre genre;
String country;
int overCategory;
Integer weight;
public static CombEntity fromModel(MembreModel model) {
if (model == null)
return null;
return new CombEntity(model.getId(), model.getLname(), model.getFname(), model.getCategorie(),
model.getClub().getId(), model.getClub().getName(), model.getGenre(), model.getCountry());
model.getClub() == null ? null : model.getClub().getId(),
model.getClub() == null ? "Sans club" : model.getClub().getName(), model.getGenre(), model.getCountry(),
0, null);
}
@ -35,6 +40,17 @@ public class CombEntity {
return null;
return new CombEntity(model.getId() * -1, model.getLname(), model.getFname(), model.getCategorie(), null,
model.getClub(), model.getGenre(), model.getCountry());
model.getClub(), model.getGenre(), model.getCountry(), 0, model.getWeight());
}
public static CombEntity fromModel(RegisterModel registerModel) {
if (registerModel == null || registerModel.getMembre() == null)
return null;
MembreModel model = registerModel.getMembre();
return new CombEntity(model.getId(), model.getLname(), model.getFname(), registerModel.getCategorie(),
registerModel.getClub2() == null ? null : registerModel.getClub2().getId(),
registerModel.getClub2() == null ? "Sans club" : registerModel.getClub2().getName(), model.getGenre(),
model.getCountry(), registerModel.getOverCategory(), registerModel.getWeight());
}
}

View File

@ -8,6 +8,7 @@ import fr.titionfire.ffsaf.utils.SecurityCtx;
import fr.titionfire.ffsaf.ws.data.WelcomeInfo;
import fr.titionfire.ffsaf.ws.recv.RCategorie;
import fr.titionfire.ffsaf.ws.recv.RMatch;
import fr.titionfire.ffsaf.ws.recv.RRegister;
import fr.titionfire.ffsaf.ws.recv.WSReceiver;
import fr.titionfire.ffsaf.ws.send.JsonUni;
import io.quarkus.hibernate.reactive.panache.common.WithSession;
@ -40,6 +41,9 @@ public class CompetitionWS {
@Inject
RCategorie rCategorie;
@Inject
RRegister rRegister;
@Inject
SecurityCtx securityCtx;
@ -72,6 +76,7 @@ public class CompetitionWS {
void init() {
getWSReceiverMethods(RMatch.class, rMatch);
getWSReceiverMethods(RCategorie.class, rCategorie);
getWSReceiverMethods(RRegister.class, rRegister);
}
@OnOpen

View File

@ -1,10 +1,20 @@
package fr.titionfire.ffsaf.ws.recv;
import fr.titionfire.ffsaf.data.model.MatchModel;
import fr.titionfire.ffsaf.data.repository.CombRepository;
import fr.titionfire.ffsaf.data.repository.CompetitionGuestRepository;
import fr.titionfire.ffsaf.data.repository.MatchRepository;
import fr.titionfire.ffsaf.domain.entity.MatchEntity;
import fr.titionfire.ffsaf.rest.exception.DForbiddenException;
import fr.titionfire.ffsaf.rest.exception.DNotFoundException;
import fr.titionfire.ffsaf.ws.CompetitionWS;
import fr.titionfire.ffsaf.ws.PermLevel;
import io.quarkus.hibernate.reactive.panache.Panache;
import io.quarkus.hibernate.reactive.panache.common.WithSession;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.quarkus.websockets.next.WebSocketConnection;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.unchecked.Unchecked;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
@ -18,6 +28,22 @@ public class RMatch {
@Inject
MatchRepository matchRepository;
@Inject
CombRepository combRepository;
@Inject
CompetitionGuestRepository competitionGuestRepository;
private Uni<MatchModel> getById(long id, WebSocketConnection connection) {
return matchRepository.findById(id)
.invoke(Unchecked.consumer(o -> {
if (o == null)
throw new DNotFoundException("Matche non trouver");
if (!o.getCategory().getCompet().getUuid().equals(connection.pathParam("uuid")))
throw new DForbiddenException("Permission denied");
}));
}
@WSReceiver(code = "getAllMatch")
public Uni<?> getAllMatch(WebSocketConnection connection, Long l) {
LOGGER.info("getAllMatch " + l);
@ -26,4 +52,45 @@ public class RMatch {
//return matchRepository.count();
}
@WSReceiver(code = "updateMatchComb", permission = PermLevel.ADMIN)
public Uni<Void> updateMatchComb(WebSocketConnection connection, MatchComb match) {
return getById(match.id(), connection)
.call(mm -> match.c1() != null ?
match.c1() >= 0 ?
combRepository.findById(match.c1()).invoke(model -> {
mm.setC1_id(model);
mm.setC1_guest(null);
}) :
competitionGuestRepository.findById(match.c1() * -1).invoke(model -> {
mm.setC1_id(null);
mm.setC1_guest(model);
}) :
Uni.createFrom().nullItem().invoke(__ -> {
mm.setC1_id(null);
mm.setC1_guest(null);
}))
.call(mm -> match.c2() != null ?
match.c2() >= 0 ?
combRepository.findById(match.c2()).invoke(model -> {
mm.setC2_id(model);
mm.setC2_guest(null);
}) :
competitionGuestRepository.findById(match.c2() * -1).invoke(model -> {
mm.setC2_id(null);
mm.setC2_guest(model);
}) :
Uni.createFrom().nullItem().invoke(__ -> {
mm.setC2_id(null);
mm.setC2_guest(null);
}))
.chain(mm -> Panache.withTransaction(() -> matchRepository.persist(mm)))
.call(mm -> CompetitionWS.sendNotifyToOtherEditor(connection, "sendMatch",
MatchEntity.fromModel(mm)))
.replaceWithVoid();
}
@RegisterForReflection
public record MatchComb(long id, Long c1, Long c2) {
}
}

View File

@ -0,0 +1,36 @@
package fr.titionfire.ffsaf.ws.recv;
import fr.titionfire.ffsaf.data.repository.CompetitionRepository;
import fr.titionfire.ffsaf.domain.entity.CombEntity;
import fr.titionfire.ffsaf.ws.PermLevel;
import io.quarkus.hibernate.reactive.panache.common.WithSession;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.quarkus.websockets.next.WebSocketConnection;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.hibernate.reactive.mutiny.Mutiny;
import java.util.ArrayList;
@WithSession
@ApplicationScoped
@RegisterForReflection
public class RRegister {
@Inject
CompetitionRepository competitionRepository;
@WSReceiver(code = "getRegister", permission = PermLevel.ADMIN)
public Uni<?> getRegister(WebSocketConnection connection, Object o) {
return competitionRepository.find("uuid", connection.pathParam("uuid")).firstResult()
.call(cm -> Mutiny.fetch(cm.getInsc()))
.call(cm -> Mutiny.fetch(cm.getGuests()))
.map(cm -> {
ArrayList<CombEntity> combEntities = new ArrayList<>();
combEntities.addAll(cm.getInsc().stream().map(CombEntity::fromModel).toList());
combEntities.addAll(cm.getGuests().stream().map(CombEntity::fromModel).toList());
return combEntities;
});
}
}

View File

@ -0,0 +1,91 @@
import {createContext, useContext, useReducer} from "react";
const CombsContext = createContext({});
const CombsDispatchContext = createContext(() => {
});
function compareCombs(a, b) {
for (const keys of Object.keys(a)) {
if (a[keys] !== b[keys]) {
return false;
}
}
return true;
}
function reducer(state, action) {
switch (action.type) {
case 'SET_COMB':
if (state[action.payload.id] === undefined || !compareCombs(state[action.payload.id], action.payload)) {
console.debug("Updating comb", action.payload);
return {
...state,
[action.payload.id]: action.payload.value
}
}
return state
case 'SET_ALL':
// By default, we only update some fields to avoid overwriting with incomplete data
const combs = (action.payload.source === "register") ? action.payload.data : action.payload.data.map(e => {
return {
id: e.id,
fname: e.fname,
lname: e.lname,
genre: e.genre,
country: e.country,
}
});
if (combs.some(e => state[e.id] === undefined || !compareCombs(e, state[e.id]))) {
const newCombs = {};
for (const o of combs) {
newCombs[o.id] = o;
}
console.debug("Updating combs", newCombs);
return {
...state,
...newCombs
}
}
return state
case 'REMOVE_COMB':
const newState = {...state}
delete newState[action.payload]
return newState
default:
return state
}
}
export function CombsProvider({children}) {
const [combs, dispatch] = useReducer(reducer, {})
return <CombsContext.Provider value={combs}>
<CombsDispatchContext.Provider value={dispatch}>
{children}
</CombsDispatchContext.Provider>
</CombsContext.Provider>
}
export function useCombs() {
const combs = useContext(CombsContext);
const getComb = (id, defaultValue = null) => {
return combs[id] !== undefined ? combs[id] : defaultValue;
}
return {getComb, combs};
}
export function useCombsDispatch() {
return useContext(CombsDispatchContext);
}
export function CombName({combId}) {
const {getComb} = useCombs();
const comb = getComb(combId, null);
if (comb) {
return <>{comb.fname} {comb.lname}</>
} else {
return <>[Comb #{combId}]</>
}
}

View File

@ -1,19 +1,14 @@
import {useEffect, useReducer, useRef, useState} from "react";
import {useEffect, 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 {build_tree, resize_tree} from "../../../utils/TreeUtils.js"
import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx";
import {SimpleReducer} from "../../../utils/SimpleReducer.jsx";
import {MarchReducer} from "../../../utils/MatchReducer.jsx";
import {CategoryContent} from "./CategoryAdminContent.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(() => {
@ -31,18 +26,6 @@ export function CMAdmin() {
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'>
@ -391,59 +374,3 @@ function ModalContent({state, setCatId, setConfirm, confirmRef}) {
</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

@ -0,0 +1,269 @@
import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {useRequestWS, useWS} from "../../../hooks/useWS.jsx";
import {useEffect, useReducer, useRef, useState} from "react";
import {MarchReducer} from "../../../utils/MatchReducer.jsx";
import {CombName, useCombs, useCombsDispatch} from "../../../hooks/useComb.jsx";
import {from_sendTree, TreeNode} from "../../../utils/TreeUtils.js";
import {DrawGraph} from "../../result/DrawGraph.jsx";
import {SelectCombModalContent} from "./SelectCombModalContent.jsx";
export function CategoryContent({cat, catId, setCat}) {
const setLoading = useLoadingSwitcher()
const {sendRequest, dispatch} = useWS();
const [matches, reducer] = useReducer(MarchReducer, []);
const [groups, setGroups] = useState([])
const groupsRef = useRef(groups);
const combDispatch = useCombsDispatch();
useEffect(() => {
groupsRef.current = groups
}, [groups]);
function readAndConvertMatch(matches, data, combsToAdd) {
matches.push({...data, c1: data.c1?.id, c2: data.c2?.id})
if (data.c1)
combsToAdd.push(data.c1)
if (data.c2)
combsToAdd.push(data.c2)
}
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 = [];
let combsToAdd = [];
data.flatMap(d => from_sendTree(d, false).flat()).forEach((data_) => readAndConvertMatch(matches2, data_, combsToAdd));
reducer({type: 'REPLACE_TREE', payload: matches2});
combDispatch({type: 'SET_ALL', payload: {source: "match", data: combsToAdd}});
}
const matchListener = ({data}) => {
reducer({type: 'UPDATE_OR_ADD', payload: {...data, c1: data.c1?.id, c2: data.c2?.id}});
combDispatch({type: 'SET_ALL', payload: {source: "match", data: [data.c1, data.c2].filter(d => d != null)}});
if (data.c1 !== null && !groupsRef.current.some(g => g.id === data.c1?.id))
setGroups(prev => [...prev, {id: data.c1?.id, poule: data.poule}])
if (data.c2 !== null && !groupsRef.current.some(g => g.id === data.c2?.id))
setGroups(prev => [...prev, {id: data.c2?.id, poule: data.poule}])
}
dispatch({type: 'addListener', payload: {callback: treeListener, code: 'sendTreeCategory'}})
dispatch({type: 'addListener', payload: {callback: matchListener, code: 'sendMatch'}})
return () => {
dispatch({type: 'removeListener', payload: treeListener})
dispatch({type: 'removeListener', payload: matchListener})
}
}, [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 = [];
let combsToAdd = [];
data.trees.flatMap(d => from_sendTree(d, false).flat()).forEach((data_) => readAndConvertMatch(matches2, data_, combsToAdd));
data.matches.forEach((data_) => readAndConvertMatch(matches2, data_, combsToAdd));
reducer({type: 'REPLACE_ALL', payload: matches2});
combDispatch({type: 'SET_ALL', payload: {source: "match", data: combsToAdd}});
const activeMatches = matches2.filter(m => m.poule !== '-')
const combsIDs = activeMatches.flatMap(d => [d.c1, d.c2]).filter((v, i, a) => v != null && a.indexOf(v) === i)
.map(d => {
return {id: d, poule: activeMatches.find(m => m.c1 === d || m.c2 === d)?.poule}
})
setGroups(combsIDs)
}).finally(() => setLoading(0))
}, [catId]);
return <>
<div className="col-md-3">
<AddComb groups={groups} setGroups={setGroups}/>
</div>
<div className="col-md-9">
{cat && <ListMatch cat={cat} matches={matches} groups={groups}/>}
</div>
</>
}
function AddComb({groups, setGroups}) {
const {data} = useRequestWS("getRegister", null)
const combDispatch = useCombsDispatch();
useEffect(() => {
if (data === null)
return;
combDispatch({type: 'SET_ALL', payload: {source: "register", data: data}});
}, [data]);
return <>
<ol className="list-group list-group-numbered">
{groups.map((comb) => (
<li key={comb.id} className="list-group-item list-group-item-action d-flex justify-content-between align-items-start">
<div className="ms-2 me-auto"><CombName combId={comb.id}/></div>
<span className="badge text-bg-primary rounded-pill">{comb.poule}</span>
</li>)
)}
</ol>
<button type="button" className="btn btn-primary mt-3 w-100" data-bs-toggle="modal" data-bs-target="#selectCombModal"
disabled={data === null}>Ajouter des combattants
</button>
<div className="modal fade" id="selectCombModal" tabIndex="-1" aria-labelledby="selectCombModalLabel" aria-hidden="true">
<div className="modal-dialog modal-dialog-scrollable modal-lg modal-fullscreen-lg-down">
<div className="modal-content">
<SelectCombModalContent data={data} setGroups={setGroups}/>
</div>
</div>
</div>
</>
}
function ListMatch({cat, matches, groups}) {
const [type, setType] = useState(1);
return <>
{cat.type === 3 && <>
<ul className="nav nav-tabs">
<li className="nav-item">
<div className={"nav-link" + (type === 1 ? " active" : "")} aria-current={(type === 1 ? " page" : "false")}
onClick={_ => setType(1)}>Poule
</div>
</li>
<li className="nav-item">
<div className={"nav-link" + (type === 2 ? " active" : "")} aria-current={(type === 2 ? " page" : "false")}
onClick={_ => setType(2)}>Tournois
</div>
</li>
</ul>
</>}
{type === 2 && <>
<BuildTree treeData={cat.trees} matches={matches} groups={groups}/>
</>}
</>
}
function BuildTree({treeData, matches, groups}) {
const scrollRef = useRef(null)
const selectRef = useRef(null)
const lastMatchClick = useRef(null)
const [combSelect, setCombSelect] = useState(0)
const {getComb} = useCombs()
const {sendRequest} = useWS();
const setLoading = useLoadingSwitcher()
function parseTree(data_in) {
if (data_in?.data == null)
return null
const matchData = matches.find(m => m.id === data_in.data)
const c1 = getComb(matchData?.c1)
const c2 = getComb(matchData?.c2)
let node = new TreeNode({
...matchData,
c1FullName: c1 !== null ? c1.fname + " " + c1.lname : null,
c2FullName: c2 !== null ? c2.fname + " " + c2.lname : null
})
node.left = parseTree(data_in?.left)
node.right = parseTree(data_in?.right)
return node
}
function initTree(data_in) {
let out = []
for (const din of data_in) {
out.push(parseTree(din))
}
return out
}
const onMatchClick = (rect, matchId, comb) => {
if (!treeData.some(t => t.isEnd(matchId)))
return
const match = matches.find(m => m.id === matchId);
if (!match)
return;
const sel = selectRef.current;
sel.style.top = rect.y + "px";
sel.style.left = rect.x + "px";
sel.style.width = rect.width + "px";
sel.style.height = rect.height + "px";
sel.style.display = "block";
lastMatchClick.current = {matchId, comb};
setCombSelect((comb === 1 ? match.c1 : match.c2) || 0);
}
const onClickVoid = () => {
const sel = selectRef.current;
sel.style.display = "none";
lastMatchClick.current = null;
}
useEffect(() => {
if (lastMatchClick.current == null)
return
const {matchId, comb} = lastMatchClick.current
const match = matches.find(m => m.id === matchId)
if (!match)
return
const combSelect_ = combSelect === 0 ? null : combSelect;
const data = {id: match.id, c1: match.c1, c2: match.c2}
if (comb === 1) {
if (match.c1 === combSelect_)
return
data.c1 = combSelect_
} else if (comb === 2) {
if (match.c2 === combSelect_)
return
data.c2 = combSelect_
}
setLoading(1)
sendRequest('updateMatchComb', data)
.finally(() => {
setLoading(0)
onClickVoid()
})
}, [combSelect])
const combsIDs = groups.map(m => m.id);
return <div ref={scrollRef} className="overflow-x-auto" style={{position: "relative"}}>
<DrawGraph root={initTree(treeData)} scrollRef={scrollRef} onMatchClick={onMatchClick} onClickVoid={onClickVoid}/>
<select ref={selectRef} className="form-select" style={{position: "absolute", top: 0, left: 0, display: "none"}}
value={combSelect} onChange={e => setCombSelect(Number(e.target.value))}>
<option value={0}>-- Sélectionner un combattant --</option>
{combsIDs.map((combId) => (
<option key={combId} value={combId}><CombName combId={combId}/></option>
))}
</select>
</div>
}

View File

@ -4,6 +4,7 @@ import {useEffect, useState} from "react";
import {useWS, WSProvider} from "../../../hooks/useWS.jsx";
import {ColoredCircle} from "../../../components/ColoredCircle.jsx";
import {CMAdmin} from "./CMAdmin.jsx";
import {CombsProvider} from "../../../hooks/useComb.jsx";
const vite_url = import.meta.env.VITE_URL;
@ -37,13 +38,15 @@ function HomeComp() {
return <WSProvider url={`${vite_url.replace('http', 'ws')}/api/ws/competition/${compUuid}`} onmessage={messageHandler}>
<WSStatus setPerm={setPerm}/>
<LoadingProvider>
<Routes>
<Route path="/" element={<Home2 perm={perm}/>}/>
<Route path="/admin" element={<CMAdmin/>}/>
<Route path="/test" element={<Test2/>}/>
</Routes>
</LoadingProvider>
<CombsProvider>
<LoadingProvider>
<Routes>
<Route path="/" element={<Home2 perm={perm}/>}/>
<Route path="/admin" element={<CMAdmin/>}/>
<Route path="/test" element={<Test2/>}/>
</Routes>
</LoadingProvider>
</CombsProvider>
</WSProvider>
}

View File

@ -0,0 +1,266 @@
import {useCountries} from "../../../hooks/useCountries.jsx";
import {useEffect, useReducer, useState} from "react";
import {CatList, getCatName} from "../../../utils/Tools.js";
import {CombName} from "../../../hooks/useComb.jsx";
function SelectReducer(state, action) {
switch (action.type) {
case 'TOGGLE_ID':
return {
...state,
[action.payload]: !(state[action.payload] || false)
}
case 'ADD_ALL':
return {
...state,
...action.payload.reduce((acc, id) => {
acc[id] = false;
return acc;
}, {})
};
case 'CLEAR_ACTIVE':
const newState = {...state};
Object.keys(newState).forEach(id => {
newState[id] = false;
});
return newState;
case 'REMOVE_ACTIVE':
const filteredState = {...state};
Object.keys(filteredState).forEach(id => {
if (filteredState[id]) {
delete filteredState[id];
}
});
return filteredState;
case 'REMOVE_IN':
const filteredState2 = {...state};
Object.keys(filteredState2).forEach(id => {
if (action.payload.includes(id)) {
delete filteredState2[id];
}
});
return filteredState2;
case 'REMOVE_ALL':
return {};
default:
return state;
}
}
export function SelectCombModalContent({data, setGroups}) {
const country = useCountries('fr')
const [dispo, dispoReducer] = useReducer(SelectReducer, {})
const [select, selectReducer] = useReducer(SelectReducer, {})
const [targetGroupe, setTargetGroupe] = useState("A")
const [search, setSearch] = useState("")
const [country_, setCountry_] = useState("")
const [club, setClub] = useState("")
const [gender, setGender] = useState({H: true, F: true, NA: true})
const [cat, setCat] = useState(-1)
const [weightMin, setWeightMin] = useState(0)
const [weightMax, setWeightMax] = useState(0)
const handleSubmit = (e) => {
e.preventDefault();
setGroups(prev => [...prev.filter(d => select[d.id] === undefined), ...Object.keys(select).map(id => {
return {id: Number(id), poule: targetGroupe}
})])
dispoReducer({type: 'REMOVE_ALL'})
selectReducer({type: 'REMOVE_ALL'})
if (data == null)
return
dispoReducer({type: 'ADD_ALL', payload: data.map(d => d.id)})
}
useEffect(() => { // TODO: add ws listener
if (data == null)
return
const selectedIds = Object.keys(select).map(g => Number(g))
dispoReducer({type: 'ADD_ALL', payload: data.map(d => d.id).filter(id => !selectedIds.includes(id))})
}, [data])
function applyFilter(dataIn, dataOut) {
Object.keys(dataIn).forEach((id) => {
const comb = data.find(d => d.id === Number(id));
if (comb == null)
return;
if ((search === "" || comb.fname.toLowerCase().includes(search.toLowerCase()) || comb.lname.toLowerCase().includes(search.toLowerCase())
|| (comb.fname + " " + comb.lname).toLowerCase().includes(search.toLowerCase()))
&& (country_ === "" || comb.country === country_)
&& (club === "" || comb.club_str === club)
&& (gender.H && comb.genre === 'H' || gender.F && comb.genre === 'F' || gender.NA && comb.genre === 'NA')
&& (cat === -1 || cat === Math.min(CatList.length, CatList.indexOf(comb.categorie) + comb.overCategory))
&& (weightMin === 0 || comb.weight !== null && comb.weight >= weightMin)
&& (weightMax === 0 || comb.weight !== null && comb.weight <= weightMax)) {
dataOut[id] = dataIn[id];
}
}
)
}
let clubList = [];
if (data != null) {
clubList = data.map(d => d.club_str).filter((v, i, a) => v !== "" && a.indexOf(v) === i);
}
const dispoFiltered = {};
applyFilter(dispo, dispoFiltered);
const selectFiltered = {};
applyFilter(select, selectFiltered);
const moveComb = (event) => {
event.preventDefault();
switch (event.target.textContent) {
case '>>':
selectReducer({type: 'ADD_ALL', payload: Object.keys(dispoFiltered)})
dispoReducer({type: 'REMOVE_IN', payload: Object.keys(dispoFiltered)});
break;
case '<<':
dispoReducer({type: 'ADD_ALL', payload: Object.keys(selectFiltered)})
selectReducer({type: 'REMOVE_IN', payload: Object.keys(selectFiltered)});
break;
case '>':
selectReducer({type: 'ADD_ALL', payload: Object.keys(dispo).filter(id => dispo[id])})
dispoReducer({type: 'REMOVE_ACTIVE'});
break;
case '<':
dispoReducer({type: 'ADD_ALL', payload: Object.keys(select).filter(id => select[id])})
selectReducer({type: 'REMOVE_ACTIVE'});
break;
}
}
return <>
<div className="modal-header">
<h1 className="modal-title fs-5" id="CategorieModalLabel">Sélectionner des combatants</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="d-flex flex-wrap justify-content-around mb-1">
<div className="col-md-5">
<label htmlFor="input4" className="form-label">Recherche</label>
<input type="text" className="form-control" id="input4" value={search} onChange={(e) => setSearch(e.target.value)}/>
</div>
<div style={{width: "12em"}}>
<label htmlFor="inputState0" className="form-label">Pays</label>
<select id="inputState0" className="form-select" value={country_} onChange={(e) => setCountry_(e.target.value)}>
<option value={""}>-- Tous --</option>
{country && Object.keys(country).sort((a, b) => {
if (a < b) return -1
if (a > b) return 1
return 0
}).map((key, _) => {
return (<option key={key} value={key}>{country[key]}</option>)
})}
</select>
</div>
<div>
<label htmlFor="inputState1" className="form-label">Club</label>
<select id="inputState1" className="form-select" value={club} onChange={(e) => setClub(e.target.value)}>
<option value={""}>-- Tous --</option>
{clubList.sort((a, b) => a.localeCompare(b)).map((club) => (
<option key={club} value={club}>{club}</option>))}
</select>
</div>
</div>
<div className="d-flex flex-wrap justify-content-around mb-1">
<div>
<label className="form-label">Genre</label>
<div className="d-flex align-items-center">
<div className="form-check" style={{marginRight: '10px'}}>
<input className="form-check-input" type="checkbox" id="gridCheck" checked={gender.H}
onChange={e => setGender((prev) => {
return {...prev, H: e.target.checked}
})}/>
<label className="form-check-label" htmlFor="gridCheck">H</label>
</div>
<div className="form-check" style={{marginRight: '10px'}}>
<input className="form-check-input" type="checkbox" id="gridCheck2" checked={gender.F}
onChange={e => setGender((prev) => {
return {...prev, F: e.target.checked}
})}/>
<label className="form-check-label" htmlFor="gridCheck2">F</label>
</div>
<div className="form-check">
<input className="form-check-input" type="checkbox" id="gridCheck3" checked={gender.NA}
onChange={e => setGender((prev) => {
return {...prev, NA: e.target.checked}
})}/>
<label className="form-check-label" htmlFor="gridCheck3">NA</label>
</div>
</div>
</div>
<div>
<label htmlFor="inputState2" className="form-label">Catégorie</label>
<select id="inputState2" className="form-select" value={cat} onChange={(e) => setCat(Number(e.target.value))}>
<option value={-1}>-- Tous --</option>
{CatList.map((cat, index) => {
return (<option key={index} value={index}>{getCatName(cat)}</option>)
})}
</select>
</div>
<div>
<label htmlFor="input5" className="form-label">Poids</label>
<div className="row-cols-sm-auto d-flex align-items-center">
<div style={{width: "4.25em"}}><input type="number" className="form-control" id="input5" value={weightMin} min="0"
name="999"
onChange={e => setWeightMin(Number(e.target.value))}/></div>
<div><span>à</span></div>
<div style={{width: "4.25em"}}><input type="number" className="form-control" value={weightMax} min="0" name="999"
onChange={e => setWeightMax(Number(e.target.value))}/></div>
<div><small>(0 = désactivé)</small></div>
</div>
</div>
<button style={{display: "none"}} onClick={event => event.preventDefault()}></button>
</div>
<hr/>
<div className="row g-3">
<div className="col-md">
<div style={{textAlign: "center"}}>Inscrit</div>
<div className="list-group overflow-y-auto" style={{maxHeight: "50vh"}}>
{dispoFiltered && Object.keys(dispoFiltered).length === 0 && <div>Aucun combattant disponible</div>}
{Object.keys(dispoFiltered).map((id) => (
<button key={id} type="button" className={"list-group-item list-group-item-action " + (dispoFiltered[id] ? "active" : "")}
onClick={() => dispoReducer({type: 'TOGGLE_ID', payload: id})}>
<CombName combId={id}/>
</button>))}
</div>
</div>
<div className="col-auto" style={{margin: "0.5em auto"}}>
<div className="d-flex flex-sm-column align-items-center h-100 justify-content-center">
<button className="btn btn-secondary" style={{margin: "0.15em"}} onClick={moveComb}>&#62;&#62;</button>
<button className="btn btn-secondary" style={{margin: "0.15em"}} onClick={moveComb}>&#60;&#60;</button>
<button className="btn btn-secondary" style={{margin: "0.15em"}} onClick={moveComb}>&#62;</button>
<button className="btn btn-secondary" style={{margin: "0.15em"}} onClick={moveComb}>&#60;</button>
</div>
</div>
<div className="col-md">
<div style={{textAlign: "center"}}>Sélectionner</div>
<div className="list-group overflow-y-auto" style={{maxHeight: "50vh"}}>
{selectFiltered && Object.keys(selectFiltered).length === 0 && <div>Aucun combattant sélectionné</div>}
{Object.keys(selectFiltered).map((id) => (
<button key={id} type="button"
className={"list-group-item list-group-item-action " + (selectFiltered[id] ? "active" : "")}
onClick={() => selectReducer({type: 'TOGGLE_ID', payload: id})}>
<CombName combId={id}/>
</button>))}
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
<div className="vr"></div>
<label htmlFor="input6" className="form-label">Poule</label>
<input type="text" className="form-control" id="input6" style={{width: "3em"}} maxLength={1} value={targetGroupe}
onChange={(e) => setTargetGroupe(e.target.value)}/>
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal" onClick={handleSubmit}>Ajouter</button>
</div>
</>
}

View File

@ -3,8 +3,26 @@ import {useEffect, useRef} from "react";
const max_x = 500;
const size = 24;
export function DrawGraph({root = []}) {
function getMousePos(canvas, evt) {
const rect = canvas.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
}
export function DrawGraph({
root = [],
scrollRef = null,
onMatchClick = function (rect, match, comb) {
},
onClickVoid = function () {
}
}) {
const canvasRef = useRef(null);
const actionCanvasRef = useRef(null);
const ctxARef = useRef(null);
const actionMapRef = useRef({});
function getBounds(root) {
let px = max_x;
@ -126,8 +144,19 @@ export function DrawGraph({root = []}) {
ctx.restore();
};
const newColor = () => {
const letters = '0123456789ABCDEF'
let color
do {
color = '#'
for (let i = 0; i < 6; i++)
color += letters[Math.floor(Math.random() * 16)]
} while (actionMapRef.current[color] !== undefined)
return color;
}
// Fonction pour dessiner un nœud
const drawNode = (ctx, tree, px, py, max_y) => {
const drawNode = (ctx, ctxA, tree, px, py, max_y) => {
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(px - size, py);
@ -147,15 +176,20 @@ export function DrawGraph({root = []}) {
ctx.stroke();
printScores(ctx, match.scores, px, py, 1);
ctx.fillStyle = "#FF0000";
printText(ctx, (match.c1FullName == null) ? "" : match.c1FullName,
px - size * 2 - size * 8, py - size - (size * 1.5 / 2 | 0),
size * 8, (size * 1.5 | 0), false, true);
ctx.fillStyle = "#0000FF";
printText(ctx, (match.c2FullName == null) ? "" : match.c2FullName,
px - size * 2 - size * 8, py + size - (size * 1.5 / 2 | 0),
size * 8, (size * 1.5 | 0), false, true);
const pos = {x: px - size * 2 - size * 8, y: py - size - (size * 1.5 / 2 | 0), width: size * 8, height: (size * 1.5 | 0)}
ctx.fillStyle = "#FF0000"
printText(ctx, (match.c1FullName == null) ? "" : match.c1FullName, pos.x, pos.y, pos.width, pos.height, false, true)
ctxA.fillStyle = newColor()
ctxA.fillRect(pos.x, pos.y, pos.width, pos.height)
actionMapRef.current[ctxA.fillStyle] = {type: 'match', rect: pos, match: match.id, comb: 1}
const pos2 = {x: px - size * 2 - size * 8, y: py + size - (size * 1.5 / 2 | 0), width: size * 8, height: (size * 1.5 | 0)}
ctx.fillStyle = "#0000FF"
printText(ctx, (match.c2FullName == null) ? "" : match.c2FullName, pos2.x, pos2.y, pos2.width, pos2.height, false, true)
ctxA.fillStyle = newColor()
ctxA.fillRect(pos2.x, pos2.y, pos2.width, pos2.height)
actionMapRef.current[ctxA.fillStyle] = {type: 'match', rect: pos2, match: match.id, comb: 2}
if (max_y.current < py + size + ((size * 1.5 / 2) | 0)) {
max_y.current = py + size + (size * 1.5 / 2 | 0);
@ -173,15 +207,20 @@ export function DrawGraph({root = []}) {
ctx.stroke();
printScores(ctx, match.scores, px, py, 1.5);
ctx.fillStyle = "#FF0000";
printText(ctx, (match.c1FullName == null) ? "" : match.c1FullName,
px - size * 2 - size * 8, py - size * 2 * death - (size * 1.5 / 2 | 0),
size * 8, (size * 1.5 | 0), true, true);
ctx.fillStyle = "#0000FF";
printText(ctx, (match.c2FullName == null) ? "" : match.c2FullName,
px - size * 2 - size * 8, py + size * 2 * death - (size * 1.5 / 2 | 0),
size * 8, (size * 1.5 | 0), true, true);
const pos = {x: px - size * 2 - size * 8, y: py - size * 2 * death - (size * 1.5 / 2 | 0), width: size * 8, height: (size * 1.5 | 0)}
ctx.fillStyle = "#FF0000"
printText(ctx, (match.c1FullName == null) ? "" : match.c1FullName, pos.x, pos.y, pos.width, pos.height, true, true)
ctxA.fillStyle = newColor()
ctxA.fillRect(pos.x, pos.y, pos.width, pos.height)
actionMapRef.current[ctxA.fillStyle] = {type: 'match', rect: pos, match: match.id, comb: 1}
const pos2 = {x: px - size * 2 - size * 8, y: py + size * 2 * death - (size * 1.5 / 2 | 0), width: size * 8, height: (size * 1.5 | 0)}
ctx.fillStyle = "#0000FF"
printText(ctx, (match.c2FullName == null) ? "" : match.c2FullName, pos2.x, pos2.y, pos2.width, pos2.height, true, true)
ctxA.fillStyle = newColor()
ctxA.fillRect(pos2.x, pos2.y, pos2.width, pos2.height)
actionMapRef.current[ctxA.fillStyle] = {type: 'match', rect: pos2, match: match.id, comb: 2}
if (max_y.current < py + size * 2 * death + ((size * 1.5 / 2) | 0)) {
max_y.current = py + size * 2 * death + ((size * 1.5 / 2 | 0));
@ -189,10 +228,10 @@ export function DrawGraph({root = []}) {
}
if (tree.left != null) {
drawNode(ctx, tree.left, px - size * 2 - size * 8, py - size * 2 * death, max_y);
drawNode(ctx, ctxA, tree.left, px - size * 2 - size * 8, py - size * 2 * death, max_y);
}
if (tree.right != null) {
drawNode(ctx, tree.right, px - size * 2 - size * 8, py + size * 2 * death, max_y);
drawNode(ctx, ctxA, tree.right, px - size * 2 - size * 8, py + size * 2 * death, max_y);
}
};
@ -211,6 +250,7 @@ export function DrawGraph({root = []}) {
useEffect(() => {
if (root.length === 0) return;
// Dessiner sur le canvas principal
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
const [minx, maxx, miny, maxy] = getBounds(root);
@ -222,6 +262,20 @@ export function DrawGraph({root = []}) {
ctx.lineWidth = 2;
ctx.strokeStyle = "#000000";
// Dessiner sur le canvas d'action
const actionCanvas = actionCanvasRef.current;
const ctxA = actionCanvas.getContext("2d", {willReadFrequently: true});
ctxARef.current = ctxA;
actionCanvas.width = maxx - minx;
actionCanvas.height = maxy - miny;
ctxA.translate(-minx, -miny);
ctxA.fillStyle = "#000000";
ctxA.lineWidth = 2;
ctxA.strokeStyle = "#000000";
actionMapRef.current = {};
let px = maxx;
let py;
const max_y = {current: 0};
@ -242,11 +296,91 @@ export function DrawGraph({root = []}) {
size * 8, (size * 1.5 | 0), true, false);
px = px - size * 2 - size * 8;
drawNode(ctx, node, px, py, max_y);
drawNode(ctx, ctxA, node, px, py, max_y);
py = max_y.current + ((size * 2 * node.death() + ((size * 1.5 / 2) | 0)));
px = maxx;
}
for (const color in actionMapRef.current) {
const old = actionMapRef.current[color]
if (old.rect == null) continue;
actionMapRef.current[color] = {
...old,
rect: {x: old.rect.x - minx, y: old.rect.y - miny + 10, width: old.rect.width, height: old.rect.height}
};
}
}, [root]);
return <canvas ref={canvasRef} style={{border: "1px solid grey", marginTop: "10px"}} id="myCanvas"></canvas>;
useEffect(() => {
let isDownScroll = false
let downColor = undefined
let startX
let scrollLeft
const mousedown = (e) => {
const pos = getMousePos(canvasRef.current, e)
const pixel = ctxARef.current.getImageData(pos.x, pos.y, 1, 1).data
downColor = (pixel[3] === 0) ? undefined : `#${((1 << 24) + (pixel[0] << 16) + (pixel[1] << 8) + pixel[2]).toString(16).slice(1)}`
if (downColor === undefined)
onClickVoid()
isDownScroll = downColor === undefined
startX = e.pageX - scrollRef.current.offsetLeft
scrollLeft = scrollRef.current.scrollLeft
}
const mouseleave = () => {
isDownScroll = false
downColor = undefined
}
const mouseup = (e) => {
if (isDownScroll || downColor === undefined) {
isDownScroll = false
return
}
const pos = getMousePos(canvasRef.current, e)
const pixel = ctxARef.current.getImageData(pos.x, pos.y, 1, 1).data
const upColor = `#${((1 << 24) + (pixel[0] << 16) + (pixel[1] << 8) + pixel[2]).toString(16).slice(1)}`
if (upColor === downColor) {
const action = actionMapRef.current[downColor]
if (action.type === 'match')
onMatchClick(action.rect, action.match, action.comb);
}
}
const mousemove = (e) => {
if (!isDownScroll) return
e.preventDefault()
const x = e.pageX - scrollRef.current.offsetLeft
const walk = (x - startX) // Ajuste la vitesse de défilement
scrollRef.current.scrollLeft = scrollLeft - walk
}
if (scrollRef) {
canvasRef.current.addEventListener("mousedown", mousedown)
canvasRef.current.addEventListener("mouseleave", mouseleave)
canvasRef.current.addEventListener("mouseup", mouseup)
canvasRef.current.addEventListener("mousemove", mousemove)
}
return () => {
if (canvasRef && canvasRef.current) {
canvasRef.current.removeEventListener("mousedown", mousedown)
canvasRef.current.removeEventListener("mouseleave", mouseleave)
canvasRef.current.removeEventListener("mouseup", mouseup)
canvasRef.current.removeEventListener("mousemove", mousemove)
}
}
}, [onMatchClick, onClickVoid]);
return <div style={{position: "relative"}}>
<canvas ref={actionCanvasRef} style={{border: "1px solid grey", marginTop: "10px", position: "absolute", top: 0, left: 0, opacity: 0}}
id="myCanvas2"></canvas>
<canvas ref={canvasRef} style={{border: "1px solid grey", marginTop: "10px", position: "relative", opacity: 1}} id="myCanvas"></canvas>
</div>
}

View File

@ -5,6 +5,7 @@ import {AxiosError} from "../../components/AxiosError.jsx";
import {ThreeDots} from "react-loader-spinner";
import {useEffect, useState} from "react";
import {DrawGraph} from "./DrawGraph.jsx";
import {TreeNode} from "../../utils/TreeUtils.js";
function CupImg() {
return <img decoding="async" loading="lazy" width="16" height="16" className="wp-image-1635"
@ -143,27 +144,6 @@ function BuildRankArray({rankArray}) {
}
function BuildTree({treeData}) {
class TreeNode {
constructor(data) {
this.data = data;
this.left = null;
this.right = null;
}
death() {
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);
}
}
function parseTree(data_in) {
if (data_in?.data == null)
return null;