feat: implement server-side pagination, eager query loading, and dynamic column filtering via HTMX

This commit is contained in:
2026-05-30 00:14:07 +02:00
parent 3ae296aa91
commit 1d259831c6
3 changed files with 306 additions and 54 deletions
@@ -10,6 +10,9 @@ import java.util.List;
public interface AdherentRepository extends JpaRepository<Adherent, Long> {
List<Adherent> findTop5ByOrderByIdDesc();
@org.springframework.data.jpa.repository.Query("SELECT DISTINCT a FROM Adherent a LEFT JOIN FETCH a.licences l LEFT JOIN FETCH l.categorie c")
List<Adherent> findAllWithLicencesAndCategories();
@org.springframework.data.jpa.repository.Query("SELECT DISTINCT a FROM Adherent a LEFT JOIN a.licences l WHERE LOWER(a.nom) LIKE LOWER(CONCAT('%', :query, '%')) OR LOWER(a.prenom) LIKE LOWER(CONCAT('%', :query, '%')) OR LOWER(l.numeroLicence) LIKE LOWER(CONCAT('%', :query, '%'))")
List<Adherent> searchByQuery(@org.springframework.data.repository.query.Param("query") String query);
}
@@ -1,6 +1,8 @@
package com.astalange.web.controller;
import com.astalange.core.entity.Adherent;
import com.astalange.core.entity.Categorie;
import com.astalange.core.entity.Licence;
import com.astalange.core.entity.Saison;
import com.astalange.core.repository.AdherentRepository;
import com.astalange.core.repository.CategorieRepository;
@@ -31,15 +33,108 @@ public class AdherentController {
}
@GetMapping
public String listAdherents(@org.springframework.web.bind.annotation.RequestParam(required = false) String query, Model model) {
List<Adherent> adherents;
public String listAdherents(
@org.springframework.web.bind.annotation.RequestParam(required = false) String query,
@org.springframework.web.bind.annotation.RequestParam(required = false) String nom,
@org.springframework.web.bind.annotation.RequestParam(required = false) String prenom,
@org.springframework.web.bind.annotation.RequestParam(required = false) String categorie,
@org.springframework.web.bind.annotation.RequestParam(required = false) String licence,
@org.springframework.web.bind.annotation.RequestParam(required = false) String email,
@org.springframework.web.bind.annotation.RequestParam(required = false) String telephone,
@org.springframework.web.bind.annotation.RequestParam(required = false) String paiement,
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "20") int size,
Model model) {
List<Adherent> adherents = adherentRepository.findAllWithLicencesAndCategories();
// 1. Filter by global query
if (query != null && !query.trim().isEmpty()) {
adherents = adherentRepository.searchByQuery(query.trim());
String q = query.trim().toLowerCase();
adherents = adherents.stream().filter(a ->
(a.getNom() != null && a.getNom().toLowerCase().contains(q)) ||
(a.getPrenom() != null && a.getPrenom().toLowerCase().contains(q)) ||
(a.getLicenceActuelle() != null && a.getLicenceActuelle().getNumeroLicence() != null && a.getLicenceActuelle().getNumeroLicence().toLowerCase().contains(q))
).collect(java.util.stream.Collectors.toList());
model.addAttribute("searchQuery", query.trim());
} else {
adherents = adherentRepository.findAll();
}
model.addAttribute("adherents", adherents);
// 2. Filter by specific column filters
if (nom != null && !nom.trim().isEmpty()) {
String n = nom.trim().toLowerCase();
adherents = adherents.stream().filter(a -> a.getNom() != null && a.getNom().toLowerCase().contains(n)).collect(java.util.stream.Collectors.toList());
model.addAttribute("nomFilter", nom.trim());
}
if (prenom != null && !prenom.trim().isEmpty()) {
String p = prenom.trim().toLowerCase();
adherents = adherents.stream().filter(a -> a.getPrenom() != null && a.getPrenom().toLowerCase().contains(p)).collect(java.util.stream.Collectors.toList());
model.addAttribute("prenomFilter", prenom.trim());
}
if (categorie != null && !categorie.trim().isEmpty()) {
adherents = adherents.stream().filter(a ->
a.getLicenceActuelle() != null &&
a.getLicenceActuelle().getCategorie() != null &&
a.getLicenceActuelle().getCategorie().getNom().equalsIgnoreCase(categorie.trim())
).collect(java.util.stream.Collectors.toList());
model.addAttribute("categorieFilter", categorie.trim());
}
if (licence != null && !licence.trim().isEmpty()) {
String l = licence.trim().toLowerCase();
adherents = adherents.stream().filter(a ->
a.getLicenceActuelle() != null &&
a.getLicenceActuelle().getNumeroLicence() != null &&
a.getLicenceActuelle().getNumeroLicence().toLowerCase().contains(l)
).collect(java.util.stream.Collectors.toList());
model.addAttribute("licenceFilter", licence.trim());
}
if (email != null && !email.trim().isEmpty()) {
String e = email.trim().toLowerCase();
adherents = adherents.stream().filter(a -> a.getEmail() != null && a.getEmail().toLowerCase().contains(e)).collect(java.util.stream.Collectors.toList());
model.addAttribute("emailFilter", email.trim());
}
if (telephone != null && !telephone.trim().isEmpty()) {
String t = telephone.trim().toLowerCase();
adherents = adherents.stream().filter(a -> a.getTelephone() != null && a.getTelephone().toLowerCase().contains(t)).collect(java.util.stream.Collectors.toList());
model.addAttribute("telephoneFilter", telephone.trim());
}
if (paiement != null && !paiement.trim().isEmpty()) {
adherents = adherents.stream().filter(a -> {
String statut = a.getStatutPaiement();
if ("A_JOUR".equalsIgnoreCase(paiement)) {
return "À jour".equalsIgnoreCase(statut);
} else if ("RESTE".equalsIgnoreCase(paiement)) {
return statut != null && statut.startsWith("Reste");
} else if ("AUCUNE".equalsIgnoreCase(paiement)) {
return "Aucune licence".equalsIgnoreCase(statut);
}
return true;
}).collect(java.util.stream.Collectors.toList());
model.addAttribute("paiementFilter", paiement.trim());
}
// 3. Paginate
int totalElements = adherents.size();
int totalPages = (int) Math.ceil((double) totalElements / size);
if (page < 0) page = 0;
if (page >= totalPages && totalPages > 0) page = totalPages - 1;
int start = page * size;
int end = Math.min(start + size, totalElements);
List<Adherent> pageContent = (start < totalElements) ? adherents.subList(start, end) : List.of();
model.addAttribute("adherents", pageContent);
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", totalPages);
model.addAttribute("totalElements", totalElements);
model.addAttribute("pageSize", size);
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
if (saisonActive != null) {
model.addAttribute("categories", categorieRepository.findBySaison(saisonActive));
} else {
model.addAttribute("categories", categorieRepository.findAll());
}
return "adherents/list";
}
@@ -56,10 +151,67 @@ public class AdherentController {
Model model) {
if (result.hasErrors()) {
if (adherent.getId() != null) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
if (saisonActive != null) {
model.addAttribute("categories", categorieRepository.findBySaison(saisonActive));
model.addAttribute("saisonActive", saisonActive);
} else {
model.addAttribute("categories", categorieRepository.findAll());
}
model.addAttribute("licences", licenceRepository.findByAdherentId(adherent.getId()));
model.addAttribute("modesPaiement", modePaiementRepository.findAll());
} else {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
if (saisonActive != null) {
model.addAttribute("categories", categorieRepository.findBySaison(saisonActive));
} else {
model.addAttribute("categories", categorieRepository.findAll());
}
}
return "adherents/form";
}
if (adherent.getId() != null) {
Adherent existing = adherentRepository.findById(adherent.getId()).orElse(null);
if (existing != null) {
boolean dateChanged = !existing.getDateNaissance().equals(adherent.getDateNaissance());
if (dateChanged) {
int newBirthYear = adherent.getDateNaissance().getYear();
List<Licence> licences = licenceRepository.findByAdherentId(adherent.getId());
for (Licence licence : licences) {
Saison saison = licence.getSaison();
Categorie newCat = categorieRepository.findBySaison(saison).stream()
.filter(c -> newBirthYear >= c.getAnneeMin() && newBirthYear <= c.getAnneeMax())
.findFirst()
.orElse(null);
if (newCat != null) {
licence.setCategorie(newCat);
licenceRepository.save(licence);
}
}
}
existing.setNom(adherent.getNom());
existing.setPrenom(adherent.getPrenom());
existing.setDateNaissance(adherent.getDateNaissance());
existing.setTelephone(adherent.getTelephone());
existing.setEmail(adherent.getEmail());
existing.setAdresse(adherent.getAdresse());
existing.setRepresentantLegal(adherent.getRepresentantLegal());
existing.setResidentTalange(adherent.isResidentTalange());
existing.setSexe(adherent.getSexe());
existing.setTypeMaillot(adherent.getTypeMaillot());
adherentRepository.save(existing);
} else {
adherentRepository.save(adherent);
}
} else {
adherentRepository.save(adherent);
}
return "redirect:/adherents";
}
@@ -34,32 +34,70 @@
</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div id="adherents-table-container" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<form th:action="@{/adherents}" method="get" class="flex items-center space-x-2">
<form hx-get="/adherents" hx-target="#adherents-table-container" hx-select="#adherents-table-container" class="flex items-center space-x-2">
<input type="text" name="query" th:value="${searchQuery}" placeholder="Nom, Prénom ou N° Licence..."
class="border border-gray-300 rounded-lg px-4 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="submit" class="bg-gray-100 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 border border-gray-300 transition-colors">Chercher</button>
<a th:if="${searchQuery != null and !searchQuery.isEmpty()}" th:href="@{/adherents}" class="text-sm text-gray-500 hover:text-gray-700 underline underline-offset-2 ml-2">Effacer</a>
</form>
</div>
<form id="filter-form" hx-get="/adherents" hx-target="#adherents-table-container" hx-select="#adherents-table-container" hx-trigger="change, keyup changed delay:300ms" hx-sync="this:replace" hx-on::config-request="if (event.detail.trigger.tagName !== 'BUTTON') { event.detail.parameters['page'] = 0; }" class="m-0">
<input type="hidden" name="query" th:value="${searchQuery}">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200">
<th class="py-3 px-6 font-medium text-left">Nom</th>
<th class="py-3 px-6 font-medium text-left">Prénom</th>
<th class="py-3 px-6 font-medium text-left">Catégorie</th>
<th class="py-3 px-6 font-medium text-center">N° Licence</th>
<th class="py-3 px-6 font-medium text-left">Email</th>
<th class="py-3 px-6 font-medium text-left">Téléphone</th>
<th class="py-3 px-6 font-medium text-center">Paiement</th>
<th class="py-3 px-6 font-medium text-right">Actions</th>
</tr>
<!-- Filter Row -->
<tr class="bg-gray-100 border-b border-gray-200 text-xs">
<td class="py-2 px-3">
<input type="text" id="filterNom" name="nom" th:value="${nomFilter}" placeholder="Filtrer Nom..." class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
</td>
<td class="py-2 px-3">
<input type="text" id="filterPrenom" name="prenom" th:value="${prenomFilter}" placeholder="Filtrer Prénom..." class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
</td>
<td class="py-2 px-3">
<select id="filterCategorie" name="categorie" class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
<option value="">Toutes</option>
<option th:each="cat : ${categories}" th:value="${cat.nom}" th:text="${cat.nom}" th:selected="${categorieFilter == cat.nom}"></option>
</select>
</td>
<td class="py-2 px-3">
<input type="text" id="filterLicence" name="licence" th:value="${licenceFilter}" placeholder="Filtrer N°..." class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
</td>
<td class="py-2 px-3">
<input type="text" id="filterEmail" name="email" th:value="${emailFilter}" placeholder="Filtrer Email..." class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
</td>
<td class="py-2 px-3">
<input type="text" id="filterTelephone" name="telephone" th:value="${telephoneFilter}" placeholder="Filtrer Tél..." class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
</td>
<td class="py-2 px-3">
<select id="filterPaiement" name="paiement" class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
<option value="">Tous</option>
<option value="A_JOUR" th:selected="${paiementFilter == 'A_JOUR'}">À jour</option>
<option value="RESTE" th:selected="${paiementFilter == 'RESTE'}">Reste à payer</option>
<option value="AUCUNE" th:selected="${paiementFilter == 'AUCUNE'}">Aucune licence</option>
</select>
</td>
<td class="py-2 px-3"></td>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(adherents)}">
<td colspan="7" class="py-8 text-center text-gray-500">Aucun adhérent enregistré.</td>
<td colspan="8" class="py-8 text-center text-gray-500">Aucun adhérent enregistré.</td>
</tr>
<tr th:each="adherent : ${adherents}" class="hover:bg-gray-50 transition-colors">
<tr th:each="adherent : ${adherents}" class="hover:bg-gray-50 transition-colors adherent-row">
<td class="py-4 px-6 font-medium text-gray-900">
<span th:text="${adherent.nom}">Dupont</span>
<div class="text-[10px] text-gray-400 font-normal mt-0.5">
@@ -69,6 +107,7 @@
</div>
</td>
<td class="py-4 px-6 font-medium text-gray-900" th:text="${adherent.prenom}">Jean</td>
<td class="py-4 px-6 text-gray-600 font-medium" th:text="${adherent.getLicenceActuelle() != null ? adherent.getLicenceActuelle().getCategorie().getNom() : '-'}">-</td>
<td class="py-4 px-6 text-center text-gray-600 font-mono text-xs"
th:text="${adherent.getLicenceActuelle() != null and adherent.getLicenceActuelle().numeroLicence != null and !adherent.getLicenceActuelle().numeroLicence.isEmpty() ? adherent.getLicenceActuelle().numeroLicence : '-'}">-</td>
<td class="py-4 px-6 text-gray-600" th:text="${adherent.email}">jean@example.com</td>
@@ -87,9 +126,67 @@
</tr>
</tbody>
</table>
</form>
<!-- Pagination Footer -->
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between bg-gray-50 text-sm text-gray-700">
<div th:if="${totalElements > 0}">
Affichage de <span class="font-semibold" th:text="${currentPage * pageSize + 1}">1</span> à
<span class="font-semibold" th:text="${T(java.lang.Math).min((currentPage + 1) * pageSize, totalElements)}">20</span> sur
<span class="font-semibold" th:text="${totalElements}">100</span> adhérents
</div>
<div th:if="${totalElements == 0}">
Aucun adhérent à afficher
</div>
<div class="flex items-center space-x-2" th:if="${totalPages > 1}">
<!-- Page Précédente -->
<button type="button"
th:if="${currentPage > 0}"
hx-get="/adherents"
hx-target="#adherents-table-container"
hx-select="#adherents-table-container"
hx-include="#filter-form"
th:attr="hx-vals=|{'page': ${currentPage - 1}}|"
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 transition-colors">
Précédent
</button>
<span th:if="${currentPage == 0}" class="px-3 py-1 border border-gray-200 rounded text-gray-400 bg-gray-50 cursor-not-allowed">
Précédent
</span>
<!-- Numéros de pages -->
<th:block th:each="pageNum : ${#numbers.sequence(0, totalPages - 1)}" th:if="${totalPages > 0}">
<button type="button"
hx-get="/adherents"
hx-target="#adherents-table-container"
hx-select="#adherents-table-container"
hx-include="#filter-form"
th:attr="hx-vals=|{'page': ${pageNum}}|"
th:text="${pageNum + 1}"
class="px-3 py-1 rounded transition-colors"
th:classappend="${currentPage == pageNum ? 'bg-blue-600 text-white' : 'border border-gray-300 hover:bg-gray-100'}">
1
</button>
</th:block>
<!-- Page Suivante -->
<button type="button"
th:if="${currentPage < totalPages - 1}"
hx-get="/adherents"
hx-target="#adherents-table-container"
hx-select="#adherents-table-container"
hx-include="#filter-form"
th:attr="hx-vals=|{'page': ${currentPage + 1}}|"
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 transition-colors">
Suivant
</button>
<span th:if="${currentPage == totalPages - 1}" class="px-3 py-1 border border-gray-200 rounded text-gray-400 bg-gray-50 cursor-not-allowed">
Suivant
</span>
</div>
</div>
</div>
</div>
</main>
</body>
</html>