Compare commits

...

3 Commits

18 changed files with 1428 additions and 56 deletions
+6
View File
@@ -61,5 +61,11 @@
<version>1.77</version> <version>1.77</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -0,0 +1,205 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
import java.time.LocalTime;
import java.time.Duration;
import java.util.Collections;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
@Entity
@Table(name = "creneau_entrainement")
public class CreneauEntrainement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "categorie_id", nullable = false)
private Categorie categorie;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "terrain_id", nullable = false)
private Terrain terrain;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ZoneTerrain zone;
@Enumerated(EnumType.STRING)
@Column(name = "jour_semaine", nullable = false)
private JourSemaine jourSemaine;
@Column(name = "heure_debut", nullable = false)
private LocalTime heureDebut;
@Column(name = "heure_fin", nullable = false)
private LocalTime heureFin;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "saison_id", nullable = false)
private Saison saison;
@Transient
private boolean enConflit = false;
@Transient
private Set<String> categoriesEnConflit = new HashSet<>();
// Logic for Gantt diagram grid placement
public int getStartColumn() {
if (heureDebut == null) return 3;
// Clip to working hours 08:00 to 22:00
LocalTime time = heureDebut;
if (time.isBefore(LocalTime.of(8, 0))) {
time = LocalTime.of(8, 0);
}
long minutes = Duration.between(LocalTime.of(8, 0), time).toMinutes();
return (int) (minutes / 15) + 3; // +3 because col 1 is Field name, col 2 is Zone name
}
public int getEndColumn() {
if (heureFin == null) return 3;
LocalTime time = heureFin;
if (time.isAfter(LocalTime.of(22, 0))) {
time = LocalTime.of(22, 0);
}
long minutes = Duration.between(LocalTime.of(8, 0), time).toMinutes();
return (int) (minutes / 15) + 3;
}
public int getStartRow(int fieldIndex) {
int baseRow = 3 + (fieldIndex * 5); // Row index matching HTML layout (base starts at 3, 5 rows per field)
if (zone == null) return baseRow;
return switch (zone) {
case COMPLET, DEMI_A, QUART_1 -> baseRow;
case QUART_2 -> baseRow + 1;
case DEMI_B, QUART_3 -> baseRow + 2;
case QUART_4 -> baseRow + 3;
};
}
public int getEndRow(int fieldIndex) {
int baseRow = 3 + (fieldIndex * 5);
if (zone == null) return baseRow + 4;
return switch (zone) {
case QUART_1 -> baseRow + 1;
case DEMI_A, QUART_2 -> baseRow + 2;
case QUART_3 -> baseRow + 3;
case DEMI_B, QUART_4, COMPLET -> baseRow + 4;
};
}
// Overlap and conflict checking logic
public boolean overlapsWith(CreneauEntrainement other) {
if (this.id != null && this.id.equals(other.id)) {
return false; // same slot
}
if (this.jourSemaine != other.jourSemaine) {
return false;
}
if (this.terrain == null || other.terrain == null || !Objects.equals(this.terrain.getId(), other.terrain.getId())) {
return false;
}
// Time overlap check: start1 < end2 and start2 < end1
boolean timeOverlap = this.heureDebut.isBefore(other.heureFin) && other.heureDebut.isBefore(this.heureFin);
if (!timeOverlap) {
return false;
}
// Spatial overlap check
if (this.zone == null || other.zone == null) {
return false;
}
Set<Integer> q1 = this.zone.getQuarters();
Set<Integer> q2 = other.zone.getQuarters();
return !Collections.disjoint(q1, q2);
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Categorie getCategorie() {
return categorie;
}
public void setCategorie(Categorie categorie) {
this.categorie = categorie;
}
public Terrain getTerrain() {
return terrain;
}
public void setTerrain(Terrain terrain) {
this.terrain = terrain;
}
public ZoneTerrain getZone() {
return zone;
}
public void setZone(ZoneTerrain zone) {
this.zone = zone;
}
public JourSemaine getJourSemaine() {
return jourSemaine;
}
public void setJourSemaine(JourSemaine jourSemaine) {
this.jourSemaine = jourSemaine;
}
public LocalTime getHeureDebut() {
return heureDebut;
}
public void setHeureDebut(LocalTime heureDebut) {
this.heureDebut = heureDebut;
}
public LocalTime getHeureFin() {
return heureFin;
}
public void setHeureFin(LocalTime heureFin) {
this.heureFin = heureFin;
}
public Saison getSaison() {
return saison;
}
public void setSaison(Saison saison) {
this.saison = saison;
}
public boolean isEnConflit() {
return enConflit;
}
public void setEnConflit(boolean enConflit) {
this.enConflit = enConflit;
}
public Set<String> getCategoriesEnConflit() {
return categoriesEnConflit;
}
public void setCategoriesEnConflit(Set<String> categoriesEnConflit) {
this.categoriesEnConflit = categoriesEnConflit;
}
}
@@ -0,0 +1,21 @@
package com.astalange.core.entity;
public enum JourSemaine {
LUNDI("Lundi"),
MARDI("Mardi"),
MERCREDI("Mercredi"),
JEUDI("Jeudi"),
VENDREDI("Vendredi"),
SAMEDI("Samedi"),
DIMANCHE("Dimanche");
private final String libelle;
JourSemaine(String libelle) {
this.libelle = libelle;
}
public String getLibelle() {
return libelle;
}
}
@@ -0,0 +1,31 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "terrain")
public class Terrain {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String nom;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getNom() {
return nom;
}
public void setNom(String nom) {
this.nom = nom;
}
}
@@ -0,0 +1,29 @@
package com.astalange.core.entity;
import java.util.Set;
public enum ZoneTerrain {
COMPLET("Terrain Complet", Set.of(1, 2, 3, 4)),
DEMI_A("Demi-terrain A", Set.of(1, 2)),
DEMI_B("Demi-terrain B", Set.of(3, 4)),
QUART_1("Quart 1", Set.of(1)),
QUART_2("Quart 2", Set.of(2)),
QUART_3("Quart 3", Set.of(3)),
QUART_4("Quart 4", Set.of(4));
private final String libelle;
private final Set<Integer> quarters;
ZoneTerrain(String libelle, Set<Integer> quarters) {
this.libelle = libelle;
this.quarters = quarters;
}
public String getLibelle() {
return libelle;
}
public Set<Integer> getQuarters() {
return quarters;
}
}
@@ -10,6 +10,9 @@ import java.util.List;
public interface AdherentRepository extends JpaRepository<Adherent, Long> { public interface AdherentRepository extends JpaRepository<Adherent, Long> {
List<Adherent> findTop5ByOrderByIdDesc(); 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, '%'))") @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); List<Adherent> searchByQuery(@org.springframework.data.repository.query.Param("query") String query);
} }
@@ -0,0 +1,21 @@
package com.astalange.core.repository;
import com.astalange.core.entity.CreneauEntrainement;
import com.astalange.core.entity.JourSemaine;
import com.astalange.core.entity.Saison;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CreneauEntrainementRepository extends JpaRepository<CreneauEntrainement, Long> {
@Query("SELECT c FROM CreneauEntrainement c JOIN FETCH c.categorie JOIN FETCH c.terrain WHERE c.saison = :saison")
List<CreneauEntrainement> findBySaisonFetch(@Param("saison") Saison saison);
@Query("SELECT c FROM CreneauEntrainement c JOIN FETCH c.categorie JOIN FETCH c.terrain WHERE c.saison = :saison AND c.jourSemaine = :jourSemaine")
List<CreneauEntrainement> findBySaisonAndJourSemaineFetch(@Param("saison") Saison saison, @Param("jourSemaine") JourSemaine jourSemaine);
}
@@ -0,0 +1,9 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Terrain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TerrainRepository extends JpaRepository<Terrain, Long> {
}
@@ -0,0 +1,26 @@
-- V10__add_terrains_and_schedules.sql
-- 1. Table des terrains
CREATE TABLE terrain (
id BIGSERIAL PRIMARY KEY,
nom VARCHAR(255) NOT NULL
);
-- Insertion des deux terrains par défaut
INSERT INTO terrain (nom) VALUES ('Terrain Vert');
INSERT INTO terrain (nom) VALUES ('Terrain Synthétique');
-- 2. Table des créneaux d'entraînement
CREATE TABLE creneau_entrainement (
id BIGSERIAL PRIMARY KEY,
categorie_id BIGINT NOT NULL,
terrain_id BIGINT NOT NULL,
zone VARCHAR(50) NOT NULL,
jour_semaine VARCHAR(50) NOT NULL,
heure_debut TIME NOT NULL,
heure_fin TIME NOT NULL,
saison_id BIGINT NOT NULL,
CONSTRAINT fk_creneau_categorie FOREIGN KEY (categorie_id) REFERENCES categorie(id) ON DELETE CASCADE,
CONSTRAINT fk_creneau_terrain FOREIGN KEY (terrain_id) REFERENCES terrain(id),
CONSTRAINT fk_creneau_saison FOREIGN KEY (saison_id) REFERENCES saison(id) ON DELETE CASCADE
);
@@ -0,0 +1,93 @@
package com.astalange.core;
import com.astalange.core.entity.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class AdherentRecalculationTest {
@Test
public void testLicencePriceCalculationBasedOnResidencyAndCategory() {
Adherent adherent = new Adherent();
adherent.setNom("Doe");
adherent.setPrenom("John");
adherent.setDateNaissance(LocalDate.of(2015, 5, 10)); // born 2015
adherent.setResidentTalange(true);
Categorie u11 = new Categorie();
u11.setNom("U11");
u11.setAnneeMin(2015);
u11.setAnneeMax(2015);
u11.setTarifBase(new BigDecimal("130.00"));
u11.setTarifExterieur(new BigDecimal("160.00"));
Licence licence = new Licence();
licence.setAdherent(adherent);
licence.setCategorie(u11);
// Under residentTalange = true, base price should be 130
assertEquals(new BigDecimal("130.00"), licence.getPrixTotal());
// Under residentTalange = false, base price should be 160
adherent.setResidentTalange(false);
assertEquals(new BigDecimal("160.00"), licence.getPrixTotal());
}
@Test
public void testCategoryUpdateOnBirthdateChangeSimulation() {
// Set up adherent
Adherent adherent = new Adherent();
adherent.setDateNaissance(LocalDate.of(2015, 5, 10)); // 2015 birth year
Saison saison = new Saison();
saison.setNom("2025-2026");
saison.setEstActive(true);
// Set up categories
Categorie u11 = new Categorie();
u11.setNom("U11");
u11.setAnneeMin(2015);
u11.setAnneeMax(2015);
u11.setSaison(saison);
u11.setTarifBase(new BigDecimal("130.00"));
Categorie u12 = new Categorie();
u12.setNom("U12");
u12.setAnneeMin(2014);
u12.setAnneeMax(2014);
u12.setSaison(saison);
u12.setTarifBase(new BigDecimal("140.00"));
List<Categorie> categories = List.of(u11, u12);
// Licence initially created for U11
Licence licence = new Licence();
licence.setAdherent(adherent);
licence.setCategorie(u11);
licence.setSaison(saison);
// Simulation of date of birth changing to 2014 (U12)
adherent.setDateNaissance(LocalDate.of(2014, 5, 10));
int newBirthYear = adherent.getDateNaissance().getYear();
// Simulate Controller's update logic
Categorie newCat = categories.stream()
.filter(c -> newBirthYear >= c.getAnneeMin() && newBirthYear <= c.getAnneeMax())
.findFirst()
.orElse(null);
assertNotNull(newCat);
assertEquals("U12", newCat.getNom());
licence.setCategorie(newCat);
// Verify the licence's category has updated to U12
assertEquals("U12", licence.getCategorie().getNom());
// Verify price total is updated dynamically
assertEquals(new BigDecimal("140.00"), licence.getPrixTotal());
}
}
@@ -0,0 +1,107 @@
package com.astalange.core;
import com.astalange.core.entity.*;
import org.junit.jupiter.api.Test;
import java.time.LocalTime;
import static org.junit.jupiter.api.Assertions.*;
public class CreneauEntrainementTest {
@Test
public void testStartAndEndColumns() {
CreneauEntrainement c1 = new CreneauEntrainement();
c1.setHeureDebut(LocalTime.of(8, 0));
c1.setHeureFin(LocalTime.of(9, 30));
// Start time 08:00 is (8 * 60) = 480 mins. Offset from 08:00 is 0 mins.
// Column is (0 / 15) + 3 = 3.
assertEquals(3, c1.getStartColumn());
// End time 09:30 is (9*60 + 30) = 570 mins. Offset from 08:00 is 90 mins.
// Column is (90 / 15) + 3 = 9.
assertEquals(9, c1.getEndColumn());
}
@Test
public void testStartAndEndRows() {
CreneauEntrainement c1 = new CreneauEntrainement();
// Test terrain complet (baseRow index 0 is 3, index 1 is 8)
c1.setZone(ZoneTerrain.COMPLET);
assertEquals(3, c1.getStartRow(0));
assertEquals(7, c1.getEndRow(0));
assertEquals(8, c1.getStartRow(1));
assertEquals(12, c1.getEndRow(1));
// Test demi-terrain A (Quarts 1 and 2)
c1.setZone(ZoneTerrain.DEMI_A);
assertEquals(3, c1.getStartRow(0));
assertEquals(5, c1.getEndRow(0));
// Test demi-terrain B (Quarts 3 and 4)
c1.setZone(ZoneTerrain.DEMI_B);
assertEquals(5, c1.getStartRow(0));
assertEquals(7, c1.getEndRow(0));
// Test single Quarts
c1.setZone(ZoneTerrain.QUART_1);
assertEquals(3, c1.getStartRow(0));
assertEquals(4, c1.getEndRow(0));
c1.setZone(ZoneTerrain.QUART_4);
assertEquals(6, c1.getStartRow(0));
assertEquals(7, c1.getEndRow(0));
}
@Test
public void testOverlapsWith() {
Terrain t1 = new Terrain();
t1.setId(1L);
Terrain t2 = new Terrain();
t2.setId(2L);
CreneauEntrainement c1 = new CreneauEntrainement();
c1.setJourSemaine(JourSemaine.LUNDI);
c1.setTerrain(t1);
c1.setZone(ZoneTerrain.DEMI_A);
c1.setHeureDebut(LocalTime.of(18, 0));
c1.setHeureFin(LocalTime.of(19, 30));
// 1. Different day -> No overlap
CreneauEntrainement c2 = new CreneauEntrainement();
c2.setJourSemaine(JourSemaine.MARDI);
c2.setTerrain(t1);
c2.setZone(ZoneTerrain.DEMI_A);
c2.setHeureDebut(LocalTime.of(18, 0));
c2.setHeureFin(LocalTime.of(19, 30));
assertFalse(c1.overlapsWith(c2));
// 2. Different terrain -> No overlap
c2.setJourSemaine(JourSemaine.LUNDI);
c2.setTerrain(t2);
assertFalse(c1.overlapsWith(c2));
// 3. Same day, same terrain, non-overlapping times -> No overlap
c2.setTerrain(t1);
c2.setHeureDebut(LocalTime.of(19, 30));
c2.setHeureFin(LocalTime.of(21, 0));
assertFalse(c1.overlapsWith(c2));
// 4. Same day, same terrain, overlapping times, non-overlapping zones (Demi_A and Demi_B don't overlap)
c2.setHeureDebut(LocalTime.of(18, 30));
c2.setHeureFin(LocalTime.of(20, 0));
c2.setZone(ZoneTerrain.DEMI_B);
assertFalse(c1.overlapsWith(c2));
// 5. Same day, same terrain, overlapping times, overlapping zones (Demi_A and Complet overlap!)
c2.setZone(ZoneTerrain.COMPLET);
assertTrue(c1.overlapsWith(c2));
// 6. Same day, same terrain, overlapping times, overlapping zones (Demi_A and Quart_1 overlap!)
c2.setZone(ZoneTerrain.QUART_1);
assertTrue(c1.overlapsWith(c2));
}
}
@@ -1,6 +1,8 @@
package com.astalange.web.controller; package com.astalange.web.controller;
import com.astalange.core.entity.Adherent; 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.entity.Saison;
import com.astalange.core.repository.AdherentRepository; import com.astalange.core.repository.AdherentRepository;
import com.astalange.core.repository.CategorieRepository; import com.astalange.core.repository.CategorieRepository;
@@ -31,15 +33,108 @@ public class AdherentController {
} }
@GetMapping @GetMapping
public String listAdherents(@org.springframework.web.bind.annotation.RequestParam(required = false) String query, Model model) { public String listAdherents(
List<Adherent> adherents; @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()) { 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()); 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"; return "adherents/list";
} }
@@ -56,10 +151,67 @@ public class AdherentController {
Model model) { Model model) {
if (result.hasErrors()) { 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"; 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); adherentRepository.save(adherent);
}
} else {
adherentRepository.save(adherent);
}
return "redirect:/adherents"; return "redirect:/adherents";
} }
@@ -0,0 +1,158 @@
package com.astalange.web.controller;
import com.astalange.core.entity.*;
import com.astalange.core.repository.CategorieRepository;
import com.astalange.core.repository.CreneauEntrainementRepository;
import com.astalange.core.repository.SaisonRepository;
import com.astalange.core.repository.TerrainRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.*;
@Controller
@RequestMapping("/planning")
public class PlanningController {
private final CreneauEntrainementRepository creneauRepository;
private final TerrainRepository terrainRepository;
private final CategorieRepository categorieRepository;
private final SaisonRepository saisonRepository;
public PlanningController(CreneauEntrainementRepository creneauRepository,
TerrainRepository terrainRepository,
CategorieRepository categorieRepository,
SaisonRepository saisonRepository) {
this.creneauRepository = creneauRepository;
this.terrainRepository = terrainRepository;
this.categorieRepository = categorieRepository;
this.saisonRepository = saisonRepository;
}
@GetMapping
public String showPlanning(@RequestParam(required = false) JourSemaine jour, Model model) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
if (saisonActive == null) {
model.addAttribute("noActiveSeason", true);
return "planning/planning";
}
JourSemaine selectedDay = (jour != null) ? jour : JourSemaine.LUNDI;
List<Terrain> terrains = terrainRepository.findAll();
// Sort terrains to ensure consistent order (Terrain Vert then Terrain Synthétique)
terrains.sort(Comparator.comparing(Terrain::getId));
Map<Long, Integer> terrainIndices = new HashMap<>();
for (int i = 0; i < terrains.size(); i++) {
terrainIndices.put(terrains.get(i).getId(), i);
}
List<CreneauEntrainement> creneaux = creneauRepository.findBySaisonAndJourSemaineFetch(saisonActive, selectedDay);
// Detect conflicts
for (int i = 0; i < creneaux.size(); i++) {
CreneauEntrainement c1 = creneaux.get(i);
for (int j = 0; j < creneaux.size(); j++) {
if (i == j) continue;
CreneauEntrainement c2 = creneaux.get(j);
if (c1.overlapsWith(c2)) {
c1.setEnConflit(true);
c1.getCategoriesEnConflit().add(c2.getCategorie().getNom());
}
}
}
model.addAttribute("saisonActive", saisonActive);
model.addAttribute("selectedDay", selectedDay);
model.addAttribute("jours", JourSemaine.values());
model.addAttribute("terrains", terrains);
model.addAttribute("terrainIndices", terrainIndices);
model.addAttribute("creneaux", creneaux);
return "planning/planning";
}
@GetMapping("/new")
public String showCreateForm(Model model, RedirectAttributes redirectAttributes) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null);
if (saisonActive == null) {
redirectAttributes.addFlashAttribute("error", "Veuillez activer une saison avant de planifier un créneau.");
return "redirect:/saisons";
}
CreneauEntrainement creneau = new CreneauEntrainement();
creneau.setSaison(saisonActive);
model.addAttribute("creneau", creneau);
model.addAttribute("categories", categorieRepository.findBySaison(saisonActive));
model.addAttribute("terrains", terrainRepository.findAll());
model.addAttribute("zones", ZoneTerrain.values());
model.addAttribute("jours", JourSemaine.values());
return "planning/form";
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
CreneauEntrainement creneau = creneauRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Créneau introuvable : " + id));
model.addAttribute("creneau", creneau);
model.addAttribute("categories", categorieRepository.findBySaison(creneau.getSaison()));
model.addAttribute("terrains", terrainRepository.findAll());
model.addAttribute("zones", ZoneTerrain.values());
model.addAttribute("jours", JourSemaine.values());
return "planning/form";
}
@PostMapping
public String saveCreneau(@ModelAttribute("creneau") CreneauEntrainement creneau, BindingResult result, Model model, RedirectAttributes redirectAttributes) {
// Validation
if (creneau.getHeureDebut() != null && creneau.getHeureFin() != null) {
if (!creneau.getHeureDebut().isBefore(creneau.getHeureFin())) {
result.rejectValue("heureDebut", "error.heureDebut", "L'heure de début doit être antérieure à l'heure de fin.");
}
if (creneau.getHeureDebut().isBefore(java.time.LocalTime.of(8, 0)) || creneau.getHeureFin().isAfter(java.time.LocalTime.of(22, 0))) {
result.rejectValue("heureDebut", "error.heureDebut", "Les entraînements doivent se dérouler entre 08:00 et 22:00.");
}
}
if (result.hasErrors()) {
Saison saison = creneau.getSaison();
if (saison == null) {
saison = saisonRepository.findByEstActiveTrue().orElseThrow();
creneau.setSaison(saison);
}
model.addAttribute("categories", categorieRepository.findBySaison(saison));
model.addAttribute("terrains", terrainRepository.findAll());
model.addAttribute("zones", ZoneTerrain.values());
model.addAttribute("jours", JourSemaine.values());
return "planning/form";
}
// Set active season if not set (for new items)
if (creneau.getSaison() == null) {
Saison saisonActive = saisonRepository.findByEstActiveTrue().orElseThrow();
creneau.setSaison(saisonActive);
}
creneauRepository.save(creneau);
redirectAttributes.addFlashAttribute("success", "Créneau d'entraînement enregistré avec succès.");
return "redirect:/planning?jour=" + creneau.getJourSemaine().name();
}
@PostMapping("/{id}/delete")
public String deleteCreneau(@PathVariable Long id, RedirectAttributes redirectAttributes) {
CreneauEntrainement creneau = creneauRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Créneau introuvable : " + id));
JourSemaine jour = creneau.getJourSemaine();
creneauRepository.delete(creneau);
redirectAttributes.addFlashAttribute("success", "Créneau d'entraînement supprimé avec succès.");
return "redirect:/planning?jour=" + jour.name();
}
}
@@ -322,8 +322,11 @@
<select id="categorieSelect" name="categorieId" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"> <select id="categorieSelect" name="categorieId" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">Sélectionnez une catégorie...</option> <option value="">Sélectionnez une catégorie...</option>
<option th:each="cat : ${categories}" th:value="${cat.id}" <option th:each="cat : ${categories}" th:value="${cat.id}"
th:data-nom="${cat.nom}"
th:data-annee-min="${cat.anneeMin}" th:data-annee-min="${cat.anneeMin}"
th:data-annee-max="${cat.anneeMax}" th:data-annee-max="${cat.anneeMax}"
th:data-tarif-base="${cat.tarifBase}"
th:data-tarif-exterieur="${cat.tarifExterieur}"
th:text="${cat.nom} + ' (Talange: ' + ${cat.tarifBase} + ' € / Ext: ' + ${cat.tarifExterieur} + ' €)'"></option> th:text="${cat.nom} + ' (Talange: ' + ${cat.tarifBase} + ' € / Ext: ' + ${cat.tarifExterieur} + ' €)'"></option>
</select> </select>
</div> </div>
@@ -443,6 +446,7 @@
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
const dateInput = document.getElementById('dateNaissance'); const dateInput = document.getElementById('dateNaissance');
const repBlock = document.getElementById('representantBlock'); const repBlock = document.getElementById('representantBlock');
const residentCheckbox = document.getElementById('residentTalange');
function calculateAge(birthday) { function calculateAge(birthday) {
const ageDifMs = Date.now() - birthday.getTime(); const ageDifMs = Date.now() - birthday.getTime();
@@ -464,7 +468,92 @@
} }
} }
dateInput.addEventListener('change', updateRepresentantBlock); function updateLicencesCategoryAndPrices() {
if (!dateInput.value) return;
const dob = new Date(dateInput.value);
const birthYear = dob.getFullYear();
const isResident = residentCheckbox ? residentCheckbox.checked : true;
const categories = [];
const catSelect = document.getElementById('categorieSelect');
if (catSelect) {
Array.from(catSelect.options).forEach(opt => {
const id = opt.value;
const nom = opt.getAttribute('data-nom');
const min = parseInt(opt.getAttribute('data-annee-min'));
const max = parseInt(opt.getAttribute('data-annee-max'));
const tarifBase = parseFloat(opt.getAttribute('data-tarif-base') || '0');
const tarifExterieur = parseFloat(opt.getAttribute('data-tarif-exterieur') || '0');
if (id) {
categories.push({ id, nom, min, max, tarifBase, tarifExterieur });
}
});
}
if (categories.length === 0) return;
const matchingCat = categories.find(c => birthYear >= c.min && birthYear <= c.max);
if (!matchingCat) return;
document.querySelectorAll('.licence-row').forEach(row => {
row.setAttribute('data-tarif-base', matchingCat.tarifBase);
row.setAttribute('data-tarif-exterieur', matchingCat.tarifExterieur);
const baseTarif = isResident ? matchingCat.tarifBase : matchingCat.tarifExterieur;
const equipContrib = parseFloat(row.getAttribute('data-equipements-contrib') || '0');
const totalPaye = parseFloat(row.getAttribute('data-total-paye') || '0');
const newTotal = baseTarif + equipContrib;
const newReste = Math.max(0, newTotal - totalPaye);
const catCell = row.querySelector('.cell-tarif-categorie');
if (catCell) {
const parentTd = catCell.parentNode;
const spanName = parentTd.querySelector('span:not(.cell-tarif-categorie)');
if (spanName) {
spanName.textContent = matchingCat.nom;
}
catCell.textContent = baseTarif.toFixed(2) + ' € (Catégorie)';
}
const globalCell = row.querySelector('.cell-tarif-global');
if (globalCell) {
globalCell.textContent = newTotal.toFixed(2) + ' €';
}
const resteCell = row.querySelector('.cell-reste-payer');
if (resteCell) {
resteCell.textContent = newReste.toFixed(2) + ' €';
if (newReste === 0) {
resteCell.classList.remove('text-red-600');
resteCell.classList.add('text-green-600');
} else {
resteCell.classList.remove('text-green-600');
resteCell.classList.add('text-red-600');
}
}
const payerBtn = row.querySelector('.btn-payer');
if (payerBtn) {
if (newReste > 0) {
payerBtn.classList.remove('hidden');
const licId = payerBtn.getAttribute('data-licence-id');
payerBtn.setAttribute('onclick', `openPaiementModal(${licId}, ${newReste})`);
} else {
payerBtn.classList.add('hidden');
}
}
const modifierBtn = row.querySelector('button[onclick^="openLicenceModal"]');
if (modifierBtn) {
modifierBtn.setAttribute('data-categorie-id', matchingCat.id);
}
});
}
dateInput.addEventListener('change', function() {
updateRepresentantBlock();
updateLicencesCategoryAndPrices();
});
// Initial check in case it's a validation error reload // Initial check in case it's a validation error reload
updateRepresentantBlock(); updateRepresentantBlock();
@@ -475,7 +564,6 @@
} }
// Recalculate price dynamically when residentTalange checkbox changes // Recalculate price dynamically when residentTalange checkbox changes
const residentCheckbox = document.getElementById('residentTalange');
if (residentCheckbox) { if (residentCheckbox) {
residentCheckbox.addEventListener('change', function() { residentCheckbox.addEventListener('change', function() {
const isResident = this.checked; const isResident = this.checked;
@@ -34,32 +34,70 @@
</a> </a>
</div> </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"> <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..." <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"> 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> <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> <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> </form>
</div> </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"> <table class="w-full text-left border-collapse">
<thead> <thead>
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200"> <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">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">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-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">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-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-center">Paiement</th>
<th class="py-3 px-6 font-medium text-right">Actions</th> <th class="py-3 px-6 font-medium text-right">Actions</th>
</tr> </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> </thead>
<tbody class="divide-y divide-gray-200 text-sm"> <tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(adherents)}"> <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>
<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"> <td class="py-4 px-6 font-medium text-gray-900">
<span th:text="${adherent.nom}">Dupont</span> <span th:text="${adherent.nom}">Dupont</span>
<div class="text-[10px] text-gray-400 font-normal mt-0.5"> <div class="text-[10px] text-gray-400 font-normal mt-0.5">
@@ -69,6 +107,7 @@
</div> </div>
</td> </td>
<td class="py-4 px-6 font-medium text-gray-900" th:text="${adherent.prenom}">Jean</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" <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> 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> <td class="py-4 px-6 text-gray-600" th:text="${adherent.email}">jean@example.com</td>
@@ -87,9 +126,67 @@
</tr> </tr>
</tbody> </tbody>
</table> </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>
</div> </div>
</main> </main>
</body> </body>
</html> </html>
@@ -12,6 +12,8 @@
<a href="/" th:classappend="${requestURI == '/' ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Tableau de bord</a> <a href="/" th:classappend="${requestURI == '/' ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Tableau de bord</a>
<a href="/adherents" th:classappend="${requestURI != null and requestURI.startsWith('/adherents') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Adhérents</a> <a href="/adherents" th:classappend="${requestURI != null and requestURI.startsWith('/adherents') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Adhérents</a>
<a href="/paiements" th:classappend="${requestURI != null and requestURI.startsWith('/paiements') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Paiements</a> <a href="/paiements" th:classappend="${requestURI != null and requestURI.startsWith('/paiements') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Paiements</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Planning</div>
<a href="/planning" th:classappend="${requestURI != null and requestURI.startsWith('/planning') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Entraînements</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div> <div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/saisons" th:classappend="${requestURI != null and requestURI.startsWith('/saisons') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Saisons</a> <a href="/saisons" th:classappend="${requestURI != null and requestURI.startsWith('/saisons') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Saisons</a>
<a href="/categories" th:classappend="${requestURI != null and requestURI.startsWith('/categories') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Catégories</a> <a href="/categories" th:classappend="${requestURI != null and requestURI.startsWith('/categories') ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}" class="block px-3 py-2 rounded-lg transition-colors">Catégories</a>
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Créneau d'Entraînement - AS Talange</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 -->
<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" th:text="${creneau.id == null ? 'Ajouter un Créneau d''Entraînement' : 'Modifier le Créneau d''Entraînement'}">Créneau</h2>
</header>
<!-- Form content -->
<div class="flex-1 overflow-auto p-6">
<div class="max-w-xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<form th:action="@{/planning}" th:object="${creneau}" method="post" class="space-y-6">
<input type="hidden" th:field="*{id}" />
<input type="hidden" th:field="*{saison.id}" th:if="${creneau.saison != null}" />
<!-- Catégorie -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Catégorie <span class="text-red-500">*</span></label>
<select th:field="*{categorie.id}" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">Sélectionnez une catégorie</option>
<option th:each="cat : ${categories}" th:value="${cat.id}" th:text="${cat.nom}">U15</option>
</select>
</div>
<!-- Terrain -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Terrain <span class="text-red-500">*</span></label>
<select th:field="*{terrain.id}" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">Sélectionnez un terrain</option>
<option th:each="t : ${terrains}" th:value="${t.id}" th:text="${t.nom}">Terrain Vert</option>
</select>
</div>
<!-- Zone de terrain -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zone de terrain <span class="text-red-500">*</span></label>
<select th:field="*{zone}" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">Sélectionnez la zone occupée</option>
<option th:each="z : ${zones}" th:value="${z.name()}" th:text="${z.libelle}">Terrain Complet</option>
</select>
</div>
<!-- Jour de la semaine -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Jour de la semaine <span class="text-red-500">*</span></label>
<select th:field="*{jourSemaine}" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">Sélectionnez le jour</option>
<option th:each="j : ${jours}" th:value="${j.name()}" th:text="${j.libelle}">Lundi</option>
</select>
</div>
<!-- Horaires (Début & Fin) -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Heure de début <span class="text-red-500">*</span></label>
<input type="time" th:field="*{heureDebut}" required min="08:00" max="22:00"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Heure de fin <span class="text-red-500">*</span></label>
<input type="time" th:field="*{heureFin}" required min="08:00" max="22:00"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
</div>
<!-- Custom Validation Error Display -->
<div th:if="${#fields.hasErrors('heureDebut')}" class="text-red-500 text-xs font-semibold mt-1">
<p th:errors="*{heureDebut}"></p>
</div>
<!-- Info notice -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-xs text-gray-500">
<p class="font-semibold text-gray-700 mb-1">Information sur les plages horaires :</p>
<p>Les entraînements doivent être planifiés entre 08:00 et 22:00.</p>
<p class="mt-1">Les éventuels conflits de superposition d'horaires et de zones de terrain seront calculés et signalés automatiquement sur le tableau de bord.</p>
</div>
<!-- Actions -->
<div class="pt-4 flex justify-end space-x-3 border-t border-gray-100">
<a th:href="@{/planning}" class="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50">Annuler</a>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">Enregistrer</button>
</div>
</form>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Planning des Entraînements - AS Talange</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; }
.gantt-grid {
display: grid;
grid-template-columns: 40px 120px repeat(56, minmax(12px, 1fr));
}
</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 -->
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<h2 class="text-lg font-semibold text-gray-800">Planning & Occupation des Terrains</h2>
<div class="flex items-center space-x-2" th:if="${saisonActive != null}">
<span class="text-xs bg-green-100 text-green-800 font-semibold px-2.5 py-0.5 rounded-full" th:text="${'Saison Active : ' + saisonActive.nom}">Saison Actuelle</span>
</div>
</header>
<!-- Main section -->
<div class="flex-1 overflow-auto p-6 bg-gray-50/50">
<!-- Warnings / Errors -->
<div th:if="${noActiveSeason}" class="bg-amber-50 border-l-4 border-amber-500 p-4 mb-6 rounded-lg">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-amber-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-amber-700 font-medium">
Aucune saison active. Veuillez <a href="/saisons" class="font-bold underline hover:text-amber-800">activer une saison</a> dans le paramétrage pour planifier les entraînements.
</p>
</div>
</div>
</div>
<div th:if="${success}" class="bg-green-50 border-l-4 border-green-500 p-4 mb-6 rounded-lg">
<p class="text-sm text-green-700 font-medium" th:text="${success}"></p>
</div>
<!-- Page actions -->
<div class="flex justify-between items-center mb-6" th:if="${saisonActive != null}">
<h3 class="text-xl font-bold text-gray-900">Planning Hebdomadaire</h3>
<a th:href="@{/planning/new}" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 shadow transition-colors">
+ Ajouter un Créneau
</a>
</div>
<!-- Tabs: Days of the week -->
<div class="mb-6 border-b border-gray-200" th:if="${saisonActive != null}">
<nav class="-mb-px flex space-x-6">
<a th:each="j : ${jours}"
th:href="@{/planning(jour=${j.name()})}"
th:classappend="${j == selectedDay} ? 'border-blue-600 text-blue-600 font-semibold' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm transition-all"
th:text="${j.libelle}">
Lundi
</a>
</nav>
</div>
<!-- Gantt view -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8 p-6" th:if="${saisonActive != null}">
<div class="overflow-x-auto">
<div class="gantt-grid min-w-[900px] border border-gray-200 rounded-lg bg-white relative">
<!-- Top Hour row -->
<div class="col-span-2 text-xs font-semibold text-gray-500 bg-gray-50 p-3 border-b flex items-center justify-center border-r">Terrain / Zone</div>
<div th:each="hour : ${#numbers.sequence(8, 21)}"
style="grid-column: span 4;"
class="text-xs font-semibold text-gray-500 bg-gray-50 py-3 border-b border-l text-center"
th:text="${#strings.concat(#numbers.formatInteger(hour, 2), ':00')}">
08:00
</div>
<!-- Terrain Vert Block -->
<!-- Full width Banner -->
<div class="col-span-58 bg-blue-50/50 text-blue-800 font-bold px-4 py-1 text-xs uppercase tracking-wider flex items-center border-b" style="grid-row: 2;">
Terrain Vert
</div>
<!-- Quarters -->
<!-- Row 3: Quart 1 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 3;">V</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 3;">Quart 1</div>
<!-- Row 4: Quart 2 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 4;">V</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 4;">Quart 2</div>
<!-- Row 5: Quart 3 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 5;">V</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 5;">Quart 3</div>
<!-- Row 6: Quart 4 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 6;">V</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 6;">Quart 4</div>
<!-- Terrain Synthétique Block -->
<!-- Full width Banner -->
<div class="col-span-58 bg-purple-50/50 text-purple-800 font-bold px-4 py-1 text-xs uppercase tracking-wider flex items-center border-b border-t" style="grid-row: 7;">
Terrain Synthétique
</div>
<!-- Quarters -->
<!-- Row 8: Quart 1 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 8;">S</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 8;">Quart 1</div>
<!-- Row 9: Quart 2 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 9;">S</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 9;">Quart 2</div>
<!-- Row 10: Quart 3 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 10;">S</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 10;">Quart 3</div>
<!-- Row 11: Quart 4 -->
<div class="bg-gray-50 text-[10px] font-bold text-gray-500 flex items-center justify-center border-b border-r" style="grid-column: 1; grid-row: 11;">S</div>
<div class="bg-gray-50 text-xs font-medium text-gray-600 pl-3 py-3 border-b border-r flex items-center" style="grid-column: 2; grid-row: 11;">Quart 4</div>
<!-- Background vertical lines to build timeline slots -->
<!-- Terrain Vert rows (3, 4, 5, 6) -->
<div th:each="r : ${#numbers.sequence(3, 6)}" class="border-b border-gray-100 flex h-14" th:style="'grid-column: 3 / 59; grid-row: ' + ${r} + '; pointer-events: none;'">
<div th:each="i : ${#numbers.sequence(1, 14)}" class="flex-1 border-l border-gray-100/70 last:border-r"></div>
</div>
<!-- Terrain Synthétique rows (8, 9, 10, 11) -->
<div th:each="r : ${#numbers.sequence(8, 11)}" class="border-b border-gray-100 flex h-14" th:style="'grid-column: 3 / 59; grid-row: ' + ${r} + '; pointer-events: none;'">
<div th:each="i : ${#numbers.sequence(1, 14)}" class="flex-1 border-l border-gray-100/70 last:border-r"></div>
</div>
<!-- Scheduled slots rendered dynamically -->
<div th:each="c : ${creneaux}"
th:style="'grid-column: ' + ${c.getStartColumn()} + ' / ' + ${c.getEndColumn()} + '; grid-row: ' + ${c.getStartRow(terrainIndices.get(c.terrain.id))} + ' / ' + ${c.getEndRow(terrainIndices.get(c.terrain.id))} + ';'"
th:classappend="${c.enConflit} ? 'bg-red-50 border-red-500 text-red-800 hover:bg-red-100' : 'bg-blue-50 border-blue-500 text-blue-800 hover:bg-blue-100'"
class="border-l-4 p-2 rounded shadow-sm flex flex-col justify-between overflow-hidden cursor-pointer hover:shadow transition-all relative z-10 m-0.5">
<div class="flex justify-between items-start">
<span class="font-bold text-xs" th:text="${c.categorie.nom}">U15</span>
<!-- Delete button or info -->
<div class="flex items-center space-x-1">
<a th:href="@{/planning/{id}/edit(id=${c.id})}" class="text-[10px] text-gray-500 hover:underline">Modifier</a>
</div>
</div>
<div class="text-[10px] font-medium mt-1">
<span th:text="${c.heureDebut} + ' - ' + ${c.heureFin}">18:00 - 19:30</span>
<span class="text-gray-400 font-normal"> | </span>
<span th:text="${c.zone.libelle}">Demi-terrain A</span>
</div>
<!-- Conflict warning -->
<div th:if="${c.enConflit}" class="absolute bottom-1 right-1 text-red-500"
th:title="'Conflit d\'espace/temps avec : ' + ${#strings.listJoin(c.categoriesEnConflit, ', ')}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
</div>
</div>
</div>
</div>
</div>
<!-- List view below Gantt -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" th:if="${saisonActive != null}">
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900" th:text="${'Liste des entraînements - ' + selectedDay.libelle}">Liste des entraînements - Lundi</h3>
</div>
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50/50 text-gray-500 text-xs uppercase tracking-wider border-b border-gray-100">
<th class="py-3 px-6 font-medium">Catégorie</th>
<th class="py-3 px-6 font-medium">Terrain</th>
<th class="py-3 px-6 font-medium">Zone</th>
<th class="py-3 px-6 font-medium">Horaires</th>
<th class="py-3 px-6 font-medium text-center">Statut</th>
<th class="py-3 px-6 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-sm">
<tr th:if="${#lists.isEmpty(creneaux)}">
<td colspan="6" class="py-8 text-center text-gray-500">Aucun entraînement planifié pour ce jour.</td>
</tr>
<tr th:each="c : ${creneaux}" class="hover:bg-gray-50 transition-colors">
<td class="py-4 px-6 font-semibold text-gray-900" th:text="${c.categorie.nom}">U11</td>
<td class="py-4 px-6 text-gray-600" th:text="${c.terrain.nom}">Terrain Synthétique</td>
<td class="py-4 px-6 text-gray-600">
<span class="px-2 py-1 bg-gray-100 text-gray-800 rounded text-xs" th:text="${c.zone.libelle}">Demi A</span>
</td>
<td class="py-4 px-6 text-gray-600 font-medium" th:text="${c.heureDebut + ' - ' + c.heureFin}">14:00 - 15:30</td>
<td class="py-4 px-6 text-center">
<span th:if="${c.enConflit}"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"
th:title="'Conflit avec : ' + ${#strings.listJoin(c.categoriesEnConflit, ', ')}">
Conflit
</span>
<span th:unless="${c.enConflit}" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Disponible
</span>
</td>
<td class="py-4 px-6 text-right flex justify-end space-x-3 items-center">
<a th:href="@{/planning/{id}/edit(id=${c.id})}" class="text-indigo-600 hover:text-indigo-900 font-medium bg-indigo-50 px-3 py-1 rounded-lg">Modifier</a>
<form th:action="@{/planning/{id}/delete(id=${c.id})}" method="post" onsubmit="return confirm('Supprimer ce créneau d\'entraînement ?');">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium">Supprimer</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>