From f6d4bb0fe4f6450cf6988016c7130d8dc6102034 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 26 Nov 2025 12:36:57 +0100 Subject: [PATCH] wip: cm admin comb selector --- .../data/model/CompetitionGuestModel.java | 4 +- .../ffsaf/domain/entity/CombEntity.java | 34 ++- .../fr/titionfire/ffsaf/ws/CompetitionWS.java | 5 + .../fr/titionfire/ffsaf/ws/recv/RMatch.java | 67 +++++ .../titionfire/ffsaf/ws/recv/RRegister.java | 36 +++ src/main/webapp/src/hooks/useComb.jsx | 91 ++++++ .../src/pages/competition/editor/CMAdmin.jsx | 79 +---- .../editor/CategoryAdminContent.jsx | 269 ++++++++++++++++++ .../editor/CompetitionManagerRoot.jsx | 17 +- .../editor/SelectCombModalContent.jsx | 266 +++++++++++++++++ .../webapp/src/pages/result/DrawGraph.jsx | 178 ++++++++++-- .../webapp/src/pages/result/ResultView.jsx | 22 +- 12 files changed, 932 insertions(+), 136 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/ws/recv/RRegister.java create mode 100644 src/main/webapp/src/hooks/useComb.jsx create mode 100644 src/main/webapp/src/pages/competition/editor/CategoryAdminContent.jsx create mode 100644 src/main/webapp/src/pages/competition/editor/SelectCombModalContent.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionGuestModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionGuestModel.java index 5defa6f..697de65 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionGuestModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionGuestModel.java @@ -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); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/entity/CombEntity.java b/src/main/java/fr/titionfire/ffsaf/domain/entity/CombEntity.java index 6f7ee6e..de99f36 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/entity/CombEntity.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/entity/CombEntity.java @@ -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()); } } diff --git a/src/main/java/fr/titionfire/ffsaf/ws/CompetitionWS.java b/src/main/java/fr/titionfire/ffsaf/ws/CompetitionWS.java index ff1ede9..b585820 100644 --- a/src/main/java/fr/titionfire/ffsaf/ws/CompetitionWS.java +++ b/src/main/java/fr/titionfire/ffsaf/ws/CompetitionWS.java @@ -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 diff --git a/src/main/java/fr/titionfire/ffsaf/ws/recv/RMatch.java b/src/main/java/fr/titionfire/ffsaf/ws/recv/RMatch.java index fcb0afa..69c1b9d 100644 --- a/src/main/java/fr/titionfire/ffsaf/ws/recv/RMatch.java +++ b/src/main/java/fr/titionfire/ffsaf/ws/recv/RMatch.java @@ -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 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 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) { + } } diff --git a/src/main/java/fr/titionfire/ffsaf/ws/recv/RRegister.java b/src/main/java/fr/titionfire/ffsaf/ws/recv/RRegister.java new file mode 100644 index 0000000..ade05eb --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/ws/recv/RRegister.java @@ -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 combEntities = new ArrayList<>(); + combEntities.addAll(cm.getInsc().stream().map(CombEntity::fromModel).toList()); + combEntities.addAll(cm.getGuests().stream().map(CombEntity::fromModel).toList()); + return combEntities; + }); + } +} diff --git a/src/main/webapp/src/hooks/useComb.jsx b/src/main/webapp/src/hooks/useComb.jsx new file mode 100644 index 0000000..6fe3604 --- /dev/null +++ b/src/main/webapp/src/hooks/useComb.jsx @@ -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 + + {children} + + +} + +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}] + } +} diff --git a/src/main/webapp/src/pages/competition/editor/CMAdmin.jsx b/src/main/webapp/src/pages/competition/editor/CMAdmin.jsx index 8461cd5..8b2665b 100644 --- a/src/main/webapp/src/pages/competition/editor/CMAdmin.jsx +++ b/src/main/webapp/src/pages/competition/editor/CMAdmin.jsx @@ -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 <>
@@ -391,59 +374,3 @@ function ModalContent({state, setCatId, setConfirm, confirmRef}) {
} - -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 <> -
- - -
-
-
-
- -} diff --git a/src/main/webapp/src/pages/competition/editor/CategoryAdminContent.jsx b/src/main/webapp/src/pages/competition/editor/CategoryAdminContent.jsx new file mode 100644 index 0000000..ae91ea9 --- /dev/null +++ b/src/main/webapp/src/pages/competition/editor/CategoryAdminContent.jsx @@ -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 <> +
+ +
+
+ {cat && } +
+ +} + +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 <> +
    + {groups.map((comb) => ( +
  1. +
    + {comb.poule} +
  2. ) + )} +
