feat: add weekly training schedule planning with Gantt chart and conflict detection

This commit is contained in:
2026-05-30 00:13:55 +02:00
parent a429e67133
commit 0baca4a334
12 changed files with 933 additions and 0 deletions
@@ -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;
}
}
@@ -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,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));
}
}
@@ -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();
}
}
@@ -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>