Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d259831c6 | |||
| 3ae296aa91 | |||
| 0baca4a334 |
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -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>
|
||||||
Reference in New Issue
Block a user