feat: club page

wip: edit af request
This commit is contained in:
Thibaut Valentin 2024-07-17 23:11:59 +02:00
parent 43f7a54b15
commit b93a08da71
16 changed files with 572 additions and 257 deletions

View File

@ -53,11 +53,10 @@ public class AffiliationService {
return repositoryRequest.listAll();
}
public Uni<String> save(AffiliationRequestForm form) {
public Uni<AffiliationRequestModel> pre_save(AffiliationRequestForm form, boolean unique) {
AffiliationRequestModel affModel = form.toModel();
int currentSaison = Utils.getSaison();
// noinspection ResultOfMethodCallIgnored
return Uni.createFrom().item(affModel)
.invoke(Unchecked.consumer(model -> {
if (model.getSaison() != currentSaison && model.getSaison() != currentSaison + 1) {
@ -67,7 +66,7 @@ public class AffiliationService {
.chain(() -> repositoryRequest.count("siret = ?1 and saison = ?2", affModel.getSiret(),
affModel.getSaison()))
.onItem().invoke(Unchecked.consumer(count -> {
if (count != 0) {
if (count != 0 && unique) {
throw new IllegalArgumentException("Affiliation request already exists");
}
}))
@ -80,26 +79,60 @@ public class AffiliationService {
}))
.map(o -> affModel)
.call(model -> ((model.getM1_lincence() != -1) ? combRepository.find("licence",
model.getM1_lincence()).count().invoke(count -> {
model.getM1_lincence()).count().invoke(Unchecked.consumer(count -> {
if (count == 0) {
throw new IllegalArgumentException("Licence membre n°1 inconnue");
}
}) : Uni.createFrom().nullItem())
})) : Uni.createFrom().nullItem())
)
.call(model -> ((model.getM2_lincence() != -1) ? combRepository.find("licence",
model.getM2_lincence()).count().invoke(count -> {
model.getM2_lincence()).count().invoke(Unchecked.consumer(count -> {
if (count == 0) {
throw new IllegalArgumentException("Licence membre n°2 inconnue");
}
}) : Uni.createFrom().nullItem())
})) : Uni.createFrom().nullItem())
)
.call(model -> ((model.getM3_lincence() != -1) ? combRepository.find("licence",
model.getM3_lincence()).count().invoke(count -> {
model.getM3_lincence()).count().invoke(Unchecked.consumer(count -> {
if (count == 0) {
throw new IllegalArgumentException("Licence membre n°3 inconnue");
}
}) : Uni.createFrom().nullItem())
).chain(model -> Panache.withTransaction(() -> repositoryRequest.persist(model)))
})) : Uni.createFrom().nullItem())
);
}
public Uni<?> saveEdit(AffiliationRequestForm form) {
return pre_save(form, false)
.chain(model -> repositoryRequest.findById(form.getId())
.onItem().ifNull().failWith(new NotFoundException("Affiliation request not found"))
.chain(origine -> {
origine.setName(model.getName());
origine.setRNA(model.getRNA());
origine.setAddress(model.getAddress());
origine.setM1_lname(model.getM1_lname());
origine.setM1_fname(model.getM1_fname());
origine.setM1_lincence(model.getM1_lincence());
origine.setM1_role(model.getM1_role());
origine.setM1_email(model.getM1_email());
origine.setM2_lname(model.getM2_lname());
origine.setM2_fname(model.getM2_fname());
origine.setM2_lincence(model.getM2_lincence());
origine.setM2_role(model.getM2_role());
origine.setM2_email(model.getM2_email());
origine.setM3_lname(model.getM3_lname());
origine.setM3_fname(model.getM3_fname());
origine.setM3_lincence(model.getM3_lincence());
origine.setM3_role(model.getM3_role());
origine.setM3_email(model.getM3_email());
return Panache.withTransaction(() -> repositoryRequest.persist(origine));
}));
}
public Uni<String> save(AffiliationRequestForm form) {
// noinspection ResultOfMethodCallIgnored
return pre_save(form, true)
.chain(model -> Panache.withTransaction(() -> repositoryRequest.persist(model)))
.onItem()
.invoke(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getLogo(), media,
"aff_request/logo")))

View File

