wip: Affiliation request

This commit is contained in:
Thibaut Valentin 2024-07-14 23:04:22 +02:00
parent b2438ec3d8
commit 682894f326
20 changed files with 322 additions and 53 deletions

View File

@ -49,7 +49,7 @@ public class ClubModel {
Long SIRET;
String no_affiliation;
Long no_affiliation;
boolean international;

View File

@ -24,7 +24,7 @@ public class ClubEntity {
private String contact_intern;
private String RNA;
private Long SIRET;
private String no_affiliation;
private Long no_affiliation;
private boolean international;
public static ClubEntity fromModel (ClubModel model) {

View File

@ -7,6 +7,7 @@ import fr.titionfire.ffsaf.data.repository.AffiliationRequestRepository;
import fr.titionfire.ffsaf.data.repository.ClubRepository;
import fr.titionfire.ffsaf.data.repository.CombRepository;
import fr.titionfire.ffsaf.rest.data.SimpleAffiliation;
import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation;
import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm;
import fr.titionfire.ffsaf.utils.Utils;
import io.quarkus.hibernate.reactive.panache.Panache;
@ -75,12 +76,24 @@ public class AffiliationService {
.map(__ -> "Ok");
}
public Uni<SimpleReqAffiliation> getRequest(long id) {
return repositoryRequest.findById(id).map(SimpleReqAffiliation::fromModel)
.call(out -> clubRepository.find("SIRET = ?1", out.getSiret()).firstResult().invoke(c -> {
if (c != null){
out.setClub(c.getId());
out.setClub_name(c.getName());
out.setClub_no_aff(c.getNo_affiliation());
}
})
);
}
public Uni<List<SimpleAffiliation>> getCurrentSaisonAffiliation() {
return repository.list("saison = ?1", Utils.getSaison())
.map(models -> models.stream().map(SimpleAffiliation::fromModel).toList())
.chain(aff -> repositoryRequest.list("saison = ?1", Utils.getSaison())
.chain(models -> Uni.join().all(models.stream().map(model ->
clubRepository.find("siret = ?1", model.getSiret()).firstResult()
clubRepository.find("SIRET = ?1", model.getSiret()).firstResult()
.map(c -> new SimpleAffiliation(model.getId() * -1, c.getId(),
model.getSaison(), false)))
.toList()).andFailFast()

View File

@ -17,7 +17,7 @@ public class SimpleClubModel {
String name;
String country;
String shieldURL;
String no_affiliation;
Long no_affiliation;
public static SimpleClubModel fromModel(ClubModel model) {
if (model == null)

View File

@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.domain.service.AffiliationService;
import fr.titionfire.ffsaf.rest.data.SimpleAffiliation;
import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation;
import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm;
import fr.titionfire.ffsaf.utils.GroupeUtils;
import io.quarkus.oidc.IdToken;
@ -35,7 +36,16 @@ public class AffiliationEndpoints {
throw new ForbiddenException();
});
@GET
@Path("/request/{id}")
@RolesAllowed({"federation_admin"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<SimpleReqAffiliation> getAffRequest(@PathParam("id") long id) {
return service.getRequest(id);
}
@POST
@Path("/request")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<String> saveAffRequest(AffiliationRequestForm form) {

View File

@ -26,6 +26,7 @@ import java.io.*;
import java.net.URISyntaxException;
import java.net.URLConnection;
import java.nio.file.Files;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.function.Consumer;
@ -68,6 +69,14 @@ public class CombEndpoints {
return membreService.searchAdmin(limit, page - 1, search, club);
}
@GET
@Path("/find/similar")
@RolesAllowed({"federation_admin"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<List<SimpleMembre>> getSimilar(@QueryParam("fname") String fname, @QueryParam("lname") String lname) {
return membreService.getSimilar(fname, lname);
}
@GET
@Path("/find/club")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@ -90,6 +99,14 @@ public class CombEndpoints {
return membreService.getById(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel);
}
@GET
@Path("/find/licence")
@RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<SimpleMembre> getByLicence(@QueryParam("id") long id) {
return membreService.getByLicence(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel);
}
@PUT
@Path("{id}")
@RolesAllowed({"federation_admin"})

View File

@ -27,7 +27,7 @@ public class SimpleClub {
private String contact_intern;
private String RNA;
private Long SIRET;
private String no_affiliation;
private Long no_affiliation;
private boolean international;
private HashMap<String, String> contactMap = null;

View File

@ -0,0 +1,60 @@
package fr.titionfire.ffsaf.rest.data;
import fr.titionfire.ffsaf.data.model.AffiliationRequestModel;
import fr.titionfire.ffsaf.utils.RoleAsso;
import io.quarkus.runtime.annotations.RegisterForReflection;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@RegisterForReflection
public class SimpleReqAffiliation {
Long id;
Long club;
String club_name;
Long club_no_aff;
String name;
long siret;
String RNA;
String address;
List<AffiliationMember> members;
int saison;
public static SimpleReqAffiliation fromModel(AffiliationRequestModel model) {
if (model == null)
return null;
return new SimpleReqAffiliation.SimpleReqAffiliationBuilder()
.id(model.getId())
.name(model.getName())
.siret(model.getSiret())
.RNA(model.getRNA())
.address(model.getAddress())
.saison(model.getSaison())
.members(List.of(
new AffiliationMember(model.getM1_lname(), model.getM1_fname(), model.getM1_email(),
model.getM1_lincence(), model.getM1_role()),
new AffiliationMember(model.getM2_lname(), model.getM2_fname(), model.getM2_email(),
model.getM2_lincence(), model.getM2_role()),
new AffiliationMember(model.getM3_lname(), model.getM3_fname(), model.getM3_email(),
model.getM3_lincence(), model.getM3_role())
))
.build();
}
@Data
@AllArgsConstructor
@RegisterForReflection
public static class AffiliationMember {
String lname;
String fname;
String email;
int licence;
RoleAsso role;
}
}

View File

@ -75,21 +75,21 @@ public class AffiliationRequestForm {
model.setM1_fname(this.getM1_fname());
model.setM1_email(this.getM1_email());
model.setM1_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank())
? 0 : Integer.parseInt(this.getM1_lincence()));
? -1 : Integer.parseInt(this.getM1_lincence()));
model.setM1_role(this.getM1_role());
model.setM2_lname(this.getM2_lname());
model.setM2_fname(this.getM2_fname());
model.setM2_email(this.getM2_email());
model.setM2_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank())
? 0 : Integer.parseInt(this.getM2_lincence()));
? -1 : Integer.parseInt(this.getM2_lincence()));
model.setM2_role(this.getM2_role());
model.setM3_lname(this.getM3_lname());
model.setM3_fname(this.getM3_fname());
model.setM3_email(this.getM3_email());
model.setM3_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank())
? 0 : Integer.parseInt(this.getM3_lincence()));
? -1 : Integer.parseInt(this.getM3_lincence()));
model.setM3_role(this.getM3_role());
return model;

View File

@ -83,7 +83,7 @@ export function LocationEditorModal({modal, sendData}) {
const [location, setLocation] = useState("")
const [locationObj, setLocationObj] = useState({text: "", lng: undefined, lat: undefined})
const [mapPosition, setMapPosition] = useState([46.652195, 2.430226])
const {data, error, refresh} = useFetch(``)
const {data, error, refresh} = useFetch(null)
const map = useRef(null)
useEffect(() => {

View File

@ -49,6 +49,20 @@ export function OptionField({name, text, values, value, disabled = false}) {
</div>
}
export function RoleList({name, text, value, disabled = false}) {
return <OptionField name={name} text={text} value={value} disabled={disabled}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire',
VPRESIDENT: 'Vise-Président',
VTRESORIER: 'Vise-Trésorier',
VSECRETAIRE: 'Vise-Secrétaire',
MEMBREBUREAU: 'Membre bureau'
}}/>
}
export function CountryList({name, text, value, values = undefined, disabled = false}) {
if (values === undefined){
values = {NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'}

View File

@ -24,7 +24,8 @@ export function useFetch(url, setLoading = null, loadingLevel = 1, config = {})
}
useEffect(() => {
refresh(url)
if (url !== null)
refresh(url)
}, []);
return {

View File

@ -32,7 +32,7 @@ export function DemandeAff() {
const formData = new FormData(event.target)
formData.append("m1_role", event.target.m1_role?.value)
toast.promise(
apiAxios.post(`/affiliation`, formData, {headers: {'Accept': '*/*'}}),
apiAxios.post(`/affiliation/request`, formData, {headers: {'Accept': '*/*'}}),
{
pending: "Enregistrement de la demande d'affiliation en cours",
success: "Demande d'affiliation enregistrée avec succès 🎉",

View File

@ -41,7 +41,7 @@ export function getAdminChildren() {
element: <ClubPage/>
},
{
path: 'affiliation/request',
path: 'affiliation/request/:id',
element: <AffiliationReqPage/>
},
{

View File

@ -1,16 +1,191 @@
import {useNavigate} from "react-router-dom";
import {useNavigate, useParams} from "react-router-dom";
import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {useFetch} from "../../../hooks/useFetch.js";
import {AxiosError} from "../../../components/AxiosError.jsx";
import {toast} from "react-toastify";
import {apiAxios} from "../../../utils/Tools.js";
import {RoleList, TextField} from "../../../components/MemberCustomFiels.jsx";
import {useEffect, useRef, useState} from "react";
export function AffiliationReqPage() {
const {id} = useParams()
const navigate = useNavigate();
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/affiliation/request/${id}`, setLoading, 1)
return <>
<h2>Page affiliation</h2>
<h2>Demande d'affiliation</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/affiliation")}>
&laquo; retour
</button>
<div>
<div className="row">
</div>
{data
? <div className="">
<Content data={data}/>
</div>
: error && <AxiosError error={error}/>
}
</div>
</>
}
function Content({data}) {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
toast.promise(
apiAxios.put(`/club/${data.id}`, formData),
{
pending: "Enregistrement du club en cours",
success: "Club enregistrée avec succès 🎉",
error: "Échec de l'enregistrement du club 😕"
}
)
}
return <>
<form onSubmit={handleSubmit}>
<div className="card mb-4">
<input name="id" value={data.id} readOnly hidden/>
<div className="card-header">Demande d'affiliation</div>
<div className="card-body text-center">
{data.club && <h5>Ce club a déjà ete affilier (affiliation n°{data.club_no_aff})</h5>}
<h4 id="saison">Saison {data.saison}-{data.saison + 1}</h4>
<div className="row mb-3">
<div className="input-group">
<span className="input-group-text" id="name">Nom du club</span>
<input type="text" className="form-control" placeholder="Nom du club" aria-label="name"
name="name" aria-describedby="name" defaultValue={data.name} required/>
</div>
{data.club && <div className="form-text" id="name">Ancien nom: {data.club_name}</div>}
</div>
<TextField type="number" name="siret" text="SIRET" value={data.siret} disabled={true}/>
<TextField name="rna" text="RNA" value={data.rna}/>
<TextField name="address" text="Adresse" value={data.address}/>
{data.members.map((member, index) => {
return <div key={index} className="row">
<MemberPart index={index} member={member}/>
</div>
})}
</div>
</div>
</form>
</>
}
function MemberPart({index, member}) {
const [mode, setMode] = useState(member.licence >= 0 ? 0 : 2)
const [current, setCurrent] = useState(-1)
useEffect(() => {
if (mode !== 1)
setCurrent(-1)
}, [mode]);
return <div className="col mb-4">
<div className="card">
<div className="card-header">Membre n°{index + 1}</div>
<div className="card-body">
<RoleList name={"m" + (index + 1) + "_role"} text="Rôle" value={member.role}/>
<div className="btn-group row" role="group">
<div className="mb-2">
<div className="card">
<div className="card-header">
<input type="radio" className="btn-check" id={"btnradio1" + index} autoComplete="off"
checked={mode === 0} onChange={() => setMode(0)}/>
<label className="btn btn-outline-primary" htmlFor={"btnradio1" + index}>Par n° de licence</label>
</div>
<div className="card-body">
<LoadingProvider>
<MemberLicence member={member} mode={mode} index={index}/>
</LoadingProvider>
</div>
</div>
</div>
<div className="col-lg-5 mb-2">
<div className="card">
<div className="card-header">
<input type="radio" className="btn-check" id={"btnradio2" + index} autoComplete="off"
checked={mode === 1} onChange={() => setMode(1)}/>
<label className="btn btn-outline-primary" htmlFor={"btnradio2" + index}>Par Membre similaire</label>
</div>
<div className="card-body">
<LoadingProvider>
<MemberSimilar member={member} mode={mode} current={current} setCurrent={setCurrent}/>
</LoadingProvider>
</div>
</div>
</div>
<div className="col mb-2">
<div className="card">
<div className="card-header">
<input type="radio" className="btn-check" id={"btnradio3" + index} autoComplete="off"
checked={mode === 2} onChange={() => setMode(2)}/>
<label className="btn btn-outline-primary" htmlFor={"btnradio3" + index}>Par Nouveau membre</label>
</div>
<div className="card-body">
<TextField name={"m" + (index + 1) + "_email"} text="Nom" value={member.lname} disabled={mode !== 2}/>
<TextField name={"m" + (index + 1) + "_email"} text="Prénom" value={member.fname} disabled={mode !== 2}/>
<TextField name={"m" + (index + 1) + "_email"} text="Email" value={member.email} disabled={mode !== 2}/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
function MemberLicence({member, mode, index}) {
const setLoading = useLoadingSwitcher()
const [licence, setLicence] = useState(member.licence)
const {data, refresh} = useFetch(null, setLoading, 1)
const ref = useRef(-1)
useEffect(() => {
if (licence === -1 || licence.length < 1 || licence === ref.current)
return
refresh(`/member/find/licence?id=${licence}`)
ref.current = licence
}, [licence]);
const name = "m" + (index + 1) + "licence";
return <>
<div className="row">
<div className="input-group mb-3">
<span className="input-group-text" id={name}>Licence</span>
<input type="text" className="form-control" placeholder="00000" aria-label={name} name={name} aria-describedby={name}
value={licence >= 0 ? String(licence) : ""} disabled={mode !== 0} required={mode === 0}
onChange={event => setLicence(event.target.value)}/>
</div>
</div>
{data && <span className="form-text">Nom: {data.lname} {data.fname}, Club: {data.club.name}</span>}
</>
}
function MemberSimilar({member, current, setCurrent, mode}) {
const setLoading = useLoadingSwitcher()
const {data} = useFetch(`/member/find/similar?fname=${encodeURI(member.fname)}&lname=${encodeURI(member.lname)}`, setLoading, 1)
return <div className="list-group">
{data && data.map((m, index) => {
return <button key={index} type="button" aria-current={current === index}
className={"list-group-item list-group-item-action" + (current === index ? " active" : "")}
onClick={() => setCurrent(index)} disabled={mode !== 1}>
{m.lname} {m.fname}<br/>
<small>{m.club.name}</small>
</button>
})}
</div>
}

View File

@ -7,12 +7,13 @@ import {AxiosError} from "../../../components/AxiosError.jsx";
import {apiAxios, getSaison} from "../../../utils/Tools.js";
import {toast} from "react-toastify";
import {SimpleReducer} from "../../../utils/SimpleReducer.jsx";
import {useNavigate} from "react-router-dom";
export function AffiliationCard({clubData}) {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/affiliation/${clubData.id}`, setLoading, 1)
const [modalAffiliation, setModal] = useState({id: -1, club: clubData.id})
const [modalAffiliation, setModal] = useState({id: 0, club: clubData.id})
const [affiliations, dispatch] = useReducer(SimpleReducer, [])
useEffect(() => {
@ -94,6 +95,7 @@ function removeAffiliation(id, dispatch) {
}
function ModalContent({affiliation, dispatch}) {
const navigate = useNavigate();
const [saison, setSaison] = useState(0)
const setSeason = (event) => {
setSaison(Number(event.target.value))
@ -130,7 +132,9 @@ function ModalContent({affiliation, dispatch}) {
{affiliation.validate ? <span className="input-group-text" id="basic-addon2">Validée</span> :
<>
<span className="input-group-text" id="basic-addon2">En attente</span>
<button type="button" className="btn btn-primary"><FontAwesomeIcon icon={faEye}/></button> // TODO
<button type="button" className="btn btn-primary"
onClick={() => navigate('/admin/affiliation/request/' + (affiliation.id * -1))}
data-bs-dismiss="modal"><FontAwesomeIcon icon={faEye}/></button>
</>}
</div>
</div>
@ -138,7 +142,7 @@ function ModalContent({affiliation, dispatch}) {
{affiliation.id === 0 && <button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Enregistrer</button>}
<button type="reset" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
{affiliation.id <= 0 || <button type="button" className="btn btn-danger" data-bs-dismiss="modal"
onClick={() => removeAffiliation(affiliation.id, dispatch)}>Supprimer</button>}
onClick={() => removeAffiliation(affiliation.id, dispatch)}>Supprimer</button>}
</div>
</form>
}

View File

@ -114,13 +114,14 @@ function InformationForm({data}) {
<div className="input-group-text">
<input type="checkbox" className="form-check-input mt-0" name="international" id="international"
checked={switchOn} onChange={() => setSwitchOn(!switchOn)}/>
<label className="input-group-text" htmlFor="international">Club externe</label>
</div>
<label className="input-group-text" htmlFor="international">Club externe</label>
</div>
{!switchOn && <>
<TextField name="siret" text="SIRET" value={data.siret} type="number"/>
<TextField name="rna" text="RNA" value={data.rna} required={false}/>
<TextField name="contact_intern" text="Contact interne" value={data.contact_intern} required={false}/>
<TextField name="contact_intern" text="Contact interne" value={data.contact_intern} required={false}
placeholder="example@test.com"/>
<div className="mb-3">
<div className="input-group">

View File

@ -2,7 +2,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {apiAxios} from "../../../utils/Tools.js";
import {toast} from "react-toastify";
import imageCompression from "browser-image-compression";
import {BirthDayField, CountryList, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx";
import {BirthDayField, CountryList, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx";
import {ClubSelect} from "../../../components/ClubSelect.jsx";
export function addPhoto(event, formData, send) {
@ -80,17 +80,7 @@ export function InformationForm({data}) {
<div className="row">
<ClubSelect defaultValue={data?.club?.id} name="club"/>
</div>
<OptionField name="role" text="Rôle" value={data.role}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire',
VPRESIDENT: 'Vise-Président',
VTRESORIER: 'Vise-Trésorier',
VSECRETAIRE: 'Vise-Secrétaire',
MEMBREBUREAU: 'Membre bureau'
}}/>
<RoleList name="role" text="Rôle" value={data.role}/>
<OptionField name="grade_arbitrage" text="Grade d'arbitrage" value={data.grade_arbitrage}
values={{NA: 'N/A', ASSESSEUR: 'Assesseur', ARBITRE: 'Arbitre'}}/>
<div className="row">

View File

@ -2,7 +2,7 @@ import {useNavigate} from "react-router-dom";
import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {apiAxios} from "../../../utils/Tools.js";
import {toast} from "react-toastify";
import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx";
import {BirthDayField, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx";
import {ClubSelect} from "../../../components/ClubSelect.jsx";
import {addPhoto} from "./InformationForm.jsx";
@ -79,13 +79,7 @@ function Form() {
<div className="row">
<ClubSelect name="club"/>
</div>
<OptionField name="role" text="Rôle" value={'MEMBRE'}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire'
}}/>
<RoleList name="role" text="Rôle" value={'MEMBRE'}/>
<OptionField name="grade_arbitrage" text="Grade d'arbitrage" value={'NA'}
values={{NA: 'N/A', ASSESSEUR: 'Assesseur', ARBITRE: 'Arbitre'}}/>
<div className="row">

View File

@ -3,7 +3,7 @@
import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {apiAxios} from "../../../utils/Tools.js";
import {toast} from "react-toastify";
import {BirthDayField, CountryList, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx";
import {BirthDayField, CountryList, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx";
import {addPhoto} from "../../admin/member/InformationForm.jsx";
export function InformationForm({data}) {
@ -55,17 +55,7 @@ export function InformationForm({data}) {
<CountryList name="country" text="Pays" value={data.country}/>
<BirthDayField inti_date={data.birth_date ? data.birth_date.split('T')[0] : ''}
inti_category={data.categorie}/>
<OptionField name="role" text="Rôle" value={data.role}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire',
VPRESIDENT: 'Vise-Président',
VTRESORIER: 'Vise-Trésorier',
VSECRETAIRE: 'Vise-Secrétaire',
MEMBREBUREAU: 'Membre bureau'
}} disabled={true}/>
<RoleList name="role" text="Rôle" value={data.role} disabled={true}/>
<OptionField name="grade_arbitrage" text="Grade d'arbitrage" value={data.grade_arbitrage}
values={{NA: 'N/A', ASSESSEUR: 'Assesseur', ARBITRE: 'Arbitre'}} disabled={true}/>
<div className="row">