feat(equipements): clean equipment search, fix category regression, and sync dotations on category change

This commit is contained in:
2026-06-15 23:11:05 +02:00
parent e1cd5930fd
commit c4bb9803ed
9 changed files with 368 additions and 11 deletions
@@ -1,6 +1,7 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Dotation;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@@ -8,6 +9,6 @@ import java.util.List;
import com.astalange.core.entity.Saison;
@Repository
public interface DotationRepository extends JpaRepository<Dotation, Long> {
public interface DotationRepository extends JpaRepository<Dotation, Long>, JpaSpecificationExecutor<Dotation> {
List<Dotation> findByLicence_SaisonAndChoisiTrueAndFourniFalse(Saison saison);
}
@@ -76,7 +76,13 @@ public class CategorieService {
}
}
private void updateDotationsForLicence(Licence licence, List<CategorieEquipement> categorieEquipements) {
public void syncDotationsForLicence(Licence licence) {
if (licence.getCategorie() != null) {
updateDotationsForLicence(licence, licence.getCategorie().getCategorieEquipements());
}
}
public void updateDotationsForLicence(Licence licence, List<CategorieEquipement> categorieEquipements) {
List<Dotation> currentDotations = licence.getDotations();
List<Dotation> toRemove = new ArrayList<>();
@@ -24,12 +24,14 @@ public class DotationService {
sb.append('\ufeff');
// Header
sb.append("Catégorie;Nom;Prénom;Équipement;Taille;Flocage;Numéro\n");
sb.append("Catégorie;Nom;Prénom;Poste;Sexe;Équipement;Taille;Flocage;Numéro\n");
for (Dotation d : dotations) {
String categorie = d.getLicence().getCategorie() != null ? d.getLicence().getCategorie().getNom() : "";
String nom = d.getLicence().getAdherent().getNom();
String prenom = d.getLicence().getAdherent().getPrenom();
String poste = d.getLicence().getAdherent().getTypeMaillot() != null ? d.getLicence().getAdherent().getTypeMaillot() : "";
String sexe = d.getLicence().getAdherent().getSexe() != null ? d.getLicence().getAdherent().getSexe() : "";
String equipement = d.getEquipement() != null ? d.getEquipement().getNom() : "";
String taille = d.getTaille() != null ? d.getTaille() : "";
String flocage = d.getFlocage() != null ? d.getFlocage() : "";
@@ -38,6 +40,8 @@ public class DotationService {
sb.append(escapeCsv(categorie)).append(";")
.append(escapeCsv(nom)).append(";")
.append(escapeCsv(prenom)).append(";")
.append(escapeCsv(poste)).append(";")
.append(escapeCsv(sexe)).append(";")
.append(escapeCsv(equipement)).append(";")
.append(escapeCsv(taille)).append(";")
.append(escapeCsv(flocage)).append(";")
@@ -47,6 +51,43 @@ public class DotationService {
return sb.toString();
}
public String genererCsvSearchEquipement(List<Dotation> dotations) {
StringBuilder sb = new StringBuilder();
// BOM for Excel to open UTF-8 correctly
sb.append('\ufeff');
// Header
sb.append("Saison;Catégorie;Nom;Prénom;Poste;Sexe;Équipement;Taille;Flocage;Numéro;Fourni\n");
for (Dotation d : dotations) {
String saison = d.getLicence().getSaison() != null ? d.getLicence().getSaison().getNom() : "";
String categorie = d.getLicence().getCategorie() != null ? d.getLicence().getCategorie().getNom() : "";
String nom = d.getLicence().getAdherent().getNom();
String prenom = d.getLicence().getAdherent().getPrenom();
String poste = d.getLicence().getAdherent().getTypeMaillot() != null ? d.getLicence().getAdherent().getTypeMaillot() : "";
String sexe = d.getLicence().getAdherent().getSexe() != null ? d.getLicence().getAdherent().getSexe() : "";
String equipement = d.getEquipement() != null ? d.getEquipement().getNom() : "";
String taille = d.getTaille() != null ? d.getTaille() : "";
String flocage = d.getFlocage() != null ? d.getFlocage() : "";
String numero = d.getNumero() != null ? d.getNumero() : "";
String fourni = Boolean.TRUE.equals(d.getFourni()) ? "Oui" : "Non";
sb.append(escapeCsv(saison)).append(";")
.append(escapeCsv(categorie)).append(";")
.append(escapeCsv(nom)).append(";")
.append(escapeCsv(prenom)).append(";")
.append(escapeCsv(poste)).append(";")
.append(escapeCsv(sexe)).append(";")
.append(escapeCsv(equipement)).append(";")
.append(escapeCsv(taille)).append(";")
.append(escapeCsv(flocage)).append(";")
.append(escapeCsv(numero)).append(";")
.append(escapeCsv(fourni)).append("\n");
}
return sb.toString();
}
private String escapeCsv(String value) {
if (value == null) {
return "";
@@ -23,13 +23,15 @@ public class AdherentController {
private final com.astalange.core.repository.LicenceRepository licenceRepository;
private final com.astalange.core.repository.ModePaiementRepository modePaiementRepository;
private final SaisonRepository saisonRepository;
private final com.astalange.core.service.CategorieService categorieService;
public AdherentController(AdherentRepository adherentRepository, CategorieRepository categorieRepository, com.astalange.core.repository.LicenceRepository licenceRepository, com.astalange.core.repository.ModePaiementRepository modePaiementRepository, SaisonRepository saisonRepository) {
public AdherentController(AdherentRepository adherentRepository, CategorieRepository categorieRepository, com.astalange.core.repository.LicenceRepository licenceRepository, com.astalange.core.repository.ModePaiementRepository modePaiementRepository, SaisonRepository saisonRepository, com.astalange.core.service.CategorieService categorieService) {
this.adherentRepository = adherentRepository;
this.categorieRepository = categorieRepository;
this.licenceRepository = licenceRepository;
this.modePaiementRepository = modePaiementRepository;
this.saisonRepository = saisonRepository;
this.categorieService = categorieService;
}
@org.springframework.web.bind.annotation.ModelAttribute("equipes")
@@ -187,7 +189,8 @@ public class AdherentController {
.orElse(null);
if (newCat != null) {
licence.setCategorie(newCat);
licenceRepository.save(licence);
licence = licenceRepository.save(licence);
categorieService.syncDotationsForLicence(licence);
}
}
}
@@ -47,8 +47,13 @@ public class CategorieController {
Categorie categorie = categorieRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid category ID: " + id));
model.addAttribute("categorie", categorie);
Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null);
model.addAttribute("allEquipements", activeSaison != null ? equipementRepository.findBySaison(activeSaison) : List.of());
Saison saison = categorie.getSaison();
if (saison == null) {
saison = saisonRepository.findByEstActiveTrue().orElse(null);
}
model.addAttribute("allEquipements", saison != null ? equipementRepository.findBySaison(saison) : List.of());
return "parametrage/categories_form";
}
@@ -21,17 +21,95 @@ import com.astalange.core.service.DotationService;
import com.astalange.core.repository.SaisonRepository;
import com.astalange.core.entity.Saison;
import com.astalange.core.repository.EquipementRepository;
import com.astalange.core.repository.CategorieRepository;
import org.springframework.ui.Model;
import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
@Controller
public class DotationController {
private final DotationRepository dotationRepository;
private final DotationService dotationService;
private final SaisonRepository saisonRepository;
private final EquipementRepository equipementRepository;
private final CategorieRepository categorieRepository;
public DotationController(DotationRepository dotationRepository, DotationService dotationService, SaisonRepository saisonRepository) {
public DotationController(DotationRepository dotationRepository, DotationService dotationService, SaisonRepository saisonRepository, EquipementRepository equipementRepository, CategorieRepository categorieRepository) {
this.dotationRepository = dotationRepository;
this.dotationService = dotationService;
this.saisonRepository = saisonRepository;
this.equipementRepository = equipementRepository;
this.categorieRepository = categorieRepository;
}
private Specification<Dotation> buildSpecification(Long equipementId, Long categorieId, Boolean fourni, Saison saisonActive) {
return (root, query, cb) -> {
if (Long.class != query.getResultType() && long.class != query.getResultType()) {
root.fetch("equipement", jakarta.persistence.criteria.JoinType.LEFT);
jakarta.persistence.criteria.Fetch<Object, Object> licenceFetch = root.fetch("licence", jakarta.persistence.criteria.JoinType.LEFT);
licenceFetch.fetch("adherent", jakarta.persistence.criteria.JoinType.LEFT);
licenceFetch.fetch("categorie", jakarta.persistence.criteria.JoinType.LEFT);
}
List<Predicate> predicates = new ArrayList<>();
if (saisonActive != null) {
predicates.add(cb.equal(root.get("licence").get("saison"), saisonActive));
}
if (equipementId != null) {
predicates.add(cb.equal(root.get("equipement").get("id"), equipementId));
}
if (categorieId != null) {
predicates.add(cb.equal(root.get("licence").get("categorie").get("id"), categorieId));
}
if (fourni != null) {
predicates.add(cb.equal(root.get("fourni"), fourni));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
@GetMapping("/admin/equipements/recherche")
public String rechercheEquipements(
@RequestParam(required = false) Long equipementId,
@RequestParam(required = false) Long categorieId,
@RequestParam(required = false) Boolean fourni,
Model model) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
Specification<Dotation> spec = buildSpecification(equipementId, categorieId, fourni, saisonActive);
List<Dotation> dotations = dotationRepository.findAll(spec);
model.addAttribute("dotations", dotations);
model.addAttribute("equipements", equipementRepository.findAll());
model.addAttribute("categories", categorieRepository.findAll());
model.addAttribute("equipementId", equipementId);
model.addAttribute("categorieId", categorieId);
model.addAttribute("fourni", fourni);
return "parametrage/equipements_recherche";
}
@GetMapping("/admin/equipements/recherche/export")
public ResponseEntity<byte[]> exportRechercheEquipements(
@RequestParam(required = false) Long equipementId,
@RequestParam(required = false) Long categorieId,
@RequestParam(required = false) Boolean fourni) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
Specification<Dotation> spec = buildSpecification(equipementId, categorieId, fourni, saisonActive);
List<Dotation> dotations = dotationRepository.findAll(spec);
String csvContent = dotationService.genererCsvSearchEquipement(dotations);
byte[] csvBytes = csvContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setContentDispositionFormData("attachment", "recherche_equipements_" + LocalDate.now() + ".csv");
headers.setContentType(MediaType.parseMediaType("text/csv; charset=UTF-8"));
return new ResponseEntity<>(csvBytes, headers, org.springframework.http.HttpStatus.OK);
}
@GetMapping("/admin/equipements/export-commande")
@@ -22,7 +22,12 @@ public class EquipementController {
@GetMapping
public String listEquipements(Model model) {
model.addAttribute("equipements", equipementRepository.findAll());
Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null);
if (activeSaison != null) {
model.addAttribute("equipements", equipementRepository.findBySaison(activeSaison));
} else {
model.addAttribute("equipements", java.util.List.of());
}
return "parametrage/equipements_list";
}
@@ -21,6 +21,7 @@ public class LicenceController {
private final DotationRepository dotationRepository;
private final SaisonRepository saisonRepository;
private final EquipeRepository equipeRepository;
private final com.astalange.core.service.CategorieService categorieService;
public LicenceController(AdherentRepository adherentRepository,
CategorieRepository categorieRepository,
@@ -28,7 +29,8 @@ public class LicenceController {
EquipementRepository equipementRepository,
DotationRepository dotationRepository,
SaisonRepository saisonRepository,
EquipeRepository equipeRepository) {
EquipeRepository equipeRepository,
com.astalange.core.service.CategorieService categorieService) {
this.adherentRepository = adherentRepository;
this.categorieRepository = categorieRepository;
this.licenceRepository = licenceRepository;
@@ -36,6 +38,7 @@ public class LicenceController {
this.dotationRepository = dotationRepository;
this.saisonRepository = saisonRepository;
this.equipeRepository = equipeRepository;
this.categorieService = categorieService;
}
@PostMapping("/adherents/{id}/licences")
@@ -129,8 +132,125 @@ public class LicenceController {
licence.setEquipe(null);
}
licenceRepository.save(licence);
licence = licenceRepository.save(licence);
// Sync dotations when category changes
categorieService.syncDotationsForLicence(licence);
return "redirect:/adherents/" + adherentId + "/edit";
}
@org.springframework.web.bind.annotation.GetMapping("/admin/licences/recherche")
public String rechercheLicences(
@RequestParam(required = false) String nom,
@RequestParam(required = false) String prenom,
@RequestParam(required = false) Long categorieId,
@RequestParam(required = false) String numeroLicence,
@RequestParam(required = false) String email,
@RequestParam(required = false) String typeDemande,
org.springframework.ui.Model model) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
org.springframework.data.jpa.domain.Specification<Licence> spec = buildLicenceSpecification(nom, prenom, categorieId, numeroLicence, email, typeDemande, saisonActive);
List<Licence> licences = licenceRepository.findAll(spec);
model.addAttribute("licences", licences);
model.addAttribute("categories", categorieRepository.findAll());
model.addAttribute("nomFilter", nom);
model.addAttribute("prenomFilter", prenom);
model.addAttribute("categorieId", categorieId);
model.addAttribute("numeroLicenceFilter", numeroLicence);
model.addAttribute("emailFilter", email);
model.addAttribute("typeDemandeFilter", typeDemande);
return "parametrage/licences_recherche";
}
@org.springframework.web.bind.annotation.GetMapping("/admin/licences/recherche/export")
public org.springframework.http.ResponseEntity<byte[]> exportRechercheLicences(
@RequestParam(required = false) String nom,
@RequestParam(required = false) String prenom,
@RequestParam(required = false) Long categorieId,
@RequestParam(required = false) String numeroLicence,
@RequestParam(required = false) String email,
@RequestParam(required = false) String typeDemande) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
org.springframework.data.jpa.domain.Specification<Licence> spec = buildLicenceSpecification(nom, prenom, categorieId, numeroLicence, email, typeDemande, saisonActive);
List<Licence> licences = licenceRepository.findAll(spec);
StringBuilder sb = new StringBuilder();
sb.append('\ufeff');
sb.append("Nom;Prénom;Catégorie;N° Licence;Email;Type de Demande;Saison;Etat\n");
for (Licence l : licences) {
String n = l.getAdherent() != null && l.getAdherent().getNom() != null ? l.getAdherent().getNom() : "";
String p = l.getAdherent() != null && l.getAdherent().getPrenom() != null ? l.getAdherent().getPrenom() : "";
String c = l.getCategorie() != null ? l.getCategorie().getNom() : "";
String num = l.getNumeroLicence() != null ? l.getNumeroLicence() : "";
String e = l.getAdherent() != null && l.getAdherent().getEmail() != null ? l.getAdherent().getEmail() : "";
String td = l.getTypeDemande() != null ? l.getTypeDemande() : "";
String s = l.getSaison() != null ? l.getSaison().getNom() : "";
String etat = l.getEtat() != null ? l.getEtat() : "";
sb.append(escapeCsv(n)).append(";")
.append(escapeCsv(p)).append(";")
.append(escapeCsv(c)).append(";")
.append(escapeCsv(num)).append(";")
.append(escapeCsv(e)).append(";")
.append(escapeCsv(td)).append(";")
.append(escapeCsv(s)).append(";")
.append(escapeCsv(etat)).append("\n");
}
byte[] csvBytes = sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
headers.setContentDispositionFormData("attachment", "recherche_licences_" + java.time.LocalDate.now() + ".csv");
headers.setContentType(org.springframework.http.MediaType.parseMediaType("text/csv; charset=UTF-8"));
return new org.springframework.http.ResponseEntity<>(csvBytes, headers, org.springframework.http.HttpStatus.OK);
}
private String escapeCsv(String value) {
if (value == null) return "";
if (value.contains("\"")) value = value.replace("\"", "\"\"");
if (value.contains(";") || value.contains("\n") || value.contains("\"")) return "\"" + value + "\"";
return value;
}
private org.springframework.data.jpa.domain.Specification<Licence> buildLicenceSpecification(
String nom, String prenom, Long categorieId, String numeroLicence, String email, String typeDemande, Saison saisonActive) {
return (root, query, cb) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new java.util.ArrayList<>();
if (Long.class != query.getResultType() && long.class != query.getResultType()) {
root.fetch("adherent", jakarta.persistence.criteria.JoinType.LEFT);
root.fetch("categorie", jakarta.persistence.criteria.JoinType.LEFT);
}
if (saisonActive != null) {
predicates.add(cb.equal(root.get("saison"), saisonActive));
}
if (nom != null && !nom.trim().isEmpty()) {
predicates.add(cb.like(cb.lower(root.get("adherent").get("nom")), "%" + nom.trim().toLowerCase() + "%"));
}
if (prenom != null && !prenom.trim().isEmpty()) {
predicates.add(cb.like(cb.lower(root.get("adherent").get("prenom")), "%" + prenom.trim().toLowerCase() + "%"));
}
if (categorieId != null) {
predicates.add(cb.equal(root.get("categorie").get("id"), categorieId));
}
if (numeroLicence != null && !numeroLicence.trim().isEmpty()) {
predicates.add(cb.like(cb.lower(root.get("numeroLicence")), "%" + numeroLicence.trim().toLowerCase() + "%"));
}
if (email != null && !email.trim().isEmpty()) {
predicates.add(cb.like(cb.lower(root.get("adherent").get("email")), "%" + email.trim().toLowerCase() + "%"));
}
if (typeDemande != null && !typeDemande.trim().isEmpty()) {
predicates.add(cb.equal(root.get("typeDemande"), typeDemande.trim()));
}
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
};
}
}
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<meta charset="UTF-8">
<title>Paramétrage - Recherche Équipements</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> body { font-family: 'Inter', sans-serif; } </style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<div th:replace="~{fragments/sidebar :: sidebar}"></div>
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center px-6">
<h2 class="text-lg font-semibold text-gray-800">Recherche Équipements</h2>
</header>
<div class="flex-1 overflow-auto p-6">
<div class="bg-white p-6 rounded-lg border border-gray-200 shadow-sm mb-6">
<form th:action="@{/admin/equipements/recherche}" method="get" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Équipement</label>
<select name="equipementId" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm px-3 py-2 border">
<option value="">Tous les équipements</option>
<option th:each="equip : ${equipements}" th:value="${equip.id}" th:text="${equip.nom}" th:selected="${equip.id == equipementId}"></option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Catégorie</label>
<select name="categorieId" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm px-3 py-2 border">
<option value="">Toutes les catégories</option>
<option th:each="cat : ${categories}" th:value="${cat.id}" th:text="${cat.nom}" th:selected="${cat.id == categorieId}"></option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Fourni</label>
<select name="fourni" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm px-3 py-2 border">
<option value="">Tous</option>
<option value="true" th:selected="${fourni != null && fourni}">Oui</option>
<option value="false" th:selected="${fourni != null && !fourni}">Non</option>
</select>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700">Rechercher</button>
<a th:href="@{/admin/equipements/recherche/export(equipementId=${equipementId},categorieId=${categorieId},fourni=${fourni})}" class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700">Exporter</a>
</div>
</form>
</div>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider border-b border-gray-200">
<th class="py-3 px-4 font-medium text-left">Licencié</th>
<th class="py-3 px-4 font-medium text-left">Poste / Sexe</th>
<th class="py-3 px-4 font-medium text-left">Catégorie</th>
<th class="py-3 px-4 font-medium text-left">Équipement</th>
<th class="py-3 px-4 font-medium text-left">Taille / Floc.</th>
<th class="py-3 px-4 font-medium text-center">Fourni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(dotations)}">
<td colspan="6" class="py-8 text-center text-gray-500">Aucun résultat trouvé pour cette recherche.</td>
</tr>
<tr th:each="dotation : ${dotations}" class="hover:bg-gray-50">
<td class="py-3 px-4 font-medium text-gray-900">
<span th:text="${dotation.licence.adherent.nom + ' ' + dotation.licence.adherent.prenom}">Nom Prénom</span>
</td>
<td class="py-3 px-4 text-gray-600 text-xs">
<div th:text="${dotation.licence.adherent.typeMaillot}">Joueur</div>
<div th:text="${dotation.licence.adherent.sexe}">Masculin</div>
</td>
<td class="py-3 px-4 text-gray-600" th:text="${dotation.licence.categorie != null ? dotation.licence.categorie.nom : '-'}">Catégorie</td>
<td class="py-3 px-4 text-gray-900 font-medium" th:text="${dotation.equipement != null ? dotation.equipement.nom : '-'}">Maillot</td>
<td class="py-3 px-4 text-gray-600">
<div th:text="'T: ' + (${dotation.taille != null && !dotation.taille.isEmpty() ? dotation.taille : '-'})">Taille</div>
<div th:if="${dotation.flocage != null && !dotation.flocage.isEmpty()}" th:text="'F: ' + ${dotation.flocage}">Flocage</div>
<div th:if="${dotation.numero != null && !dotation.numero.isEmpty()}" th:text="'N: ' + ${dotation.numero}"></div>
</td>
<td class="py-3 px-4 text-center">
<span th:if="${dotation.fourni}" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">Oui</span>
<span th:unless="${dotation.fourni}" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">Non</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>