@ -2,18 +2,19 @@ package fr.titionfire.ffsaf.domain.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import fr.titionfire.ffsaf.data.model.AffiliationModel;
import fr.titionfire.ffsaf.data.model.ClubModel;
import fr.titionfire.ffsaf.data.model.MembreModel;
import fr.titionfire.ffsaf.data.repository.ClubRepository;
import fr.titionfire.ffsaf.data.repository.CombRepository;
import fr.titionfire.ffsaf.net2.ServerCustom;
import fr.titionfire.ffsaf.net2.data.SimpleClubModel;
import fr.titionfire.ffsaf.net2.request.SReqClub;
import fr.titionfire.ffsaf.rest.data.RenewAffData;
import fr.titionfire.ffsaf.rest.data.SimpleClubList;
import fr.titionfire.ffsaf.rest.from.FullClubForm;
import fr.titionfire.ffsaf.utils.Contact;
import fr.titionfire.ffsaf.utils.PageResult;
import fr.titionfire.ffsaf.utils.RoleAsso;
import fr.titionfire.ffsaf.utils.Utils;
import fr.titionfire.ffsaf.rest.from.PartClubForm;
import fr.titionfire.ffsaf.utils.*;
import io.quarkus.hibernate.reactive.panache.Panache;
import io.quarkus.hibernate.reactive.panache.PanacheQuery;
import io.quarkus.hibernate.reactive.panache.common.WithSession;
@ -25,10 +26,14 @@ import io.smallrye.mutiny.unchecked.Unchecked;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.hibernate.reactive.mutiny.Mutiny;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
@ -114,6 +119,44 @@ public class ClubService {
return repository.find("clubId", clubId).firstResult();
}
public Uni<ClubModel> getOfUser(JsonWebToken idToken) {
return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> {
if (m == null || m.getClub() == null)
throw new NotFoundException("Club not found");
}))
.map(MembreModel::getClub)
.call(club -> Mutiny.fetch(club.getContact()));
}
public Uni<String> updateOfUser(JsonWebToken idToken, PartClubForm form) {
TypeReference<HashMap<Contact, String>> typeRef = new TypeReference<>() {
};
return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> {
if (m == null || m.getClub() == null)
throw new NotFoundException("Club not found");
if (!GroupeUtils.isInClubGroup(m.getClub().getId(), idToken))
throw new ForbiddenException();
}))
.map(MembreModel::getClub)
.call(club -> Mutiny.fetch(club.getContact()))
.chain(Unchecked.function(club -> {
club.setContact_intern(form.getContact_intern());
club.setAddress(form.getAddress());
try {
club.setContact(MAPPER.readValue(form.getContact(), typeRef));
} catch (JsonProcessingException e) {
throw new BadRequestException();
}
club.setTraining_location(form.getTraining_location());
club.setTraining_day_time(form.getTraining_day_time());
return Panache.withTransaction(() -> repository.persist(club));
}))
.map(__ -> "OK");
}
public Uni<String> update(long id, FullClubForm input) {
return repository.findById(id).call(m -> Mutiny.fetch(m.getContact()))
.onItem().transformToUni(Unchecked.function(m -> {
@ -201,4 +244,29 @@ public class ClubService {
.call(__ -> Utils.deleteMedia(id, media, "ppClub"))
.call(__ -> Utils.deleteMedia(id, media, "clubStatus"));
}
public Uni<RenewAffData> getRenewData(long id) {
RenewAffData data = new RenewAffData();
return repository.findById(id)
.call(clubModel -> Mutiny.fetch(clubModel.getAffiliations()))
.invoke(clubModel -> {
data.setName(clubModel.getName());
data.setSiret(clubModel.getSIRET());
data.setRna(clubModel.getRNA());
data.setAddress(clubModel.getAddress());
data.setSaison(
clubModel.getAffiliations().stream().max(Comparator.comparing(AffiliationModel::getSaison))
.map(AffiliationModel::getSaison).map(i -> Math.min(i + 1, Utils.getSaison() + 1))
.orElse(Utils.getSaison()));
})
.chain(club -> combRepository.list("club = ?1", club))
.invoke(combs -> data.setMembers(combs.stream()
.filter(o -> o.getRole() != null && o.getRole().level >= RoleAsso.MEMBREBUREAU.level)
.sorted((o1, o2) -> o2.getRole().level - o1.getRole().level)
.limit(3)
.map(RenewAffData.RenewMember::new)
.toList()))
.map(o -> data);
}
}

View File

@ -63,10 +63,13 @@ public class AffiliationEndpoints {
@GET
@Path("/request/{id}")
@RolesAllowed({"federation_admin"})
@RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<SimpleReqAffiliation> getAffRequest(@PathParam("id") long id) {
return service.getRequest(id);
return service.getRequest(id).invoke(Unchecked.consumer(o -> {
if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin"))
throw new ForbiddenException();
})).invoke(o -> checkPerm.accept(o.getClub()));
}
@DELETE
@ -74,7 +77,11 @@ public class AffiliationEndpoints {
@RolesAllowed({"federation_admin"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<?> getDelAffRequest(@PathParam("id") long id) {
return service.deleteReqAffiliation(id);
return service.getRequest(id).invoke(Unchecked.consumer(o -> {
if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin"))
throw new ForbiddenException();
})).invoke(o -> checkPerm.accept(o.getClub()))
.chain(o -> service.deleteReqAffiliation(id));
}
@PUT
@ -86,6 +93,15 @@ public class AffiliationEndpoints {
return service.saveAdmin(form);
}
@PUT
@Path("/request/edit")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<?> saveEditAffRequest(AffiliationRequestForm form) {
return service.saveEdit(form);
}
@PUT
@Path("/request/apply")
@RolesAllowed({"federation_admin"})

View File

@ -3,9 +3,11 @@ package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.data.model.ClubModel;
import fr.titionfire.ffsaf.domain.service.ClubService;
import fr.titionfire.ffsaf.net2.data.SimpleClubModel;
import fr.titionfire.ffsaf.rest.data.RenewAffData;
import fr.titionfire.ffsaf.rest.data.SimpleClub;
import fr.titionfire.ffsaf.rest.data.SimpleClubList;
import fr.titionfire.ffsaf.rest.from.FullClubForm;
import fr.titionfire.ffsaf.rest.from.PartClubForm;
import fr.titionfire.ffsaf.utils.Contact;
import fr.titionfire.ffsaf.utils.GroupeUtils;
import fr.titionfire.ffsaf.utils.PageResult;
@ -44,11 +46,15 @@ public class ClubEndpoints {
@ConfigProperty(name = "upload_dir")
String media;
Consumer<ClubModel> checkPerm = Unchecked.consumer(membreModel -> {
if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getId(),
Consumer<ClubModel> checkPerm = Unchecked.consumer(clubModel -> {
if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(),
idToken))
throw new ForbiddenException();
});
Consumer<Long> checkPerm2 = Unchecked.consumer(id -> {
if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken))
throw new ForbiddenException();
});
@GET
@Path("/no_detail")
@ -125,7 +131,6 @@ public class ClubEndpoints {
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<Long> addAdminClub(FullClubForm input) {
System.out.println(input);
return clubService.add(input)
.invoke(Unchecked.consumer(id -> {
if (id == null) throw new InternalError("Fail to create club data");
@ -152,6 +157,31 @@ public class ClubEndpoints {
return clubService.delete(id);
}
@GET
@Path("/me")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<SimpleClub> getOfUser() {
return clubService.getOfUser(idToken).map(SimpleClub::fromModel)
.invoke(m -> m.setContactMap(Contact.toSite()));
}
@PUT
@Path("/me")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<String> setClubOfUser(PartClubForm form) {
return clubService.updateOfUser(idToken, form);
}
@GET
@Path("/renew/{id}")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<RenewAffData> getOfUser(@PathParam("id") long id) {
return Uni.createFrom().item(id).invoke(checkPerm2).chain(__ -> clubService.getRenewData(id));
}
@GET
@Path("{clubId}/logo")

View File

@ -198,32 +198,6 @@ public class CombEndpoints {
return membreService.delete(id, idToken);
}
private Future<String> replacePhoto(long id, byte[] input) {
return CompletableFuture.supplyAsync(() -> {
try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) {
String mimeType = URLConnection.guessContentTypeFromStream(is);
String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false);
if (detectedExtensions.length == 0)
throw new IOException("Fail to detect file extension for MIME type " + mimeType);
FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id));
File[] files = new File(media, "ppMembre").listFiles(filter);
if (files != null) {
for (File file : files) {
//noinspection ResultOfMethodCallIgnored
file.delete();
}
}
String extension = "." + detectedExtensions[0];
Files.write(new File(media, "ppMembre/" + id + extension).toPath(), input);
return "OK";
} catch (IOException e) {
return e.getMessage();
}
});
}
@GET
@Path("{id}/photo")
@RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})

View File

@ -0,0 +1,45 @@
package fr.titionfire.ffsaf.rest.data;
import fr.titionfire.ffsaf.data.model.MembreModel;
import fr.titionfire.ffsaf.utils.RoleAsso;
import io.quarkus.runtime.annotations.RegisterForReflection;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@RegisterForReflection
public class RenewAffData {
String name;
Long siret;
String rna;
String address;
int saison;
List<RenewMember> members;
@Data
@AllArgsConstructor
@RegisterForReflection
public static class RenewMember {
String lname;
String fname;
String email;
int licence;
RoleAsso role;
public RenewMember(MembreModel o) {
this.lname = o.getLname();
this.fname = o.getFname();
this.email = o.getEmail();
this.licence = o.getLicence();
this.role = o.getRole();
}
}
}

View File

@ -1,4 +0,0 @@
package fr.titionfire.ffsaf.rest.from;
public class AffiliationForm {
}

View File

@ -11,6 +11,9 @@ import org.jboss.resteasy.reactive.PartType;
@Getter
@ToString
public class AffiliationRequestForm {
@FormParam("id")
private Long id = null;
@FormParam("name")
private String name = null;
@FormParam("siret")

View File

@ -0,0 +1,27 @@
package fr.titionfire.ffsaf.rest.from;
import jakarta.ws.rs.FormParam;
import lombok.Getter;
import lombok.ToString;
@ToString
@Getter
public class PartClubForm {
@FormParam("id")
private String id = null;
@FormParam("contact")
private String contact = null;
@FormParam("training_location")
private String training_location = null;
@FormParam("training_day_time")
private String training_day_time = null;
@FormParam("contact_intern")
private String contact_intern = null;
@FormParam("address")
private String address = null;
}

View File

@ -54,8 +54,8 @@ function ClubMenu() {
Club
</div>
<ul className="dropdown-menu">
<li className="nav-item"><NavLink className="nav-link" to="/club/me">Mon club</NavLink></li>
<li className="nav-item"><NavLink className="nav-link" to="/club/member">Member</NavLink></li>
<li className="nav-item"><NavLink className="nav-link" to="/club/b">B</NavLink></li>
</ul>
</li>
}

View File

@ -1,8 +1,9 @@
import {useState} from "react";
import {useEffect, useState} from "react";
import {apiAxios, getSaison} from "../utils/Tools.js";
import {toast} from "react-toastify";
import {useNavigate} from "react-router-dom";
import {useLocation, useNavigate} from "react-router-dom";
import {RoleList} from "../components/MemberCustomFiels.jsx";
import {useAuth} from "../hooks/useAuth.jsx";
const notUpperCase = ["de", "la", "le", "les", "des", "du", "d'", "l'", "sur"];
@ -40,13 +41,38 @@ function reconstruireAdresse(infos) {
export function DemandeAff() {
const {hash} = useLocation();
const navigate = useNavigate();
const [initData, setInitData] = useState(null)
const [needFile, setNeedFile] = useState(true)
useEffect(() => {
if (hash.startsWith("#d")) {
const data = JSON.parse(decodeURI(hash.substring(2)));
setInitData(data)
setNeedFile(false)
} else if (hash.startsWith("#e")) {
apiAxios.get(`/affiliation/request/${hash.substring(2)}`).then(data => {
for (let i = 0; i < data.data.members.length; i++) {
if (data.data.members[i].licence === -1)
data.data.members[i].licence = ""
}
setInitData(data.data)
setNeedFile(false)
}).catch(_ => {
setInitData({})
})
} else {
setInitData({})
}
}, []);
const submit = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
formData.append("m1_role", event.target.m1_role?.value)
formData.append("rna", event.target.rna?.value)
formData.append("siret", event.target.siret?.value)
let error = false;
for (let i = 1; i <= 3; i++) {
@ -59,16 +85,44 @@ export function DemandeAff() {
return;
}
toast.promise(
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 🎉",
error: "Échec de la demande d'affiliation 😕"
}
).then(_ => {
navigate("/affiliation/ok")
})
if (event.nativeEvent.submitter.value === "undo") {
toast.promise(
apiAxios.delete(`/affiliation/request/${initData.id}`),
{
pending: "Annulation de la demande d'affiliation en cours",
success: "Demande d'affiliation annulée avec succès 🎉",
error: "Échec de l'annulation de la demande d'affiliation 😕"
}
).then(_ => {
navigate("/club/me")
})
}else if (event.nativeEvent.submitter.value === "edit") {
formData.append("id", initData.id)
toast.promise(
apiAxios.put(`/affiliation/request/edit`, formData),
{
pending: "Enregistrement des modifications en cours",
success: "Modifications enregistrées avec succès 🎉",
error: "Échec de l'enregistrement des modifications 😕"
}
).then(_ => {
navigate("/club/me")
})
}else {
formData.append("id", -1)
toast.promise(
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 🎉",
error: "Échec de la demande d'affiliation 😕"
}
).then(_ => {
navigate("/affiliation/ok")
})
}
}
return <div>
@ -91,46 +145,59 @@ export function DemandeAff() {
</li>
</ul>
<div className="card mb-4">
{initData && <div className="card mb-4">
<form onSubmit={submit}>
<div className="card-body">
<h4>L'association</h4>
<AssoInfo/>
<AssoInfo initData={initData} needFile={needFile}/>
<h4>Membre n°1</h4>
<MembreInfo role="m1"/>
<MembreInfo role="m1" initData={initData.members?.at(0) || {}}/>
<h4 style={{marginTop: '1em'}}>Membre n°2</h4>
<MembreInfo role="m2"/>
<MembreInfo role="m2" initData={initData.members?.at(1) || {}}/>
<h4 style={{marginTop: '1em'}}>Membre n°3</h4>
<MembreInfo role="m3"/>
<MembreInfo role="m3" initData={initData.members?.at(2) || {}}/>
<div className="mb-3" style={{marginTop: '1em'}}>
<p>Après validation de votre demande, vous recevrez un identifiant et mot de passe provisoire pour
accéder à votre espace FFSAF</p>
{!initData.id && <p>Après validation de votre demande, vous recevrez un identifiant et mot de passe provisoire pour
accéder à votre espace FFSAF</p>}
Notez que pour finaliser votre affiliation, il vous faudra :
<ul>
<li>Disposer dau moins trois membres licenciés, dont le président</li>
<li>S'être acquitté des cotisations prévues par les règlements fédéraux</li>
</ul>
</div>
<div className="row">
<div className="d-grid gap-2 d-md-flex justify-content-md-center">
<button type="submit" className="btn btn-primary">Confirmer ma demande d'affiliation
</button>
{!initData.id ?
<div className="row">
<div className="d-grid gap-2 d-md-flex justify-content-md-center">
<button type="submit" value="new" className="btn btn-primary">Confirmer ma demande d'affiliation
</button>
</div>
</div> :
<div className="row">
<div className="d-grid gap-2 d-md-flex justify-content-md-center col">
<button type="submit" value="undo" className="btn btn-danger">Annuler ma demande
</button>
</div>
<div className="d-grid gap-2 d-md-flex justify-content-md-center col">
<button type="submit" value="edit" className="btn btn-primary">Enregistrer les modifications
</button>
</div>
</div>
</div>
}
</div>
</form>
</div>
</div>}
</div>
}
function AssoInfo() {
function AssoInfo({initData, needFile}) {
const [denomination, setDenomination] = useState("")
const [siret, setSiret] = useState("")
const [rna, setRna] = useState("")
const [siret, setSiret] = useState(initData.siret ? String(initData.siret) : "")
const [rna, setRna] = useState(initData.rna ? initData.rna : "")
const [rnaEnable, setRnaEnable] = useState(false)
const [adresse, setAdresse] = useState("")
const [saison, setSaison] = useState(getSaison())
const [adresse, setAdresse] = useState(initData.address ? initData.address : "")
const [saison, setSaison] = useState(initData.saison ? initData.saison : getSaison())
const fetchSiret = () => {
if (siret.length < 14) {
@ -150,7 +217,8 @@ function AssoInfo() {
setDenomination(data2.denomination)
setRnaEnable(data2.identifiant_association === null)
setRna(data2.identifiant_association ? data2.identifiant_association : "")
setAdresse(reconstruireAdresse(data2.etablissement_siege))
if (!initData.saison || adresse === "")
setAdresse(reconstruireAdresse(data2.etablissement_siege))
})
}
@ -178,13 +246,13 @@ function AssoInfo() {
<span className="input-group-text" id="basic-addon1">Nom de l'association*</span>
<input type="text" className="form-control" placeholder="Nom de l'association" name="name"
aria-label="Nom de l'association"
aria-describedby="basic-addon1" required defaultValue="Mesnie"/>
aria-describedby="basic-addon1" required defaultValue={initData.name ? initData.name : ""}/>
</div>
<div className="input-group mb-3">
<span className="input-group-text">N° SIRET*</span>
<input type="number" className="form-control" placeholder="siret" name="siret" required value={siret}
onChange={e => setSiret(e.target.value)} defaultValue={500213731}/>
<input type="number" className="form-control" placeholder="siret" name="siret" required value={siret} disabled={!needFile}
onChange={e => setSiret(e.target.value)}/>
<button className="btn btn-outline-secondary" type="button" id="button-addon2"
onClick={fetchSiret}>Rechercher
</button>
@ -218,25 +286,25 @@ function AssoInfo() {
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="status">Status*</label>
<input type="file" className="form-control" id="status" name="status" accept=".pdf,.txt" required/>
<input type="file" className="form-control" id="status" name="status" accept=".pdf,.txt" required={needFile}/>
</div>
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="logo">Logo*</label>
<input type="file" className="form-control" id="logo" name="logo" accept=".jpg,.jpeg,.gif,.png,.svg"
required/>
required={needFile}/>
</div>
</>;
}
function MembreInfo({role}) {
const [switchOn, setSwitchOn] = useState(false);
function MembreInfo({role, initData}) {
const [switchOn, setSwitchOn] = useState(!!initData.licence);
return <>
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="inputGroupSelect01">Rôles</label>
<select className="form-select" id="inputGroupSelect01" defaultValue={role === "m1" ? "PRESIDENT" : 0}
disabled={role === "m1"} name={role + "_role"} required>
<select className="form-select" id="inputGroupSelect01" defaultValue={initData.role ? initData.role : (role === "m1" ? "PRESIDENT" : 0)}
disabled={initData.role ? initData.role === "PRESIDENT" : role === "m1"} name={role + "_role"} required>
<option value="0">Sélectionner...</option>
<option value="PRESIDENT">Président</option>
<option value="TRESORIER">Trésorier</option>
@ -251,22 +319,22 @@ function MembreInfo({role}) {
<div className="row g-3 mb-3">
<div className="col-sm-3">
<div className="form-floating">
<input type="text" className="form-control" id="floatingInput" placeholder="Nom" name={role + "_nom"} defaultValue={role + "-nom"}
required/>
<input type="text" className="form-control" id="floatingInput" placeholder="Nom" name={role + "_nom"}
defaultValue={initData.lname ? initData.lname : ""} required/>
<label htmlFor="floatingInput">Nom*</label>
</div>
</div>
<div className="col-sm-3">
<div className="form-floating">
<input type="text" className="form-control" id="floatingInput" placeholder="Prénom"
name={role + "_prenom"} defaultValue={role + "_prenom"} required/>
name={role + "_prenom"} defaultValue={initData.fname ? initData.fname : ""} required/>
<label htmlFor="floatingInput">Prénom*</label>
</div>
</div>
<div className="col-sm-5">
<div className="form-floating">
<input type="email" className="form-control" id="floatingInput" placeholder="name@example.com"
name={role + "_mail"} defaultValue={role + "-mail@test.com"} required/>
name={role + "_mail"} defaultValue={initData.email ? initData.email : ""} required/>
<label htmlFor="floatingInput">Email*</label>
</div>
</div>
@ -283,7 +351,7 @@ function MembreInfo({role}) {
<div className="col-sm-3">
<div className="form-floating">
<input type="number" className="form-control" id="floatingInput" placeholder="N° Licence"
name={role + "_licence"} required/>
name={role + "_licence"} defaultValue={initData.licence ? Number(initData.licence) : ""} required/>
<label htmlFor="floatingInput">N° Licence</label>
</div>
</div>

View File

@ -129,7 +129,7 @@ function InformationForm({data}) {
<div className="mb-3">
<div className="input-group">
<label className="input-group-text" htmlFor="status">Status</label>
<label className="input-group-text" htmlFor="status">Statue</label>
<a href={`${vite_url}/api/club/${data.id}/status`} target='_blank'>
<button className="btn btn-outline-secondary" type="button" id="button-addon1"
onClick={e => null}><FontAwesomeIcon icon={faFilePdf}></FontAwesomeIcon>

View File

@ -4,6 +4,7 @@ import {MemberPage} from "./member/MemberPage.jsx";
import {useAuth} from "../../hooks/useAuth.jsx";
import {MemberList} from "../MemberList.jsx";
import {NewMemberPage} from "./member/NewMemberPage.jsx";
import {MyClubPage} from "./club/MyClubPage.jsx";
export function ClubRoot() {
const {userinfo} = useAuth()
@ -41,8 +42,8 @@ export function getClubChildren() {
element: <NewMemberPage/>
},
{
path: 'b',
element: <div>Club B</div>
path: 'me',
element: <MyClubPage/>
}
]
}

View File

@ -0,0 +1,95 @@
import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {useFetch} from "../../../hooks/useFetch.js";
import {useEffect, useReducer, useState} from "react";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faEye, faPen} from "@fortawesome/free-solid-svg-icons";
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 navigate = useNavigate();
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/affiliation/${clubData.id}`, setLoading, 1)
const [modalAffiliation, setModal] = useState({id: 0, club: clubData.id})
const sendAffiliationRequest = () => {
let createData = {}
apiAxios.get(`/club/renew/${clubData.id}`).then(data => {
navigate('/affiliation#d' + encodeURI(JSON.stringify(data.data)))
})
}
return <div className="card mb-4 mb-md-0">
<div className="card-header container-fluid">
<div className="row">
<div className="col">Affiliation</div>
<div className="col" style={{textAlign: 'right'}}>
<button className="btn btn-primary btn-sm" onClick={e => sendAffiliationRequest()}>Renouveler
</button>
</div>
</div>
</div>
<div className="card-body">
<ul className="list-group">
{data && data.sort((a, b) => b.saison - a.saison).map((affiliation, index) => {
return <div key={index}
className={"list-group-item d-flex justify-content-between align-items-start list-group-item-" +
(affiliation.validate ? "success" : "warning")}>
<div className="me-auto">{affiliation?.saison}-{affiliation?.saison + 1}</div>
<button className="badge btn btn-primary rounded-pill" data-bs-toggle="modal"
data-bs-target="#AffiliationModal" onClick={_ => setModal(affiliation)}>
<FontAwesomeIcon icon={faEye}/></button>
</div>
})}
{error && <AxiosError error={error}/>}
</ul>
</div>
<div className="modal fade" id="AffiliationModal" tabIndex="-1" aria-labelledby="AffiliationModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<ModalContent affiliation={modalAffiliation}/>
</div>
</div>
</div>
</div>;
}
function ModalContent({affiliation}) {
const navigate = useNavigate();
return <>
{affiliation && <div>
<div className="modal-header">
<h1 className="modal-title fs-5" id="AffiliationModalLabel">Etat de l'affiliation</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="input-group mb-3 justify-content-md-center">
<span className="input-group-text" id="basic-addon2">{affiliation.saison || 0}</span>
<span className="input-group-text" id="basic-addon2">-</span>
<span className="input-group-text" id="basic-addon2">{(affiliation.saison || 0) + 1}</span>
</div>
<div className="input-group mb-3 justify-content-md-center">
<span className="input-group-text" id="basic-addon2">État de la demande</span>
{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"
onClick={() => navigate('/affiliation#e' + (affiliation.id * -1))}
data-bs-dismiss="modal"><FontAwesomeIcon icon={faEye}/></button>
</>}
</div>
</div>
<div className="modal-footer">
<button type="reset" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
</div>
</div>
}
</>
}

View File

@ -1,158 +0,0 @@
import {useNavigate, useParams} from "react-router-dom";
import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {useFetch} from "../../../hooks/useFetch.js";
import {toast} from "react-toastify";
import {apiAxios} from "../../../utils/Tools.js";
import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx";
import {AxiosError} from "../../../components/AxiosError.jsx";
import {AffiliationCard} from "./AffiliationCard.jsx";
import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx";
import {useRef, useState} from "react";
import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx";
import {ContactEditor} from "../../../components/Club/ContactEditor.jsx";
import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faFilePdf} from "@fortawesome/free-solid-svg-icons";
const vite_url = import.meta.env.VITE_URL;
export function ClubPage() {
const {id} = useParams()
const navigate = useNavigate();
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/club/${id}`, setLoading, 1)
const handleRm = () => {
toast.promise(
apiAxios.delete(`/club/${id}`),
{
pending: "Suppression du club en cours...",
success: "Club supprimé avec succès 🎉",
error: "Échec de la suppression du club 😕"
}
).then(_ => {
navigate("/admin/club")
})
}
return <>
<h2>Page club</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/club")}>
&laquo; retour
</button>
{data
? <div>
<div className="row">
<div className="col-lg-9">
<LoadingProvider>
<InformationForm data={data}/>
</LoadingProvider>
</div>
<div className="col-lg-3">
<LoadingProvider><AffiliationCard clubData={data}/></LoadingProvider>
<div className="col" style={{textAlign: 'right', marginTop: '1em'}}>
<button className="btn btn-danger btn-sm" data-bs-toggle="modal"
data-bs-target="#confirm-delete">Supprimer le club
</button>
</div>
<ConfirmDialog title="Supprimer le club"
message="Êtes-vous sûr de vouloir supprimer ce club ?"
onConfirm={handleRm}/>
</div>
</div>
</div>
: error && <AxiosError error={error}/>
}
</>
}
function InformationForm({data}) {
const [switchOn, setSwitchOn] = useState(data.international);
const [modal, setModal] = useState({id: -1})
const locationModalCallback = useRef(null)
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">Affiliation n°{data.no_affiliation}</div>
<div className="card-body text-center">
<TextField name="clubId" text="ClubID" value={data.clubId} disabled={true}/>
<TextField name="name" text="Nom" value={data.name}/>
<CountryList name="country" text="Pays" value={data.country}/>
<img
src={`${vite_url}/api/club/${data.clubId}/logo`}
alt="avatar"
className="img-fluid" style={{object_fit: 'contain', maxHeight: '15em'}}/>
<div className="mb-3">
<div className="input-group">
<label className="input-group-text" htmlFor="logo">Blason</label>
<input type="file" className="form-control" id="logo" name="logo"
accept=".jpg,.jpeg,.gif,.png,.svg"/>
</div>
<div className="form-text" id="logo">Laissez vide pour ne rien changer.</div>
</div>
<div className="input-group mb-3">
<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>
</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}
placeholder="example@test.com"/>
<TextField name="address" text="Adresse administrative" value={data.address} placeholder="Adresse administrative"/>
<div className="mb-3">
<div className="input-group">
<label className="input-group-text" htmlFor="status">Status</label>
<a href={`${vite_url}/api/club/${data.id}/status`} target='_blank'>
<button className="btn btn-outline-secondary" type="button" id="button-addon1"
onClick={e => null}><FontAwesomeIcon icon={faFilePdf}></FontAwesomeIcon>
</button>
</a>
<input type="file" className="form-control" id="status" name="status" accept=".pdf,.txt"/>
</div>
<div className="form-text" id="status">Laissez vide pour ne rien changer.</div>
</div>
<ContactEditor data={data}/>
<LocationEditor data={data} setModal={setModal} sendData={locationModalCallback}/>
<HoraireEditor data={data}/>
</>
}
</div>
<div className="row mb-3">
<div className="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" className="btn btn-primary">Enregistrer</button>
</div>
</div>
</div>
</form>
<LocationEditorModal modal={modal} sendData={locationModalCallback}/>
</>
}

View File

@ -0,0 +1,117 @@
import {useNavigate, useParams} from "react-router-dom";
import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {useFetch} from "../../../hooks/useFetch.js";
import {toast} from "react-toastify";
import {apiAxios} from "../../../utils/Tools.js";
import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx";
import {AxiosError} from "../../../components/AxiosError.jsx";
import {AffiliationCard} from "./AffiliationCard.jsx";
import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx";
import {useRef, useState} from "react";
import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx";
import {ContactEditor} from "../../../components/Club/ContactEditor.jsx";
import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faFilePdf} from "@fortawesome/free-solid-svg-icons";
const vite_url = import.meta.env.VITE_URL;
export function MyClubPage() {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/club/me`, setLoading, 1)
return <>
<h2>Mon club</h2>
{data
? <div>
<div className="row">
<div className="col-lg-9">
<LoadingProvider>
<InformationForm data={data}/>
</LoadingProvider>
</div>
<div className="col-lg-3">
<LoadingProvider><AffiliationCard clubData={data}/></LoadingProvider>
</div>
</div>
</div>
: error && <AxiosError error={error}/>
}
</>
}
function InformationForm({data}) {
const [modal, setModal] = useState({id: -1})
const locationModalCallback = useRef(null)
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
toast.promise(
apiAxios.put(`/club/me`, formData),
{
pending: "Enregistrement des modifications en cours",
success: "Modifications enregistrées avec succès 🎉",
error: "Échec de l'enregistrement des modifications 😕"
}
)
}
return <>
<form onSubmit={handleSubmit}>
<div className="card mb-4">
<input name="id" value={data.id} readOnly hidden/>
<div className="card-header">Affiliation n°{data.no_affiliation}</div>
<div className="card-body text-center">
<TextField name="name" text="Nom" value={data.name} disabled={true}/>
<CountryList name="country" text="Pays" value={data.country} disabled={true}/>
{!data.international && <>
<TextField name="siret" text="SIRET" value={data.siret} type="number" disabled={true}/>
<TextField name="rna" text="RNA" value={data.rna} required={false} disabled={true}/>
</>}
<div className="row mb-3">
<div className="col-md-6">
<img
src={`${vite_url}/api/club/${data.clubId}/logo`}
alt="avatar"
className="img-fluid" style={{object_fit: 'contain', maxHeight: '15em'}}/>
</div>
<div className="col-md-6">
<a href={`${vite_url}/api/club/${data.id}/status`} target='_blank'>
<button className="btn btn-outline-secondary" type="button" id="button-addon1"
onClick={e => null}>
<FontAwesomeIcon icon={faFilePdf} size="5x"></FontAwesomeIcon><br/>
Voir les statues
</button>
</a>
</div>
<div className="form-text" id="status">Pour modifier les informations ci-dessus, merci de contacter la FFSAF par mail.</div>
</div>
{!data.international && <>
<TextField name="contact_intern" text="Contact interne" value={data.contact_intern} required={false}
placeholder="example@test.com"/>
<TextField name="address" text="Adresse administrative" value={data.address} placeholder="Adresse administrative"/>
<ContactEditor data={data}/>
<LocationEditor data={data} setModal={setModal} sendData={locationModalCallback}/>
<HoraireEditor data={data}/>
</>
}
</div>
<div className="row mb-3">
<div className="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" className="btn btn-primary">Enregistrer</button>
</div>
</div>
</div>
</form>
<LocationEditorModal modal={modal} sendData={locationModalCallback}/>
</>
}