feat: rework competition perm, naming, competition data
All checks were successful
Deploy Production Server / if_merged (pull_request) Successful in 10m6s

This commit is contained in:
2025-08-17 22:07:12 +02:00
parent 9e9391465d
commit dedae02676
35 changed files with 791 additions and 266 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -4,9 +4,7 @@ import {AxiosError} from "./AxiosError.jsx";
export function ClubSelect({defaultValue, name, na = false, disabled = false}) {
return <LoadingProvider>
<div className="input-group mb-3">
<ClubSelect_ defaultValue={defaultValue} name={name} na={na} disabled={disabled}/>
</div>
<ClubSelect_ defaultValue={defaultValue} name={name} na={na} disabled={disabled}/>
</LoadingProvider>
}
@@ -19,7 +17,7 @@ function ClubSelect_({defaultValue, name, na, disabled}) {
? <div className="input-group mb-3">
<label className="input-group-text" id="inputGroupSelect02">Club</label>
<select className="form-select" id="inputGroupSelect02" disabled={disabled}
defaultValue={defaultValue? defaultValue : -1} name={name}>
defaultValue={defaultValue ? defaultValue : -1} name={name}>
<option>Sélectionner...</option>
{na && <option value={-1}>-- Non licencier --</option>}
{data.map(club => (<option key={club.id} value={club.id}>{club.name}</option>))}

View File

@@ -2,7 +2,7 @@ import {useNavigate, useParams} from "react-router-dom";
import {useLoadingSwitcher} from "../../hooks/useLoading.jsx";
import {useFetch} from "../../hooks/useFetch.js";
import {AxiosError} from "../../components/AxiosError.jsx";
import {Checkbox, CheckField, OptionField, TextField} from "../../components/MemberCustomFiels.jsx";
import {CheckField, OptionField, TextField} from "../../components/MemberCustomFiels.jsx";
import {ClubSelect} from "../../components/ClubSelect.jsx";
import {ConfirmDialog} from "../../components/ConfirmDialog.jsx";
import {toast} from "react-toastify";
@@ -10,14 +10,14 @@ import {apiAxios, errFormater} from "../../utils/Tools.js";
import {useEffect, useReducer, useState} from "react";
import {SimpleReducer} from "../../utils/SimpleReducer.jsx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faAdd, faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons";
import {faAdd, faTrashCan} from "@fortawesome/free-solid-svg-icons";
export function CompetitionEdit() {
const {id} = useParams()
const navigate = useNavigate();
const setLoading = useLoadingSwitcher()
const {data, refresh, error} = useFetch(`/competition/${id}`, setLoading, 1)
const {data, refresh, error} = useFetch(`/competition/${id}?light=false`, setLoading, 1)
const handleRm = () => {
toast.promise(
@@ -49,7 +49,7 @@ export function CompetitionEdit() {
{data.id !== null && <button style={{marginBottom: "1.5em", width: "100%"}} className="btn btn-primary"
onClick={_ => navigate(`/competition/${data.id}/register`)}>Voir/Modifier les participants</button>}
{data.id !== null && <ContentSAFCA data2={data}/>}
{data.id !== null && data.system === "SAFCA" && <ContentSAFCA data2={data}/>}
{data.id !== null && <>
<div className="col" style={{textAlign: 'right', marginTop: '1em'}}>
@@ -212,17 +212,74 @@ function ContentSAFCA({data2}) {
function Content({data}) {
const navigate = useNavigate();
const [registerMode, setRegisterMode] = useState(data.registerMode || "FREE");
const handleSubmit = (event) => {
event.preventDefault();
let err = false;
const out = {}
out['id'] = (data.id === "") ? null : data.id
out['name'] = event.target.name?.value
out['date'] = event.target.date?.value
out['toDate'] = event.target.toDate?.value
out['system'] = event.target.system?.value
out['club'] = event.target.club?.value
out['owner'] = event.target.owner?.value
out['description'] = event.target.description?.value
out['adresse'] = event.target.adresse?.value
out['publicVisible'] = event.target.publicVisible?.checked
out['startRegister'] = event.target.startRegister?.value
out['endRegister'] = event.target.endRegister?.value
out['registerMode'] = registerMode
if (out['registerMode'] === "HELLOASSO") {
out['data3'] = event.target.data3?.value
const url = event.target.helloassoUrl?.value
if (!url || !event.target.data3?.value) {
toast.error("Veuillez renseigner l'URL de la billetterie HelloAsso et les tarifs associés.")
err = true;
} else {
const regex = /\/associations\/([^/]+)\/evenements\/([^/]+)/;
const match = url.match(regex);
if (match) {
out['data1'] = match[1]
out['data2'] = match[2]
} else {
toast.error("L'URL de la billetterie HelloAsso n'est pas valide. Veuillez vérifier le format de l'URL.")
err = true;
}
}
out['data4'] = event.target.data4?.value
if (!out['data4']) {
toast.error("Veuillez renseigner l'email de réception des inscriptions échouées.")
err = true;
}
}
if (out['date'] > out['toDate']) {
toast.error("La date de fin doit être postérieure à la date de début.")
err = true;
}
if ((out['registerMode'] === "FREE" || out['registerMode'] === "CLUB_ADMIN") && (!out['startRegister'] || !out['endRegister'])) {
toast.error("Veuillez renseigner les dates de début et de fin d'inscription.")
err = true;
}
if ((out['registerMode'] === "FREE" || out['registerMode'] === "CLUB_ADMIN") && out['startRegister'] > out['endRegister']) {
toast.error("La date de fin d'inscription doit être postérieure à la date de début d'inscription.")
err = true;
}
if (err) {
return;
}
toast.promise(
apiAxios.post(`/competition`, out),
@@ -246,24 +303,135 @@ function Content({data}) {
<input name="id" value={data.id || ""} readOnly hidden/>
<div className="card-header">{data.id ? "Edition competition" : "Création competition"}</div>
<div className="card-body text-center">
<TextField name="uuid" text="UUID" value={data.uuid} disabled={true}/>
<TextField name="name" text="Nom" value={data.name}/>
<div className="input-group mb-3">
<span className="input-group-text" id="birth_date">Date</span>
<input type="date" className="form-control" placeholder="jj/mm/aaaa" aria-label="date"
name="date" aria-describedby="date" defaultValue={data.date ? data.date.split('T')[0] : ''} required/>
</div>
{data.id !== null && <TextField name="owner" text="Propriétaire" value={data.owner}/>}
<OptionField name="system" text="System" value={data.system} values={{SAFCA: 'SAFCA'}} disabled={data.id !== null}/>
{data.id !== null &&
<div className="row">
<ClubSelect defaultValue={data.club} name="club" na={false} disabled={true}/>
<div className="accordion" id="accordionExample">
<div className="accordion-item">
<h2 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne"
aria-expanded="false" aria-controls="collapseOne">
Informations techniques
</button>
</h2>
<div id="collapseOne" className="accordion-collapse collapse" data-bs-parent="#accordionExample">
<div className="accordion-body">
<TextField name="uuid" text="UUID" value={data.uuid} disabled={true}/>
<OptionField name="system" text="System" value={data.system} values={{SAFCA: 'SAFCA', NONE: "intranet"}}
disabled={data.id !== null}/>
{data.id !== null &&
<div className="row">
<ClubSelect defaultValue={data.club} name="club" na={false} disabled={true}/>
</div>
}
{data.id !== null && <TextField name="owner" text="Propriétaire" value={data.owner}/>}
</div>
</div>
</div>
}
<div className="accordion-item">
<h2 className="accordion-header">
<button className="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo"
aria-expanded="true" aria-controls="collapseTwo">
Informations générales sur la competition
</button>
</h2>
<div id="collapseTwo" className="accordion-collapse collapse show" data-bs-parent="#accordionExample">
<div className="accordion-body">
<TextField name="name" text="Nom*" value={data.name}/>
<div className="input-group mb-3">
<span className="input-group-text" id="date">Date*</span>
<span className="input-group-text" id="date">Du</span>
<input type="date" className="form-control" placeholder="jj/mm/aaaa" aria-label="date"
name="date" aria-describedby="date" defaultValue={data.date ? data.date.split('T')[0] : ''} required/>
<span className="input-group-text" id="date">Au</span>
<input type="date" className="form-control" placeholder="jj/mm/aaaa" aria-label="toDate"
name="toDate" aria-describedby="toDate" defaultValue={data.toDate ? data.toDate.split('T')[0] : ''}
required/>
</div>
<TextField name="description" text="Description" value={data.description} required={false}/>
<TextField name="adresse" text="Adresse" value={data.adresse} required={false}/>
<CheckField name="publicVisible" text="Visible par le public (Apparaît dans la liste des compétitions)"
value={data.publicVisible} row={true}/>
<small>Si non coché, la compétition ne sera visible que par les personnes pouvant y inscrire des participants.</small>
</div>
</div>
</div>
<div className="accordion-item">
<h2 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree"
aria-expanded="false" aria-controls="collapseThree">
Informations sur le mode d'inscription
</button>
</h2>
<div id="collapseThree" className="accordion-collapse collapse" data-bs-parent="#accordionExample">
<div className="accordion-body">
<div className="row">
<div className="input-group mb-3">
<label className="input-group-text">Qui peut inscrire</label>
<select className="form-select" value={registerMode} onChange={event => setRegisterMode(event.target.value)}>
<option value="FREE">Tous les membres de la FFSAF</option>
<option value="CLUB_ADMIN">Responsables et bureaux des associations</option>
<option value="ADMIN">Uniquement les administrateurs de la compétition</option>
<option value="HELLOASSO">Billetterie HelloAsso</option>
</select>
</div>
</div>
<div className="input-group mb-3"
style={{display: registerMode === "FREE" || registerMode === "CLUB_ADMIN" ? "flex" : "none"}}>
<span className="input-group-text" id="startRegister">Date d'inscription</span>
<span className="input-group-text" id="startRegister">Du</span>
<input type="datetime-local" className="form-control" placeholder="jj/mm/aaaa" aria-label="date"
name="startRegister" aria-describedby="startRegister"
defaultValue={data.startRegister ? data.startRegister.split('+')[0] : ''}/>
<span className="input-group-text" id="endRegister">Au</span>
<input type="datetime-local" className="form-control" placeholder="jj/mm/aaaa" aria-label="endRegister"
name="endRegister" aria-describedby="endRegister"
defaultValue={data.endRegister ? data.endRegister.split('+')[0] : ''}/>
</div>
<div style={{display: registerMode === "HELLOASSO" ? "initial" : "none"}}>
<span style={{textAlign: "left"}}>
<div>Afin de permettre une bonne interconnexion avec HelloAsso, merci de suivre les instructions suivantes :</div>
<ul>
<li><strong>Configurer l'url de notification : </strong>afin que nous puissions recevoir une notification à
chaque inscription, il est nécessaire de configurer l'url de notification de votre compte HelloAsso pour
qu'il redirige vers "https://intra.ffsaf.fr/api/webhook/ha". Pour ce faire, depuis la page d'accueil de
votre association sur HelloAsso, allez dans <strong>Mon compte</strong> &gt; <strong>Paramètres</strong> &gt;
<strong> Intégrations et API</strong> section Notification et copier-coller <strong>https://intra.ffsaf.fr/api/webhook/ha </strong>
dans le champ <strong>Mon URL de callback</strong> et enregister.
<img src="/img/HA-help-4.png" alt="" className="img-fluid" style={{objectFit: "contain"}}/></li>
<li><strong>Copier-coller le nom exacte des tarifs</strong> <em>-sépare par des point-virgules-</em> qui donneront
lieux à une inscription automatique. Tous ces tarifs doivent impérativement demander le numéro de licence
en champs obligatoire. Pour ce faire, lors de la configuration de votre billetterie à l'étape n°3,
cliquer sur <strong>+ Ajouter une information</strong>, saisissez l'intituler exact suivant
<strong> Numéro de licence</strong>, dans <strong>Type de réponse souhaitée</strong> rentrer Nombre,
sélectionner les tarifs entrés plus précédemment et rendre l'information obligatoire.
<img src="/img/HA-help-3.png" className="img-fluid" alt="..." style={{object_fit: 'contain'}}/>
<img src="/img/HA-help-2.png" className="img-fluid" alt="..." style={{object_fit: 'contain'}}/></li>
<li><strong>Copier-coller l'url de votre billetterie</strong> dans le champs si dessous. Il devrais avoir la forme suivante:
<em> https://www.helloasso.com/associations/&lt;nom-asso-sur-helloasso&gt;/evenements/&lt;nom-billetterie&gt;</em></li>
</ul>
</span>
<TextField name="helloassoUrl" text="Url de la billetterie HelloAsso" required={false}
value={data.data1 && data.data2 && `https://www.helloasso.com/associations/${data.data1}/evenements/${data.data2}` || ""}
placeholder="https://www.helloasso.com/associations/nom-asso-sur-helloasso/evenements/nom-billetterie"/>
<TextField name="data3" text="Tarifs HelloAsso"
value={data.data3 || ""} required={false}
placeholder="Tarif1;Tarif2;Tarif3"/>
<TextField name="data4" text="Email de réception des inscriptions échoué"
value={data.data4 || ""} required={false}/>
<small>Si pour une raison quelconque l'inscription automatique échoue, un email sera envoyé à cette adresse pour
vous en informer</small>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="row mb-3">
@@ -273,4 +441,4 @@ function Content({data}) {
</div>
</div>
</form>
}
}

View File

@@ -42,14 +42,32 @@ function MakeCentralPanel({data, navigate}) {
</>
}
const inscText = (type) => {
if (type === "FREE") {
return "Inscriptions libres"
} else if (type === "CLUB_ADMIN") {
return "Inscriptions par les responsables de club"
} else if (type === "ADMIN") {
return "Inscriptions par les administrateurs de la compétition"
} else if (type === "HELLOASSO") {
return "Inscriptions sur la billetterie HelloAsso"
}
return ""
}
function MakeRow({data, navigate}) {
return <div className="list-group-item d-flex justify-content-between align-items-start list-group-item-action"
onClick={() => navigate("" + data.id)}>
<div className="ms-2 col-auto">
<div className="fw-bold">{data.name}</div>
<small>{data.date.split('T')[0]}</small>
return <div className="list-group-item list-group-item-action"
onClick={() => data.canEdit ? navigate("" + data.id) : navigate("view/" + data.id)}>
<div className="row justify-content-between align-items-start ">
<div className="ms-2 col-auto">
<div><strong>{data.name}</strong> <small>par {data.clubName}</small></div>
<small>Du {new Date(data.date.split('T')[0]).toLocaleDateString()} au {new Date(data.toDate.split('T')[0]).toLocaleDateString()}</small>
</div>
<div className="ms-2 col-auto">
<small style={{textAlign: 'right'}}>{inscText(data.registerMode)}</small>
</div>
</div>
<small style={{textAlign: 'right'}}>{data.clubName}<br/>{data.system}</small>
</div>
}

View File

@@ -3,6 +3,7 @@ import {Outlet} from "react-router-dom";
import {CompetitionList} from "./CompetitionList.jsx";
import {CompetitionEdit} from "./CompetitionEdit.jsx";
import {CompetitionRegisterAdmin} from "./CompetitionRegisterAdmin.jsx";
import {CompetitionView} from "./CompetitionView.jsx";
export function CompetitionRoot() {
return <>
@@ -23,9 +24,13 @@ export function getCompetitionChildren() {
path: ':id',
element: <CompetitionEdit/>
},
{
path: 'view/:id',
element: <CompetitionView/>
},
{
path: ':id/register',
element: <CompetitionRegisterAdmin/>
}
]
}
}

View File

@@ -0,0 +1,72 @@
import {useNavigate, useParams} from "react-router-dom";
import {useLoadingSwitcher} from "../../hooks/useLoading.jsx";
import {useFetch} from "../../hooks/useFetch.js";
import {AxiosError} from "../../components/AxiosError.jsx";
import {useAuth} from "../../hooks/useAuth.jsx";
import {isClubAdmin} from "../../utils/Tools.js";
export function CompetitionView() {
const {id} = useParams()
const navigate = useNavigate();
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/competition/${id}?light=true`, setLoading, 1)
return <>
<button type="button" className="btn btn-link" onClick={() => navigate("/competition")}>
&laquo; retour
</button>
<div>
{data ? <>
<MakeContent data={data}/>
</>
: error && <AxiosError error={error}/>
}
</div>
</>
}
const inscText = (type) => {
if (type === "FREE") {
return "Libres"
} else if (type === "CLUB_ADMIN") {
return "Par les responsables de club"
} else if (type === "ADMIN") {
return "Par les administrateurs de la compétition"
} else if (type === "HELLOASSO") {
return "Sur la billetterie HelloAsso"
}
return ""
}
function MakeContent({data}) {
const {userinfo} = useAuth()
return <div className="card mb-4">
<div className="card-header">
<h2 className="card-title" style={{textAlign: "center"}}>{data.name}</h2>
</div>
<div className="card-body">
<p>{data.description}</p>
<p><strong>Date
:</strong> Du {new Date(data.date.split('T')[0]).toLocaleDateString()} au {new Date(data.toDate.split('T')[0]).toLocaleDateString()}
</p>
<p><strong>Lieu :</strong> {data.adresse}</p>
<p><strong>Organisateur :</strong> {data.clubName}</p>
<p><strong>Type d'inscription :</strong> {inscText(data.registerMode)}</p>
{(data.registerMode === "FREE" || data.registerMode === "CLUB_ADMIN") &&
<p><strong>Date d'inscription :</strong> Du {new Date(data.startRegister.split('+')[0]).toLocaleString()} au {new Date(data.endRegister.split('+')[0]).toLocaleString()}</p>
}
{(data.registerMode === "CLUB_ADMIN" && isClubAdmin(userinfo)) || data.registerMode === "FREE" &&
<button type="button" className="btn btn-primary" disabled={new Date() < new Date(data.startRegister.split('+')[0]) || new Date() > new Date(data.endRegister.split('+')[0])}>Inscription</button>
}
{data.registerMode === "HELLOASSO" &&
<p><strong>Billetterie :</strong> <a
href={`https://www.helloasso.com/associations/${data.data1}/evenements/${data.data2}`} target="_blank"
rel="noopener noreferrer">{`https://www.helloasso.com/associations/${data.data1}/evenements/${data.data2}`}</a></p>
}
</div>
</div>
}

View File

@@ -7,6 +7,15 @@ export const apiAxios = axios.create({
});
apiAxios.defaults.headers.post['Accept'] = 'application/json; charset=UTF-8';
export function isInClub(userinfo, clubId) {
return userinfo?.groups.filter((g => g.startsWith("/club/"))).map(g => g.split("/")[2]).map(g => Number(g.split("-")[0])).includes(clubId)
}
export function isClubAdmin(userinfo) {
return userinfo?.roles?.includes("club_president") || userinfo?.roles?.includes("club_respo_intra")
|| userinfo?.roles?.includes("club_secretaire") || userinfo?.roles?.includes("club_tresorier");
}
export const errFormater = (data, msg) => {
if (typeof data.response.data === 'string' || data.response.data instanceof String)