+ + + + +} + +function ListMatch({cat, matches, groups}) { + const [type, setType] = useState(1); + + return <> + {cat.type === 3 && <> +
    +
  • +
    setType(1)}>Poule +
    +
  • +
  • +
    setType(2)}>Tournois +
    +
  • +
+ } + + {type === 2 && <> + + } + +} + +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
+ + +
+} diff --git a/src/main/webapp/src/pages/competition/editor/CompetitionManagerRoot.jsx b/src/main/webapp/src/pages/competition/editor/CompetitionManagerRoot.jsx index 2b76a9b..1017971 100644 --- a/src/main/webapp/src/pages/competition/editor/CompetitionManagerRoot.jsx +++ b/src/main/webapp/src/pages/competition/editor/CompetitionManagerRoot.jsx @@ -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 - - - }/> - }/> - }/> - - + + + + }/> + }/> + }/> + + + } diff --git a/src/main/webapp/src/pages/competition/editor/SelectCombModalContent.jsx b/src/main/webapp/src/pages/competition/editor/SelectCombModalContent.jsx new file mode 100644 index 0000000..91746e7 --- /dev/null +++ b/src/main/webapp/src/pages/competition/editor/SelectCombModalContent.jsx @@ -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 <> +
+

Sélectionner des combatants

+ +
+
+
+
+ + setSearch(e.target.value)}/> +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +
+
+ setGender((prev) => { + return {...prev, H: e.target.checked} + })}/> + +
+
+ setGender((prev) => { + return {...prev, F: e.target.checked} + })}/> + +
+
+ setGender((prev) => { + return {...prev, NA: e.target.checked} + })}/> + +
+
+
+
+ + +
+
+ +
+
setWeightMin(Number(e.target.value))}/>
+
à
+
setWeightMax(Number(e.target.value))}/>
+
(0 = désactivé)
+
+
+ +
+
+
+
+
Inscrit
+
+ {dispoFiltered && Object.keys(dispoFiltered).length === 0 &&
Aucun combattant disponible
} + {Object.keys(dispoFiltered).map((id) => ( + ))} +
+
+
+
+ + + + +
+
+
+
Sélectionner
+
+ {selectFiltered && Object.keys(selectFiltered).length === 0 &&
Aucun combattant sélectionné
} + {Object.keys(selectFiltered).map((id) => ( + ))} +
+
+
+
+
+ +
+ + setTargetGroupe(e.target.value)}/> + +
+ +} diff --git a/src/main/webapp/src/pages/result/DrawGraph.jsx b/src/main/webapp/src/pages/result/DrawGraph.jsx index 47a88ea..dd534c1 100644 --- a/src/main/webapp/src/pages/result/DrawGraph.jsx +++ b/src/main/webapp/src/pages/result/DrawGraph.jsx @@ -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 ; + 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
+ + +
} diff --git a/src/main/webapp/src/pages/result/ResultView.jsx b/src/main/webapp/src/pages/result/ResultView.jsx index 9efe245..1ed6424 100644 --- a/src/main/webapp/src/pages/result/ResultView.jsx +++ b/src/main/webapp/src/pages/result/ResultView.jsx @@ -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