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,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();
}
}