From 5fbf76868f2e45093e0a3b44cd0e657b6455db99 Mon Sep 17 00:00:00 2001 From: Loic Masi Date: Fri, 7 Aug 2026 16:13:43 +0200 Subject: [PATCH] pour prod --- README.md | 69 + README_V6.md | 37 + README_V7.md | 61 + README_V7_1.md | 10 + README_V7_2.md | 31 + README_V7_3.md | 15 + README_V7_4.md | 48 + README_V7_5.md | 18 + README_V7_6.md | 21 + README_V7_7.md | 19 + administration.php | 6 + agents.php | 6 + annual.php | 6 + api/agent_assignments.php | 12 + api/agent_hours_contract_pdf.php | 10 + api/agent_month_overview.php | 12 + api/agent_overview.php | 12 + api/agent_planning_pdf.php | 10 + api/agent_time_summary_pdf.php | 10 + api/agents.php | 12 + api/annual_overview.php | 12 + api/annual_validate.php | 12 + api/apply_week_template.php | 12 + api/apply_week_template_period.php | 12 + api/copy_agent_week.php | 12 + api/coverage_alerts.php | 12 + api/create_agent.php | 12 + api/create_structure.php | 12 + api/delete_agent_entry.php | 12 + api/delete_vacation_period.php | 12 + api/delete_week_template.php | 12 + api/move_or_duplicate_entry.php | 12 + api/pending_plannings.php | 12 + api/planning.php | 12 + api/preview_school_holidays.php | 12 + api/preview_week_template_period.php | 12 + api/pta_constraint.php | 12 + api/pta_summary.php | 12 + api/pta_wish.php | 12 + api/save_structure_coverage_rules.php | 12 + api/save_vacation_periods.php | 12 + api/save_week_template.php | 12 + api/structure_coverage_rules.php | 12 + api/structure_overview.php | 12 + api/structure_planning_pdf.php | 10 + api/team_members.php | 12 + api/team_status.php | 12 + api/teams.php | 12 + api/update_agent.php | 12 + api/update_agent_entry.php | 12 + api/update_structure.php | 12 + api/vacation_periods.php | 12 + api/week_templates.php | 12 + app/Controllers/AgentController.php | 529 ++++ app/Controllers/AnnualOverviewController.php | 210 ++ app/Controllers/CoverageController.php | 233 ++ app/Controllers/PageController.php | 271 ++ app/Controllers/PlanningController.php | 644 +++++ app/Controllers/PtaController.php | 174 ++ app/Controllers/RoleController.php | 59 + app/Controllers/StructureController.php | 225 ++ app/Controllers/TeamController.php | 144 + app/Controllers/VacationController.php | 170 ++ app/Controllers/WeekTemplateController.php | 335 +++ app/Core/Access.php | 368 +++ app/Core/Controller.php | 19 + app/Core/Csrf.php | 31 + app/Core/JsonResponse.php | 51 + app/Core/PdfResponse.php | 27 + app/Core/Request.php | 25 + app/Models/AgentModel.php | 440 +++ app/Models/AnnualOverviewModel.php | 342 +++ app/Models/BaseModel.php | 14 + app/Models/CoverageModel.php | 225 ++ app/Models/MotifModel.php | 26 + app/Models/PlanningModel.php | 986 +++++++ app/Models/PtaModel.php | 642 +++++ app/Models/QuotaModel.php | 209 ++ app/Models/SchemaModel.php | 82 + app/Models/StructureModel.php | 114 + app/Models/TeamModel.php | 236 ++ app/Models/VacationModel.php | 131 + app/Models/WeekTemplateModel.php | 861 ++++++ app/Services/AgentHoursContractPdfService.php | 208 ++ app/Services/AgentTimeSummaryPdfService.php | 184 ++ app/Services/PlanningPdfService.php | 426 +++ app/Services/SchoolHolidayApiService.php | 484 ++++ app/Services/SimplePdf.php | 291 ++ app/Views/layouts/footer.php | 36 + app/Views/layouts/header.php | 63 + app/Views/pages/administration.php | 4 + app/Views/pages/agents.php | 4 + app/Views/pages/annual.php | 4 + app/Views/pages/coverage.php | 4 + app/Views/pages/pending.php | 4 + app/Views/pages/planning.php | 4 + app/Views/pages/pta.php | 4 + app/Views/pages/role.php | 120 + app/Views/pages/structures.php | 4 + app/Views/pages/teams.php | 4 + app/Views/pages/vacations.php | 4 + app/Views/partials/administration.php | 356 +++ app/Views/partials/agent_overview.php | 136 + app/Views/partials/annual_overview.php | 101 + app/Views/partials/coverage_alerts.php | 73 + app/Views/partials/pending_validation.php | 51 + app/Views/partials/planning_editor.php | 330 +++ app/Views/partials/pta.php | 133 + app/Views/partials/structure_overview.php | 35 + app/Views/partials/teams.php | 54 + app/Views/partials/vacation_calendar.php | 144 + app/autoload.php | 17 + app/bootstrap.php | 14 + assets/js/administration.js | 482 ++++ assets/js/agent-overview.js | 535 ++++ assets/js/annual-overview.js | 306 ++ assets/js/core.js | 175 ++ assets/js/coverage.js | 284 ++ assets/js/page-bootstrap.js | 80 + assets/js/pending-validation.js | 165 ++ assets/js/planning-editor.js | 1130 ++++++++ assets/js/pta.js | 184 ++ assets/js/structure-overview.js | 177 ++ assets/js/teams.js | 158 + assets/js/vacation-calendar.js | 263 ++ assets/js/week-templates.js | 691 +++++ assets/style.css | 2555 +++++++++++++++++ config.php | 14 + coverage.php | 6 + database/install_complet.sql | 1735 +++++++++++ .../migration_agent_structure_principale.sql | 70 + database/migration_planning_motifs.sql | 449 +++ database/migration_quota_annuel_vues.sql | 145 + database/migration_v4_9_type_affectation.sql | 10 + .../migration_v5_13_couverture_structures.sql | 39 + .../migration_v5_16_informations_agents.sql | 15 + .../migration_v5_17_vacances_scolaires.sql | 36 + .../migration_v5_20_annee_contractuelle.sql | 27 + database/migration_v5_5_motifs.sql | 14 + ...ration_v5_7_type_affectation_structure.sql | 33 + database/migration_v5_9_semaines_types.sql | 60 + .../migration_v7_5_quota_annee_civile.sql | 95 + database/migration_v7_pta_annuel_equipes.sql | 313 ++ database/pta_mysql_test.sql | 1739 +++++++++++ db.php | 30 + index.php | 17 + page_bootstrap.php | 26 + pending.php | 6 + planning.php | 6 + pta.php | 6 + reopen_planning.php | 12 + role.php | 17 + save_planning.php | 12 + structures.php | 6 + teams.php | 6 + vacations.php | 6 + validate_planning.php | 12 + 157 files changed, 24085 insertions(+) create mode 100644 README.md create mode 100644 README_V6.md create mode 100644 README_V7.md create mode 100644 README_V7_1.md create mode 100644 README_V7_2.md create mode 100644 README_V7_3.md create mode 100644 README_V7_4.md create mode 100644 README_V7_5.md create mode 100644 README_V7_6.md create mode 100644 README_V7_7.md create mode 100644 administration.php create mode 100644 agents.php create mode 100644 annual.php create mode 100644 api/agent_assignments.php create mode 100644 api/agent_hours_contract_pdf.php create mode 100644 api/agent_month_overview.php create mode 100644 api/agent_overview.php create mode 100644 api/agent_planning_pdf.php create mode 100644 api/agent_time_summary_pdf.php create mode 100644 api/agents.php create mode 100644 api/annual_overview.php create mode 100644 api/annual_validate.php create mode 100644 api/apply_week_template.php create mode 100644 api/apply_week_template_period.php create mode 100644 api/copy_agent_week.php create mode 100644 api/coverage_alerts.php create mode 100644 api/create_agent.php create mode 100644 api/create_structure.php create mode 100644 api/delete_agent_entry.php create mode 100644 api/delete_vacation_period.php create mode 100644 api/delete_week_template.php create mode 100644 api/move_or_duplicate_entry.php create mode 100644 api/pending_plannings.php create mode 100644 api/planning.php create mode 100644 api/preview_school_holidays.php create mode 100644 api/preview_week_template_period.php create mode 100644 api/pta_constraint.php create mode 100644 api/pta_summary.php create mode 100644 api/pta_wish.php create mode 100644 api/save_structure_coverage_rules.php create mode 100644 api/save_vacation_periods.php create mode 100644 api/save_week_template.php create mode 100644 api/structure_coverage_rules.php create mode 100644 api/structure_overview.php create mode 100644 api/structure_planning_pdf.php create mode 100644 api/team_members.php create mode 100644 api/team_status.php create mode 100644 api/teams.php create mode 100644 api/update_agent.php create mode 100644 api/update_agent_entry.php create mode 100644 api/update_structure.php create mode 100644 api/vacation_periods.php create mode 100644 api/week_templates.php create mode 100644 app/Controllers/AgentController.php create mode 100644 app/Controllers/AnnualOverviewController.php create mode 100644 app/Controllers/CoverageController.php create mode 100644 app/Controllers/PageController.php create mode 100644 app/Controllers/PlanningController.php create mode 100644 app/Controllers/PtaController.php create mode 100644 app/Controllers/RoleController.php create mode 100644 app/Controllers/StructureController.php create mode 100644 app/Controllers/TeamController.php create mode 100644 app/Controllers/VacationController.php create mode 100644 app/Controllers/WeekTemplateController.php create mode 100644 app/Core/Access.php create mode 100644 app/Core/Controller.php create mode 100644 app/Core/Csrf.php create mode 100644 app/Core/JsonResponse.php create mode 100644 app/Core/PdfResponse.php create mode 100644 app/Core/Request.php create mode 100644 app/Models/AgentModel.php create mode 100644 app/Models/AnnualOverviewModel.php create mode 100644 app/Models/BaseModel.php create mode 100644 app/Models/CoverageModel.php create mode 100644 app/Models/MotifModel.php create mode 100644 app/Models/PlanningModel.php create mode 100644 app/Models/PtaModel.php create mode 100644 app/Models/QuotaModel.php create mode 100644 app/Models/SchemaModel.php create mode 100644 app/Models/StructureModel.php create mode 100644 app/Models/TeamModel.php create mode 100644 app/Models/VacationModel.php create mode 100644 app/Models/WeekTemplateModel.php create mode 100644 app/Services/AgentHoursContractPdfService.php create mode 100644 app/Services/AgentTimeSummaryPdfService.php create mode 100644 app/Services/PlanningPdfService.php create mode 100644 app/Services/SchoolHolidayApiService.php create mode 100644 app/Services/SimplePdf.php create mode 100644 app/Views/layouts/footer.php create mode 100644 app/Views/layouts/header.php create mode 100644 app/Views/pages/administration.php create mode 100644 app/Views/pages/agents.php create mode 100644 app/Views/pages/annual.php create mode 100644 app/Views/pages/coverage.php create mode 100644 app/Views/pages/pending.php create mode 100644 app/Views/pages/planning.php create mode 100644 app/Views/pages/pta.php create mode 100644 app/Views/pages/role.php create mode 100644 app/Views/pages/structures.php create mode 100644 app/Views/pages/teams.php create mode 100644 app/Views/pages/vacations.php create mode 100644 app/Views/partials/administration.php create mode 100644 app/Views/partials/agent_overview.php create mode 100644 app/Views/partials/annual_overview.php create mode 100644 app/Views/partials/coverage_alerts.php create mode 100644 app/Views/partials/pending_validation.php create mode 100644 app/Views/partials/planning_editor.php create mode 100644 app/Views/partials/pta.php create mode 100644 app/Views/partials/structure_overview.php create mode 100644 app/Views/partials/teams.php create mode 100644 app/Views/partials/vacation_calendar.php create mode 100644 app/autoload.php create mode 100644 app/bootstrap.php create mode 100644 assets/js/administration.js create mode 100644 assets/js/agent-overview.js create mode 100644 assets/js/annual-overview.js create mode 100644 assets/js/core.js create mode 100644 assets/js/coverage.js create mode 100644 assets/js/page-bootstrap.js create mode 100644 assets/js/pending-validation.js create mode 100644 assets/js/planning-editor.js create mode 100644 assets/js/pta.js create mode 100644 assets/js/structure-overview.js create mode 100644 assets/js/teams.js create mode 100644 assets/js/vacation-calendar.js create mode 100644 assets/js/week-templates.js create mode 100644 assets/style.css create mode 100644 config.php create mode 100644 coverage.php create mode 100644 database/install_complet.sql create mode 100644 database/migration_agent_structure_principale.sql create mode 100644 database/migration_planning_motifs.sql create mode 100644 database/migration_quota_annuel_vues.sql create mode 100644 database/migration_v4_9_type_affectation.sql create mode 100644 database/migration_v5_13_couverture_structures.sql create mode 100644 database/migration_v5_16_informations_agents.sql create mode 100644 database/migration_v5_17_vacances_scolaires.sql create mode 100644 database/migration_v5_20_annee_contractuelle.sql create mode 100644 database/migration_v5_5_motifs.sql create mode 100644 database/migration_v5_7_type_affectation_structure.sql create mode 100644 database/migration_v5_9_semaines_types.sql create mode 100644 database/migration_v7_5_quota_annee_civile.sql create mode 100644 database/migration_v7_pta_annuel_equipes.sql create mode 100644 database/pta_mysql_test.sql create mode 100644 db.php create mode 100644 index.php create mode 100644 page_bootstrap.php create mode 100644 pending.php create mode 100644 planning.php create mode 100644 pta.php create mode 100644 reopen_planning.php create mode 100644 role.php create mode 100644 save_planning.php create mode 100644 structures.php create mode 100644 teams.php create mode 100644 vacations.php create mode 100644 validate_planning.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..323df16 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# PTA V6 — application PHP MVC multipage + +PTA n’est plus une SPA. Chaque fonction principale correspond à une page PHP distincte : + +- `planning.php` : saisie, brouillon, validation et semaines types ; +- `pending.php` : plannings en attente de validation ; +- `coverage.php` : contrôle des créneaux sans agent ; +- `structures.php` : planning hebdomadaire par lieu ; +- `agents.php` : planning mensuel et bilan par agent ; +- `vacations.php` : aperçu et enregistrement des vacances scolaires ; +- `administration.php` : lieux, agents et règles de couverture. + +`index.php` redirige vers le choix de rôle lors de la première visite, puis vers la page d’accueil autorisée pour le profil sélectionné. + +## Architecture + +```text +page PHP + → PageController + → Models / Services + → View dédiée + +View + → API JSON uniquement pour les actions asynchrones utiles +``` + +Les contrôleurs métier, modèles, services PDF et endpoints API de la V5.20 sont conservés. La différence principale porte sur l’interface : plus de routeur JavaScript, plus de vues cachées et plus de chargement de tous les modules sur une seule page. + +## JavaScript chargé par page + +- Saisie : `planning-editor.js` et `week-templates.js` ; +- À valider : `pending-validation.js` ; +- Couverture : `coverage.js` ; +- Par lieu : `structure-overview.js` ; +- Par agent : `agent-overview.js` ; +- Vacances : `vacation-calendar.js` ; +- Administration : `administration.js` et la partie configuration de `coverage.js`. + +`assets/js/page-bootstrap.js` initialise uniquement les modules déclarés par la page et applique les filtres transmis dans l’URL. + +## Liens directs + +Une page de contrôle peut ouvrir directement le planning concerné : + +```text +planning.php?structure_id=2&agent_id=7&week=2026-W31&focus=validation +``` + +## Installation + +1. Configurer `config.php`. +2. Utiliser le schéma SQL correspondant à la V5.20. +3. Placer le dossier sur le serveur PHP. +4. Ouvrir `index.php` ou `planning.php`. + +Aucune migration SQL supplémentaire n’est nécessaire pour passer de la V5.20 à la V6. + +## Version 7 + +Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis/vacances et les contrôles métier associés. + +## V7.2 — Vue annuelle + +La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`. + + +## V7.4 — Rôles et droits d’accès + +La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`. diff --git a/README_V6.md b/README_V6.md new file mode 100644 index 0000000..eacee65 --- /dev/null +++ b/README_V6.md @@ -0,0 +1,37 @@ +# PTA V6 — architecture multipage simplifiée + +La V6 abandonne le fonctionnement SPA. Chaque fonction principale possède sa propre URL et sa propre vue PHP : + +- `planning.php` : saisie et validation d’un planning ; +- `pending.php` : plannings en attente de validation ; +- `coverage.php` : alertes de couverture ; +- `structures.php` : planning hebdomadaire d’un lieu ; +- `agents.php` : planning mensuel d’un agent ; +- `vacations.php` : périodes de vacances scolaires ; +- `administration.php` : agents, lieux et règles de couverture. + +`index.php` redirige vers `planning.php`. + +## JavaScript + +Il n’existe plus de routeur JavaScript et plus de chargement simultané de toutes les vues. Chaque page charge uniquement : + +1. `assets/js/core.js` ; +2. les modules nécessaires à cette page ; +3. `assets/js/page-bootstrap.js`, qui initialise les modules et les filtres transmis dans l’URL. + +Les appels `fetch()` sont conservés uniquement pour les opérations utiles sans rechargement complet : chargement d’un planning, modification/suppression d’un créneau, validation, glisser-déposer, import des vacances, semaines types, etc. + +## Liens directs + +Les pages de contrôle redirigent vers la saisie avec les bons filtres : + +```text +planning.php?structure_id=2&agent_id=7&week=2026-W31&focus=validation +``` + +Cela rend les pages partageables et utilisables avec les boutons Précédent/Suivant du navigateur. + +## Base de données + +Aucune migration SQL n’est nécessaire depuis la V5.20. diff --git a/README_V7.md b/README_V7.md new file mode 100644 index 0000000..498c820 --- /dev/null +++ b/README_V7.md @@ -0,0 +1,61 @@ +# PTA V7 — socle métier annuel et équipes + +Cette version transforme le projet multipage V6 en outil de construction et de contrôle du PTA annuel. + +## Nouvelles pages + +- `pta.php` : synthèse annuelle par agent, ventilation du temps, contrôles, contraintes médicales / TPT, préférences et interdictions de lieux. +- `teams.php` : constitution des équipes des mercredis périscolaires, des périodes extrascolaires et du périscolaire de semaine. + +## Données métier ajoutées + +- postes PTA : responsable, animation relais, animation, restauration/entretien ; +- contrat permanent ou temporaire ; +- date de fin éventuelle du contrat ; +- formation déjà prévue dans la répartition annuelle ; +- PTA annuel sur l’année civile (01/01 → 31/12) ; +- préconisations médicales et temps partiels thérapeutiques ; +- préférences/interdictions de lieux et distance domicile-lieu ; +- équipes avec effectif, qualification, responsable et statut ; +- motifs détaillés : ALP matin, restauration, ALP soir, mercredi périscolaire, extrascolaire, préparation, ménage de fond, JF et JNT. + +## Contrôles intégrés + +- éligibilité au PTA (contrat permanent et poste renseigné) ; +- quota proratisé à partir de 1 607 h ; +- périodes contractuelles ; +- durées et amplitudes journalières ; +- pause après une séquence continue ; +- repos entre deux journées ; +- durée hebdomadaire ; +- durée habituelle des mercredis et journées extrascolaires selon le poste ; +- semaines de vacances incomplètes pour les métiers d’animation ; +- préparation interdite aux agents de restauration/entretien ; +- enveloppe de formation de 14 h ; +- cible de congés et jours de fractionnement ; +- ménage de fond à deux agents sur deux journées de 7 h ; +- effectif et qualification des équipes ; +- contraintes médicales/TPT et préférences de lieu visibles avant validation. + +## Installation + +1. Sauvegarder la base. +2. Mettre à jour les fichiers PHP/JS. +3. Exécuter une seule fois : + +```sql +SOURCE database/migration_v7_pta_annuel_equipes.sql; +``` + +4. Ouvrir `administration.php` et renseigner le poste et le type de contrat des agents existants. + +## Points volontairement paramétrables + +Les ratios d’encadrement, le taux minimal de qualification et l’effectif minimal sont saisis au niveau de chaque équipe. Ils ne sont pas figés dans le code, car ils dépendent du cadre de l’accueil, de l’âge des enfants et de l’organisation locale. + +## Limites de ce socle + +- Les temps de préparation ne sont pas encore créés automatiquement à partir des heures ALP/restauration : la table `structure_creneau_reference` est prête pour cette prochaine étape. +- Les temps partiels thérapeutiques sont signalés et contrôlés, mais ils ne recalculent pas automatiquement le quota contractuel sans décision explicite. +- La génération automatique fidèle au PTA de l’année passée nécessite le fichier Excel/PDF d’origine et ses formules. +- La cible « 25 congés annuels » est interprétée comme 25 jours ; ce point doit être confirmé métier. diff --git a/README_V7_1.md b/README_V7_1.md new file mode 100644 index 0000000..4d6c201 --- /dev/null +++ b/README_V7_1.md @@ -0,0 +1,10 @@ +# PTA V7.1 — Correction du poste PTA + +Cette version corrige l'emplacement du champ **Poste PTA** dans l'administration. + +- Le lieu d'affectation ne possède aucun poste PTA. +- Le poste PTA est une caractéristique de l'agent. +- À la création d'un agent, le poste est choisi parmi : responsable d'accueil, agent d'animation relais, agent d'animation, agent de restauration collective et d'entretien. +- Le type de contrat est lui aussi renseigné sur l'agent. + +Aucune migration SQL n'est nécessaire : le modèle de données V7 stockait déjà correctement `agent.id_poste`. La correction concerne le formulaire d'administration. diff --git a/README_V7_2.md b/README_V7_2.md new file mode 100644 index 0000000..f378b17 --- /dev/null +++ b/README_V7_2.md @@ -0,0 +1,31 @@ +# PTA V7.2 — Vue annuelle de contrôle + +Cette version ajoute une page `annual.php` destinée au responsable qui contrôle le prévisionnel annuel d’un agent. + +## Fonctionnement + +- sélection d’un agent et d’une année civile ; +- affichage de janvier à décembre dans un tableau horizontal compact ; +- en-tête de synthèse : poste, lieu principal, contrat, quotité, quota et reste à affecter ; +- affichage des périodes contractuelles qui traversent l’année civile ; +- colonnes proches du tableau d’annualisation historique : + - `AP` : activité périscolaire ; + - `PP` : préparation périscolaire ; + - `AE` : activité extrascolaire ; + - `PE` : préparation extrascolaire ; +- code couleur pour le travail, la préparation, la formation, les congés, les absences, les JNT et les journées de fractionnement ; +- vacances scolaires visibles sur le calendrier ; +- contour orange pour les affectations encore en brouillon ; +- clic sur une journée pour ouvrir directement la semaine correspondante dans la saisie du planning ; +- mode plein écran et impression A3 paysage. + +## Architecture + +- Page : `annual.php` +- Vue : `app/Views/partials/annual_overview.php` +- API : `api/annual_overview.php` +- Contrôleur : `app/Controllers/AnnualOverviewController.php` +- Modèle SQL/PHP : `app/Models/AnnualOverviewModel.php` +- JavaScript ciblé : `assets/js/annual-overview.js` + +Aucune migration SQL n’est nécessaire depuis la V7.1. diff --git a/README_V7_3.md b/README_V7_3.md new file mode 100644 index 0000000..fab2e50 --- /dev/null +++ b/README_V7_3.md @@ -0,0 +1,15 @@ +# PTA V7.3 — Correctif de la recherche des vacances scolaires + +La recherche des vacances a été rendue plus robuste : + +- l'API Explore v2.0 publiée par data.gouv.fr est interrogée en priorité ; +- v2.1 et l'ancienne API 1.0 servent de solutions de repli ; +- les enregistrements `Élèves`, `Tous` et `-` sont pris en compte ; +- une graphie sans accent de l'académie est essayée automatiquement ; +- pour les académies métropolitaines, une recherche de secours par zone A/B/C est effectuée ; +- l'indisponibilité d'une des deux années scolaires ne bloque plus toute l'année civile ; +- les rentrées et prérentrées sont exclues des propositions ; +- les avertissements de publication partielle sont visibles dans la page ; +- une liste d'académies aide à éviter les erreurs de saisie. + +Aucune migration SQL n'est nécessaire. diff --git a/README_V7_4.md b/README_V7_4.md new file mode 100644 index 0000000..b24476b --- /dev/null +++ b/README_V7_4.md @@ -0,0 +1,48 @@ +# PTA V7.4 — Rôles et droits d’accès + +Cette version ajoute un choix de rôle lors de la première arrivée dans PTA. Le choix est conservé dans la session PHP. + +## Rôles + +### Agent + +- consultation de son propre planning mensuel ; +- consultation du planning hebdomadaire de son lieu principal ; +- accès en lecture seule ; +- téléchargement de ses documents PDF. + +L’agent ne peut pas modifier, supprimer, déplacer, dupliquer ou valider un créneau. + +### Responsable de structure + +- consultation du planning de son lieu ; +- consultation des agents rattachés ou planifiés sur ce lieu ; +- saisie et correction des horaires et des motifs ; +- déplacement et suppression des créneaux de son lieu ; +- consultation des alertes de couverture de son lieu. + +Toute modification reste en **brouillon**. Le responsable ne peut pas valider un planning, gérer les référentiels, appliquer les semaines types globales ni intervenir sur un autre lieu. + +### Service Enfance + +- accès à toutes les pages ; +- contrôle de tous les agents et lieux ; +- validation et réouverture des plannings ; +- préparation du prévisionnel annuel N+1 ; +- gestion des vacances, équipes, PTA annuel, couvertures et référentiels ; +- administration des agents et des lieux. + +## Sécurité + +Les contrôles sont appliqués à deux niveaux : + +1. l’interface masque ou verrouille les actions interdites ; +2. les contrôleurs PHP vérifient systématiquement le rôle et le périmètre avant toute lecture sensible ou modification. + +Le sélecteur de rôle fourni ici sert à tester les profils. Il ne constitue pas une authentification. En production, l’identité, le rôle, l’agent associé et le lieu géré doivent être obtenus depuis un compte utilisateur authentifié (annuaire, SSO ou table de comptes dédiée), et non choisis librement. + +## Installation + +Aucune migration SQL n’est nécessaire depuis la V7.3. + +Après déploiement, ouvrir `index.php`. Lors de la première visite de la session, l’application redirige vers `role.php`. diff --git a/README_V7_5.md b/README_V7_5.md new file mode 100644 index 0000000..aa46b1e --- /dev/null +++ b/README_V7_5.md @@ -0,0 +1,18 @@ +# PTA V7.5 — année civile et agents mixtes péri/extra + +## Corrections métier + +- Le quota PTA est désormais suivi sur l'année civile, du **1er janvier au 31 décembre**. +- La date de début de contrat reste conservée comme information RH mais ne décale plus la période des 1 607 h. +- Un agent n'a pas de type « périscolaire » ou « extrascolaire » : il peut participer aux deux types d'équipes au cours de la même année. +- Le lieu principal reste un rattachement par défaut et n'empêche pas une affectation sur d'autres lieux. +- Les vacances scolaires servent à déterminer le contexte scolaire / extrascolaire ; elles ne définissent pas l'année de quota. + +## Mise à jour + +Depuis la V7.4 : + +1. remplacer les fichiers applicatifs par ceux de la V7.5 ; +2. exécuter `database/migration_v7_5_quota_annee_civile.sql`. + +La migration conserve les anciennes lignes PTA contractuelles comme historique et crée, si nécessaire, une ligne annuelle civile équivalente. diff --git a/README_V7_6.md b/README_V7_6.md new file mode 100644 index 0000000..a91314f --- /dev/null +++ b/README_V7_6.md @@ -0,0 +1,21 @@ +# PTA V7.6 — Application d'une semaine type selon le calendrier + +Cette version ajoute un filtre lors de l'application d'une semaine type sur une période. + +Trois périmètres sont disponibles : +- **Toutes les semaines** : le modèle est répété sur toutes les dates du lundi au vendredi comprises dans la période. +- **Périodes scolaires uniquement** : les dates comprises dans une période de vacances validée dans PTA sont ignorées. +- **Périodes extrascolaires uniquement** : seules les dates comprises dans une période de vacances validée dans PTA sont retenues. + +Le filtre est choisi explicitement par l'utilisateur. Il n'est plus déduit du type du lieu d'affectation. + +L'aperçu avant application affiche : +- le périmètre retenu ; +- les vacances prises en compte ; +- les créneaux créés ; +- les créneaux ignorés ; +- les conflits éventuels. + +Le mode **Compléter** et le mode **Remplacer les jours concernés** restent disponibles. + +Aucune migration SQL n'est nécessaire depuis la V7.5. diff --git a/README_V7_7.md b/README_V7_7.md new file mode 100644 index 0000000..b8a8bc0 --- /dev/null +++ b/README_V7_7.md @@ -0,0 +1,19 @@ +# PTA V7.7 — Validation annuelle depuis la Vue annuelle + +## Fonctionnalité + +Réservé au rôle **Service Enfance** : la page `annual.php` propose en bas du tableau annuel le bouton **Valider toutes les semaines de l’année**. + +Le traitement : + +1. recherche tous les plannings en brouillon de l’agent contenant des créneaux dans l’année civile affichée ; +2. contrôle toutes les semaines avant toute écriture ; +3. si une erreur est présente, **aucune semaine n’est validée** ; +4. les semaines en erreur sont listées avec un bouton **Corriger cette semaine** ; +5. ce bouton ouvre `planning.php` dans un **nouvel onglet**, préchargé sur le bon agent, le bon lieu et la bonne semaine ; +6. la Vue annuelle reste donc ouverte pour servir de tableau de contrôle ; +7. une fois les corrections effectuées, il suffit de relancer la validation annuelle ; +8. si seul le quota annuel est dépassé, une confirmation explicite est demandée ; +9. si tout est correct, tous les plannings en brouillon concernés passent à `VALIDE` en une seule opération SQL. + +Aucune migration SQL n’est nécessaire. diff --git a/administration.php b/administration.php new file mode 100644 index 0000000..301ac98 --- /dev/null +++ b/administration.php @@ -0,0 +1,6 @@ +assignments(); diff --git a/api/agent_hours_contract_pdf.php b/api/agent_hours_contract_pdf.php new file mode 100644 index 0000000..3df69cc --- /dev/null +++ b/api/agent_hours_contract_pdf.php @@ -0,0 +1,10 @@ +hoursContractPdf(); diff --git a/api/agent_month_overview.php b/api/agent_month_overview.php new file mode 100644 index 0000000..b2f9b06 --- /dev/null +++ b/api/agent_month_overview.php @@ -0,0 +1,12 @@ +monthOverview(); diff --git a/api/agent_overview.php b/api/agent_overview.php new file mode 100644 index 0000000..022a497 --- /dev/null +++ b/api/agent_overview.php @@ -0,0 +1,12 @@ +overview(); diff --git a/api/agent_planning_pdf.php b/api/agent_planning_pdf.php new file mode 100644 index 0000000..202424e --- /dev/null +++ b/api/agent_planning_pdf.php @@ -0,0 +1,10 @@ +pdf(); diff --git a/api/agent_time_summary_pdf.php b/api/agent_time_summary_pdf.php new file mode 100644 index 0000000..56a2e2b --- /dev/null +++ b/api/agent_time_summary_pdf.php @@ -0,0 +1,10 @@ +timeSummaryPdf(); diff --git a/api/agents.php b/api/agents.php new file mode 100644 index 0000000..563c090 --- /dev/null +++ b/api/agents.php @@ -0,0 +1,12 @@ +listForPlanning(); diff --git a/api/annual_overview.php b/api/annual_overview.php new file mode 100644 index 0000000..d011411 --- /dev/null +++ b/api/annual_overview.php @@ -0,0 +1,12 @@ +overview(); diff --git a/api/annual_validate.php b/api/annual_validate.php new file mode 100644 index 0000000..f164237 --- /dev/null +++ b/api/annual_validate.php @@ -0,0 +1,12 @@ +validateYear(); diff --git a/api/apply_week_template.php b/api/apply_week_template.php new file mode 100644 index 0000000..7e13519 --- /dev/null +++ b/api/apply_week_template.php @@ -0,0 +1,12 @@ +apply(); diff --git a/api/apply_week_template_period.php b/api/apply_week_template_period.php new file mode 100644 index 0000000..b4105d6 --- /dev/null +++ b/api/apply_week_template_period.php @@ -0,0 +1,12 @@ +applyPeriod(); diff --git a/api/copy_agent_week.php b/api/copy_agent_week.php new file mode 100644 index 0000000..7c8f312 --- /dev/null +++ b/api/copy_agent_week.php @@ -0,0 +1,12 @@ +copyAgentWeek(); diff --git a/api/coverage_alerts.php b/api/coverage_alerts.php new file mode 100644 index 0000000..741160a --- /dev/null +++ b/api/coverage_alerts.php @@ -0,0 +1,12 @@ +alerts(); diff --git a/api/create_agent.php b/api/create_agent.php new file mode 100644 index 0000000..60d68c4 --- /dev/null +++ b/api/create_agent.php @@ -0,0 +1,12 @@ +create(); diff --git a/api/create_structure.php b/api/create_structure.php new file mode 100644 index 0000000..47b754c --- /dev/null +++ b/api/create_structure.php @@ -0,0 +1,12 @@ +create(); diff --git a/api/delete_agent_entry.php b/api/delete_agent_entry.php new file mode 100644 index 0000000..fc607a7 --- /dev/null +++ b/api/delete_agent_entry.php @@ -0,0 +1,12 @@ +deleteAgentEntry(); diff --git a/api/delete_vacation_period.php b/api/delete_vacation_period.php new file mode 100644 index 0000000..93c8a40 --- /dev/null +++ b/api/delete_vacation_period.php @@ -0,0 +1,12 @@ +delete(); diff --git a/api/delete_week_template.php b/api/delete_week_template.php new file mode 100644 index 0000000..e819d94 --- /dev/null +++ b/api/delete_week_template.php @@ -0,0 +1,12 @@ +destroy(); diff --git a/api/move_or_duplicate_entry.php b/api/move_or_duplicate_entry.php new file mode 100644 index 0000000..3366cf8 --- /dev/null +++ b/api/move_or_duplicate_entry.php @@ -0,0 +1,12 @@ +moveOrDuplicateEntry(); diff --git a/api/pending_plannings.php b/api/pending_plannings.php new file mode 100644 index 0000000..d1ea996 --- /dev/null +++ b/api/pending_plannings.php @@ -0,0 +1,12 @@ +pendingValidation(); diff --git a/api/planning.php b/api/planning.php new file mode 100644 index 0000000..8c90dd6 --- /dev/null +++ b/api/planning.php @@ -0,0 +1,12 @@ +show(); diff --git a/api/preview_school_holidays.php b/api/preview_school_holidays.php new file mode 100644 index 0000000..facc083 --- /dev/null +++ b/api/preview_school_holidays.php @@ -0,0 +1,12 @@ +preview(); diff --git a/api/preview_week_template_period.php b/api/preview_week_template_period.php new file mode 100644 index 0000000..c92f450 --- /dev/null +++ b/api/preview_week_template_period.php @@ -0,0 +1,12 @@ +previewPeriod(); diff --git a/api/pta_constraint.php b/api/pta_constraint.php new file mode 100644 index 0000000..0ff25a1 --- /dev/null +++ b/api/pta_constraint.php @@ -0,0 +1,12 @@ +constraint(); diff --git a/api/pta_summary.php b/api/pta_summary.php new file mode 100644 index 0000000..7938517 --- /dev/null +++ b/api/pta_summary.php @@ -0,0 +1,12 @@ +summary(); diff --git a/api/pta_wish.php b/api/pta_wish.php new file mode 100644 index 0000000..8583ce2 --- /dev/null +++ b/api/pta_wish.php @@ -0,0 +1,12 @@ +wish(); diff --git a/api/save_structure_coverage_rules.php b/api/save_structure_coverage_rules.php new file mode 100644 index 0000000..10222e2 --- /dev/null +++ b/api/save_structure_coverage_rules.php @@ -0,0 +1,12 @@ +saveRules(); diff --git a/api/save_vacation_periods.php b/api/save_vacation_periods.php new file mode 100644 index 0000000..5ae31dd --- /dev/null +++ b/api/save_vacation_periods.php @@ -0,0 +1,12 @@ +save(); diff --git a/api/save_week_template.php b/api/save_week_template.php new file mode 100644 index 0000000..8f9a9a5 --- /dev/null +++ b/api/save_week_template.php @@ -0,0 +1,12 @@ +store(); diff --git a/api/structure_coverage_rules.php b/api/structure_coverage_rules.php new file mode 100644 index 0000000..ce0a208 --- /dev/null +++ b/api/structure_coverage_rules.php @@ -0,0 +1,12 @@ +rules(); diff --git a/api/structure_overview.php b/api/structure_overview.php new file mode 100644 index 0000000..654b499 --- /dev/null +++ b/api/structure_overview.php @@ -0,0 +1,12 @@ +overview(); diff --git a/api/structure_planning_pdf.php b/api/structure_planning_pdf.php new file mode 100644 index 0000000..9ef0080 --- /dev/null +++ b/api/structure_planning_pdf.php @@ -0,0 +1,10 @@ +pdf(); diff --git a/api/team_members.php b/api/team_members.php new file mode 100644 index 0000000..1f59272 --- /dev/null +++ b/api/team_members.php @@ -0,0 +1,12 @@ +member(); diff --git a/api/team_status.php b/api/team_status.php new file mode 100644 index 0000000..42b64d5 --- /dev/null +++ b/api/team_status.php @@ -0,0 +1,12 @@ +status(); diff --git a/api/teams.php b/api/teams.php new file mode 100644 index 0000000..ad5a11a --- /dev/null +++ b/api/teams.php @@ -0,0 +1,12 @@ +index(); diff --git a/api/update_agent.php b/api/update_agent.php new file mode 100644 index 0000000..573b26c --- /dev/null +++ b/api/update_agent.php @@ -0,0 +1,12 @@ +updateProfile(); diff --git a/api/update_agent_entry.php b/api/update_agent_entry.php new file mode 100644 index 0000000..0438955 --- /dev/null +++ b/api/update_agent_entry.php @@ -0,0 +1,12 @@ +updateAgentEntry(); diff --git a/api/update_structure.php b/api/update_structure.php new file mode 100644 index 0000000..5a2ce11 --- /dev/null +++ b/api/update_structure.php @@ -0,0 +1,12 @@ +updateType(); diff --git a/api/vacation_periods.php b/api/vacation_periods.php new file mode 100644 index 0000000..302c4a0 --- /dev/null +++ b/api/vacation_periods.php @@ -0,0 +1,12 @@ +index(); diff --git a/api/week_templates.php b/api/week_templates.php new file mode 100644 index 0000000..40cc564 --- /dev/null +++ b/api/week_templates.php @@ -0,0 +1,12 @@ +index(); diff --git a/app/Controllers/AgentController.php b/app/Controllers/AgentController.php new file mode 100644 index 0000000..10322f4 --- /dev/null +++ b/app/Controllers/AgentController.php @@ -0,0 +1,529 @@ +agentModel = new AgentModel($pdo); + $this->structureModel = new StructureModel($pdo); + $this->quotaModel = new QuotaModel($pdo); + $this->access = new Access($pdo); + } + + public function listForPlanning(): void + { + $this->access->requireDraftEditor(); + $selectedLocation = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT) ?: 0; + if ($selectedLocation) { + $this->access->requireStructureEdit((int) $selectedLocation); + } + JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]); + } + + public function overview(): void + { + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $weekValue = trim((string) ($_GET['week'] ?? '')); + $week = $this->parseWeek($weekValue); + + if (!$agentId || $week === null) { + JsonResponse::send(400, ['error' => 'Agent ou semaine invalide.']); + } + $this->access->requireAgentView((int) $agentId); + + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable.']); + } + + [$year, $weekNumber] = $week; + $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); + $weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d'); + + JsonResponse::send(200, [ + 'agent' => $agent, + 'week' => ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd], + 'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStart), + 'entries' => $this->agentModel->entriesForWeek((int) $agentId, $year, $weekNumber), + ]); + } + + public function monthOverview(): void + { + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $monthValue = trim((string) ($_GET['month'] ?? '')); + + if (!$agentId || !preg_match('/^(\d{4})-(\d{2})$/', $monthValue, $matches)) { + JsonResponse::send(400, ['error' => 'Agent ou mois invalide.']); + } + $this->access->requireAgentView((int) $agentId); + + $year = (int) $matches[1]; + $monthNumber = (int) $matches[2]; + if ($monthNumber < 1 || $monthNumber > 12) { + JsonResponse::send(400, ['error' => 'Mois invalide.']); + } + + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable.']); + } + + $firstDay = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $monthNumber)); + $lastDay = $firstDay->modify('last day of this month'); + $gridStart = $firstDay->modify('-' . ((int) $firstDay->format('N') - 1) . ' days'); + $lastWeekMonday = $lastDay->modify('-' . ((int) $lastDay->format('N') - 1) . ' days'); + $gridEnd = $lastWeekMonday->modify('+4 days'); + + $dayLabels = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi']; + $monthLabels = [ + 1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril', 5 => 'Mai', 6 => 'Juin', + 7 => 'Juillet', 8 => 'Août', 9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre', + ]; + $weeks = []; + + for ($monday = $gridStart; $monday <= $lastWeekMonday; $monday = $monday->modify('+7 days')) { + $days = []; + for ($weekday = 1; $weekday <= 5; $weekday++) { + $date = $monday->modify('+' . ($weekday - 1) . ' days'); + $days[] = [ + 'date' => $date->format('Y-m-d'), + 'label' => $dayLabels[$weekday], + 'display' => $date->format('d/m'), + 'in_month' => (int) $date->format('n') === $monthNumber, + ]; + } + $weeks[] = [ + 'value' => sprintf('%04d-W%02d', (int) $monday->format('o'), (int) $monday->format('W')), + 'number' => (int) $monday->format('W'), + 'date_debut' => $monday->format('Y-m-d'), + 'date_fin' => $monday->modify('+4 days')->format('Y-m-d'), + 'days' => $days, + ]; + } + + JsonResponse::send(200, [ + 'agent' => $agent, + 'month' => [ + 'value' => $monthValue, + 'year' => $year, + 'month' => $monthNumber, + 'label' => $monthLabels[$monthNumber] . ' ' . $year, + 'date_debut' => $firstDay->format('Y-m-d'), + 'date_fin' => $lastDay->format('Y-m-d'), + 'grid_start' => $gridStart->format('Y-m-d'), + 'grid_end' => $gridEnd->format('Y-m-d'), + ], + 'weeks' => $weeks, + 'quota' => $this->quotaModel->forDateForApi((int) $agentId, $firstDay->format('Y-m-d')), + 'entries' => $this->agentModel->entriesForDateRange( + (int) $agentId, + $gridStart->format('Y-m-d'), + $gridEnd->format('Y-m-d') + ), + ]); + } + + public function timeSummaryPdf(): void + { + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $dateValue = trim((string) ($_GET['date'] ?? '')); + $year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT); + + if ($dateValue === '' && $year) { + $dateValue = sprintf('%04d-01-01', (int) $year); + } + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $dateValue); + + if (!$agentId || !$date || $date->format('Y-m-d') !== $dateValue) { + http_response_code(400); + echo 'Agent ou date de référence invalide.'; + return; + } + $this->access->requireAgentView((int) $agentId); + + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { + http_response_code(404); + echo 'Agent introuvable.'; + return; + } + + $quota = $this->quotaModel->forDateForApi((int) $agentId, $dateValue); + $summary = $this->agentModel->timeSummaryForRange( + (int) $agentId, + (string) $quota['date_debut_periode'], + (string) $quota['date_fin_periode'] + ); + $pdf = (new AgentTimeSummaryPdfService())->annualSummary($agent, $quota, $summary); + + PdfResponse::inline( + $pdf, + sprintf( + 'bilan_heures_%s_%s_%s_%s.pdf', + $agent['nom'], + $agent['prenom'], + str_replace('-', '', (string) $quota['date_debut_periode']), + str_replace('-', '', (string) $quota['date_fin_periode']) + ) + ); + } + + public function pdf(): void + { + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $weekValue = trim((string) ($_GET['week'] ?? '')); + $week = $this->parseWeek($weekValue); + $weekCount = filter_input(INPUT_GET, 'weeks', FILTER_VALIDATE_INT) ?: 7; + $weekCount = in_array((int) $weekCount, [6, 7], true) ? (int) $weekCount : 7; + + if (!$agentId || $week === null) { + http_response_code(400); + echo 'Agent ou semaine invalide.'; + return; + } + $this->access->requireAgentView((int) $agentId); + + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { + http_response_code(404); + echo 'Agent introuvable.'; + return; + } + + [$year, $weekNumber] = $week; + $firstMonday = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1); + $weeks = []; + + for ($index = 0; $index < $weekCount; $index++) { + $monday = $firstMonday->modify('+' . ($index * 7) . ' days'); + $isoYear = (int) $monday->format('o'); + $isoWeek = (int) $monday->format('W'); + $weeks[] = [ + 'value' => sprintf('%04d-W%02d', $isoYear, $isoWeek), + 'date_debut' => $monday->format('Y-m-d'), + 'date_fin' => $monday->modify('+6 days')->format('Y-m-d'), + ]; + } + + $lastFriday = $firstMonday->modify('+' . (($weekCount - 1) * 7 + 4) . ' days'); + $entries = $this->agentModel->entriesForDateRange( + (int) $agentId, + $firstMonday->format('Y-m-d'), + $lastFriday->format('Y-m-d') + ); + + $pdf = (new PlanningPdfService())->agentWeeks( + $agent, + $entries, + $weeks, + $this->quotaModel->forDateForApi((int) $agentId, $firstMonday->format('Y-m-d')) + ); + + PdfResponse::inline( + $pdf, + sprintf( + 'planning_%s_%s_%s_%d_semaines.pdf', + $agent['nom'], + $agent['prenom'], + str_replace('-W', '_S', $weekValue), + $weekCount + ) + ); + } + + public function hoursContractPdf(): void + { + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $dateValue = trim((string) ($_GET['date'] ?? '')); + $year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT); + + if ($dateValue === '' && $year) { + $dateValue = sprintf('%04d-01-01', (int) $year); + } + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $dateValue); + + if (!$agentId || !$date || $date->format('Y-m-d') !== $dateValue) { + http_response_code(400); + echo 'Agent ou date de référence invalide.'; + return; + } + $this->access->requireAgentView((int) $agentId); + + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { + http_response_code(404); + echo 'Agent introuvable.'; + return; + } + + $quota = $this->quotaModel->forDateForApi((int) $agentId, $dateValue); + $pdf = (new AgentHoursContractPdfService())->annualContract($agent, $quota); + + PdfResponse::inline( + $pdf, + sprintf( + 'contrat_horaire_%s_%s_%s.pdf', + $agent['nom'], + $agent['prenom'], + str_replace('-', '', (string) $quota['date_debut_periode']) + ) + ); + } + + public function assignments(): void + { + $this->access->requireService(); + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') { + JsonResponse::send(200, ['agents' => $this->agentModel->allActive()]); + } + + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $rawStructure = $payload['structure_id'] ?? null; + $structureId = ($rawStructure === null || $rawStructure === '' || $rawStructure === 0) + ? null + : filter_var($rawStructure, FILTER_VALIDATE_INT); + if (!$agentId) { + JsonResponse::send(422, ['error' => 'Agent invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable.']); + } + if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) { + JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); + } + + try { + $this->agentModel->updateAssignment((int) $agentId, $structureId !== null ? (int) $structureId : null); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de modifier les paramètres de l’agent.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'message' => $structureId === null + ? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.' + : 'Le lieu principal de l’agent a été mis à jour.', + ]); + } + + public function create(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $matricule = trim((string) ($payload['matricule'] ?? '')); + $nom = trim((string) ($payload['nom'] ?? '')); + $prenom = trim((string) ($payload['prenom'] ?? '')); + $email = trim((string) ($payload['email'] ?? '')); + $telephone = trim((string) ($payload['telephone'] ?? '')); + $adresse = trim((string) ($payload['adresse'] ?? '')); + $estDiplome = filter_var($payload['est_diplome'] ?? false, FILTER_VALIDATE_BOOLEAN); + $diplomeLibelle = trim((string) ($payload['diplome_libelle'] ?? '')); + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); + $rate = filter_var($payload['quotite_travail'] ?? null, FILTER_VALIDATE_FLOAT); + $contractStart = trim((string) ($payload['date_debut_contrat'] ?? '')); + $contractStartDate = DateTimeImmutable::createFromFormat('!Y-m-d', $contractStart); + $contractEnd = trim((string) ($payload['date_fin_contrat'] ?? '')); + $contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null; + $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); + $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); + $trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); + $ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); + $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); + + if ($matricule === '' || $nom === '' || $prenom === '' || !$structureId || !$postId + || !in_array((int) $postId, $validPostIds, true) + || !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true) + || !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart + || ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) { + JsonResponse::send(422, ['error' => 'Le matricule, le nom, le prénom, le poste, le type de contrat, le lieu principal et des dates de contrat cohérentes sont obligatoires.']); + } + if ($rate === false || $rate <= 0 || $rate > 100) { + JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']); + } + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']); + } + if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 + || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { + JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']); + } + if ($this->structureModel->findActive((int) $structureId) === null) { + JsonResponse::send(422, ['error' => 'Le lieu d’affectation principal sélectionné est invalide ou inactif.']); + } + if ($this->agentModel->matriculeExists($matricule)) { + JsonResponse::send(409, ['error' => 'Un agent utilise déjà ce matricule.']); + } + + try { + $created = $this->agentModel->createWithDefaults([ + 'matricule' => $matricule, + 'nom' => $nom, + 'prenom' => $prenom, + 'email' => $email !== '' ? $email : null, + 'telephone' => $telephone !== '' ? $telephone : null, + 'adresse' => $adresse !== '' ? $adresse : null, + 'est_diplome' => $estDiplome, + 'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null, + 'structure_id' => (int) $structureId, + 'quotite_travail' => (float) $rate, + 'date_debut_contrat' => $contractStart, + 'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null, + 'id_poste' => (int) $postId, + 'type_contrat' => $contractType, + 'formation_repartition_annuelle' => $trainingPlanned, + 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, + ]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de créer l’agent.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + $structure = $this->structureModel->findActive((int) $structureId); + JsonResponse::send(201, [ + 'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']), + 'agent' => array_merge($created, [ + 'matricule' => $matricule, + 'nom' => $nom, + 'prenom' => $prenom, + 'email' => $email !== '' ? $email : null, + 'telephone' => $telephone !== '' ? $telephone : null, + 'adresse' => $adresse !== '' ? $adresse : null, + 'est_diplome' => $estDiplome ? 1 : 0, + 'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null, + 'id_structure' => (int) $structureId, + 'structure_nom' => (string) $structure['nom'], + 'structure_type_affectation' => (string) $structure['type_affectation'], + 'quotite_travail' => (float) $rate, + 'date_debut_contrat' => $contractStart, + 'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null, + 'id_poste' => (int) $postId, + 'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''), + 'type_contrat' => $contractType, + 'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0, + 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, + ]), + ]); + } + + public function updateProfile(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $email = trim((string) ($payload['email'] ?? '')); + $telephone = trim((string) ($payload['telephone'] ?? '')); + $adresse = trim((string) ($payload['adresse'] ?? '')); + $estDiplome = filter_var($payload['est_diplome'] ?? false, FILTER_VALIDATE_BOOLEAN); + $diplomeLibelle = trim((string) ($payload['diplome_libelle'] ?? '')); + $contractStart = trim((string) ($payload['date_debut_contrat'] ?? '')); + $contractStartDate = DateTimeImmutable::createFromFormat('!Y-m-d', $contractStart); + $contractEnd = trim((string) ($payload['date_fin_contrat'] ?? '')); + $contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null; + $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); + $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); + $trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); + $ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); + $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); + $rawStructure = $payload['structure_id'] ?? null; + $structureId = ($rawStructure === null || $rawStructure === '' || $rawStructure === 0) + ? null + : filter_var($rawStructure, FILTER_VALIDATE_INT); + + if (!$agentId) { + JsonResponse::send(422, ['error' => 'Agent invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable.']); + } + if (!$postId || !in_array((int) $postId, $validPostIds, true) + || !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true) + || !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart + || ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) { + JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']); + } + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']); + } + if (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { + JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']); + } + if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) { + JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']); + } + + try { + $this->agentModel->updateProfile((int) $agentId, [ + 'email' => $email !== '' ? $email : null, + 'telephone' => $telephone !== '' ? $telephone : null, + 'adresse' => $adresse !== '' ? $adresse : null, + 'est_diplome' => $estDiplome, + 'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null, + 'date_debut_contrat' => $contractStart, + 'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null, + 'id_poste' => (int) $postId, + 'type_contrat' => $contractType, + 'formation_repartition_annuelle' => $trainingPlanned, + 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, + 'structure_id' => $structureId !== null ? (int) $structureId : null, + ]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de modifier les informations de l’agent.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'message' => 'Les informations de l’agent ont été mises à jour.', + 'agent' => $this->agentModel->findActive((int) $agentId), + ]); + } + + private function parseWeek(string $value): ?array + { + if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { + return null; + } + $week = (int) $matches[2]; + return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null; + } +} diff --git a/app/Controllers/AnnualOverviewController.php b/app/Controllers/AnnualOverviewController.php new file mode 100644 index 0000000..8ee008c --- /dev/null +++ b/app/Controllers/AnnualOverviewController.php @@ -0,0 +1,210 @@ +pdo))->requireService(); + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT); + $year ??= (int) date('Y'); + + if (!$agentId || $year < 2000 || $year > 2100) { + JsonResponse::send(422, ['error' => 'Agent ou année invalide.']); + } + + try { + JsonResponse::send(200, (new AnnualOverviewModel($this->pdo))->overview((int) $agentId, (int) $year)); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de générer la vue annuelle.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + } + + /** + * Contrôle puis validation en une seule opération de tous les plannings + * en brouillon de l'agent pour l'année civile affichée. + * + * Aucune validation partielle : si une erreur est trouvée, aucun planning + * n'est validé. Les semaines concernées sont retournées au front afin de + * pouvoir les ouvrir dans un nouvel onglet et les corriger. + */ + public function validateYear(): void + { + (new Access($this->pdo))->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $year = filter_var($payload['year'] ?? null, FILTER_VALIDATE_INT); + $forceQuota = filter_var($payload['force_quota'] ?? false, FILTER_VALIDATE_BOOLEAN); + + if (!$agentId || !$year || $year < 2000 || $year > 2100) { + JsonResponse::send(422, ['error' => 'Agent ou année invalide.']); + } + + try { + $planningModel = new PlanningModel($this->pdo); + $quotaModel = new QuotaModel($this->pdo); + $plannings = $planningModel->pendingValidationForAgentYear((int) $agentId, (int) $year); + + if ($plannings === []) { + JsonResponse::send(200, [ + 'success' => true, + 'status' => 'nothing_to_validate', + 'message' => 'Aucun planning en brouillon avec des créneaux n’est à valider pour cette année.', + 'validated_count' => 0, + 'plannings' => [], + 'quota' => $quotaModel->annualForApi((int) $agentId, (int) $year), + ]); + } + + $results = []; + $hasErrors = false; + foreach ($plannings as $planning) { + $controls = $this->controlsForPlanning($planningModel, $planning); + $errors = array_values(array_filter( + $controls, + static fn(array $row): bool => $row['niveau'] === 'ERREUR' + )); + $hasErrors = $hasErrors || $errors !== []; + $results[] = [ + 'id_planning' => (int) $planning['id_planning'], + 'id_structure' => (int) $planning['id_structure'], + 'structure_nom' => (string) $planning['structure_nom'], + 'annee' => (int) $planning['annee'], + 'numero_semaine' => (int) $planning['numero_semaine'], + 'date_debut' => (string) $planning['date_debut'], + 'date_fin' => (string) $planning['date_fin'], + 'nombre_creneaux' => (int) $planning['nombre_creneaux'], + 'total_minutes' => (int) $planning['total_minutes'], + 'controls' => $controls, + 'has_errors' => $errors !== [], + ]; + } + + $quota = $quotaModel->annualForApi((int) $agentId, (int) $year); + $quotaWarning = (int) $quota['minutes_restantes'] < 0; + + if ($hasErrors) { + JsonResponse::send(422, [ + 'success' => false, + 'status' => 'errors', + 'error' => 'Certaines semaines contiennent des erreurs. Aucune semaine n’a été validée.', + 'plannings' => $results, + 'quota' => $quota, + ]); + } + + if ($quotaWarning && !$forceQuota) { + JsonResponse::send(409, [ + 'success' => false, + 'status' => 'warning', + 'message' => sprintf( + 'Le quota annuel est dépassé de %s. Confirmez pour valider malgré cet avertissement.', + $quotaModel->formatMinutes(abs((int) $quota['minutes_restantes']))['libelle'] + ), + 'plannings' => $results, + 'quota' => $quota, + ]); + } + + $ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings); + $validatedCount = $planningModel->markValidatedMany($ids); + + JsonResponse::send(200, [ + 'success' => true, + 'status' => 'validated', + 'validated_count' => $validatedCount, + 'message' => sprintf( + '%d planning(s) ont été validés pour l’année %d.', + $validatedCount, + (int) $year + ), + 'plannings' => $results, + 'quota' => $quotaModel->annualForApi((int) $agentId, (int) $year), + ]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de contrôler ou valider l’année complète.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + } + + private function controlsForPlanning(PlanningModel $model, array $planning): array + { + $planningId = (int) $planning['id_planning']; + $controls = []; + + if ($model->countEntries($planningId) === 0) { + $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); + } + + foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + sprintf('L’affectation #%d du %s est en dehors de la semaine sélectionnée.', $row['id_creneau'], $row['date_jour']) + ); + } + + foreach ($model->internalOverlaps($planningId) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + sprintf('Les affectations #%d et #%d se chevauchent le %s.', $row['c1'], $row['c2'], $row['date_jour']) + ); + } + + foreach ($model->externalOverlaps($planningId) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_STRUCTURE', + sprintf( + 'L’affectation #%d chevauche l’affectation #%d sur le lieu « %s » le %s (%s).', + $row['c1'], + $row['c2'], + $row['structure_nom'], + $row['date_jour'], + strtolower((string) $row['autre_statut']) + ) + ); + } + + if ($controls === []) { + $this->addControl($controls, 'INFO', 'OK', 'Aucune anomalie bloquante détectée pour cette semaine.'); + } + + return $controls; + } + + private function addControl(array &$controls, string $level, string $code, string $message): void + { + $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; + } +} diff --git a/app/Controllers/CoverageController.php b/app/Controllers/CoverageController.php new file mode 100644 index 0000000..8bef9fe --- /dev/null +++ b/app/Controllers/CoverageController.php @@ -0,0 +1,233 @@ +coverageModel = new CoverageModel($pdo); + $this->structureModel = new StructureModel($pdo); + $this->access = new Access($pdo); + } + + public function alerts(): void + { + $this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]); + $weekValue = trim((string) ($_GET['week'] ?? '')); + $week = $this->parseWeek($weekValue); + $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); + if ($this->access->role() === Access::ROLE_RESPONSABLE) { + $structureId = $this->access->structureId(); + } + + if ($week === null) { + JsonResponse::send(400, ['error' => 'Semaine invalide.']); + } + + [$year, $weekNumber] = $week; + $structures = $this->structureModel->allActive(); + if ($structureId) { + $structures = array_values(array_filter( + $structures, + static fn (array $structure): bool => (int) $structure['id_structure'] === (int) $structureId + )); + } + + $alerts = []; + $validationWarnings = []; + $unconfigured = []; + $affectedStructures = []; + $gapMinutes = 0; + + foreach ($structures as $structure) { + $id = (int) $structure['id_structure']; + $report = $this->coverageModel->reportForStructure($id, $year, $weekNumber); + + if (!$report['configured']) { + $unconfigured[] = [ + 'id_structure' => $id, + 'nom' => $structure['nom'], + 'code' => $structure['code'], + ]; + continue; + } + + foreach ($report['gaps'] as $gap) { + $gap['id_structure'] = $id; + $gap['structure_nom'] = $structure['nom']; + $gap['structure_code'] = $structure['code']; + $gap['structure_type_affectation'] = $structure['type_affectation']; + $alerts[] = $gap; + $affectedStructures[$id] = true; + $gapMinutes += $this->durationMinutes($gap['heure_debut'], $gap['heure_fin']); + } + + foreach ($report['validation_warnings'] as $warning) { + $warning['id_structure'] = $id; + $warning['structure_nom'] = $structure['nom']; + $validationWarnings[] = $warning; + } + } + + JsonResponse::send(200, [ + 'week' => $weekValue, + 'alerts' => $alerts, + 'validation_warnings' => $validationWarnings, + 'unconfigured_structures' => $unconfigured, + 'summary' => [ + 'gap_count' => count($alerts), + 'affected_structure_count' => count($affectedStructures), + 'gap_minutes' => $gapMinutes, + 'validation_warning_count' => count($validationWarnings), + 'unconfigured_structure_count' => count($unconfigured), + ], + ]); + } + + public function rules(): void + { + $this->access->requireService(); + $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); + if (!$structureId) { + JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide.']); + } + + $structure = $this->structureModel->findActive((int) $structureId); + if ($structure === null) { + JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); + } + + JsonResponse::send(200, [ + 'structure' => $structure, + 'rules' => $this->coverageModel->rulesForStructure((int) $structureId), + ]); + } + + public function saveRules(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); + $rawRules = $payload['rules'] ?? null; + + if (!$structureId || !is_array($rawRules)) { + JsonResponse::send(422, ['error' => 'Paramètres de couverture invalides.']); + } + if ($this->structureModel->findActive((int) $structureId) === null) { + JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); + } + + $rules = []; + foreach ($rawRules as $index => $rawRule) { + if (!is_array($rawRule)) { + JsonResponse::send(422, ['error' => 'Une règle de couverture est invalide.']); + } + + $day = filter_var($rawRule['jour_semaine'] ?? null, FILTER_VALIDATE_INT); + $start = trim((string) ($rawRule['heure_debut'] ?? '')); + $end = trim((string) ($rawRule['heure_fin'] ?? '')); + $minimum = filter_var($rawRule['minimum_agents'] ?? 1, FILTER_VALIDATE_INT); + + if (!$day || $day < 1 || $day > 5 || !$this->validQuarterHour($start) || !$this->validQuarterHour($end)) { + JsonResponse::send(422, ['error' => sprintf('La règle de couverture n°%d contient un jour ou un horaire invalide.', $index + 1)]); + } + if ($this->timeToMinutes($end) <= $this->timeToMinutes($start)) { + JsonResponse::send(422, ['error' => sprintf('La fin doit être postérieure au début pour la règle n°%d.', $index + 1)]); + } + if (!$minimum || $minimum < 1 || $minimum > 50) { + JsonResponse::send(422, ['error' => 'Le nombre minimum d’agents doit être compris entre 1 et 50.']); + } + + $rules[] = [ + 'jour_semaine' => (int) $day, + 'heure_debut' => $start, + 'heure_fin' => $end, + 'minimum_agents' => (int) $minimum, + ]; + } + + $this->assertNoOverlap($rules); + + try { + $this->coverageModel->replaceRules((int) $structureId, $rules); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible d’enregistrer les horaires de couverture.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'message' => 'Les horaires de couverture du lieu ont été enregistrés.', + 'rules' => $this->coverageModel->rulesForStructure((int) $structureId), + ]); + } + + private function assertNoOverlap(array $rules): void + { + $byDay = []; + foreach ($rules as $rule) { + $byDay[(int) $rule['jour_semaine']][] = $rule; + } + + foreach ($byDay as $day => $dayRules) { + usort($dayRules, fn (array $a, array $b): int => $this->timeToMinutes($a['heure_debut']) <=> $this->timeToMinutes($b['heure_debut'])); + $previousEnd = null; + foreach ($dayRules as $rule) { + $start = $this->timeToMinutes($rule['heure_debut']); + $end = $this->timeToMinutes($rule['heure_fin']); + if ($previousEnd !== null && $start < $previousEnd) { + JsonResponse::send(422, ['error' => sprintf('Deux plages de couverture se chevauchent pour le jour %d.', $day)]); + } + $previousEnd = $end; + } + } + } + + private function validQuarterHour(string $time): bool + { + if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) { + return false; + } + return true; + } + + private function timeToMinutes(string $time): int + { + [$hours, $minutes] = array_map('intval', explode(':', $time)); + return ($hours * 60) + $minutes; + } + + private function durationMinutes(string $start, string $end): int + { + return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start)); + } + + private function parseWeek(string $value): ?array + { + if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { + return null; + } + $week = (int) $matches[2]; + return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null; + } +} diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php new file mode 100644 index 0000000..bb78b62 --- /dev/null +++ b/app/Controllers/PageController.php @@ -0,0 +1,271 @@ +access = new Access($pdo); + } + + public function planning(): void + { + $this->access->requirePage('planning'); + $forcedStructure = $this->access->editableStructureId(); + $permissions = $this->access->permissions(); + $scripts = ['planning-editor.js']; + $modules = ['planning']; + if (!empty($permissions['can_use_templates'])) { + $scripts[] = 'week-templates.js'; + $modules[] = 'weekTemplates'; + } + $this->renderPage('planning', 'Saisie du planning', 'planning', [ + 'structures' => $this->loadStructures(), + 'motifs' => $this->loadMotifs(), + 'currentWeek' => $this->currentWeek(), + 'scripts' => $scripts, + 'modules' => $modules, + 'pageConfig' => [ + 'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null), + 'agentId' => $this->positiveInt($_GET['agent_id'] ?? null), + 'week' => $this->weekValue($_GET['week'] ?? null), + 'focus' => (string) ($_GET['focus'] ?? ''), + 'notice' => trim((string) ($_GET['notice'] ?? '')), + 'structureLocked' => $forcedStructure !== null, + ], + ]); + } + + public function pending(): void + { + $this->access->requirePage('pending'); + $this->renderPage('pending', 'Plannings à valider', 'pending', [ + 'scripts' => ['pending-validation.js'], + 'modules' => ['pendingValidation'], + ]); + } + + public function coverage(): void + { + $this->access->requirePage('coverage'); + $forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null; + $this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [ + 'structures' => $this->loadStructures(), + 'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(), + 'scripts' => ['coverage.js'], + 'modules' => ['coverage'], + 'pageConfig' => [ + 'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null), + 'structureLocked' => $forcedStructure !== null, + ], + ]); + } + + public function structures(): void + { + $this->access->requirePage('structures'); + $forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null; + $this->renderPage('structures', 'Planning par lieu', 'structures', [ + 'structures' => $this->loadStructures(), + 'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(), + 'scripts' => ['structure-overview.js'], + 'modules' => ['structureOverview'], + 'pageConfig' => [ + 'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null), + 'structureLocked' => $forcedStructure !== null, + ], + ]); + } + + public function agents(): void + { + $this->access->requirePage('agents'); + $forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null; + $this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [ + 'agents' => $this->loadAgents(), + 'structures' => $this->loadStructuresForAgentEditor(), + 'motifs' => $this->loadMotifs(), + 'scripts' => ['agent-overview.js'], + 'modules' => ['agentOverview'], + 'pageConfig' => [ + 'agentId' => $forcedAgent ?: $this->positiveInt($_GET['agent_id'] ?? null), + 'month' => $this->monthValue($_GET['month'] ?? null), + 'agentLocked' => $forcedAgent !== null, + ], + ]); + } + + public function annual(): void + { + $this->access->requirePage('annual'); + $this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [ + 'agents' => (new AgentModel($this->pdo))->allActive(), + 'scripts' => ['annual-overview.js'], + 'modules' => ['annualOverview'], + 'pageConfig' => [ + 'agentId' => $this->positiveInt($_GET['agent_id'] ?? null), + 'year' => $this->yearValue($_GET['year'] ?? null), + ], + ]); + } + + public function pta(): void + { + $this->access->requirePage('pta'); + $this->renderPage('pta', 'PTA annuel par agent', 'pta', [ + 'agents' => (new AgentModel($this->pdo))->allActive(), + 'structures' => (new StructureModel($this->pdo))->allActive(), + 'scripts' => ['pta.js'], + 'modules' => ['pta'], + 'pageConfig' => [ + 'agentId' => $this->positiveInt($_GET['agent_id'] ?? null), + 'date' => $this->dateValue($_GET['date'] ?? null), + ], + ]); + } + + public function teams(): void + { + $this->access->requirePage('teams'); + $this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [ + 'agents' => (new AgentModel($this->pdo))->allActive(), + 'structures' => (new StructureModel($this->pdo))->allActive(), + 'scripts' => ['teams.js'], + 'modules' => ['teams'], + 'pageConfig' => [ + 'dateDebut' => $this->dateValue($_GET['date_debut'] ?? null), + 'dateFin' => $this->dateValue($_GET['date_fin'] ?? null), + ], + ]); + } + + public function vacations(): void + { + $this->access->requirePage('vacations'); + $this->renderPage('vacations', 'Vacances scolaires', 'vacations', [ + 'scripts' => ['vacation-calendar.js'], + 'modules' => ['vacationCalendar'], + ]); + } + + public function administration(): void + { + $this->access->requirePage('administration'); + $this->renderPage('administration', 'Administration', 'administration', [ + 'agents' => (new AgentModel($this->pdo))->allActive(), + 'structures' => (new StructureModel($this->pdo))->allActive(), + 'posts' => (new AgentModel($this->pdo))->allPosts(), + 'scripts' => ['administration.js', 'coverage.js'], + 'modules' => ['administration', 'coverage'], + ]); + } + + private function renderPage(string $view, string $title, string $activePage, array $data = []): void + { + $profile = $this->access->profile(); + $this->render('pages/' . $view, array_merge([ + 'pageTitle' => $title, + 'activePage' => $activePage, + 'csrfToken' => Csrf::ensureToken(), + 'scripts' => [], + 'modules' => [], + 'motifs' => [], + 'pageConfig' => [], + 'accessProfile' => $profile, + 'permissions' => $profile['permissions'], + 'allowedPages' => $this->access->allowedPages(), + ], $data)); + } + + private function loadStructures(): array + { + $model = new StructureModel($this->pdo); + if ($this->access->role() === Access::ROLE_SERVICE) { + return $model->allActive(); + } + $structureId = $this->access->structureId(); + if ($structureId === null) { + return []; + } + $structure = $model->findActive($structureId); + return $structure ? [$structure] : []; + } + + private function loadStructuresForAgentEditor(): array + { + if ($this->access->role() === Access::ROLE_SERVICE) { + return (new StructureModel($this->pdo))->allActive(); + } + return $this->loadStructures(); + } + + private function loadAgents(): array + { + $model = new AgentModel($this->pdo); + if ($this->access->role() === Access::ROLE_SERVICE) { + return $model->allActive(); + } + if ($this->access->role() === Access::ROLE_AGENT) { + $agent = $this->access->agentId() ? $model->findActive((int) $this->access->agentId()) : null; + return $agent ? [$agent] : []; + } + $structureId = $this->access->structureId(); + return $structureId ? $model->allVisibleForStructure($structureId) : []; + } + + private function loadMotifs(): array + { + return (new MotifModel($this->pdo))->allActive(); + } + + private function currentWeek(): string + { + return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW'); + } + + private function positiveInt(mixed $value): ?int + { + $number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + return $number === false ? null : (int) $number; + } + + private function weekValue(mixed $value): ?string + { + $value = trim((string) $value); + return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null; + } + + private function dateValue(mixed $value): ?string + { + $value = trim((string) $value); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date && $date->format('Y-m-d') === $value ? $value : null; + } + + private function yearValue(mixed $value): ?int + { + $number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]); + return $number === false ? null : (int) $number; + } + + private function monthValue(mixed $value): ?string + { + $value = trim((string) $value); + return preg_match('/^\\d{4}-(0[1-9]|1[0-2])$/', $value) === 1 ? $value : null; + } +} diff --git a/app/Controllers/PlanningController.php b/app/Controllers/PlanningController.php new file mode 100644 index 0000000..4754d62 --- /dev/null +++ b/app/Controllers/PlanningController.php @@ -0,0 +1,644 @@ +planningModel = new PlanningModel($pdo); + $this->agentModel = new AgentModel($pdo); + $this->structureModel = new StructureModel($pdo); + $this->motifModel = new MotifModel($pdo); + $this->quotaModel = new QuotaModel($pdo); + $this->vacationModel = new VacationModel($pdo); + $this->access = new Access($pdo); + } + + public function show(): void + { + $this->access->requireDraftEditor(); + $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $weekValue = trim((string) ($_GET['week'] ?? '')); + $week = $this->parseWeek($weekValue); + + if (!$structureId || !$agentId || $week === null) { + JsonResponse::send(400, ['error' => 'Paramètres invalides.']); + } + $this->access->requireStructureEdit((int) $structureId); + + $structure = $this->structureModel->findActive((int) $structureId); + if ($structure === null) { + JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); + } + + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); + } + + [$year, $weekNumber] = $week; + $weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); + $planning = $this->planningModel->findByContext((int) $structureId, (int) $agentId, $year, $weekNumber); + + JsonResponse::send(200, [ + 'planning' => $planning, + 'entries' => $planning ? $this->planningModel->entries((int) $planning['id_planning']) : [], + 'other_entries' => $this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId), + 'current_structure' => $structure, + 'agent' => $agent, + 'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate), + 'calendar_days' => $this->vacationModel->typesForIsoWeek($year, $weekNumber), + ]); + } + + public function save(): void + { + $this->access->requireDraftEditor(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $weekValue = trim((string) ($payload['week'] ?? '')); + $rawEntries = $payload['entries'] ?? []; + $week = $this->parseWeek($weekValue); + + if (!$structureId || !$agentId || $week === null) { + JsonResponse::send(422, ['error' => 'Lieu d’affectation, agent ou semaine invalide.']); + } + $this->access->requireStructureEdit((int) $structureId); + if (!is_array($rawEntries)) { + JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); + } + if ($this->structureModel->findActive((int) $structureId) === null) { + JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); + } + + [$year, $weekNumber] = $week; + $weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); + $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds()); + $this->assertNoInternalOverlap($entries); + $this->assertNoOtherLocationOverlap( + $entries, + $this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId) + ); + + try { + $planningId = $this->planningModel->saveDraft( + (int) $structureId, + (int) $agentId, + $year, + $weekNumber, + $entries + ); + } catch (DomainException $e) { + JsonResponse::send(409, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Erreur lors de l’enregistrement du planning.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'success' => true, + 'planning_id' => $planningId, + 'empty' => count($entries) === 0, + 'message' => count($entries) === 0 + ? 'Toutes les affectations ont été supprimées. Le planning vide a été enregistré.' + : 'Brouillon enregistré avec succès.', + 'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate), + ]); + } + + public function updateAgentEntry(): void + { + $this->access->requireDraftEditor(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT); + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); + $motifId = filter_var($payload['motif_id'] ?? null, FILTER_VALIDATE_INT); + $date = trim((string) ($payload['date'] ?? '')); + $start = trim((string) ($payload['start'] ?? '')); + $end = trim((string) ($payload['end'] ?? '')); + + if (!$entryId || !$structureId || !$motifId) { + JsonResponse::send(422, ['error' => 'Créneau, lieu ou motif invalide.']); + } + $this->access->requireEntryEdit((int) $entryId); + $this->access->requireStructureEdit((int) $structureId); + + $entry = $this->planningModel->findEntryDetailed((int) $entryId); + if ($entry === null) { + JsonResponse::send(404, ['error' => 'Créneau introuvable.']); + } + if ($this->structureModel->findActive((int) $structureId) === null) { + JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); + } + if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) { + JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); + } + + $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date); + $validDate = $dateObj && $dateObj->format('Y-m-d') === $date; + $quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time); + + if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { + JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']); + } + + try { + $result = $this->planningModel->updateEntryFromAgentView( + (int) $entryId, + (int) $structureId, + (int) $motifId, + $date, + $start . ':00', + $end . ':00' + ); + } catch (DomainException $e) { + JsonResponse::send(409, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de modifier le créneau.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + $reopened = count($result['reopened_planning_ids']) > 0; + JsonResponse::send(200, [ + 'success' => true, + 'message' => $reopened + ? 'Créneau modifié. Le ou les plannings validés concernés ont été repassés en brouillon et devront être validés à nouveau.' + : 'Créneau modifié avec succès.', + 'requires_revalidation' => $reopened, + 'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $date), + ]); + } + + + public function deleteAgentEntry(): void + { + $this->access->requireDraftEditor(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT); + if (!$entryId) { + JsonResponse::send(422, ['error' => 'Créneau invalide.']); + } + $this->access->requireEntryEdit((int) $entryId); + + try { + $result = $this->planningModel->deleteEntryFromAgentView((int) $entryId); + } catch (DomainException $e) { + JsonResponse::send(409, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de supprimer le créneau.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + $message = $result['remaining_entries'] === 0 + ? 'Le dernier créneau a été supprimé. Le planning est maintenant vide.' + : 'Créneau supprimé avec succès.'; + + if ($result['reopened']) { + $message .= ' Le planning validé concerné a été repassé en brouillon.'; + } + + JsonResponse::send(200, [ + 'success' => true, + 'message' => $message, + 'planning_empty' => $result['remaining_entries'] === 0, + 'requires_revalidation' => $result['reopened'], + 'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], (string) $result['date']), + ]); + } + + + public function moveOrDuplicateEntry(): void + { + $this->access->requireDraftEditor(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT); + $targetDate = trim((string) ($payload['target_date'] ?? '')); + $action = strtolower(trim((string) ($payload['action'] ?? 'move'))); + + if (!$entryId || !in_array($action, ['move', 'duplicate'], true)) { + JsonResponse::send(422, ['error' => 'Créneau ou action invalide.']); + } + $this->access->requireEntryEdit((int) $entryId); + + $entry = $this->planningModel->findEntryDetailed((int) $entryId); + if ($entry === null) { + JsonResponse::send(404, ['error' => 'Créneau introuvable.']); + } + + $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $targetDate); + $validDate = $dateObj && $dateObj->format('Y-m-d') === $targetDate; + if (!$validDate || (int) $dateObj->format('N') > 5) { + JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']); + } + try { + $result = $this->planningModel->moveOrDuplicateEntry( + (int) $entryId, + $targetDate, + $action === 'duplicate' + ); + } catch (DomainException $e) { + JsonResponse::send(409, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => $action === 'duplicate' + ? 'Impossible de dupliquer le créneau.' + : 'Impossible de déplacer le créneau.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + $reopened = count($result['reopened_planning_ids']) > 0; + $message = match ($result['action']) { + 'duplicate' => 'Créneau dupliqué avec succès.', + 'move' => 'Créneau déplacé avec succès.', + default => 'Le créneau se trouve déjà sur ce jour.', + }; + if ($reopened) { + $message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.'; + } + + JsonResponse::send(200, [ + 'success' => true, + 'action' => $result['action'], + 'message' => $message, + 'requires_revalidation' => $reopened, + 'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $targetDate), + ]); + } + + public function copyAgentWeek(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $sourceWeekValue = trim((string) ($payload['source_week'] ?? '')); + $targetWeekValue = trim((string) ($payload['target_week'] ?? '')); + $mode = strtolower(trim((string) ($payload['mode'] ?? 'merge'))); + $sourceWeek = $this->parseWeek($sourceWeekValue); + $targetWeek = $this->parseWeek($targetWeekValue); + + if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) { + JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); + } + + try { + $result = $this->planningModel->copyAgentWeek( + (int) $agentId, + $sourceWeek[0], + $sourceWeek[1], + $targetWeek[0], + $targetWeek[1], + $mode + ); + } catch (DomainException $e) { + JsonResponse::send(409, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de dupliquer la semaine.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + $message = sprintf( + '%d créneau(x) ont été copiés vers %s.', + (int) $result['copied_entries'], + $targetWeekValue + ); + if ($result['reopened_planning_ids']) { + $message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; + } + + JsonResponse::send(200, [ + 'success' => true, + 'message' => $message, + 'requires_revalidation' => count($result['reopened_planning_ids']) > 0, + 'quota' => $this->quotaModel->forDateForApi( + (int) $agentId, + (new DateTimeImmutable())->setISODate($targetWeek[0], $targetWeek[1], 1)->format('Y-m-d') + ), + ]); + } + + public function pendingValidation(): void + { + $this->access->requireService(); + $plannings = $this->planningModel->pendingValidation(); + $totalMinutes = array_sum(array_map( + static fn(array $planning): int => (int) $planning['total_minutes'], + $plannings + )); + $countedMinutes = array_sum(array_map( + static fn(array $planning): int => (int) $planning['minutes_decomptees'], + $plannings + )); + + JsonResponse::send(200, [ + 'plannings' => $plannings, + 'summary' => [ + 'count' => count($plannings), + 'total_minutes' => $totalMinutes, + 'counted_minutes' => $countedMinutes, + ], + ]); + } + + public function validate(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT); + $forceQuota = filter_var($payload['force_quota'] ?? false, FILTER_VALIDATE_BOOLEAN); + if (!$planningId) { + JsonResponse::send(422, ['error' => 'Planning invalide.']); + } + + $planning = $this->planningModel->findDetailed((int) $planningId); + if ($planning === null) { + JsonResponse::send(404, ['error' => 'Planning introuvable.']); + } + + $controls = []; + if ($this->planningModel->countEntries((int) $planningId) === 0) { + $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); + } + + foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + sprintf('L’affectation #%d du %s est en dehors de la semaine sélectionnée.', $row['id_creneau'], $row['date_jour']) + ); + } + + foreach ($this->planningModel->internalOverlaps((int) $planningId) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + sprintf('Les affectations #%d et #%d se chevauchent le %s.', $row['c1'], $row['c2'], $row['date_jour']) + ); + } + + foreach ($this->planningModel->validatedExternalOverlaps((int) $planningId) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_STRUCTURE', + sprintf( + 'L’affectation #%d chevauche l’affectation #%d déjà validée sur le lieu « %s » le %s.', + $row['c1'], + $row['c2'], + $row['structure_nom'], + $row['date_jour'] + ) + ); + } + + $quota = $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']); + if ((int) $quota['minutes_restantes'] < 0) { + $this->addControl( + $controls, + 'ALERTE', + 'QUOTA_DEPASSE', + sprintf( + 'Le quota annuel est dépassé de %s. Quota cible : %s ; affecté : %s.', + $this->quotaModel->formatMinutes(abs((int) $quota['minutes_restantes']))['libelle'], + $quota['quota_cible']['libelle'], + $quota['affectees']['libelle'] + ) + ); + } + + $errors = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ERREUR')); + $alerts = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ALERTE')); + + if ($errors) { + JsonResponse::send(422, [ + 'success' => false, + 'status' => 'errors', + 'controls' => $controls, + 'quota' => $quota, + 'error' => 'Le planning contient des erreurs bloquantes.', + ]); + } + + if ($alerts && !$forceQuota) { + JsonResponse::send(409, [ + 'success' => false, + 'status' => 'warning', + 'controls' => $controls, + 'quota' => $quota, + 'message' => 'Le planning dépasse le quota annuel. Une confirmation est nécessaire.', + ]); + } + + $this->planningModel->markValidated((int) $planningId); + $this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.'); + + JsonResponse::send(200, [ + 'success' => true, + 'status' => 'validated', + 'controls' => $controls, + 'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']), + 'message' => 'Planning validé avec succès.', + ]); + } + + public function reopen(): void + { + $this->access->requireDraftEditor(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT); + if (!$planningId) { + JsonResponse::send(422, ['error' => 'Planning invalide.']); + } + $this->access->requirePlanningEdit((int) $planningId); + + $planning = $this->planningModel->findDetailed((int) $planningId); + if ($planning === null) { + JsonResponse::send(404, ['error' => 'Planning introuvable.']); + } + if ($planning['statut'] !== 'VALIDE') { + JsonResponse::send(409, ['error' => 'Ce planning est déjà modifiable.']); + } + if (!$this->planningModel->reopen((int) $planningId)) { + JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']); + } + + JsonResponse::send(200, [ + 'success' => true, + 'status' => 'BROUILLON', + 'message' => 'Le planning est repassé en brouillon. Vous pouvez maintenant corriger les affectations puis le valider à nouveau.', + 'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']), + ]); + } + + private function parseWeek(string $value): ?array + { + if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { + return null; + } + + $year = (int) $matches[1]; + $week = (int) $matches[2]; + if ($week < 1 || $week > 53) { + return null; + } + + return [$year, $week]; + } + + private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array + { + $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); + $weekEnd = $weekStart->modify('+6 days'); + $normalized = []; + + foreach ($entries as $index => $entry) { + $date = (string) ($entry['date'] ?? ''); + $motifId = filter_var($entry['motif_id'] ?? null, FILTER_VALIDATE_INT); + $start = (string) ($entry['start'] ?? ''); + $end = (string) ($entry['end'] ?? ''); + $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date); + $validDate = $dateObj && $dateObj->format('Y-m-d') === $date; + $quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time); + + if (!$validDate || !$motifId || !in_array((int) $motifId, $validMotifs, true) + || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { + JsonResponse::send(422, ['error' => 'Affectation invalide à la ligne ' . ($index + 1) . '.']); + } + if ($dateObj < $weekStart || $dateObj > $weekEnd) { + JsonResponse::send(422, ['error' => 'Une affectation se trouve en dehors de la semaine sélectionnée.']); + } + + $normalized[] = [ + 'date' => $date, + 'motif_id' => (int) $motifId, + 'start' => $start . ':00', + 'end' => $end . ':00', + ]; + } + + return $normalized; + } + + private function assertNoInternalOverlap(array $entries): void + { + for ($i = 0, $count = count($entries); $i < $count; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + $first = $entries[$i]; + $second = $entries[$j]; + if ($first['date'] === $second['date'] && $first['start'] < $second['end'] && $first['end'] > $second['start']) { + JsonResponse::send(409, [ + 'error' => sprintf( + 'Deux affectations saisies se chevauchent le %s (%s-%s et %s-%s).', + $first['date'], + substr($first['start'], 0, 5), + substr($first['end'], 0, 5), + substr($second['start'], 0, 5), + substr($second['end'], 0, 5) + ), + 'code' => 'CHEVAUCHEMENT_SAISIE', + ]); + } + } + } + } + + private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void + { + foreach ($entries as $entry) { + foreach ($existingEntries as $existing) { + $existingStart = $existing['heure_debut'] . ':00'; + $existingEnd = $existing['heure_fin'] . ':00'; + if ($entry['date'] === $existing['date_jour'] && $entry['start'] < $existingEnd && $entry['end'] > $existingStart) { + JsonResponse::send(409, [ + 'error' => sprintf( + 'Double affectation impossible : l’agent est déjà planifié sur le lieu « %s » le %s de %s à %s (%s).', + $existing['structure_nom'], + $existing['date_jour'], + $existing['heure_debut'], + $existing['heure_fin'], + strtolower((string) $existing['statut']) + ), + 'code' => 'CHEVAUCHEMENT_AUTRE_STRUCTURE', + 'conflict' => [ + 'structure' => $existing['structure_nom'], + 'date' => $existing['date_jour'], + 'start' => $existing['heure_debut'], + 'end' => $existing['heure_fin'], + 'status' => $existing['statut'], + ], + ]); + } + } + } + } + + private function addControl(array &$controls, string $level, string $code, string $message): void + { + $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; + } +} diff --git a/app/Controllers/PtaController.php b/app/Controllers/PtaController.php new file mode 100644 index 0000000..b0b2580 --- /dev/null +++ b/app/Controllers/PtaController.php @@ -0,0 +1,174 @@ +ptaModel = new PtaModel($pdo); + $this->agentModel = new AgentModel($pdo); + $this->structureModel = new StructureModel($pdo); + $this->access = new Access($pdo); + } + + public function summary(): void + { + $this->access->requireService(); + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + $date = trim((string) ($_GET['date'] ?? date('Y-m-d'))); + $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date); + if (!$agentId || !$parsed || $parsed->format('Y-m-d') !== $date) { + JsonResponse::send(422, ['error' => 'Agent ou date de référence invalide.']); + } + + try { + JsonResponse::send(200, $this->ptaModel->summary((int) $agentId, $date)); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de calculer le PTA de l’agent.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + } + + public function constraint(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $action = strtoupper(trim((string) ($payload['action'] ?? 'SAVE'))); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + if (!$agentId || $this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(422, ['error' => 'Agent invalide.']); + } + + if ($action === 'DELETE') { + $constraintId = filter_var($payload['constraint_id'] ?? null, FILTER_VALIDATE_INT); + if (!$constraintId) { + JsonResponse::send(422, ['error' => 'Contrainte invalide.']); + } + $deleted = $this->ptaModel->deleteConstraint((int) $constraintId, (int) $agentId); + JsonResponse::send($deleted ? 200 : 404, [ + 'message' => $deleted ? 'La contrainte a été supprimée.' : 'Contrainte introuvable.', + ]); + } + + $type = strtoupper(trim((string) ($payload['type_contrainte'] ?? ''))); + $start = trim((string) ($payload['date_debut'] ?? '')); + $end = trim((string) ($payload['date_fin'] ?? '')); + $comment = trim((string) ($payload['commentaire'] ?? '')); + $startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $start); + $endDate = $end !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $end) : null; + + if (!in_array($type, ['MEDICALE', 'TEMPS_PARTIEL_THERAPEUTIQUE'], true) + || !$startDate || $startDate->format('Y-m-d') !== $start + || ($end !== '' && (!$endDate || $endDate->format('Y-m-d') !== $end || $endDate < $startDate)) + || $comment === '') { + JsonResponse::send(422, ['error' => 'Type, dates ou commentaire de contrainte invalides.']); + } + + $rate = $payload['quotite_temporaire'] ?? null; + $rate = ($rate === null || $rate === '') ? null : filter_var($rate, FILTER_VALIDATE_FLOAT); + if ($rate !== null && ($rate === false || $rate <= 0 || $rate > 100)) { + JsonResponse::send(422, ['error' => 'La quotité temporaire doit être comprise entre 1 et 100 %.']); + } + + $maxDayHours = $payload['maximum_heures_jour'] ?? null; + $maxWeekHours = $payload['maximum_heures_semaine'] ?? null; + $maxDayMinutes = ($maxDayHours === null || $maxDayHours === '') ? null : (int) round((float) $maxDayHours * 60); + $maxWeekMinutes = ($maxWeekHours === null || $maxWeekHours === '') ? null : (int) round((float) $maxWeekHours * 60); + + try { + $id = $this->ptaModel->saveConstraint([ + 'id_agent' => (int) $agentId, + 'type_contrainte' => $type, + 'date_debut' => $start, + 'date_fin' => $end !== '' ? $end : null, + 'quotite_temporaire' => $rate, + 'maximum_minutes_jour' => $maxDayMinutes, + 'maximum_minutes_semaine' => $maxWeekMinutes, + 'interdit_matin' => !empty($payload['interdit_matin']) ? 1 : 0, + 'interdit_midi' => !empty($payload['interdit_midi']) ? 1 : 0, + 'interdit_soir' => !empty($payload['interdit_soir']) ? 1 : 0, + 'commentaire' => $comment, + ]); + JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]); + } catch (Throwable $e) { + JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la contrainte.', 'details' => $e->getMessage()]); + } + } + + public function wish(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $action = strtoupper(trim((string) ($payload['action'] ?? 'SAVE'))); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + if (!$agentId || $this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(422, ['error' => 'Agent invalide.']); + } + + if ($action === 'DELETE') { + $wishId = filter_var($payload['wish_id'] ?? null, FILTER_VALIDATE_INT); + if (!$wishId) { + JsonResponse::send(422, ['error' => 'Préférence invalide.']); + } + $deleted = $this->ptaModel->deleteWish((int) $wishId, (int) $agentId); + JsonResponse::send($deleted ? 200 : 404, [ + 'message' => $deleted ? 'La préférence ou interdiction a été supprimée.' : 'Élément introuvable.', + ]); + } + + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); + $type = strtoupper(trim((string) ($payload['type_souhait'] ?? ''))); + $priority = filter_var($payload['priorite'] ?? 1, FILTER_VALIDATE_INT); + $distance = $payload['distance_km'] ?? null; + $distance = ($distance === null || $distance === '') ? null : filter_var($distance, FILTER_VALIDATE_FLOAT); + $comment = trim((string) ($payload['commentaire'] ?? '')); + + if (!$structureId || $this->structureModel->findActive((int) $structureId) === null + || !in_array($type, ['PREFERENCE', 'INTERDICTION'], true) + || !$priority || $priority < 1 || $priority > 5 + || ($distance !== null && ($distance === false || $distance < 0))) { + JsonResponse::send(422, ['error' => 'Lieu, type, priorité ou distance invalide.']); + } + + try { + $id = $this->ptaModel->saveWish([ + 'id_agent' => (int) $agentId, + 'id_structure' => (int) $structureId, + 'type_souhait' => $type, + 'priorite' => (int) $priority, + 'distance_km' => $distance, + 'commentaire' => $comment !== '' ? $comment : null, + ]); + JsonResponse::send(201, ['message' => 'La préférence de lieu a été enregistrée.', 'id_souhait' => $id]); + } catch (Throwable $e) { + JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la préférence.', 'details' => $e->getMessage()]); + } + } +} diff --git a/app/Controllers/RoleController.php b/app/Controllers/RoleController.php new file mode 100644 index 0000000..63ca68e --- /dev/null +++ b/app/Controllers/RoleController.php @@ -0,0 +1,59 @@ +pdo); + + if (($_GET['action'] ?? '') === 'reset') { + $access->clear(); + header('Location: role.php', true, 302); + exit; + } + + $error = ''; + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + $expected = Csrf::ensureToken(); + $provided = (string) ($_POST['csrf_token'] ?? ''); + if ($provided === '' || !hash_equals($expected, $provided)) { + $error = 'La session a expiré. Rechargez la page et recommencez.'; + } else { + try { + $role = strtoupper(trim((string) ($_POST['role'] ?? ''))); + $agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null; + $structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null; + $access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null); + header('Location: ' . $access->homeUrl(), true, 302); + exit; + } catch (\DomainException $e) { + $error = $e->getMessage(); + } + } + } + + $this->render('pages/role', [ + 'pageTitle' => 'Choisir votre accès', + 'csrfToken' => Csrf::ensureToken(), + 'agents' => (new AgentModel($this->pdo))->allActive(), + 'structures' => (new StructureModel($this->pdo))->allActive(), + 'error' => $error, + 'currentProfile' => $access->isConfigured() ? $access->profile() : null, + ]); + } +} diff --git a/app/Controllers/StructureController.php b/app/Controllers/StructureController.php new file mode 100644 index 0000000..297f884 --- /dev/null +++ b/app/Controllers/StructureController.php @@ -0,0 +1,225 @@ +structureModel = new StructureModel($pdo); + $this->quotaModel = new QuotaModel($pdo); + $this->coverageModel = new CoverageModel($pdo); + $this->access = new Access($pdo); + } + + public function overview(): void + { + $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); + $weekValue = trim((string) ($_GET['week'] ?? '')); + $week = $this->parseWeek($weekValue); + + if (!$structureId || $week === null) { + JsonResponse::send(400, ['error' => 'Lieu d’affectation ou semaine invalide.']); + } + $this->access->requireStructureView((int) $structureId); + + $structure = $this->structureModel->findActive((int) $structureId); + if ($structure === null) { + JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); + } + + [$year, $weekNumber] = $week; + $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); + $weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d'); + $entries = $this->structureModel->entriesForOverview((int) $structureId, $year, $weekNumber); + $entriesByAgent = []; + foreach ($entries as $entry) { + $entriesByAgent[(int) $entry['id_agent']][] = $entry; + } + + $agents = []; + foreach ($this->structureModel->agentsForOverview((int) $structureId, $year, $weekNumber) as $agent) { + $agentId = (int) $agent['id_agent']; + $agents[] = [ + 'id_agent' => $agentId, + 'matricule' => $agent['matricule'], + 'nom' => $agent['nom'], + 'prenom' => $agent['prenom'], + 'lieu_principal' => (int) ($agent['id_structure_principale'] ?? 0) === (int) $structureId, + 'quota' => $this->quotaModel->forDateForApi($agentId, $weekStart), + 'entries' => $entriesByAgent[$agentId] ?? [], + ]; + } + + JsonResponse::send(200, [ + 'structure' => $structure, + 'week' => ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd], + 'agents' => $agents, + 'coverage' => $this->coverageModel->reportForStructure((int) $structureId, $year, $weekNumber), + ]); + } + + public function pdf(): void + { + $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); + $weekValue = trim((string) ($_GET['week'] ?? '')); + $week = $this->parseWeek($weekValue); + + if (!$structureId || $week === null) { + http_response_code(400); + echo 'Lieu d’affectation ou semaine invalide.'; + return; + } + $this->access->requireStructureView((int) $structureId); + + $structure = $this->structureModel->findActive((int) $structureId); + if ($structure === null) { + http_response_code(404); + echo 'Lieu d’affectation introuvable.'; + return; + } + + [$year, $weekNumber] = $week; + $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); + $weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d'); + $weekData = ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd]; + + $entries = $this->structureModel->entriesForOverview((int) $structureId, $year, $weekNumber); + $entriesByAgent = []; + foreach ($entries as $entry) { + $entriesByAgent[(int) $entry['id_agent']][] = $entry; + } + + $agents = []; + foreach ($this->structureModel->agentsForOverview((int) $structureId, $year, $weekNumber) as $agent) { + $agentId = (int) $agent['id_agent']; + $agents[] = [ + 'id_agent' => $agentId, + 'matricule' => $agent['matricule'], + 'nom' => $agent['nom'], + 'prenom' => $agent['prenom'], + 'lieu_principal' => (int) ($agent['id_structure_principale'] ?? 0) === (int) $structureId, + 'quota' => $this->quotaModel->forDateForApi($agentId, $weekStart), + 'entries' => $entriesByAgent[$agentId] ?? [], + ]; + } + + $pdf = (new PlanningPdfService())->structureWeek($structure, $agents, $weekData); + PdfResponse::inline( + $pdf, + sprintf('planning_lieu_%s_%s.pdf', $structure['code'] ?: $structure['id_structure'], str_replace('-W', '_S', $weekValue)) + ); + } + + public function create(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $rawCode = trim((string) ($payload['code'] ?? '')); + $code = strtoupper((string) preg_replace('/\s+/', '_', $rawCode)); + $name = trim((string) ($payload['nom'] ?? '')); + $address = trim((string) ($payload['adresse'] ?? '')); + $typeAffectation = strtoupper(trim((string) ($payload['type_affectation'] ?? ''))); + + if ($code === '' || $name === '') { + JsonResponse::send(422, ['error' => 'Le code et le nom du lieu d’affectation sont obligatoires.']); + } + if (!in_array($typeAffectation, ['PERISCOLAIRE', 'EXTRASCOLAIRE'], true)) { + JsonResponse::send(422, ['error' => 'Le type du lieu doit être Périscolaire ou Extrascolaire.']); + } + if (!preg_match('/^[A-Z0-9_-]{2,50}$/', $code)) { + JsonResponse::send(422, ['error' => 'Le code doit contenir uniquement des lettres, chiffres, tirets ou underscores.']); + } + if (strlen($name) > 150 || strlen($address) > 255) { + JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']); + } + if ($this->structureModel->codeExists($code)) { + JsonResponse::send(409, ['error' => 'Un lieu d’affectation utilise déjà ce code.']); + } + + try { + $structure = $this->structureModel->create($code, $name, $address !== '' ? $address : null, $typeAffectation); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de créer le lieu d’affectation.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(201, [ + 'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name), + 'structure' => $structure, + ]); + } + + public function updateType(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); + $typeAffectation = strtoupper(trim((string) ($payload['type_affectation'] ?? ''))); + + if (!$structureId) { + JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide.']); + } + if (!in_array($typeAffectation, ['PERISCOLAIRE', 'EXTRASCOLAIRE'], true)) { + JsonResponse::send(422, ['error' => 'Le type du lieu doit être Périscolaire ou Extrascolaire.']); + } + $structure = $this->structureModel->findActive((int) $structureId); + if ($structure === null) { + JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); + } + + try { + $this->structureModel->updateType((int) $structureId, $typeAffectation); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de modifier le type du lieu d’affectation.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'message' => sprintf('Le type du lieu « %s » a été mis à jour.', $structure['nom']), + 'structure' => [ + 'id_structure' => (int) $structureId, + 'type_affectation' => $typeAffectation, + ], + ]); + } + + private function parseWeek(string $value): ?array + { + if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { + return null; + } + $week = (int) $matches[2]; + return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null; + } +} diff --git a/app/Controllers/TeamController.php b/app/Controllers/TeamController.php new file mode 100644 index 0000000..1e2c828 --- /dev/null +++ b/app/Controllers/TeamController.php @@ -0,0 +1,144 @@ +teamModel = new TeamModel($pdo); + $this->agentModel = new AgentModel($pdo); + $this->structureModel = new StructureModel($pdo); + $this->access = new Access($pdo); + } + + public function index(): void + { + $this->access->requireService(); + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') { + $start = trim((string) ($_GET['date_debut'] ?? '')) ?: null; + $end = trim((string) ($_GET['date_fin'] ?? '')) ?: null; + JsonResponse::send(200, ['teams' => $this->teamModel->all($start, $end)]); + } + + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); + $type = strtoupper(trim((string) ($payload['type_equipe'] ?? ''))); + $label = trim((string) ($payload['libelle'] ?? '')); + $start = trim((string) ($payload['date_debut'] ?? '')); + $end = trim((string) ($payload['date_fin'] ?? '')); + $startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $start); + $endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $end); + + if (!$structureId || $this->structureModel->findActive((int) $structureId) === null + || !in_array($type, ['MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE', 'PERISCOLAIRE_SEMAINE'], true) + || $label === '' || !$startDate || !$endDate + || $startDate->format('Y-m-d') !== $start || $endDate->format('Y-m-d') !== $end + || $endDate < $startDate) { + JsonResponse::send(422, ['error' => 'Les informations de l’équipe sont incomplètes ou invalides.']); + } + + $under6 = max(0, (int) ($payload['nombre_enfants_moins_6'] ?? 0)); + $over6 = max(0, (int) ($payload['nombre_enfants_6_plus'] ?? 0)); + $ratioUnder6 = max(1, (int) ($payload['ratio_moins_6'] ?? 8)); + $ratioOver6 = max(1, (int) ($payload['ratio_6_plus'] ?? 12)); + $minAgents = max(1, (int) ($payload['minimum_agents'] ?? 1)); + $qualified = (float) ($payload['pourcentage_diplomes_min'] ?? 50); + if ($qualified < 0 || $qualified > 100) { + JsonResponse::send(422, ['error' => 'Le pourcentage minimal de diplômés doit être compris entre 0 et 100 %.']); + } + + try { + $id = $this->teamModel->create([ + 'id_structure' => (int) $structureId, + 'type_equipe' => $type, + 'libelle' => $label, + 'date_debut' => $start, + 'date_fin' => $end, + 'nombre_enfants_moins_6' => $under6, + 'nombre_enfants_6_plus' => $over6, + 'ratio_moins_6' => $ratioUnder6, + 'ratio_6_plus' => $ratioOver6, + 'minimum_agents' => $minAgents, + 'pourcentage_diplomes_min' => $qualified, + 'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null, + ]); + JsonResponse::send(201, ['message' => 'L’équipe a été créée.', 'id_equipe' => $id]); + } catch (Throwable $e) { + JsonResponse::send(500, ['error' => 'Impossible de créer l’équipe.', 'details' => $e->getMessage()]); + } + } + + public function member(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $action = strtoupper(trim((string) ($payload['action'] ?? 'ADD'))); + $teamId = filter_var($payload['team_id'] ?? null, FILTER_VALIDATE_INT); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + if (!$teamId || !$agentId || $this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(422, ['error' => 'Équipe ou agent invalide.']); + } + + if ($action === 'REMOVE') { + $deleted = $this->teamModel->removeMember((int) $teamId, (int) $agentId); + JsonResponse::send($deleted ? 200 : 404, ['message' => $deleted ? 'L’agent a été retiré de l’équipe.' : 'Affectation introuvable.']); + } + + $function = strtoupper(trim((string) ($payload['fonction_equipe'] ?? 'ANIMATION'))); + if (!in_array($function, ['RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN'], true)) { + JsonResponse::send(422, ['error' => 'Fonction dans l’équipe invalide.']); + } + + try { + $this->teamModel->addMember( + (int) $teamId, + (int) $agentId, + $function, + trim((string) ($payload['commentaire'] ?? '')) ?: null + ); + JsonResponse::send(200, ['message' => 'L’agent a été ajouté à l’équipe.']); + } catch (Throwable $e) { + JsonResponse::send(422, ['error' => $e->getMessage()]); + } + } + + public function status(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + $teamId = filter_var($payload['team_id'] ?? null, FILTER_VALIDATE_INT); + $status = strtoupper(trim((string) ($payload['statut'] ?? ''))); + if (!$teamId || !in_array($status, ['BROUILLON', 'A_CONTROLER', 'VALIDEE'], true)) { + JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']); + } + $this->teamModel->setStatus((int) $teamId, $status); + JsonResponse::send(200, ['message' => 'Le statut de l’équipe a été mis à jour.']); + } +} diff --git a/app/Controllers/VacationController.php b/app/Controllers/VacationController.php new file mode 100644 index 0000000..fb0045d --- /dev/null +++ b/app/Controllers/VacationController.php @@ -0,0 +1,170 @@ +vacationModel = new VacationModel($pdo); + $this->access = new Access($pdo); + } + + public function index(): void + { + $this->access->requireService(); + $schoolYear = trim((string) ($_GET['school_year'] ?? '')); + if ($schoolYear !== '' && !$this->isSchoolYear($schoolYear)) { + JsonResponse::send(422, ['error' => 'Année scolaire invalide. Format attendu : 2026-2027.']); + } + + JsonResponse::send(200, [ + 'periods' => $this->vacationModel->all($schoolYear !== '' ? $schoolYear : null), + ]); + } + + public function preview(): void + { + $this->access->requireService(); + $academy = trim((string) ($_GET['academy'] ?? '')); + $calendarYearRaw = trim((string) ($_GET['year'] ?? '')); + + if ($academy === '' || (function_exists('mb_strlen') ? mb_strlen($academy, 'UTF-8') : strlen($academy)) > 100) { + JsonResponse::send(422, ['error' => 'Renseignez une académie valide.']); + } + + if (!preg_match('/^\d{4}$/', $calendarYearRaw)) { + JsonResponse::send(422, ['error' => 'Année civile invalide. Format attendu : 2026.']); + } + $calendarYear = (int) $calendarYearRaw; + if ($calendarYear < 2000 || $calendarYear > 2100) { + JsonResponse::send(422, ['error' => 'Année civile invalide.']); + } + + try { + $result = (new SchoolHolidayApiService())->previewCalendarYearDetailed($academy, $calendarYear); + $periods = $result['periods']; + $warnings = $result['warnings']; + } catch (Throwable $e) { + JsonResponse::send(502, [ + 'error' => 'Impossible de charger les propositions depuis l’API officielle.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'academy' => $academy, + 'year' => $calendarYear, + 'periods' => $periods, + 'warnings' => $warnings ?? [], + 'message' => $periods === [] + ? 'Aucune période n’a été trouvée pour l’année ' . $calendarYear . '.' + : sprintf('%d période%s proposée%s pour toute l’année %d. Aucune donnée n’a été enregistrée en base.', count($periods), count($periods) > 1 ? 's' : '', count($periods) > 1 ? 's' : '', $calendarYear), + ]); + } + + public function save(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $rawPeriods = $payload['periods'] ?? []; + if (!is_array($rawPeriods) || $rawPeriods === []) { + JsonResponse::send(422, ['error' => 'Sélectionnez au moins une période à enregistrer.']); + } + if (count($rawPeriods) > 50) { + JsonResponse::send(422, ['error' => 'Trop de périodes ont été envoyées en une seule fois.']); + } + + $periods = []; + foreach ($rawPeriods as $index => $raw) { + if (!is_array($raw)) { + JsonResponse::send(422, ['error' => 'Période invalide à la ligne ' . ($index + 1) . '.']); + } + + $label = trim((string) ($raw['libelle'] ?? '')); + $start = trim((string) ($raw['date_debut'] ?? '')); + $end = trim((string) ($raw['date_fin'] ?? '')); + $schoolYear = trim((string) ($raw['annee_scolaire'] ?? '')); + $academy = trim((string) ($raw['academie'] ?? '')); + $zone = trim((string) ($raw['zone'] ?? '')); + $source = strtoupper(trim((string) ($raw['source'] ?? 'MANUEL'))); + + if ($label === '' || (function_exists('mb_strlen') ? mb_strlen($label, 'UTF-8') : strlen($label)) > 150 || !$this->isDate($start) || !$this->isDate($end) || $end < $start) { + JsonResponse::send(422, ['error' => 'Dates ou libellé invalides à la ligne ' . ($index + 1) . '.']); + } + if (!$this->isSchoolYear($schoolYear)) { + JsonResponse::send(422, ['error' => 'Année scolaire invalide à la ligne ' . ($index + 1) . '.']); + } + if (!in_array($source, ['MANUEL', 'DATA_GOUV'], true)) { + $source = 'MANUEL'; + } + + $periods[] = [ + 'libelle' => $label, + 'date_debut' => $start, + 'date_fin' => $end, + 'annee_scolaire' => $schoolYear, + 'academie' => function_exists('mb_substr') ? mb_substr($academy, 0, 100, 'UTF-8') : substr($academy, 0, 100), + 'zone' => function_exists('mb_substr') ? mb_substr($zone, 0, 50, 'UTF-8') : substr($zone, 0, 50), + 'source' => $source, + ]; + } + + $count = $this->vacationModel->saveMany($periods); + JsonResponse::send(200, [ + 'success' => true, + 'count' => $count, + 'message' => sprintf('%d période%s de vacances enregistrée%s après validation manuelle.', $count, $count > 1 ? 's' : '', $count > 1 ? 's' : ''), + ]); + } + + public function delete(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $id = filter_var($payload['period_id'] ?? null, FILTER_VALIDATE_INT); + if (!$id) { + JsonResponse::send(422, ['error' => 'Période invalide.']); + } + if (!$this->vacationModel->delete((int) $id)) { + JsonResponse::send(404, ['error' => 'Période introuvable.']); + } + + JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']); + } + + private function isDate(string $value): bool + { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value; + } + + private function isSchoolYear(string $value): bool + { + if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) { + return false; + } + return (int) $matches[2] === (int) $matches[1] + 1; + } +} diff --git a/app/Controllers/WeekTemplateController.php b/app/Controllers/WeekTemplateController.php new file mode 100644 index 0000000..a0440ec --- /dev/null +++ b/app/Controllers/WeekTemplateController.php @@ -0,0 +1,335 @@ +templateModel = new WeekTemplateModel($pdo); + $this->agentModel = new AgentModel($pdo); + $this->quotaModel = new QuotaModel($pdo); + $this->access = new Access($pdo); + } + + public function index(): void + { + $this->access->requireService(); + $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); + if (!$agentId) { + JsonResponse::send(422, ['error' => 'Agent invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); + } + + $templates = array_map(static function (array $template): array { + return [ + 'id' => (int) $template['id_modele'], + 'name' => (string) $template['nom'], + 'entry_count' => (int) $template['nombre_creneaux'], + 'location_count' => (int) $template['nombre_lieux'], + 'updated_at' => (string) $template['date_modification'], + ]; + }, $this->templateModel->listForAgent((int) $agentId)); + + JsonResponse::send(200, ['templates' => $templates]); + } + + public function store(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $week = $this->parseWeek(trim((string) ($payload['week'] ?? ''))); + $name = trim((string) ($payload['name'] ?? '')); + $replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN); + + if (!$agentId || $week === null) { + JsonResponse::send(422, ['error' => 'Agent ou semaine invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); + } + + try { + [$year, $weekNumber] = $week; + $template = $this->templateModel->saveFromWeek((int) $agentId, $year, $weekNumber, $name, $replace); + } catch (DomainException $e) { + $status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422; + JsonResponse::send($status, [ + 'error' => $e->getMessage(), + 'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID', + ]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible d’enregistrer la semaine type.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'success' => true, + 'template' => $template, + 'message' => sprintf('La semaine type « %s » a été enregistrée avec %d créneau%s.', $template['nom'], $template['nombre_creneaux'], $template['nombre_creneaux'] > 1 ? 'x' : ''), + ]); + } + + public function apply(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $week = $this->parseWeek(trim((string) ($payload['week'] ?? ''))); + $mode = strtolower(trim((string) ($payload['mode'] ?? 'merge'))); + + if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) { + JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); + } + + try { + [$year, $weekNumber] = $week; + $result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode); + } catch (DomainException $e) { + JsonResponse::send(409, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible d’importer la semaine type.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + $reopened = count($result['reopened_planning_ids']) > 0; + $message = sprintf( + 'La semaine type « %s » a été appliquée : %d créneau%s importé%s.', + $result['template']['nom'], + $result['inserted'], + $result['inserted'] > 1 ? 'x' : '', + $result['inserted'] > 1 ? 's' : '' + ); + if ($reopened) { + $message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; + } + + JsonResponse::send(200, [ + 'success' => true, + 'message' => $message, + 'requires_revalidation' => $reopened, + 'quota' => $this->quotaModel->forDateForApi( + (int) $agentId, + (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d') + ), + ]); + } + + public function previewPeriod(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $startDate = trim((string) ($payload['start_date'] ?? '')); + $endDate = trim((string) ($payload['end_date'] ?? '')); + $mode = strtolower(trim((string) ($payload['mode'] ?? 'merge'))); + $periodFilter = strtolower(trim((string) ($payload['period_filter'] ?? 'all'))); + + if ( + !$templateId + || !$agentId + || $startDate === '' + || $endDate === '' + || !in_array($mode, ['merge', 'replace'], true) + || !in_array($periodFilter, ['all', 'school', 'extra'], true) + ) { + JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); + } + + try { + $preview = $this->templateModel->previewPeriod( + (int) $templateId, + (int) $agentId, + $startDate, + $endDate, + $mode, + $periodFilter + ); + } catch (DomainException $e) { + JsonResponse::send(422, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible de prévisualiser l’application de la semaine type.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + // Les créneaux détaillés sont utiles au modèle mais pas nécessaires au navigateur. + unset($preview['target_entries']); + + JsonResponse::send(200, [ + 'success' => true, + 'preview' => $preview, + ]); + } + + public function applyPeriod(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $startDate = trim((string) ($payload['start_date'] ?? '')); + $endDate = trim((string) ($payload['end_date'] ?? '')); + $mode = strtolower(trim((string) ($payload['mode'] ?? 'merge'))); + $periodFilter = strtolower(trim((string) ($payload['period_filter'] ?? 'all'))); + + if ( + !$templateId + || !$agentId + || $startDate === '' + || $endDate === '' + || !in_array($mode, ['merge', 'replace'], true) + || !in_array($periodFilter, ['all', 'school', 'extra'], true) + ) { + JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']); + } + if ($this->agentModel->findActive((int) $agentId) === null) { + JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); + } + + try { + $result = $this->templateModel->applyToPeriod( + (int) $templateId, + (int) $agentId, + $startDate, + $endDate, + $mode, + $periodFilter + ); + } catch (DomainException $e) { + JsonResponse::send(409, ['error' => $e->getMessage()]); + } catch (Throwable $e) { + JsonResponse::send(500, [ + 'error' => 'Impossible d’appliquer la semaine type sur la période.', + 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, + ]); + } + + $message = sprintf( + 'La semaine type « %s » a été appliquée du %s au %s : %d créneau%s créé%s sur %d jour%s et %d semaine%s.', + $result['template']['name'], + $result['start_date'], + $result['end_date'], + $result['inserted'], + $result['inserted'] > 1 ? 'x' : '', + $result['inserted'] > 1 ? 's' : '', + $result['days_touched'], + $result['days_touched'] > 1 ? 's' : '', + $result['weeks_touched'], + $result['weeks_touched'] > 1 ? 's' : '' + ); + + $scopeLabel = match ($result['period_filter']) { + 'school' => 'uniquement sur les périodes scolaires', + 'extra' => 'uniquement sur les périodes extrascolaires', + default => 'sur toutes les semaines de la période', + }; + $message .= ' Périmètre : ' . $scopeLabel . '.'; + + if ($result['skipped_count'] > 0) { + $message .= sprintf( + ' %d créneau%s ignoré%s pour respecter les périodes scolaires/vacances.', + $result['skipped_count'], + $result['skipped_count'] > 1 ? 'x' : '', + $result['skipped_count'] > 1 ? 's' : '' + ); + } + if ($result['deleted'] > 0) { + $message .= sprintf(' %d ancien%s créneau%s remplacé%s.', $result['deleted'], $result['deleted'] > 1 ? 's' : '', $result['deleted'] > 1 ? 'x' : '', $result['deleted'] > 1 ? 's' : ''); + } + if ($result['reopened_planning_ids'] !== []) { + $message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; + } + + $quotas = []; + foreach (array_unique([(string) $result['start_date'], (string) $result['end_date']]) as $contextDate) { + $quota = $this->quotaModel->forDateForApi((int) $agentId, $contextDate); + $quotas[(string) $quota['date_debut_periode']] = $quota; + } + + JsonResponse::send(200, [ + 'success' => true, + 'message' => $message, + 'result' => $result, + 'requires_revalidation' => $result['reopened_planning_ids'] !== [], + 'quotas' => $quotas, + ]); + } + + public function destroy(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + if (!$templateId || !$agentId) { + JsonResponse::send(422, ['error' => 'Semaine type ou agent invalide.']); + } + + if (!$this->templateModel->delete((int) $templateId, (int) $agentId)) { + JsonResponse::send(404, ['error' => 'Semaine type introuvable.']); + } + + JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']); + } + + private function parseWeek(string $value): ?array + { + if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { + return null; + } + $year = (int) $matches[1]; + $week = (int) $matches[2]; + return $week >= 1 && $week <= 53 ? [$year, $week] : null; + } +} diff --git a/app/Core/Access.php b/app/Core/Access.php new file mode 100644 index 0000000..9033011 --- /dev/null +++ b/app/Core/Access.php @@ -0,0 +1,368 @@ +role(); + if ($role === self::ROLE_AGENT) { + return $this->agentId() !== null && $this->agentRecord() !== null; + } + if ($role === self::ROLE_RESPONSABLE) { + return $this->structureId() !== null && $this->structureRecord() !== null; + } + return $role === self::ROLE_SERVICE; + } + + public function role(): ?string + { + $role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? ''))); + return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true) + ? $role + : null; + } + + public function agentId(): ?int + { + $value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1], + ]); + return $value === false ? null : (int) $value; + } + + public function structureId(): ?int + { + if ($this->role() === self::ROLE_AGENT) { + $agent = $this->agentRecord(); + return isset($agent['id_structure']) && $agent['id_structure'] !== null + ? (int) $agent['id_structure'] + : null; + } + + $value = filter_var($_SESSION[self::SESSION_KEY]['structure_id'] ?? null, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1], + ]); + return $value === false ? null : (int) $value; + } + + public function select(string $role, ?int $agentId = null, ?int $structureId = null): void + { + $role = strtoupper(trim($role)); + if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) { + throw new \DomainException('Rôle invalide.'); + } + + $selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null]; + + if ($role === self::ROLE_AGENT) { + if (!$agentId || !$this->activeAgentExists($agentId)) { + throw new \DomainException('Sélectionnez un agent actif.'); + } + $selection['agent_id'] = $agentId; + } elseif ($role === self::ROLE_RESPONSABLE) { + if (!$structureId || !$this->activeStructureExists($structureId)) { + throw new \DomainException('Sélectionnez un lieu d’affectation actif.'); + } + $selection['structure_id'] = $structureId; + } + + $_SESSION[self::SESSION_KEY] = $selection; + session_regenerate_id(true); + } + + public function clear(): void + { + unset($_SESSION[self::SESSION_KEY]); + session_regenerate_id(true); + } + + public function profile(): array + { + $role = $this->role(); + $permissions = $this->permissions(); + $profile = [ + 'role' => $role, + 'role_label' => $this->roleLabel(), + 'label' => $this->roleLabel(), + 'agent_id' => $this->agentId(), + 'structure_id' => $this->structureId(), + 'permissions' => $permissions, + ]; + + if ($role === self::ROLE_AGENT) { + $agent = $this->agentRecord(); + if ($agent !== null) { + $profile['label'] = trim((string) $agent['prenom'] . ' ' . (string) $agent['nom']); + $profile['matricule'] = $agent['matricule']; + $profile['structure_name'] = $agent['structure_nom']; + $profile['structure_id'] = $agent['id_structure'] !== null ? (int) $agent['id_structure'] : null; + } + } elseif ($role === self::ROLE_RESPONSABLE) { + $structure = $this->structureRecord(); + if ($structure !== null) { + $profile['label'] = (string) $structure['nom']; + $profile['structure_name'] = (string) $structure['nom']; + $profile['structure_code'] = (string) $structure['code']; + } + } + + return $profile; + } + + public function permissions(): array + { + $role = $this->role(); + $service = $role === self::ROLE_SERVICE; + $responsable = $role === self::ROLE_RESPONSABLE; + + return [ + 'read_only' => $role === self::ROLE_AGENT, + 'can_edit_draft' => $service || $responsable, + 'can_edit_cross_structure' => $service, + 'can_validate' => $service, + 'can_reopen' => $service || $responsable, + 'can_use_templates' => $service, + 'can_copy_full_week' => $service, + 'can_view_pending' => $service, + 'can_manage_coverage' => $service, + 'can_manage_agents' => $service, + 'can_manage_structures' => $service, + 'can_manage_vacations' => $service, + 'can_manage_teams' => $service, + 'can_manage_pta' => $service, + 'can_forecast_next_year' => $service, + ]; + } + + public function allowedPages(): array + { + return match ($this->role()) { + self::ROLE_AGENT => ['agents', 'structures'], + self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'], + self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'], + default => [], + }; + } + + public function canAccessPage(string $page): bool + { + return in_array($page, $this->allowedPages(), true); + } + + public function homeUrl(): string + { + return match ($this->role()) { + self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(), + self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(), + self::ROLE_SERVICE => 'planning.php', + default => 'role.php', + }; + } + + public function requirePage(string $page): void + { + if (!$this->isConfigured()) { + header('Location: role.php', true, 302); + exit; + } + if (!$this->canAccessPage($page)) { + header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302); + exit; + } + } + + public function requireRoles(array $roles): void + { + if (!$this->isConfigured()) { + JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']); + } + if (!in_array($this->role(), $roles, true)) { + JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']); + } + } + + public function requireService(): void + { + $this->requireRoles([self::ROLE_SERVICE]); + } + + public function requireDraftEditor(): void + { + $this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); + } + + public function requireStructureView(int $structureId): void + { + $this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); + if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) { + JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']); + } + } + + public function requireStructureEdit(int $structureId): void + { + $this->requireDraftEditor(); + if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) { + JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']); + } + } + + public function requireAgentView(int $agentId): void + { + $this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); + if ($this->role() === self::ROLE_SERVICE) { + return; + } + if ($this->role() === self::ROLE_AGENT && $this->agentId() === $agentId) { + return; + } + if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) { + return; + } + JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']); + } + + public function requireEntryEdit(int $entryId): void + { + $this->requireDraftEditor(); + if ($this->role() === self::ROLE_SERVICE) { + return; + } + $stmt = $this->pdo->prepare( + 'SELECT p.id_structure + FROM creneau_horaire c + INNER JOIN planning p ON p.id_planning = c.id_planning + WHERE c.id_creneau = :id LIMIT 1' + ); + $stmt->execute(['id' => $entryId]); + $structureId = $stmt->fetchColumn(); + if ($structureId === false) { + JsonResponse::send(404, ['error' => 'Créneau introuvable.']); + } + $this->requireStructureEdit((int) $structureId); + } + + public function requirePlanningEdit(int $planningId): void + { + $this->requireDraftEditor(); + if ($this->role() === self::ROLE_SERVICE) { + return; + } + $stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1'); + $stmt->execute(['id' => $planningId]); + $structureId = $stmt->fetchColumn(); + if ($structureId === false) { + JsonResponse::send(404, ['error' => 'Planning introuvable.']); + } + $this->requireStructureEdit((int) $structureId); + } + + public function editableStructureId(): ?int + { + return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null; + } + + private function roleLabel(): string + { + return match ($this->role()) { + self::ROLE_AGENT => 'Agent', + self::ROLE_RESPONSABLE => 'Responsable de structure', + self::ROLE_SERVICE => 'Service Enfance', + default => 'Rôle non sélectionné', + }; + } + + private function activeAgentExists(int $agentId): bool + { + $stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1'); + $stmt->execute(['id' => $agentId]); + return $stmt->fetchColumn() !== false; + } + + private function activeStructureExists(int $structureId): bool + { + $stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1'); + $stmt->execute(['id' => $structureId]); + return $stmt->fetchColumn() !== false; + } + + private function agentRecord(): ?array + { + $agentId = $this->agentId(); + if ($agentId === null) { + return null; + } + $stmt = $this->pdo->prepare( + 'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure, + s.nom AS structure_nom + FROM agent a + LEFT JOIN structure s ON s.id_structure = a.id_structure + WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1' + ); + $stmt->execute(['id' => $agentId]); + return $stmt->fetch() ?: null; + } + + private function structureRecord(): ?array + { + $structureId = $this->structureId(); + if ($structureId === null) { + return null; + } + $stmt = $this->pdo->prepare( + 'SELECT id_structure, code, nom + FROM structure + WHERE id_structure = :id AND actif = TRUE LIMIT 1' + ); + $stmt->execute(['id' => $structureId]); + return $stmt->fetch() ?: null; + } + + private function responsibleCanViewAgent(int $agentId): bool + { + $structureId = $this->structureId(); + if ($structureId === null) { + return false; + } + $stmt = $this->pdo->prepare( + 'SELECT 1 + FROM agent a + WHERE a.id_agent = :agent_id + AND a.actif = TRUE + AND ( + a.id_structure = :structure_id_default + OR EXISTS ( + SELECT 1 FROM planning p + WHERE p.id_agent = a.id_agent + AND p.id_structure = :structure_id_planning + ) + ) + LIMIT 1' + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'structure_id_default' => $structureId, + 'structure_id_planning' => $structureId, + ]); + return $stmt->fetchColumn() !== false; + } +} diff --git a/app/Core/Controller.php b/app/Core/Controller.php new file mode 100644 index 0000000..965619e --- /dev/null +++ b/app/Core/Controller.php @@ -0,0 +1,19 @@ + 'Jeton de sécurité invalide. Rechargez la page.']); + } + } +} diff --git a/app/Core/JsonResponse.php b/app/Core/JsonResponse.php new file mode 100644 index 0000000..e05c5fd --- /dev/null +++ b/app/Core/JsonResponse.php @@ -0,0 +1,51 @@ +getMessage(), + $exception->getFile(), + $exception->getLine() + )); + + $payload = ['error' => 'Une erreur serveur est survenue.']; + if ((getenv('PTA_DEBUG') ?: '1') !== '0') { + $payload['details'] = $exception->getMessage(); + } + + self::send(500, $payload); + }); + } + + public static function send(int $status, array $payload): void + { + if (!headers_sent()) { + header('Content-Type: application/json; charset=utf-8'); + } + + http_response_code($status); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + exit; + } +} diff --git a/app/Core/PdfResponse.php b/app/Core/PdfResponse.php new file mode 100644 index 0000000..2c94ec0 --- /dev/null +++ b/app/Core/PdfResponse.php @@ -0,0 +1,27 @@ + 'Corps JSON invalide.']); + } + + return $payload; + } + + public static function requireMethod(string $method): void + { + if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) { + JsonResponse::send(405, ['error' => 'Méthode non autorisée.']); + } + } +} diff --git a/app/Models/AgentModel.php b/app/Models/AgentModel.php new file mode 100644 index 0000000..bf890f3 --- /dev/null +++ b/app/Models/AgentModel.php @@ -0,0 +1,440 @@ +pdo->query( + self::AGENT_SELECT . " WHERE a.actif = TRUE ORDER BY a.nom, a.prenom" + )->fetchAll(); + } + + public function allVisibleForStructure(int $structureId): array + { + $stmt = $this->pdo->prepare( + self::AGENT_SELECT . " + WHERE a.actif = TRUE + AND ( + a.id_structure = :structure_id_default + OR EXISTS ( + SELECT 1 + FROM planning p + WHERE p.id_agent = a.id_agent + AND p.id_structure = :structure_id_planning + ) + ) + ORDER BY a.nom, a.prenom" + ); + $stmt->execute([ + 'structure_id_default' => $structureId, + 'structure_id_planning' => $structureId, + ]); + return $stmt->fetchAll(); + } + + public function allEligibleForPta(): array + { + return $this->pdo->query( + self::AGENT_SELECT . " + WHERE a.actif = TRUE + AND a.type_contrat = 'PERMANENT' + AND a.id_poste IS NOT NULL + ORDER BY a.nom, a.prenom" + )->fetchAll(); + } + + public function allPosts(): array + { + return $this->pdo->query( + "SELECT id_poste, code, libelle, famille, heures_mercredi_minutes, + heures_extrascolaire_minutes, autorise_preparation, + peut_assurer_animation, preparation_lundi_si_100 + FROM poste_agent + WHERE actif = TRUE + ORDER BY ordre_affichage, libelle" + )->fetchAll(); + } + + public function listForLocation(int $selectedLocation = 0): array + { + $stmt = $this->pdo->prepare( + "SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure, + a.id_poste, po.libelle AS poste_libelle, + s.nom AS structure_nom, + s.type_affectation AS structure_type_affectation, + CASE WHEN a.id_structure = :selected_location THEN 1 ELSE 0 END AS lieu_principal_selectionne + FROM agent a + LEFT JOIN poste_agent po ON po.id_poste = a.id_poste + LEFT JOIN structure s ON s.id_structure = a.id_structure + WHERE a.actif = TRUE + ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom" + ); + $stmt->execute(['selected_location' => $selectedLocation]); + return $stmt->fetchAll(); + } + + public function findActive(int $agentId): ?array + { + $stmt = $this->pdo->prepare( + self::AGENT_SELECT . " WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1" + ); + $stmt->execute(['id' => $agentId]); + return $stmt->fetch() ?: null; + } + + public function matriculeExists(string $matricule): bool + { + $stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1'); + $stmt->execute(['matricule' => $matricule]); + return $stmt->fetchColumn() !== false; + } + + public function createWithDefaults(array $data): array + { + $contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']); + $contractEnd = !empty($data['date_fin_contrat']) + ? new DateTimeImmutable((string) $data['date_fin_contrat']) + : null; + $year = (int) $contractStart->format('Y'); + // Le quota PTA est suivi sur l'année civile, indépendamment de la date + // anniversaire du contrat. La date de contrat reste une donnée RH. + $periodStart = new DateTimeImmutable(sprintf('%04d-01-01', $year)); + $periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year)); + $quotaReference = QuotaModel::REFERENCE_MINUTES; + $quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100)); + $trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840; + + try { + $this->pdo->beginTransaction(); + + $insertAgent = $this->pdo->prepare( + 'INSERT INTO agent ( + matricule, nom, prenom, email, telephone, adresse, + est_diplome, diplome_libelle, date_debut_contrat, + id_poste, type_contrat, date_fin_contrat, + formation_repartition_annuelle, commentaire_pta, + id_structure, actif + ) VALUES ( + :matricule, :nom, :prenom, :email, :telephone, :adresse, + :est_diplome, :diplome_libelle, :date_debut_contrat, + :id_poste, :type_contrat, :date_fin_contrat, + :formation_repartition_annuelle, :commentaire_pta, + :structure_id, TRUE + )' + ); + $insertAgent->execute([ + 'matricule' => $data['matricule'], + 'nom' => $data['nom'], + 'prenom' => $data['prenom'], + 'email' => $data['email'], + 'telephone' => $data['telephone'], + 'adresse' => $data['adresse'], + 'est_diplome' => $data['est_diplome'] ? 1 : 0, + 'diplome_libelle' => $data['est_diplome'] ? $data['diplome_libelle'] : null, + 'date_debut_contrat' => $contractStart->format('Y-m-d'), + 'id_poste' => $data['id_poste'], + 'type_contrat' => $data['type_contrat'], + 'date_fin_contrat' => $contractEnd?->format('Y-m-d'), + 'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0, + 'commentaire_pta' => $data['commentaire_pta'], + 'structure_id' => $data['structure_id'], + ]); + $agentId = (int) $this->pdo->lastInsertId(); + + $history = $this->pdo->prepare( + 'INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) + VALUES (:agent_id, :structure_id, :date_debut, NULL, TRUE)' + ); + $history->execute([ + 'agent_id' => $agentId, + 'structure_id' => $data['structure_id'], + 'date_debut' => $contractStart->format('Y-m-d'), + ]); + + $quota = $this->pdo->prepare( + 'INSERT INTO quota_agent_annuel ( + id_agent, annee, quotite_travail, quota_reference_minutes, + quota_cible_minutes, commentaire + ) VALUES ( + :agent_id, :annee, :quotite, :quota_reference, + :quota_cible, :commentaire + )' + ); + $quota->execute([ + 'agent_id' => $agentId, + 'annee' => $year, + 'quotite' => $data['quotite_travail'], + 'quota_reference' => $quotaReference, + 'quota_cible' => $quotaTarget, + 'commentaire' => 'Quota PTA de référence - année civile', + ]); + + $pta = $this->pdo->prepare( + 'INSERT INTO pta_annuel ( + id_agent, date_debut, date_fin, quotite_travail, + quota_reference_minutes, quota_cible_minutes, + enveloppe_formation_minutes, statut + ) VALUES ( + :agent_id, :date_debut, :date_fin, :quotite, + :quota_reference, :quota_cible, + :formation, \'BROUILLON\' + )' + ); + $pta->execute([ + 'agent_id' => $agentId, + 'date_debut' => $periodStart->format('Y-m-d'), + 'date_fin' => $periodEnd->format('Y-m-d'), + 'quotite' => $data['quotite_travail'], + 'quota_reference' => $quotaReference, + 'quota_cible' => $quotaTarget, + 'formation' => $trainingEnvelope, + ]); + + $this->pdo->commit(); + + return [ + 'id_agent' => $agentId, + 'annee_quota' => $year, + 'date_debut_contrat' => $contractStart->format('Y-m-d'), + 'date_fin_contrat' => $contractEnd?->format('Y-m-d'), + 'quota_cible_minutes' => $quotaTarget, + 'id_pta' => (int) $this->pdo->lastInsertId(), + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function updateProfile(int $agentId, array $data): void + { + try { + $this->pdo->beginTransaction(); + + $currentStmt = $this->pdo->prepare('SELECT id_structure FROM agent WHERE id_agent = :agent_id FOR UPDATE'); + $currentStmt->execute(['agent_id' => $agentId]); + $currentStructure = $currentStmt->fetchColumn(); + if ($currentStructure === false) { + throw new \RuntimeException('Agent introuvable.'); + } + $currentStructureId = $currentStructure !== null ? (int) $currentStructure : null; + $newStructureId = $data['structure_id']; + + $update = $this->pdo->prepare( + 'UPDATE agent + SET email = :email, + telephone = :telephone, + adresse = :adresse, + est_diplome = :est_diplome, + diplome_libelle = :diplome_libelle, + date_debut_contrat = :date_debut_contrat, + id_poste = :id_poste, + type_contrat = :type_contrat, + date_fin_contrat = :date_fin_contrat, + formation_repartition_annuelle = :formation_repartition_annuelle, + commentaire_pta = :commentaire_pta, + id_structure = :structure_id + WHERE id_agent = :agent_id' + ); + $update->bindValue(':agent_id', $agentId, PDO::PARAM_INT); + $update->bindValue(':email', $data['email']); + $update->bindValue(':telephone', $data['telephone']); + $update->bindValue(':adresse', $data['adresse']); + $update->bindValue(':est_diplome', $data['est_diplome'] ? 1 : 0, PDO::PARAM_INT); + $update->bindValue(':diplome_libelle', $data['est_diplome'] ? $data['diplome_libelle'] : null); + $update->bindValue(':date_debut_contrat', $data['date_debut_contrat']); + $update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT); + $update->bindValue(':type_contrat', $data['type_contrat']); + $update->bindValue(':date_fin_contrat', $data['date_fin_contrat']); + $update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT); + $update->bindValue(':commentaire_pta', $data['commentaire_pta']); + if ($newStructureId === null) { + $update->bindValue(':structure_id', null, PDO::PARAM_NULL); + } else { + $update->bindValue(':structure_id', $newStructureId, PDO::PARAM_INT); + } + $update->execute(); + + if ($currentStructureId !== $newStructureId) { + $close = $this->pdo->prepare( + "UPDATE agent_structure + SET actif = FALSE, + date_fin = CASE + WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut + ELSE CURRENT_DATE + END + WHERE id_agent = :agent_id AND actif = TRUE" + ); + $close->execute(['agent_id' => $agentId]); + + if ($newStructureId !== null) { + $history = $this->pdo->prepare( + "INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) + VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE) + ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE" + ); + $history->execute(['agent_id' => $agentId, 'structure_id' => $newStructureId]); + } + } + + $this->pdo->commit(); + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function updateAssignment(int $agentId, ?int $structureId): void + { + try { + $this->pdo->beginTransaction(); + + $update = $this->pdo->prepare('UPDATE agent SET id_structure = :structure_id WHERE id_agent = :agent_id'); + $update->bindValue(':agent_id', $agentId, PDO::PARAM_INT); + if ($structureId === null) { + $update->bindValue(':structure_id', null, PDO::PARAM_NULL); + } else { + $update->bindValue(':structure_id', $structureId, PDO::PARAM_INT); + } + $update->execute(); + + $close = $this->pdo->prepare( + "UPDATE agent_structure + SET actif = FALSE, + date_fin = CASE + WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut + ELSE CURRENT_DATE + END + WHERE id_agent = :agent_id AND actif = TRUE" + ); + $close->execute(['agent_id' => $agentId]); + + if ($structureId !== null) { + $history = $this->pdo->prepare( + "INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) + VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE) + ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE" + ); + $history->execute(['agent_id' => $agentId, 'structure_id' => $structureId]); + } + + $this->pdo->commit(); + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function entriesForWeek(int $agentId, int $year, int $week): array + { + $stmt = $this->pdo->prepare( + "SELECT p.id_planning, p.statut, + s2.id_structure, s2.nom AS structure_nom, + s2.type_affectation AS structure_type_affectation, + c.id_creneau, c.id_motif, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, + m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota + FROM planning p + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + INNER JOIN structure s2 ON s2.id_structure = p.id_structure + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.id_agent = :agent_id + AND sem.annee = :annee + AND sem.numero_semaine = :semaine + ORDER BY c.date_jour, c.heure_debut, s2.nom" + ); + $stmt->execute(['agent_id' => $agentId, 'annee' => $year, 'semaine' => $week]); + return $stmt->fetchAll(); + } + + public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array + { + $stmt = $this->pdo->prepare( + "SELECT p.id_planning, p.statut, + s2.id_structure, s2.nom AS structure_nom, + s2.type_affectation AS structure_type_affectation, + c.id_creneau, c.id_motif, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, + m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota + FROM planning p + INNER JOIN structure s2 ON s2.id_structure = p.id_structure + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.id_agent = :agent_id + AND c.date_jour BETWEEN :date_debut AND :date_fin + ORDER BY c.date_jour, c.heure_debut, s2.nom" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'date_debut' => $startDate, + 'date_fin' => $endDate, + ]); + return $stmt->fetchAll(); + } + + public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array + { + $stmt = $this->pdo->prepare( + "SELECT UPPER(m.code) AS motif_code, + m.libelle AS motif_libelle, + m.compte_dans_quota, + COALESCE(SUM(TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))), 0) AS minutes_total, + COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_valides, + COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_brouillon + FROM planning p + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.id_agent = :agent_id + AND c.date_jour BETWEEN :date_debut AND :date_fin + GROUP BY UPPER(m.code), m.libelle, m.compte_dans_quota + ORDER BY m.compte_dans_quota DESC, m.libelle" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'date_debut' => $startDate, + 'date_fin' => $endDate, + ]); + return $stmt->fetchAll(); + } + + public function timeSummaryForYear(int $agentId, int $year): array + { + return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)); + } +} diff --git a/app/Models/AnnualOverviewModel.php b/app/Models/AnnualOverviewModel.php new file mode 100644 index 0000000..49664a5 --- /dev/null +++ b/app/Models/AnnualOverviewModel.php @@ -0,0 +1,342 @@ +pdo); + $agent = $agentModel->findActive($agentId); + if ($agent === null) { + throw new RuntimeException('Agent introuvable.'); + } + + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + $entries = $agentModel->entriesForDateRange($agentId, $start, $end); + $vacations = (new VacationModel($this->pdo))->periodsBetween($start, $end); + $quotaPeriods = $this->quotaPeriodsForYear($agentId, $year, $agent); + + $entriesByDate = []; + foreach ($entries as $entry) { + $entriesByDate[(string) $entry['date_jour']][] = $entry; + } + + $months = []; + $yearTotals = array_fill_keys(self::CELL_KEYS, 0); + $statusTotals = ['VALIDE' => 0, 'BROUILLON' => 0]; + $specialTotals = []; + + for ($month = 1; $month <= 12; $month++) { + $monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)); + $monthEnd = $monthStart->modify('last day of this month'); + $days = []; + $monthTotals = array_fill_keys(self::CELL_KEYS, 0); + + $period = new DatePeriod($monthStart, new DateInterval('P1D'), $monthEnd->modify('+1 day')); + foreach ($period as $date) { + $dateValue = $date->format('Y-m-d'); + $vacation = $this->vacationForDate($dateValue, $vacations); + $day = $this->buildDay($date, $entriesByDate[$dateValue] ?? [], $vacation, $agent); + $days[] = $day; + + foreach (self::CELL_KEYS as $key) { + $minutes = (int) $day['cells'][$key]['minutes']; + $monthTotals[$key] += $minutes; + $yearTotals[$key] += $minutes; + } + foreach ($day['status_minutes'] as $status => $minutes) { + $statusTotals[$status] = ($statusTotals[$status] ?? 0) + $minutes; + } + foreach ($day['special_minutes'] as $code => $minutes) { + $specialTotals[$code] = ($specialTotals[$code] ?? 0) + $minutes; + } + } + + $months[] = [ + 'number' => $month, + 'name' => $this->monthName($month), + 'days' => $days, + 'totals' => $this->formatTotals($monthTotals), + ]; + } + + $calendarMinutes = array_sum($yearTotals); + + return [ + 'agent' => $agent, + 'year' => $year, + 'generated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'), + 'quota_periods' => $quotaPeriods, + 'calendar_totals' => [ + 'minutes' => $calendarMinutes, + 'duration' => $this->formatMinutes($calendarMinutes), + 'cells' => $this->formatTotals($yearTotals), + 'validated' => $this->formatMinutes((int) ($statusTotals['VALIDE'] ?? 0)), + 'draft' => $this->formatMinutes((int) ($statusTotals['BROUILLON'] ?? 0)), + 'specials' => array_map(fn(int $minutes): string => $this->formatMinutes($minutes), $specialTotals), + ], + 'months' => $months, + 'legend' => [ + ['code' => 'TRAVAIL', 'label' => 'Temps de travail', 'class' => 'annual-work'], + ['code' => 'PREPA', 'label' => 'Temps de préparation', 'class' => 'annual-preparation'], + ['code' => 'FOR', 'label' => 'Formation', 'class' => 'annual-training'], + ['code' => 'CA', 'label' => 'Congé annuel', 'class' => 'annual-leave'], + ['code' => 'ABS', 'label' => 'Absence', 'class' => 'annual-absence'], + ['code' => 'JNT', 'label' => 'Journée non travaillée', 'class' => 'annual-jnt'], + ['code' => 'JF', 'label' => 'Journée de fractionnement', 'class' => 'annual-fraction'], + ['code' => 'VAC', 'label' => 'Vacances scolaires', 'class' => 'annual-vacation'], + ], + ]; + } + + private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array + { + $cells = []; + foreach (self::CELL_KEYS as $key) { + $cells[$key] = [ + 'minutes' => 0, + 'display' => '', + 'details' => [], + 'codes' => [], + 'class' => '', + 'structure_id' => null, + ]; + } + + $statusMinutes = ['VALIDE' => 0, 'BROUILLON' => 0]; + $specialMinutes = []; + $hasDraft = false; + $firstStructureId = null; + + foreach ($entries as $entry) { + $minutes = $this->minutesBetween((string) $entry['heure_debut'], (string) $entry['heure_fin']); + if ($minutes <= 0) { + continue; + } + + $code = strtoupper((string) $entry['motif_code']); + $cellKey = $this->cellForEntry($code, $vacation !== null); + $shortCode = $this->shortCode($code); + $status = strtoupper((string) $entry['statut']) === 'VALIDE' ? 'VALIDE' : 'BROUILLON'; + $statusMinutes[$status] += $minutes; + $hasDraft = $hasDraft || $status === 'BROUILLON'; + $firstStructureId ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null; + + $cells[$cellKey]['minutes'] += $minutes; + $cells[$cellKey]['structure_id'] ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null; + $cells[$cellKey]['details'][] = sprintf( + '%s–%s · %s · %s%s', + (string) $entry['heure_debut'], + (string) $entry['heure_fin'], + (string) $entry['motif_libelle'], + (string) $entry['structure_nom'], + $status === 'BROUILLON' ? ' · Brouillon' : '' + ); + if ($shortCode !== null) { + $cells[$cellKey]['codes'][$shortCode] = true; + $specialMinutes[$code] = ($specialMinutes[$code] ?? 0) + $minutes; + } + $cells[$cellKey]['class'] = $this->strongestClass( + (string) $cells[$cellKey]['class'], + $this->classForCode($code, $cellKey) + ); + } + + foreach (self::CELL_KEYS as $key) { + $codes = array_keys($cells[$key]['codes']); + $cells[$key]['display'] = $this->cellDisplay((int) $cells[$key]['minutes'], $codes); + $cells[$key]['title'] = implode("\n", $cells[$key]['details']); + unset($cells[$key]['details'], $cells[$key]['codes']); + } + + $weekday = (int) $date->format('N'); + $week = (int) $date->format('W'); + $principalStructure = isset($agent['id_structure']) && $agent['id_structure'] !== null + ? (int) $agent['id_structure'] + : null; + + return [ + 'date' => $date->format('Y-m-d'), + 'day' => (int) $date->format('j'), + 'weekday' => $weekday, + 'weekday_short' => $this->weekdayShort($weekday), + 'week' => $week, + 'week_year' => (int) $date->format('o'), + 'show_week' => $weekday === 1 || (int) $date->format('j') === 1, + 'is_weekend' => $weekday >= 6, + 'is_vacation' => $vacation !== null, + 'vacation_label' => $vacation['libelle'] ?? null, + 'has_entries' => $entries !== [], + 'has_draft' => $hasDraft, + 'structure_id' => $firstStructureId ?? $principalStructure, + 'cells' => $cells, + 'status_minutes' => $statusMinutes, + 'special_minutes' => $specialMinutes, + ]; + } + + private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array + { + $quotaModel = new QuotaModel($this->pdo); + $contexts = [sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)]; + $contractStart = trim((string) ($agent['date_debut_contrat'] ?? '')); + if ($contractStart !== '' && str_starts_with($contractStart, (string) $year)) { + $contexts[] = $contractStart; + } + + $periods = []; + foreach ($contexts as $context) { + $quota = $quotaModel->forDateForApi($agentId, $context); + $key = $quota['date_debut_periode'] . '|' . $quota['date_fin_periode']; + $periods[$key] = $quota; + } + + uasort($periods, static fn(array $a, array $b): int => strcmp( + (string) $a['date_debut_periode'], + (string) $b['date_debut_periode'] + )); + + return array_values($periods); + } + + private function cellForEntry(string $code, bool $isVacation): string + { + return match ($code) { + 'PREPARATION_PERISCOLAIRE', 'PREPARATION_LUNDI' => 'PP', + 'PREPARATION_EXTRASCOLAIRE' => 'PE', + 'EXTRASCOLAIRE' => 'AE', + 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', 'MERCREDI_PERISCOLAIRE' => 'AP', + 'JOUR_FRACTIONNEMENT' => 'AP', + default => $isVacation ? 'AE' : 'AP', + }; + } + + private function shortCode(string $code): ?string + { + return match ($code) { + 'FORMATION' => 'FOR', + 'CONGE' => 'CA', + 'ABSENCE', 'MALADIE' => 'ABS', + 'AUTRE_ABSENCE' => 'A.ND', + 'JNT' => 'JNT', + 'JOUR_FRACTIONNEMENT' => 'JF', + 'MENAGE_FOND' => 'MF', + default => null, + }; + } + + private function classForCode(string $code, string $cellKey): string + { + return match ($code) { + 'FORMATION' => 'annual-training', + 'CONGE' => 'annual-leave', + 'ABSENCE', 'MALADIE' => 'annual-absence', + 'AUTRE_ABSENCE' => 'annual-other-absence', + 'JNT' => 'annual-jnt', + 'JOUR_FRACTIONNEMENT' => 'annual-fraction', + 'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'annual-preparation', + 'EXTRASCOLAIRE' => 'annual-extra', + default => in_array($cellKey, ['PP', 'PE'], true) ? 'annual-preparation' : 'annual-work', + }; + } + + private function strongestClass(string $current, string $candidate): string + { + $priority = [ + '' => 0, + 'annual-work' => 1, + 'annual-extra' => 2, + 'annual-preparation' => 3, + 'annual-fraction' => 4, + 'annual-training' => 5, + 'annual-leave' => 6, + 'annual-other-absence' => 7, + 'annual-absence' => 8, + 'annual-jnt' => 9, + ]; + return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current; + } + + private function cellDisplay(int $minutes, array $codes): string + { + if ($minutes <= 0) { + return ''; + } + if ($codes !== []) { + return implode('/', $codes); + } + return $this->formatCompactMinutes($minutes); + } + + private function vacationForDate(string $date, array $vacations): ?array + { + foreach ($vacations as $vacation) { + if ($date >= (string) $vacation['date_debut'] && $date <= (string) $vacation['date_fin']) { + return $vacation; + } + } + return null; + } + + private function minutesBetween(string $start, string $end): int + { + [$startHour, $startMinute] = array_map('intval', explode(':', $start)); + [$endHour, $endMinute] = array_map('intval', explode(':', $end)); + return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute)); + } + + private function formatTotals(array $totals): array + { + $formatted = []; + foreach ($totals as $key => $minutes) { + $formatted[$key] = [ + 'minutes' => (int) $minutes, + 'duration' => $this->formatMinutes((int) $minutes), + 'compact' => $this->formatCompactMinutes((int) $minutes), + ]; + } + return $formatted; + } + + private function formatMinutes(int $minutes): string + { + $sign = $minutes < 0 ? '-' : ''; + $minutes = abs($minutes); + return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); + } + + private function formatCompactMinutes(int $minutes): string + { + if ($minutes <= 0) { + return ''; + } + $hours = intdiv($minutes, 60); + $mins = $minutes % 60; + return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins); + } + + private function monthName(int $month): string + { + return [ + 1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril', + 5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août', + 9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre', + ][$month]; + } + + private function weekdayShort(int $weekday): string + { + return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday]; + } +} diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php new file mode 100644 index 0000000..c289a65 --- /dev/null +++ b/app/Models/BaseModel.php @@ -0,0 +1,14 @@ + 'Lundi', + 2 => 'Mardi', + 3 => 'Mercredi', + 4 => 'Jeudi', + 5 => 'Vendredi', + ]; + + public function rulesForStructure(int $structureId): array + { + $stmt = $this->pdo->prepare( + "SELECT id_couverture, id_structure, jour_semaine, + TIME_FORMAT(heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(heure_fin, '%H:%i') AS heure_fin, + minimum_agents + FROM structure_couverture_horaire + WHERE id_structure = :structure_id + ORDER BY jour_semaine, heure_debut, heure_fin" + ); + $stmt->execute(['structure_id' => $structureId]); + return $stmt->fetchAll(); + } + + public function replaceRules(int $structureId, array $rules): void + { + try { + $this->pdo->beginTransaction(); + + $delete = $this->pdo->prepare( + 'DELETE FROM structure_couverture_horaire WHERE id_structure = :structure_id' + ); + $delete->execute(['structure_id' => $structureId]); + + if ($rules !== []) { + $insert = $this->pdo->prepare( + 'INSERT INTO structure_couverture_horaire + (id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents) + VALUES + (:structure_id, :jour_semaine, :heure_debut, :heure_fin, :minimum_agents)' + ); + + foreach ($rules as $rule) { + $insert->execute([ + 'structure_id' => $structureId, + 'jour_semaine' => (int) $rule['jour_semaine'], + 'heure_debut' => $rule['heure_debut'], + 'heure_fin' => $rule['heure_fin'], + 'minimum_agents' => (int) $rule['minimum_agents'], + ]); + } + } + + $this->pdo->commit(); + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function reportForStructure(int $structureId, int $year, int $weekNumber): array + { + $rules = $this->rulesForStructure($structureId); + $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); + $weekEnd = $weekStart->modify('+4 days'); + + if ($rules === []) { + return [ + 'configured' => false, + 'rules' => [], + 'gaps' => [], + 'validation_warnings' => [], + 'summary' => [ + 'gap_count' => 0, + 'gap_minutes' => 0, + 'validation_warning_count' => 0, + ], + ]; + } + + $entries = $this->workEntries( + $structureId, + $weekStart->format('Y-m-d'), + $weekEnd->format('Y-m-d') + ); + + $entriesByDate = []; + foreach ($entries as $entry) { + $entriesByDate[$entry['date_jour']][] = $entry; + } + + $gaps = []; + $validationWarnings = []; + + foreach ($rules as $rule) { + $dayNumber = (int) $rule['jour_semaine']; + $date = $weekStart->modify('+' . ($dayNumber - 1) . ' days')->format('Y-m-d'); + $start = $this->timeToMinutes((string) $rule['heure_debut']); + $end = $this->timeToMinutes((string) $rule['heure_fin']); + $minimum = (int) $rule['minimum_agents']; + + for ($slotStart = $start; $slotStart < $end; $slotStart += 15) { + $slotEnd = min($slotStart + 15, $end); + $plannedAgents = []; + $validatedAgents = []; + + foreach ($entriesByDate[$date] ?? [] as $entry) { + $entryStart = $this->timeToMinutes((string) $entry['heure_debut']); + $entryEnd = $this->timeToMinutes((string) $entry['heure_fin']); + if ($entryStart >= $slotEnd || $entryEnd <= $slotStart) { + continue; + } + + $agentId = (int) $entry['id_agent']; + $plannedAgents[$agentId] = true; + if ($entry['statut'] === 'VALIDE') { + $validatedAgents[$agentId] = true; + } + } + + $plannedCount = count($plannedAgents); + $validatedCount = count($validatedAgents); + $base = [ + 'date' => $date, + 'jour_semaine' => $dayNumber, + 'jour_libelle' => self::DAY_LABELS[$dayNumber] ?? 'Jour', + 'heure_debut' => $this->minutesToTime($slotStart), + 'heure_fin' => $this->minutesToTime($slotEnd), + 'minimum_agents' => $minimum, + 'agents_planifies' => $plannedCount, + 'agents_valides' => $validatedCount, + ]; + + if ($plannedCount < $minimum) { + $base['agents_manquants'] = $minimum - $plannedCount; + $this->appendMergedInterval($gaps, $base, ['minimum_agents', 'agents_planifies', 'agents_manquants']); + } elseif ($validatedCount < $minimum) { + $base['validations_manquantes'] = $minimum - $validatedCount; + $this->appendMergedInterval($validationWarnings, $base, ['minimum_agents', 'agents_valides', 'validations_manquantes']); + } + } + } + + $gapMinutes = 0; + foreach ($gaps as $gap) { + $gapMinutes += $this->timeToMinutes($gap['heure_fin']) - $this->timeToMinutes($gap['heure_debut']); + } + + return [ + 'configured' => true, + 'rules' => $rules, + 'gaps' => $gaps, + 'validation_warnings' => $validationWarnings, + 'summary' => [ + 'gap_count' => count($gaps), + 'gap_minutes' => $gapMinutes, + 'validation_warning_count' => count($validationWarnings), + ], + ]; + } + + private function workEntries(int $structureId, string $dateStart, string $dateEnd): array + { + $stmt = $this->pdo->prepare( + "SELECT p.id_agent, p.statut, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin + FROM planning p + INNER JOIN agent a ON a.id_agent = p.id_agent AND a.actif = TRUE + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.id_structure = :structure_id + AND c.date_jour BETWEEN :date_start AND :date_end + AND m.code = 'TRAVAIL' + ORDER BY c.date_jour, c.heure_debut, c.heure_fin" + ); + $stmt->execute([ + 'structure_id' => $structureId, + 'date_start' => $dateStart, + 'date_end' => $dateEnd, + ]); + return $stmt->fetchAll(); + } + + private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void + { + $lastIndex = count($target) - 1; + if ($lastIndex >= 0) { + $last = $target[$lastIndex]; + $same = $last['date'] === $slot['date'] && $last['heure_fin'] === $slot['heure_debut']; + foreach ($comparisonKeys as $key) { + $same = $same && (int) $last[$key] === (int) $slot[$key]; + } + if ($same) { + $target[$lastIndex]['heure_fin'] = $slot['heure_fin']; + return; + } + } + $target[] = $slot; + } + + private function timeToMinutes(string $time): int + { + [$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5))); + return ($hours * 60) + $minutes; + } + + private function minutesToTime(int $minutes): string + { + return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60); + } +} diff --git a/app/Models/MotifModel.php b/app/Models/MotifModel.php new file mode 100644 index 0000000..be6add5 --- /dev/null +++ b/app/Models/MotifModel.php @@ -0,0 +1,26 @@ +pdo->query( + "SELECT id_motif, code, libelle, compte_dans_quota + FROM motif_planning + WHERE actif = TRUE + ORDER BY ordre_affichage, libelle" + )->fetchAll(); + } + + public function activeIds(): array + { + return array_map( + 'intval', + $this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN) + ); + } +} diff --git a/app/Models/PlanningModel.php b/app/Models/PlanningModel.php new file mode 100644 index 0000000..586da69 --- /dev/null +++ b/app/Models/PlanningModel.php @@ -0,0 +1,986 @@ +pdo->prepare( + "SELECT p.id_planning, p.statut, p.commentaire, s.date_debut, s.date_fin + FROM planning p + INNER JOIN semaine s ON s.id_semaine = p.id_semaine + WHERE p.id_structure = :structure_id + AND p.id_agent = :agent_id + AND s.annee = :annee + AND s.numero_semaine = :semaine + LIMIT 1" + ); + $stmt->execute([ + 'structure_id' => $structureId, + 'agent_id' => $agentId, + 'annee' => $year, + 'semaine' => $week, + ]); + return $stmt->fetch() ?: null; + } + + public function entries(int $planningId): array + { + $stmt = $this->pdo->prepare( + "SELECT c.id_creneau, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, + m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle, + m.compte_dans_quota + FROM creneau_horaire c + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE c.id_planning = :planning_id + ORDER BY c.date_jour, c.heure_debut, c.heure_fin" + ); + $stmt->execute(['planning_id' => $planningId]); + return $stmt->fetchAll(); + } + + public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array + { + $stmt = $this->pdo->prepare( + "SELECT p.id_planning, p.statut, + st.id_structure, st.code AS structure_code, st.nom AS structure_nom, + st.type_affectation AS structure_type_affectation, + c.id_creneau, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, + m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle, + m.compte_dans_quota + FROM planning p + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + INNER JOIN structure st ON st.id_structure = p.id_structure + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.id_agent = :agent_id + AND sem.annee = :annee + AND sem.numero_semaine = :semaine + AND p.id_structure <> :structure_id + ORDER BY c.date_jour, c.heure_debut, st.nom" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'annee' => $year, + 'semaine' => $week, + 'structure_id' => $structureId, + ]); + return $stmt->fetchAll(); + } + + public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int + { + $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); + $weekEnd = $weekStart->modify('+6 days'); + + try { + $this->pdo->beginTransaction(); + + $weekStmt = $this->pdo->prepare( + "INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut) + VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE') + ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)" + ); + $weekStmt->execute([ + 'annee' => $year, + 'numero' => $weekNumber, + 'date_debut' => $weekStart->format('Y-m-d'), + 'date_fin' => $weekEnd->format('Y-m-d'), + ]); + $weekId = (int) $this->pdo->lastInsertId(); + + if ($weekId === 0) { + $findWeek = $this->pdo->prepare( + 'SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero' + ); + $findWeek->execute(['annee' => $year, 'numero' => $weekNumber]); + $weekId = (int) $findWeek->fetchColumn(); + } + + $planningStmt = $this->pdo->prepare( + "SELECT id_planning, statut + FROM planning + WHERE id_agent = :agent_id + AND id_semaine = :semaine_id + AND id_structure = :structure_id + FOR UPDATE" + ); + $planningStmt->execute([ + 'agent_id' => $agentId, + 'semaine_id' => $weekId, + 'structure_id' => $structureId, + ]); + $planning = $planningStmt->fetch(); + + if ($planning && $planning['statut'] === 'VALIDE') { + throw new \DomainException('Ce planning est déjà validé et doit être rouvert avant modification.'); + } + + if ($planning) { + $planningId = (int) $planning['id_planning']; + $delete = $this->pdo->prepare('DELETE FROM creneau_horaire WHERE id_planning = :planning_id'); + $delete->execute(['planning_id' => $planningId]); + } else { + $insertPlanning = $this->pdo->prepare( + "INSERT INTO planning (id_agent, id_semaine, id_structure, statut) + VALUES (:agent_id, :semaine_id, :structure_id, 'BROUILLON')" + ); + $insertPlanning->execute([ + 'agent_id' => $agentId, + 'semaine_id' => $weekId, + 'structure_id' => $structureId, + ]); + $planningId = (int) $this->pdo->lastInsertId(); + } + + $insertEntry = $this->pdo->prepare( + "INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin) + VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)" + ); + + foreach ($entries as $entry) { + $insertEntry->execute([ + 'planning_id' => $planningId, + 'motif_id' => $entry['motif_id'], + 'date_jour' => $entry['date'], + 'heure_debut' => $entry['start'], + 'heure_fin' => $entry['end'], + ]); + } + + $this->pdo->commit(); + return $planningId; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function pendingValidation(): array + { + $stmt = $this->pdo->query( + "SELECT p.id_planning, p.id_agent, p.id_structure, p.statut, + p.date_creation, p.date_modification, + a.matricule, a.nom AS agent_nom, a.prenom AS agent_prenom, + st.nom AS structure_nom, st.type_affectation AS structure_type_affectation, + sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin, + DATE_ADD(sem.date_debut, INTERVAL 4 DAY) AS date_fin_ouvrable, + COUNT(c.id_creneau) AS nombre_creneaux, + COALESCE(SUM(TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + )), 0) AS total_minutes, + COALESCE(SUM(CASE WHEN m.compte_dans_quota = 1 THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) ELSE 0 END), 0) AS minutes_decomptees + FROM planning p + INNER JOIN agent a ON a.id_agent = p.id_agent + INNER JOIN structure st ON st.id_structure = p.id_structure + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning + LEFT JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.statut = 'BROUILLON' + GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut, + p.date_creation, p.date_modification, + a.matricule, a.nom, a.prenom, + st.nom, st.type_affectation, + sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin + HAVING COUNT(c.id_creneau) > 0 + ORDER BY sem.date_debut ASC, a.nom ASC, a.prenom ASC, st.nom ASC" + ); + + return $stmt->fetchAll(); + } + + + /** + * Plannings en brouillon de l'agent qui contiennent au moins un créneau + * dans l'année civile demandée. + */ + public function pendingValidationForAgentYear(int $agentId, int $year): array + { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + $stmt = $this->pdo->prepare( + "SELECT p.id_planning, p.id_agent, p.id_structure, p.statut, + st.nom AS structure_nom, + sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin, + COUNT(c.id_creneau) AS nombre_creneaux, + COALESCE(SUM(TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + )), 0) AS total_minutes + FROM planning p + INNER JOIN structure st ON st.id_structure = p.id_structure + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning + WHERE p.id_agent = :agent_id + AND p.statut = 'BROUILLON' + AND EXISTS ( + SELECT 1 + FROM creneau_horaire cy + WHERE cy.id_planning = p.id_planning + AND cy.date_jour BETWEEN :date_debut AND :date_fin + ) + GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut, + st.nom, sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin + HAVING COUNT(c.id_creneau) > 0 + ORDER BY sem.date_debut, st.nom" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'date_debut' => $start, + 'date_fin' => $end, + ]); + return $stmt->fetchAll(); + } + + /** + * Chevauchements avec n'importe quel autre planning de l'agent, + * qu'il soit déjà validé ou encore en brouillon. + */ + public function externalOverlaps(int $planningId): array + { + $stmt = $this->pdo->prepare( + "SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2, + c1.date_jour, st.nom AS structure_nom, p2.statut AS autre_statut + FROM planning p1 + INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning + INNER JOIN planning p2 + ON p2.id_agent = p1.id_agent + AND p2.id_planning <> p1.id_planning + INNER JOIN structure st ON st.id_structure = p2.id_structure + INNER JOIN creneau_horaire c2 + ON c2.id_planning = p2.id_planning + AND c2.date_jour = c1.date_jour + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE p1.id_planning = :planning_id" + ); + $stmt->execute(['planning_id' => $planningId]); + return $stmt->fetchAll(); + } + + public function findDetailed(int $planningId): ?array + { + $stmt = $this->pdo->prepare( + "SELECT p.id_planning, p.id_agent, p.id_structure, p.statut, + s.annee, s.numero_semaine, s.date_debut, s.date_fin + FROM planning p + INNER JOIN semaine s ON s.id_semaine = p.id_semaine + WHERE p.id_planning = :planning_id + LIMIT 1" + ); + $stmt->execute(['planning_id' => $planningId]); + return $stmt->fetch() ?: null; + } + + public function countEntries(int $planningId): int + { + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id'); + $stmt->execute(['planning_id' => $planningId]); + return (int) $stmt->fetchColumn(); + } + + public function entriesOutsideWeek(int $planningId, string $start, string $end): array + { + $stmt = $this->pdo->prepare( + "SELECT id_creneau, date_jour + FROM creneau_horaire + WHERE id_planning = :planning_id + AND date_jour NOT BETWEEN :date_debut AND :date_fin" + ); + $stmt->execute(['planning_id' => $planningId, 'date_debut' => $start, 'date_fin' => $end]); + return $stmt->fetchAll(); + } + + public function internalOverlaps(int $planningId): array + { + $stmt = $this->pdo->prepare( + "SELECT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour + FROM creneau_horaire c1 + INNER JOIN creneau_horaire c2 + ON c2.id_planning = c1.id_planning + AND c2.date_jour = c1.date_jour + AND c2.id_creneau > c1.id_creneau + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE c1.id_planning = :planning_id" + ); + $stmt->execute(['planning_id' => $planningId]); + return $stmt->fetchAll(); + } + + public function validatedExternalOverlaps(int $planningId): array + { + $stmt = $this->pdo->prepare( + "SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour, st.nom AS structure_nom + FROM planning p1 + INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning + INNER JOIN planning p2 + ON p2.id_agent = p1.id_agent + AND p2.id_planning <> p1.id_planning + AND p2.statut = 'VALIDE' + INNER JOIN structure st ON st.id_structure = p2.id_structure + INNER JOIN creneau_horaire c2 + ON c2.id_planning = p2.id_planning + AND c2.date_jour = c1.date_jour + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE p1.id_planning = :planning_id" + ); + $stmt->execute(['planning_id' => $planningId]); + return $stmt->fetchAll(); + } + + public function findEntryDetailed(int $entryId): ?array + { + $stmt = $this->pdo->prepare( + "SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, + p.id_agent, p.id_structure, p.id_semaine, p.statut, + sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin + FROM creneau_horaire c + INNER JOIN planning p ON p.id_planning = c.id_planning + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + WHERE c.id_creneau = :entry_id + LIMIT 1" + ); + $stmt->execute(['entry_id' => $entryId]); + return $stmt->fetch() ?: null; + } + + /** + * Modifie un créneau depuis la vue agent, y compris vers une autre semaine. + * Le planning cible est créé si nécessaire et tout planning validé modifié + * repasse automatiquement en brouillon. + */ + public function updateEntryFromAgentView( + int $entryId, + int $targetStructureId, + int $motifId, + string $date, + string $start, + string $end + ): array { + try { + $this->pdo->beginTransaction(); + + $entryStmt = $this->pdo->prepare( + "SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin, + p.id_agent, p.id_structure, p.id_semaine, p.statut, + sem.annee, sem.numero_semaine + FROM creneau_horaire c + INNER JOIN planning p ON p.id_planning = c.id_planning + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + WHERE c.id_creneau = :entry_id + FOR UPDATE" + ); + $entryStmt->execute(['entry_id' => $entryId]); + $entry = $entryStmt->fetch(); + if (!$entry) { + throw new \DomainException('Le créneau à modifier est introuvable.'); + } + + $targetDate = new DateTimeImmutable($date); + if ((int) $targetDate->format('N') > 5) { + throw new \DomainException('Le créneau doit être positionné entre le lundi et le vendredi.'); + } + + $conflictStmt = $this->pdo->prepare( + "SELECT c.id_creneau, p.statut, st.nom AS structure_nom, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin + FROM creneau_horaire c + INNER JOIN planning p ON p.id_planning = c.id_planning + INNER JOIN structure st ON st.id_structure = p.id_structure + WHERE p.id_agent = :agent_id + AND c.id_creneau <> :entry_id + AND c.date_jour = :date_jour + AND c.heure_debut < :heure_fin + AND c.heure_fin > :heure_debut + LIMIT 1 + FOR UPDATE" + ); + $conflictStmt->execute([ + 'agent_id' => (int) $entry['id_agent'], + 'entry_id' => $entryId, + 'date_jour' => $date, + 'heure_debut' => $start, + 'heure_fin' => $end, + ]); + $conflict = $conflictStmt->fetch(); + if ($conflict) { + throw new \DomainException(sprintf( + 'Double affectation impossible : un autre créneau existe déjà sur le lieu « %s » de %s à %s (%s).', + $conflict['structure_nom'], + $conflict['heure_debut'], + $conflict['heure_fin'], + strtolower((string) $conflict['statut']) + )); + } + + $targetYear = (int) $targetDate->format('o'); + $targetWeekNumber = (int) $targetDate->format('W'); + $targetWeekStart = $targetDate->modify('-' . ((int) $targetDate->format('N') - 1) . ' days')->setTime(0, 0); + $targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetWeekStart, $targetWeekStart->modify('+6 days')); + + $sourcePlanningId = (int) $entry['id_planning']; + $targetPlanning = $this->findOrCreatePlanning( + (int) $entry['id_agent'], + $targetWeekId, + $targetStructureId + ); + $targetPlanningId = (int) $targetPlanning['id_planning']; + + $updateEntry = $this->pdo->prepare( + "UPDATE creneau_horaire + SET id_planning = :planning_id, + id_motif = :motif_id, + date_jour = :date_jour, + heure_debut = :heure_debut, + heure_fin = :heure_fin + WHERE id_creneau = :entry_id" + ); + $updateEntry->execute([ + 'planning_id' => $targetPlanningId, + 'motif_id' => $motifId, + 'date_jour' => $date, + 'heure_debut' => $start, + 'heure_fin' => $end, + 'entry_id' => $entryId, + ]); + + $reopenedPlanningIds = []; + if ((string) $entry['statut'] === 'VALIDE') { + $reopenedPlanningIds[] = $sourcePlanningId; + } + if ((string) $targetPlanning['statut'] === 'VALIDE') { + $reopenedPlanningIds[] = $targetPlanningId; + } + $reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds); + + $this->pdo->commit(); + + return [ + 'entry_id' => $entryId, + 'agent_id' => (int) $entry['id_agent'], + 'year' => $targetYear, + 'week' => $targetWeekNumber, + 'source_planning_id' => $sourcePlanningId, + 'target_planning_id' => $targetPlanningId, + 'reopened_planning_ids' => $reopenedPlanningIds, + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + /** + * Supprime un créneau depuis la vue par agent. + * Si le planning était validé, il repasse en brouillon afin de signaler + * que son contenu a été modifié. Le planning est conservé même s'il ne + * contient plus aucun créneau. + */ + public function deleteEntryFromAgentView(int $entryId): array + { + try { + $this->pdo->beginTransaction(); + + $entryStmt = $this->pdo->prepare( + "SELECT c.id_creneau, c.id_planning, c.date_jour, + p.id_agent, p.statut, + sem.annee, sem.numero_semaine + FROM creneau_horaire c + INNER JOIN planning p ON p.id_planning = c.id_planning + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + WHERE c.id_creneau = :entry_id + FOR UPDATE" + ); + $entryStmt->execute(['entry_id' => $entryId]); + $entry = $entryStmt->fetch(); + + if (!$entry) { + throw new \DomainException('Le créneau à supprimer est introuvable.'); + } + + $deleteStmt = $this->pdo->prepare( + 'DELETE FROM creneau_horaire WHERE id_creneau = :entry_id' + ); + $deleteStmt->execute(['entry_id' => $entryId]); + + if ($deleteStmt->rowCount() !== 1) { + throw new \DomainException('Le créneau a déjà été supprimé ou modifié. Rechargez la page.'); + } + + $reopened = (string) $entry['statut'] === 'VALIDE'; + if ($reopened) { + $reopenStmt = $this->pdo->prepare( + "UPDATE planning + SET statut = 'BROUILLON' + WHERE id_planning = :planning_id" + ); + $reopenStmt->execute(['planning_id' => (int) $entry['id_planning']]); + } + + $countStmt = $this->pdo->prepare( + 'SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id' + ); + $countStmt->execute(['planning_id' => (int) $entry['id_planning']]); + $remainingEntries = (int) $countStmt->fetchColumn(); + + $this->pdo->commit(); + + return [ + 'entry_id' => $entryId, + 'planning_id' => (int) $entry['id_planning'], + 'agent_id' => (int) $entry['id_agent'], + 'year' => (int) $entry['annee'], + 'week' => (int) $entry['numero_semaine'], + 'date' => (string) $entry['date_jour'], + 'reopened' => $reopened, + 'remaining_entries' => $remainingEntries, + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + /** + * Déplace ou duplique un créneau vers n'importe quel jour ouvré. + * Le lieu, le motif et les horaires sont conservés, y compris lors d'un + * changement de semaine. + */ + public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array + { + try { + $this->pdo->beginTransaction(); + + $entryStmt = $this->pdo->prepare( + "SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin, + p.id_agent, p.id_structure, p.id_semaine, p.statut, + sem.annee, sem.numero_semaine + FROM creneau_horaire c + INNER JOIN planning p ON p.id_planning = c.id_planning + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + WHERE c.id_creneau = :entry_id + FOR UPDATE" + ); + $entryStmt->execute(['entry_id' => $entryId]); + $entry = $entryStmt->fetch(); + if (!$entry) { + throw new \DomainException('Le créneau à déplacer est introuvable.'); + } + + $targetDateObject = new DateTimeImmutable($targetDate); + if ((int) $targetDateObject->format('N') > 5) { + throw new \DomainException('Le jour cible doit être compris entre le lundi et le vendredi.'); + } + + if (!$duplicate && $targetDate === (string) $entry['date_jour']) { + $this->pdo->commit(); + return [ + 'entry_id' => $entryId, + 'created_entry_id' => null, + 'agent_id' => (int) $entry['id_agent'], + 'year' => (int) $entry['annee'], + 'week' => (int) $entry['numero_semaine'], + 'planning_id' => (int) $entry['id_planning'], + 'target_planning_id' => (int) $entry['id_planning'], + 'reopened_planning_ids' => [], + 'action' => 'noop', + ]; + } + + $excludeEntryId = $duplicate ? 0 : $entryId; + $conflictStmt = $this->pdo->prepare( + "SELECT c.id_creneau, p.statut, st.nom AS structure_nom, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin + FROM creneau_horaire c + INNER JOIN planning p ON p.id_planning = c.id_planning + INNER JOIN structure st ON st.id_structure = p.id_structure + WHERE p.id_agent = :agent_id + AND (:exclude_entry_id = 0 OR c.id_creneau <> :exclude_entry_id_compare) + AND c.date_jour = :date_jour + AND c.heure_debut < :heure_fin + AND c.heure_fin > :heure_debut + LIMIT 1 + FOR UPDATE" + ); + $conflictStmt->execute([ + 'agent_id' => (int) $entry['id_agent'], + 'exclude_entry_id' => $excludeEntryId, + 'exclude_entry_id_compare' => $excludeEntryId, + 'date_jour' => $targetDate, + 'heure_fin' => $entry['heure_fin'], + 'heure_debut' => $entry['heure_debut'], + ]); + $conflict = $conflictStmt->fetch(); + if ($conflict) { + throw new \DomainException(sprintf( + 'Action impossible : l’agent possède déjà un créneau sur le lieu « %s » de %s à %s (%s).', + $conflict['structure_nom'], + $conflict['heure_debut'], + $conflict['heure_fin'], + strtolower((string) $conflict['statut']) + )); + } + + $targetYear = (int) $targetDateObject->format('o'); + $targetWeekNumber = (int) $targetDateObject->format('W'); + $targetMonday = $targetDateObject->modify('-' . ((int) $targetDateObject->format('N') - 1) . ' days')->setTime(0, 0); + $targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days')); + $targetPlanning = $this->findOrCreatePlanning( + (int) $entry['id_agent'], + $targetWeekId, + (int) $entry['id_structure'] + ); + $targetPlanningId = (int) $targetPlanning['id_planning']; + $sourcePlanningId = (int) $entry['id_planning']; + + $createdEntryId = null; + if ($duplicate) { + $insertStmt = $this->pdo->prepare( + "INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin) + VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)" + ); + $insertStmt->execute([ + 'planning_id' => $targetPlanningId, + 'motif_id' => (int) $entry['id_motif'], + 'date_jour' => $targetDate, + 'heure_debut' => $entry['heure_debut'], + 'heure_fin' => $entry['heure_fin'], + ]); + $createdEntryId = (int) $this->pdo->lastInsertId(); + } else { + $moveStmt = $this->pdo->prepare( + "UPDATE creneau_horaire + SET id_planning = :target_planning_id, + date_jour = :date_jour + WHERE id_creneau = :entry_id" + ); + $moveStmt->execute([ + 'target_planning_id' => $targetPlanningId, + 'date_jour' => $targetDate, + 'entry_id' => $entryId, + ]); + } + + $reopenedPlanningIds = []; + if (!$duplicate && (string) $entry['statut'] === 'VALIDE') { + $reopenedPlanningIds[] = $sourcePlanningId; + } + if ((string) $targetPlanning['statut'] === 'VALIDE') { + $reopenedPlanningIds[] = $targetPlanningId; + } + $reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds); + + $this->pdo->commit(); + + return [ + 'entry_id' => $entryId, + 'created_entry_id' => $createdEntryId, + 'agent_id' => (int) $entry['id_agent'], + 'year' => $targetYear, + 'week' => $targetWeekNumber, + 'planning_id' => $sourcePlanningId, + 'target_planning_id' => $targetPlanningId, + 'reopened_planning_ids' => $reopenedPlanningIds, + 'action' => $duplicate ? 'duplicate' : 'move', + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function copyAgentWeek( + int $agentId, + int $sourceYear, + int $sourceWeekNumber, + int $targetYear, + int $targetWeekNumber, + string $mode + ): array { + if (!in_array($mode, ['merge', 'replace'], true)) { + throw new \DomainException('Mode de duplication invalide.'); + } + if ($sourceYear === $targetYear && $sourceWeekNumber === $targetWeekNumber) { + throw new \DomainException('La semaine cible doit être différente de la semaine source.'); + } + + $sourceStmt = $this->pdo->prepare( + "SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine, + p.id_structure, c.id_motif, + TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin + FROM planning p + INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + WHERE p.id_agent = :agent_id + AND sem.annee = :annee + AND sem.numero_semaine = :semaine + AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4 + ORDER BY c.date_jour, c.heure_debut" + ); + $sourceStmt->execute([ + 'agent_id' => $agentId, + 'annee' => $sourceYear, + 'semaine' => $sourceWeekNumber, + ]); + $sourceEntries = $sourceStmt->fetchAll(); + if (!$sourceEntries) { + throw new \DomainException('La semaine source ne contient aucun créneau à dupliquer.'); + } + + $targetMonday = (new DateTimeImmutable())->setISODate($targetYear, $targetWeekNumber, 1)->setTime(0, 0); + $targetEntries = array_map(static function (array $entry) use ($targetMonday): array { + return [ + 'date_jour' => $targetMonday->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d'), + 'id_structure' => (int) $entry['id_structure'], + 'id_motif' => (int) $entry['id_motif'], + 'heure_debut' => (string) $entry['heure_debut'], + 'heure_fin' => (string) $entry['heure_fin'], + ]; + }, $sourceEntries); + + try { + $this->pdo->beginTransaction(); + $targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days')); + + $existingPlanningStmt = $this->pdo->prepare( + "SELECT id_planning, id_structure, statut + FROM planning + WHERE id_agent = :agent_id AND id_semaine = :week_id + FOR UPDATE" + ); + $existingPlanningStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]); + $existingPlannings = $existingPlanningStmt->fetchAll(); + + $reopened = []; + if ($mode === 'replace') { + if ($existingPlannings) { + $ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings); + foreach ($existingPlannings as $planning) { + if ((string) $planning['statut'] === 'VALIDE') { + $reopened[] = (int) $planning['id_planning']; + } + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)"); + $delete->execute($ids); + } + } else { + $existingEntriesStmt = $this->pdo->prepare( + "SELECT c.date_jour, c.heure_debut, c.heure_fin, st.nom AS structure_nom + FROM planning p + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN structure st ON st.id_structure = p.id_structure + WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id + FOR UPDATE" + ); + $existingEntriesStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]); + $existingEntries = $existingEntriesStmt->fetchAll(); + foreach ($targetEntries as $candidate) { + foreach ($existingEntries as $existing) { + if ($candidate['date_jour'] === $existing['date_jour'] + && $candidate['heure_debut'] < $existing['heure_fin'] + && $candidate['heure_fin'] > $existing['heure_debut']) { + throw new \DomainException(sprintf( + 'Duplication impossible : un créneau existe déjà le %s de %s à %s sur « %s ».', + $candidate['date_jour'], + substr((string) $existing['heure_debut'], 0, 5), + substr((string) $existing['heure_fin'], 0, 5), + $existing['structure_nom'] + )); + } + } + } + } + + $planningCache = []; + foreach ($existingPlannings as $planning) { + $planningCache[(int) $planning['id_structure']] = [ + 'id_planning' => (int) $planning['id_planning'], + 'statut' => (string) $planning['statut'], + ]; + } + + $insertEntry = $this->pdo->prepare( + "INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin) + VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)" + ); + + foreach ($targetEntries as $candidate) { + $structureId = (int) $candidate['id_structure']; + if (!isset($planningCache[$structureId])) { + $planningCache[$structureId] = $this->findOrCreatePlanning($agentId, $targetWeekId, $structureId); + } + $planning = $planningCache[$structureId]; + if ((string) $planning['statut'] === 'VALIDE') { + $reopened[] = (int) $planning['id_planning']; + } + $insertEntry->execute([ + 'planning_id' => (int) $planning['id_planning'], + 'motif_id' => (int) $candidate['id_motif'], + 'date_jour' => $candidate['date_jour'], + 'heure_debut' => $candidate['heure_debut'], + 'heure_fin' => $candidate['heure_fin'], + ]); + } + + $reopened = $this->reopenPlanningIds($reopened); + $this->pdo->commit(); + + return [ + 'agent_id' => $agentId, + 'year' => $targetYear, + 'week' => $targetWeekNumber, + 'copied_entries' => count($targetEntries), + 'reopened_planning_ids' => $reopened, + 'mode' => $mode, + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int + { + $stmt = $this->pdo->prepare( + "INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut) + VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE') + ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)" + ); + $stmt->execute([ + 'annee' => $year, + 'numero' => $weekNumber, + 'date_debut' => $weekStart->format('Y-m-d'), + 'date_fin' => $weekEnd->format('Y-m-d'), + ]); + $weekId = (int) $this->pdo->lastInsertId(); + if ($weekId > 0) { + return $weekId; + } + + $find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero'); + $find->execute(['annee' => $year, 'numero' => $weekNumber]); + $weekId = (int) $find->fetchColumn(); + if ($weekId <= 0) { + throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.'); + } + return $weekId; + } + + /** @return array{id_planning:int,statut:string} */ + private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array + { + $stmt = $this->pdo->prepare( + "SELECT id_planning, statut + FROM planning + WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id + FOR UPDATE" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'week_id' => $weekId, + 'structure_id' => $structureId, + ]); + $planning = $stmt->fetch(); + if ($planning) { + return ['id_planning' => (int) $planning['id_planning'], 'statut' => (string) $planning['statut']]; + } + + $insert = $this->pdo->prepare( + "INSERT INTO planning (id_agent, id_semaine, id_structure, statut) + VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')" + ); + $insert->execute([ + 'agent_id' => $agentId, + 'week_id' => $weekId, + 'structure_id' => $structureId, + ]); + return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON']; + } + + /** @return int[] */ + private function reopenPlanningIds(array $planningIds): array + { + $planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds)))); + if (!$planningIds) { + return []; + } + $placeholders = implode(',', array_fill(0, count($planningIds), '?')); + $stmt = $this->pdo->prepare( + "UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)" + ); + $stmt->execute($planningIds); + return $planningIds; + } + + + /** @param int[] $planningIds */ + public function markValidatedMany(array $planningIds): int + { + $planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0))); + if ($planningIds === []) { + return 0; + } + $placeholders = implode(',', array_fill(0, count($planningIds), '?')); + $stmt = $this->pdo->prepare( + "UPDATE planning + SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP + WHERE statut = 'BROUILLON' AND id_planning IN ($placeholders)" + ); + $stmt->execute($planningIds); + return $stmt->rowCount(); + } + + public function markValidated(int $planningId): void + { + $stmt = $this->pdo->prepare( + "UPDATE planning SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP WHERE id_planning = :planning_id" + ); + $stmt->execute(['planning_id' => $planningId]); + } + + public function reopen(int $planningId): bool + { + $stmt = $this->pdo->prepare( + "UPDATE planning + SET statut = 'BROUILLON', date_validation = NULL + WHERE id_planning = :planning_id AND statut = 'VALIDE'" + ); + $stmt->execute(['planning_id' => $planningId]); + return $stmt->rowCount() === 1; + } +} diff --git a/app/Models/PtaModel.php b/app/Models/PtaModel.php new file mode 100644 index 0000000..8122398 --- /dev/null +++ b/app/Models/PtaModel.php @@ -0,0 +1,642 @@ +pdo); + $agent = $agentModel->findActive($agentId); + if ($agent === null) { + throw new RuntimeException('Agent introuvable.'); + } + + $quota = (new QuotaModel($this->pdo))->forDateForApi($agentId, $contextDate); + $pta = $this->ensurePta($agent, $quota); + $entries = $agentModel->entriesForDateRange( + $agentId, + (string) $quota['date_debut_periode'], + (string) $quota['date_fin_periode'] + ); + $vacations = (new VacationModel($this->pdo))->periodsBetween( + (string) $quota['date_debut_periode'], + (string) $quota['date_fin_periode'] + ); + + $analysis = $this->analyseEntries($agent, $entries, $vacations, $quota); + $constraints = $this->constraintsForAgent( + $agentId, + (string) $quota['date_debut_periode'], + (string) $quota['date_fin_periode'] + ); + $wishes = $this->wishesForAgent($agentId); + $controls = array_merge( + $this->eligibilityControls($agent), + $analysis['controls'], + $this->constraintControls($analysis, $constraints), + $this->deepCleaningControls($agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode']) + ); + + usort($controls, static function (array $a, array $b): int { + $order = ['ERREUR' => 0, 'ALERTE' => 1, 'INFO' => 2]; + return ($order[$a['niveau']] ?? 9) <=> ($order[$b['niveau']] ?? 9); + }); + + return [ + 'agent' => $agent, + 'pta' => $pta, + 'quota' => $quota, + 'categories' => $analysis['categories'], + 'counters' => $analysis['counters'], + 'controls' => $controls, + 'constraints' => $constraints, + 'wishes' => $wishes, + 'vacations' => $vacations, + ]; + } + + public function saveConstraint(array $data): int + { + $stmt = $this->pdo->prepare( + "INSERT INTO agent_contrainte ( + id_agent, type_contrainte, date_debut, date_fin, + quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine, + interdit_matin, interdit_midi, interdit_soir, commentaire, actif + ) VALUES ( + :id_agent, :type_contrainte, :date_debut, :date_fin, + :quotite_temporaire, :maximum_minutes_jour, :maximum_minutes_semaine, + :interdit_matin, :interdit_midi, :interdit_soir, :commentaire, TRUE + )" + ); + $stmt->execute($data); + return (int) $this->pdo->lastInsertId(); + } + + public function deleteConstraint(int $constraintId, int $agentId): bool + { + $stmt = $this->pdo->prepare( + 'DELETE FROM agent_contrainte WHERE id_contrainte = :id AND id_agent = :agent_id' + ); + $stmt->execute(['id' => $constraintId, 'agent_id' => $agentId]); + return $stmt->rowCount() > 0; + } + + public function saveWish(array $data): int + { + $stmt = $this->pdo->prepare( + "INSERT INTO agent_structure_souhait ( + id_agent, id_structure, type_souhait, priorite, distance_km, commentaire, actif + ) VALUES ( + :id_agent, :id_structure, :type_souhait, :priorite, :distance_km, :commentaire, TRUE + ) + ON DUPLICATE KEY UPDATE + type_souhait = VALUES(type_souhait), + priorite = VALUES(priorite), + distance_km = VALUES(distance_km), + commentaire = VALUES(commentaire), + actif = TRUE" + ); + $stmt->execute($data); + return (int) ($this->pdo->lastInsertId() ?: 0); + } + + public function deleteWish(int $wishId, int $agentId): bool + { + $stmt = $this->pdo->prepare( + 'DELETE FROM agent_structure_souhait WHERE id_souhait = :id AND id_agent = :agent_id' + ); + $stmt->execute(['id' => $wishId, 'agent_id' => $agentId]); + return $stmt->rowCount() > 0; + } + + private function ensurePta(array $agent, array $quota): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM pta_annuel + WHERE id_agent = :agent_id + AND date_debut = :date_debut + AND date_fin = :date_fin + LIMIT 1' + ); + $stmt->execute([ + 'agent_id' => $agent['id_agent'], + 'date_debut' => $quota['date_debut_periode'], + 'date_fin' => $quota['date_fin_periode'], + ]); + $pta = $stmt->fetch(); + if ($pta) { + return $pta; + } + + $training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; + $insert = $this->pdo->prepare( + "INSERT INTO pta_annuel ( + id_agent, date_debut, date_fin, quotite_travail, + quota_reference_minutes, quota_cible_minutes, + enveloppe_formation_minutes, statut + ) VALUES ( + :agent_id, :date_debut, :date_fin, :quotite, + :quota_reference, :quota_cible, :formation, 'BROUILLON' + )" + ); + $insert->execute([ + 'agent_id' => $agent['id_agent'], + 'date_debut' => $quota['date_debut_periode'], + 'date_fin' => $quota['date_fin_periode'], + 'quotite' => $quota['quotite_travail'], + 'quota_reference' => $quota['quota_reference_minutes'], + 'quota_cible' => $quota['quota_cible_minutes'], + 'formation' => $training, + ]); + + $stmt->execute([ + 'agent_id' => $agent['id_agent'], + 'date_debut' => $quota['date_debut_periode'], + 'date_fin' => $quota['date_fin_periode'], + ]); + return $stmt->fetch() ?: []; + } + + private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array + { + $categories = [ + 'PERISCOLAIRE' => 0, + 'MERCREDI_PERISCOLAIRE' => 0, + 'EXTRASCOLAIRE' => 0, + 'FORMATION' => 0, + 'CONGE' => 0, + 'JOUR_FRACTIONNEMENT' => 0, + 'PREPARATION' => 0, + 'MENAGE_FOND' => 0, + 'ABSENCE' => 0, + 'NON_DECOMPTE' => 0, + ]; + $daysByCategory = []; + $daily = []; + $weekly = []; + $controls = []; + + foreach ($entries as $entry) { + $date = (string) $entry['date_jour']; + $start = (string) $entry['heure_debut']; + $end = (string) $entry['heure_fin']; + $minutes = $this->minutesBetween($start, $end); + $code = strtoupper((string) $entry['motif_code']); + $isVacation = $this->vacationForDate($date, $vacations) !== null; + $weekday = (int) (new DateTimeImmutable($date))->format('N'); + $category = $this->categoryForEntry($code, $isVacation, $weekday, (bool) $entry['compte_dans_quota']); + + $categories[$category] = ($categories[$category] ?? 0) + $minutes; + $daysByCategory[$category][$date] = true; + + $daily[$date] ??= ['minutes' => 0, 'starts' => [], 'ends' => [], 'entries' => []]; + $daily[$date]['minutes'] += $minutes; + $daily[$date]['starts'][] = $start; + $daily[$date]['ends'][] = $end; + $daily[$date]['entries'][] = [ + 'start' => $start, + 'end' => $end, + 'minutes' => $minutes, + 'code' => $code, + 'category' => $category, + ]; + + $monday = (new DateTimeImmutable($date))->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d'); + $weekly[$monday] = ($weekly[$monday] ?? 0) + $minutes; + } + + foreach ($daily as $date => &$day) { + sort($day['starts']); + sort($day['ends']); + usort($day['entries'], static fn(array $a, array $b): int => strcmp($a['start'], $b['start'])); + $first = min($day['starts']); + $last = max($day['ends']); + $day['amplitude_minutes'] = $this->minutesBetween($first, $last); + $day['first'] = $first; + $day['last'] = $last; + } + unset($day); + + foreach ($daily as $date => $day) { + if ($day['minutes'] > 600) { + $controls[] = $this->control('ERREUR', 'DUREE_QUOTIDIENNE', sprintf( + '%s : %s planifiées, au-delà de 10 h de travail quotidien.', + $this->formatDate($date), $this->formatMinutes($day['minutes']) + )); + } + if ($day['amplitude_minutes'] > 720) { + $controls[] = $this->control('ERREUR', 'AMPLITUDE_QUOTIDIENNE', sprintf( + '%s : amplitude de %s, au-delà de 12 h.', + $this->formatDate($date), $this->formatMinutes($day['amplitude_minutes']) + )); + } + + $continuous = 0; + $previousEnd = null; + foreach ($day['entries'] as $entry) { + if ($previousEnd === null || $this->minutesBetween($previousEnd, $entry['start']) >= 20) { + $continuous = $entry['minutes']; + } else { + $continuous += max(0, $this->minutesBetween(max($previousEnd, $entry['start']), $entry['end'])); + } + $previousEnd = $previousEnd === null || $entry['end'] > $previousEnd ? $entry['end'] : $previousEnd; + if ($continuous > 360) { + $controls[] = $this->control('ERREUR', 'PAUSE_OBLIGATOIRE', sprintf( + '%s : plus de 6 h consécutives sans coupure d’au moins 20 minutes.', + $this->formatDate($date) + )); + break; + } + } + } + + $dates = array_keys($daily); + sort($dates); + for ($i = 1, $count = count($dates); $i < $count; $i++) { + $previousDate = $dates[$i - 1]; + $currentDate = $dates[$i]; + $previousEnd = new DateTimeImmutable($previousDate . ' ' . $daily[$previousDate]['last']); + $currentStart = new DateTimeImmutable($currentDate . ' ' . $daily[$currentDate]['first']); + $restMinutes = (int) round(($currentStart->getTimestamp() - $previousEnd->getTimestamp()) / 60); + if ($restMinutes < 660) { + $controls[] = $this->control('ERREUR', 'REPOS_QUOTIDIEN', sprintf( + 'Repos insuffisant entre le %s et le %s : %s au lieu de 11 h minimum.', + $this->formatDate($previousDate), $this->formatDate($currentDate), $this->formatMinutes($restMinutes) + )); + } + } + + foreach ($weekly as $monday => $minutes) { + if ($minutes > 2880) { + $controls[] = $this->control('ERREUR', 'DUREE_HEBDOMADAIRE', sprintf( + 'Semaine du %s : %s planifiées, au-delà de 48 h.', + $this->formatDate($monday), $this->formatMinutes($minutes) + )); + } + } + + // Contrôle de la moyenne maximale de 44 h sur 12 semaines consécutives. + $periodStart = new DateTimeImmutable((string) $quota['date_debut_periode']); + $periodEnd = new DateTimeImmutable((string) $quota['date_fin_periode']); + $firstMonday = $periodStart->modify('-' . ((int) $periodStart->format('N') - 1) . ' days'); + $lastMonday = $periodEnd->modify('-' . ((int) $periodEnd->format('N') - 1) . ' days'); + $weekSeries = []; + for ($monday = $firstMonday; $monday <= $lastMonday; $monday = $monday->modify('+7 days')) { + $key = $monday->format('Y-m-d'); + $weekSeries[] = ['date' => $key, 'minutes' => (int) ($weekly[$key] ?? 0)]; + } + for ($index = 0, $totalWeeks = count($weekSeries); $index + 11 < $totalWeeks; $index++) { + $window = array_slice($weekSeries, $index, 12); + $average = (int) round(array_sum(array_column($window, 'minutes')) / 12); + if ($average > 2640) { + $controls[] = $this->control('ERREUR', 'MOYENNE_12_SEMAINES', sprintf( + 'Du %s au %s : moyenne de %s par semaine sur 12 semaines, au-delà de 44 h.', + $this->formatDate($window[0]['date']), + $this->formatDate((new DateTimeImmutable($window[11]['date']))->modify('+6 days')->format('Y-m-d')), + $this->formatMinutes($average) + )); + break; + } + } + + $expectedWednesday = (int) ($agent['heures_mercredi_minutes'] ?? 0); + $expectedExtra = (int) ($agent['heures_extrascolaire_minutes'] ?? 0); + $vacationWeeks = []; + foreach ($daily as $date => $day) { + $dateObject = new DateTimeImmutable($date); + $weekday = (int) $dateObject->format('N'); + $vacation = $this->vacationForDate($date, $vacations); + $workingMinutes = 0; + foreach ($day['entries'] as $entry) { + if (in_array($entry['code'], self::WORK_CODES, true)) { + $workingMinutes += $entry['minutes']; + } + } + + if ($weekday === 3 && $vacation === null && $workingMinutes > 0 && $expectedWednesday > 0 && $workingMinutes !== $expectedWednesday) { + $controls[] = $this->control('ALERTE', 'MERCREDI_DUREE', sprintf( + '%s : %s de travail le mercredi scolaire, contre %s attendues pour le poste.', + $this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedWednesday) + )); + } + if ($vacation !== null && $workingMinutes > 0 && $expectedExtra > 0 && $workingMinutes !== $expectedExtra) { + $controls[] = $this->control('ALERTE', 'EXTRA_DUREE', sprintf( + '%s : %s de travail extrascolaire, contre %s attendues pour le poste.', + $this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedExtra) + )); + } + if ($vacation !== null && $workingMinutes > 0) { + $weekKey = $dateObject->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d'); + $vacationWeeks[$weekKey][$date] = true; + } + } + + if (($agent['poste_famille'] ?? null) === 'ANIMATION') { + foreach ($vacationWeeks as $weekStart => $workedDays) { + $count = count($workedDays); + if ($count > 0 && $count < 5) { + $controls[] = $this->control('ALERTE', 'VACANCES_SEMAINE_INCOMPLETE', sprintf( + 'Semaine de vacances du %s : seulement %d jour(s) travaillé(s). La préférence métier est une semaine complète.', + $this->formatDate($weekStart), $count + )); + } + } + } + + if (($agent['poste_famille'] ?? null) === 'RESTAURATION_ENTRETIEN') { + foreach (self::PREPARATION_CODES as $code) { + $hasPreparation = false; + foreach ($entries as $entry) { + if (strtoupper((string) $entry['motif_code']) === $code) { + $hasPreparation = true; + break; + } + } + if ($hasPreparation) { + $controls[] = $this->control('ERREUR', 'PREPARATION_NON_AUTORISEE', + 'Un agent de restauration collective et d’entretien ne doit pas recevoir de temps de préparation.'); + break; + } + } + } + + $formationMinutes = $categories['FORMATION']; + $formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; + if ($formationTarget > 0 && $formationMinutes > $formationTarget) { + $controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf( + 'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.', + $this->formatMinutes($formationMinutes) + )); + } + if ($formationTarget > 0 && $formationMinutes < $formationTarget) { + $controls[] = $this->control('INFO', 'FORMATION_RESTANTE', sprintf( + 'Il reste %s dans l’enveloppe de formation CNFPT de 14 h.', + $this->formatMinutes($formationTarget - $formationMinutes) + )); + } + + $caDays = count($daysByCategory['CONGE'] ?? []); + $jfDays = count($daysByCategory['JOUR_FRACTIONNEMENT'] ?? []); + if ($caDays !== 25) { + $controls[] = $this->control('ALERTE', 'CONGES_ANNUELS', sprintf( + '%d jour(s) de congé annuel positionné(s) sur un objectif métier de 25 jours.', + $caDays + )); + } + if ($jfDays !== 2) { + $controls[] = $this->control('ALERTE', 'JOURS_FRACTIONNEMENT', sprintf( + '%d journée(s) de fractionnement positionnée(s) sur un objectif de 2.', + $jfDays + )); + } + + if ($controls === []) { + $controls[] = $this->control('INFO', 'OK', 'Aucune anomalie détectée sur le PTA de cette période.'); + } + + $formattedCategories = []; + foreach ($categories as $code => $minutes) { + $formattedCategories[] = [ + 'code' => $code, + 'minutes' => $minutes, + 'libelle' => $this->categoryLabel($code), + 'duree' => $this->formatMinutes($minutes), + 'jours' => count($daysByCategory[$code] ?? []), + ]; + } + + return [ + 'categories' => $formattedCategories, + 'counters' => [ + 'conges_annuels_jours' => $caDays, + 'conges_annuels_cible' => 25, + 'jours_fractionnement' => $jfDays, + 'jours_fractionnement_cible' => 2, + 'formation_minutes' => $formationMinutes, + 'formation_cible_minutes' => $formationTarget, + 'formation_duree' => $this->formatMinutes($formationMinutes), + 'formation_cible_duree' => $this->formatMinutes($formationTarget), + 'quota_cible' => $quota['quota_cible'], + 'heures_affectees' => $quota['affectees'], + 'heures_restantes' => $quota['restantes'], + ], + 'controls' => $controls, + 'daily' => $daily, + 'weekly' => $weekly, + ]; + } + + private function eligibilityControls(array $agent): array + { + $controls = []; + if (($agent['type_contrat'] ?? '') !== 'PERMANENT') { + $controls[] = $this->control('ERREUR', 'CONTRAT_NON_PERMANENT', + 'Le dispositif PTA est réservé ici aux agents en contrat permanent.'); + } + if (empty($agent['id_poste'])) { + $controls[] = $this->control('ERREUR', 'POSTE_MANQUANT', + 'Le poste de l’agent doit être renseigné pour appliquer les règles du PTA.'); + } + return $controls; + } + + private function constraintControls(array $analysis, array $constraints): array + { + $controls = []; + foreach ($constraints as $constraint) { + $label = $constraint['type_contrainte'] === 'TEMPS_PARTIEL_THERAPEUTIQUE' + ? 'Temps partiel thérapeutique' + : 'Préconisation médicale'; + $controls[] = $this->control('ALERTE', 'CONTRAINTE_PRIORITAIRE', sprintf( + '%s du %s au %s : %s', + $label, + $this->formatDate((string) $constraint['date_debut']), + $constraint['date_fin'] ? $this->formatDate((string) $constraint['date_fin']) : 'sans date de fin', + (string) $constraint['commentaire'] + )); + + foreach ($analysis['daily'] as $date => $day) { + if ($date < $constraint['date_debut'] || ($constraint['date_fin'] && $date > $constraint['date_fin'])) { + continue; + } + if ($constraint['maximum_minutes_jour'] !== null + && $day['minutes'] > (int) $constraint['maximum_minutes_jour']) { + $controls[] = $this->control('ERREUR', 'CONTRAINTE_MAX_JOUR', sprintf( + '%s : %s planifiées alors que la contrainte limite la journée à %s.', + $this->formatDate($date), $this->formatMinutes($day['minutes']), + $this->formatMinutes((int) $constraint['maximum_minutes_jour']) + )); + } + } + } + return $controls; + } + + private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array + { + $stmt = $this->pdo->prepare( + "SELECT c.date_jour, p.id_structure, s.nom AS structure_nom, + COUNT(DISTINCT p.id_agent) AS agents, + SUM(CASE WHEN p.id_agent = :agent_id THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END) AS minutes_agent + FROM creneau_horaire c + JOIN planning p ON p.id_planning = c.id_planning + JOIN motif_planning m ON m.id_motif = c.id_motif + JOIN structure s ON s.id_structure = p.id_structure + WHERE m.code = 'MENAGE_FOND' + AND c.date_jour BETWEEN :date_debut AND :date_fin + AND EXISTS ( + SELECT 1 FROM planning px + JOIN creneau_horaire cx ON cx.id_planning = px.id_planning + JOIN motif_planning mx ON mx.id_motif = cx.id_motif + WHERE px.id_agent = :agent_exists + AND px.id_structure = p.id_structure + AND cx.date_jour = c.date_jour + AND mx.code = 'MENAGE_FOND' + ) + GROUP BY c.date_jour, p.id_structure, s.nom" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'agent_exists' => $agentId, + 'date_debut' => $startDate, + 'date_fin' => $endDate, + ]); + $controls = []; + foreach ($stmt->fetchAll() as $row) { + if ((int) $row['agents'] < 2) { + $controls[] = $this->control('ERREUR', 'MENAGE_EQUIPE', sprintf( + '%s — %s : le ménage de fond ne compte que %d agent(s), alors que 2 sont requis.', + $this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], (int) $row['agents'] + )); + } + if ((int) $row['minutes_agent'] !== 420) { + $controls[] = $this->control('ALERTE', 'MENAGE_DUREE', sprintf( + '%s — %s : %s de ménage de fond pour l’agent, au lieu de 7 h.', + $this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], + $this->formatMinutes((int) $row['minutes_agent']) + )); + } + } + return $controls; + } + + private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array + { + $stmt = $this->pdo->prepare( + "SELECT id_contrainte, type_contrainte, date_debut, date_fin, + quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine, + interdit_matin, interdit_midi, interdit_soir, commentaire + FROM agent_contrainte + WHERE id_agent = :agent_id + AND actif = TRUE + AND date_debut <= :date_fin + AND COALESCE(date_fin, '9999-12-31') >= :date_debut + ORDER BY date_debut DESC" + ); + $stmt->execute(['agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate]); + return $stmt->fetchAll(); + } + + private function wishesForAgent(int $agentId): array + { + $stmt = $this->pdo->prepare( + "SELECT sw.id_souhait, sw.id_structure, s.nom AS structure_nom, + sw.type_souhait, sw.priorite, sw.distance_km, sw.commentaire + FROM agent_structure_souhait sw + JOIN structure s ON s.id_structure = sw.id_structure + WHERE sw.id_agent = :agent_id AND sw.actif = TRUE + ORDER BY FIELD(sw.type_souhait, 'INTERDICTION', 'PREFERENCE'), sw.priorite DESC, s.nom" + ); + $stmt->execute(['agent_id' => $agentId]); + return $stmt->fetchAll(); + } + + private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string + { + return match ($code) { + 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR' => 'PERISCOLAIRE', + 'MERCREDI_PERISCOLAIRE' => 'MERCREDI_PERISCOLAIRE', + 'EXTRASCOLAIRE' => 'EXTRASCOLAIRE', + 'FORMATION' => 'FORMATION', + 'CONGE' => 'CONGE', + 'JOUR_FRACTIONNEMENT' => 'JOUR_FRACTIONNEMENT', + 'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'PREPARATION', + 'MENAGE_FOND' => 'MENAGE_FOND', + 'ABSENCE' => 'ABSENCE', + 'AUTRE_ABSENCE', 'JNT' => 'NON_DECOMPTE', + 'TRAVAIL' => $isVacation ? 'EXTRASCOLAIRE' : ($weekday === 3 ? 'MERCREDI_PERISCOLAIRE' : 'PERISCOLAIRE'), + default => $counts ? 'PERISCOLAIRE' : 'NON_DECOMPTE', + }; + } + + private function categoryLabel(string $code): string + { + return match ($code) { + 'PERISCOLAIRE' => 'Temps périscolaire lundi, mardi, jeudi et vendredi', + 'MERCREDI_PERISCOLAIRE' => 'Mercredis périscolaires', + 'EXTRASCOLAIRE' => 'Temps extrascolaire', + 'FORMATION' => 'Formation', + 'CONGE' => 'Congés annuels non décomptés', + 'JOUR_FRACTIONNEMENT' => 'Journées de fractionnement', + 'PREPARATION' => 'Temps de préparation', + 'MENAGE_FOND' => 'Ménages de fond', + 'ABSENCE' => 'Absences décomptées', + 'NON_DECOMPTE' => 'JNT et absences non décomptées', + default => $code, + }; + } + + private function vacationForDate(string $date, array $vacations): ?array + { + foreach ($vacations as $vacation) { + if ($date >= $vacation['date_debut'] && $date <= $vacation['date_fin']) { + return $vacation; + } + } + return null; + } + + private function minutesBetween(string $start, string $end): int + { + [$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5))); + [$eh, $em] = array_map('intval', explode(':', substr($end, 0, 5))); + return ($eh * 60 + $em) - ($sh * 60 + $sm); + } + + private function formatMinutes(int $minutes): string + { + $sign = $minutes < 0 ? '-' : ''; + $minutes = abs($minutes); + return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); + } + + private function formatDate(string $date): string + { + return (new DateTimeImmutable($date))->format('d/m/Y'); + } + + private function control(string $level, string $code, string $message): array + { + return ['niveau' => $level, 'code' => $code, 'message' => $message]; + } +} diff --git a/app/Models/QuotaModel.php b/app/Models/QuotaModel.php new file mode 100644 index 0000000..1085698 --- /dev/null +++ b/app/Models/QuotaModel.php @@ -0,0 +1,209 @@ +pdo->prepare( + 'INSERT INTO quota_agent_annuel ( + id_agent, annee, quotite_travail, quota_reference_minutes, quota_cible_minutes, commentaire + ) VALUES ( + :agent_id, :annee, :quotite, :quota_reference, :quota_cible, :commentaire + )' + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'annee' => $year, + 'quotite' => $rate, + 'quota_reference' => self::REFERENCE_MINUTES, + 'quota_cible' => $target, + 'commentaire' => 'Quota PTA de référence pour l’année civile', + ]); + } + + /** + * Retourne le quota de l'année civile contenant la date fournie. + * + * Exemple : une date de contexte au 15/09/2026 renvoie toujours la période + * 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat. + */ + public function forDate(int $agentId, string $contextDate): array + { + $period = $this->referencePeriod($agentId, $contextDate); + $quota = $this->quotaProfile($agentId, (int) $period['annee_periode']); + + $usedStmt = $this->pdo->prepare( + "SELECT + COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN + TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) + ELSE 0 END), 0) AS minutes_valides, + COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN + TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) + ELSE 0 END), 0) AS minutes_brouillon + FROM planning p + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.id_agent = :agent_id + AND c.date_jour BETWEEN :date_debut AND :date_fin + AND m.compte_dans_quota = TRUE" + ); + $usedStmt->execute([ + 'agent_id' => $agentId, + 'date_debut' => $period['date_debut_periode'], + 'date_fin' => $period['date_fin_periode'], + ]); + $used = $usedStmt->fetch() ?: ['minutes_valides' => 0, 'minutes_brouillon' => 0]; + + $validated = (int) $used['minutes_valides']; + $draft = (int) $used['minutes_brouillon']; + $target = (int) $quota['quota_cible_minutes']; + $allocated = $validated + $draft; + + return array_merge($period, [ + 'annee' => (int) $period['annee_periode'], + 'quotite_travail' => (float) $quota['quotite_travail'], + 'quota_reference_minutes' => (int) $quota['quota_reference_minutes'], + 'quota_cible_minutes' => $target, + 'quota_source_annee' => (int) $quota['quota_source_annee'], + 'minutes_valides' => $validated, + 'minutes_brouillon' => $draft, + 'minutes_affectees' => $allocated, + 'minutes_restantes' => $target - $allocated, + 'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 2) : 0, + ]); + } + + public function forDateForApi(int $agentId, string $contextDate): array + { + return $this->enrich($this->forDate($agentId, $contextDate)); + } + + public function annual(int $agentId, int $year): array + { + return $this->forDate($agentId, sprintf('%04d-01-01', $year)); + } + + public function annualForApi(int $agentId, int $year): array + { + return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year)); + } + + /** + * Période de référence PTA : toujours l'année civile. + * La date de contrat est retournée uniquement à titre informatif. + */ + public function referencePeriod(int $agentId, string $contextDate): array + { + $context = DateTimeImmutable::createFromFormat('!Y-m-d', $contextDate); + if (!$context || $context->format('Y-m-d') !== $contextDate) { + throw new InvalidArgumentException('Date de référence du quota invalide.'); + } + + $stmt = $this->pdo->prepare( + 'SELECT date_debut_contrat FROM agent WHERE id_agent = :agent_id LIMIT 1' + ); + $stmt->execute(['agent_id' => $agentId]); + $contractStartValue = $stmt->fetchColumn(); + + $year = (int) $context->format('Y'); + $start = new DateTimeImmutable(sprintf('%04d-01-01', $year)); + $end = new DateTimeImmutable(sprintf('%04d-12-31', $year)); + + return [ + 'date_reference' => $context->format('Y-m-d'), + 'date_debut_contrat' => $contractStartValue ? (string) $contractStartValue : null, + 'date_debut_periode' => $start->format('Y-m-d'), + 'date_fin_periode' => $end->format('Y-m-d'), + 'annee_periode' => $year, + 'periode_libelle' => sprintf('année civile %d · du %s au %s', $year, $start->format('d/m/Y'), $end->format('d/m/Y')), + 'periode_contractuelle' => false, + 'periode_civile' => true, + // Conservé pour compatibilité avec les anciens écrans : la date de + // contrat n'est plus requise pour calculer la période du quota. + 'date_contrat_manquante' => false, + ]; + } + + public function enrich(array $quota): array + { + return array_merge($quota, [ + 'quota_cible' => $this->formatMinutes((int) $quota['quota_cible_minutes']), + 'valides' => $this->formatMinutes((int) $quota['minutes_valides']), + 'brouillons' => $this->formatMinutes((int) $quota['minutes_brouillon']), + 'affectees' => $this->formatMinutes((int) $quota['minutes_affectees']), + 'restantes' => $this->formatMinutes((int) $quota['minutes_restantes']), + ]); + } + + public function formatMinutes(int $minutes): array + { + $sign = $minutes < 0 ? -1 : 1; + $absolute = abs($minutes); + $hours = intdiv($absolute, 60); + $mins = $absolute % 60; + + return [ + 'minutes' => $minutes, + 'heures_decimales' => round($minutes / 60, 2), + 'libelle' => ($sign < 0 ? '-' : '') . $hours . ' h ' . str_pad((string) $mins, 2, '0', STR_PAD_LEFT), + ]; + } + + private function quotaProfile(int $agentId, int $year): array + { + $stmt = $this->pdo->prepare( + "SELECT annee, quotite_travail, quota_reference_minutes, quota_cible_minutes + FROM quota_agent_annuel + WHERE id_agent = :agent_id + ORDER BY CASE + WHEN annee = :year_exact THEN 0 + WHEN annee < :year_past THEN 1 + ELSE 2 + END, + CASE WHEN annee <= :year_latest THEN annee END DESC, + CASE WHEN annee > :year_future THEN annee END ASC + LIMIT 1" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'year_exact' => $year, + 'year_past' => $year, + 'year_latest' => $year, + 'year_future' => $year, + ]); + $quota = $stmt->fetch(); + + if (!$quota) { + return [ + 'quotite_travail' => 100.00, + 'quota_reference_minutes' => self::REFERENCE_MINUTES, + 'quota_cible_minutes' => self::REFERENCE_MINUTES, + 'quota_source_annee' => $year, + ]; + } + + return [ + 'quotite_travail' => (float) $quota['quotite_travail'], + 'quota_reference_minutes' => (int) $quota['quota_reference_minutes'], + 'quota_cible_minutes' => (int) $quota['quota_cible_minutes'], + 'quota_source_annee' => (int) $quota['annee'], + ]; + } +} diff --git a/app/Models/SchemaModel.php b/app/Models/SchemaModel.php new file mode 100644 index 0000000..10610ba --- /dev/null +++ b/app/Models/SchemaModel.php @@ -0,0 +1,82 @@ +pdo->prepare( + "SELECT TABLE_NAME + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ($placeholders)" + ); + $stmt->execute($requiredTables); + $existing = $stmt->fetchAll(\PDO::FETCH_COLUMN); + $missingTables = array_values(array_diff($requiredTables, $existing)); + + if ($missingTables) { + throw new \RuntimeException( + 'Base PTA incomplète : table(s) manquante(s) : ' + . implode(', ', $missingTables) + . '. Exécutez database/install_complet.sql ou les migrations nécessaires.' + ); + } + + $requiredColumns = [ + 'creneau_horaire' => ['id_motif'], + 'planning' => ['date_validation'], + 'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'commentaire_pta'], + 'structure' => ['type_affectation'], + ]; + + foreach ($requiredColumns as $table => $columns) { + $columnPlaceholders = implode(',', array_fill(0, count($columns), '?')); + $params = array_merge([$table], $columns); + $columnStmt = $this->pdo->prepare( + "SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME IN ($columnPlaceholders)" + ); + $columnStmt->execute($params); + $existingColumns = $columnStmt->fetchAll(\PDO::FETCH_COLUMN); + $missingColumns = array_values(array_diff($columns, $existingColumns)); + + if ($missingColumns) { + throw new \RuntimeException(sprintf( + 'Base PTA incomplète : colonne(s) manquante(s) dans %s : %s.', + $table, + implode(', ', $missingColumns) + )); + } + } + } +} diff --git a/app/Models/StructureModel.php b/app/Models/StructureModel.php new file mode 100644 index 0000000..a9601e2 --- /dev/null +++ b/app/Models/StructureModel.php @@ -0,0 +1,114 @@ +pdo->query( + 'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE actif = TRUE ORDER BY nom' + )->fetchAll(); + } + + public function findActive(int $id): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1' + ); + $stmt->execute(['id' => $id]); + return $stmt->fetch() ?: null; + } + + public function codeExists(string $code): bool + { + $stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1'); + $stmt->execute(['code' => $code]); + return $stmt->fetchColumn() !== false; + } + + public function create(string $code, string $name, ?string $address, string $typeAffectation): array + { + $stmt = $this->pdo->prepare( + 'INSERT INTO structure (code, nom, adresse, type_affectation, actif) + VALUES (:code, :nom, :adresse, :type_affectation, TRUE)' + ); + $stmt->execute([ + 'code' => $code, + 'nom' => $name, + 'adresse' => $address, + 'type_affectation' => $typeAffectation, + ]); + + return [ + 'id_structure' => (int) $this->pdo->lastInsertId(), + 'code' => $code, + 'nom' => $name, + 'adresse' => $address, + 'type_affectation' => $typeAffectation, + ]; + } + + public function updateType(int $structureId, string $typeAffectation): void + { + $stmt = $this->pdo->prepare( + 'UPDATE structure SET type_affectation = :type_affectation WHERE id_structure = :id_structure' + ); + $stmt->execute([ + 'type_affectation' => $typeAffectation, + 'id_structure' => $structureId, + ]); + } + + public function agentsForOverview(int $structureId, int $year, int $week): array + { + $stmt = $this->pdo->prepare( + "SELECT DISTINCT a.id_agent, a.matricule, a.nom, a.prenom, + a.id_structure AS id_structure_principale + FROM agent a + LEFT JOIN planning p + ON p.id_agent = a.id_agent + AND p.id_structure = :structure_id_planning + LEFT JOIN semaine sem + ON sem.id_semaine = p.id_semaine + AND sem.annee = :annee + AND sem.numero_semaine = :semaine + WHERE a.actif = TRUE + AND (a.id_structure = :structure_id_default OR sem.id_semaine IS NOT NULL) + ORDER BY a.nom, a.prenom" + ); + $stmt->execute([ + 'structure_id_planning' => $structureId, + 'structure_id_default' => $structureId, + 'annee' => $year, + 'semaine' => $week, + ]); + return $stmt->fetchAll(); + } + + public function entriesForOverview(int $structureId, int $year, int $week): array + { + $stmt = $this->pdo->prepare( + "SELECT p.id_agent, p.statut, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, + m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota + FROM planning p + INNER JOIN semaine s ON s.id_semaine = p.id_semaine + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE p.id_structure = :structure_id + AND s.annee = :annee + AND s.numero_semaine = :semaine + ORDER BY p.id_agent, c.date_jour, c.heure_debut" + ); + $stmt->execute([ + 'structure_id' => $structureId, + 'annee' => $year, + 'semaine' => $week, + ]); + return $stmt->fetchAll(); + } +} diff --git a/app/Models/TeamModel.php b/app/Models/TeamModel.php new file mode 100644 index 0000000..e204b50 --- /dev/null +++ b/app/Models/TeamModel.php @@ -0,0 +1,236 @@ += :date_debut'; + $params['date_debut'] = $startDate; + } + if ($endDate !== null) { + $conditions[] = 'e.date_debut <= :date_fin'; + $params['date_fin'] = $endDate; + } + if ($conditions !== []) { + $sql .= ' WHERE ' . implode(' AND ', $conditions); + } + $sql .= " GROUP BY e.id_equipe, e.id_structure, s.nom, e.type_equipe, e.libelle, + e.date_debut, e.date_fin, e.nombre_enfants_moins_6, + e.nombre_enfants_6_plus, e.ratio_moins_6, e.ratio_6_plus, + e.minimum_agents, e.pourcentage_diplomes_min, e.statut, + e.commentaire + ORDER BY e.date_debut, s.nom, e.libelle"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $teams = $stmt->fetchAll(); + foreach ($teams as &$team) { + $team['members'] = $this->members((int) $team['id_equipe']); + $team['controls'] = $this->controls($team, $team['members']); + } + unset($team); + return $teams; + } + + public function find(int $teamId): ?array + { + foreach ($this->all() as $team) { + if ((int) $team['id_equipe'] === $teamId) { + return $team; + } + } + return null; + } + + public function create(array $data): int + { + $stmt = $this->pdo->prepare( + "INSERT INTO equipe_pta ( + id_structure, type_equipe, libelle, date_debut, date_fin, + nombre_enfants_moins_6, nombre_enfants_6_plus, + ratio_moins_6, ratio_6_plus, minimum_agents, + pourcentage_diplomes_min, statut, commentaire + ) VALUES ( + :id_structure, :type_equipe, :libelle, :date_debut, :date_fin, + :nombre_enfants_moins_6, :nombre_enfants_6_plus, + :ratio_moins_6, :ratio_6_plus, :minimum_agents, + :pourcentage_diplomes_min, 'BROUILLON', :commentaire + )" + ); + $stmt->execute($data); + return (int) $this->pdo->lastInsertId(); + } + + public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void + { + $team = $this->teamDates($teamId); + if ($team === null) { + throw new \RuntimeException('Équipe introuvable.'); + } + + $forbidden = $this->pdo->prepare( + "SELECT 1 + FROM agent_structure_souhait + WHERE id_agent = :agent_id + AND id_structure = :structure_id + AND type_souhait = 'INTERDICTION' + AND actif = TRUE + LIMIT 1" + ); + $forbidden->execute(['agent_id' => $agentId, 'structure_id' => $team['id_structure']]); + if ($forbidden->fetchColumn() !== false) { + throw new \RuntimeException('Cet agent a une interdiction active pour ce lieu.'); + } + + // Les contraintes médicales et thérapeutiques restent prioritaires, mais + // l’affectation n’est pas bloquée automatiquement : elles sont signalées + // dans les contrôles de l’équipe pour validation humaine. + + $stmt = $this->pdo->prepare( + "INSERT INTO equipe_pta_membre (id_equipe, id_agent, fonction_equipe, commentaire) + VALUES (:id_equipe, :id_agent, :fonction_equipe, :commentaire) + ON DUPLICATE KEY UPDATE + fonction_equipe = VALUES(fonction_equipe), + commentaire = VALUES(commentaire)" + ); + $stmt->execute([ + 'id_equipe' => $teamId, + 'id_agent' => $agentId, + 'fonction_equipe' => $function, + 'commentaire' => $comment, + ]); + } + + public function removeMember(int $teamId, int $agentId): bool + { + $stmt = $this->pdo->prepare( + 'DELETE FROM equipe_pta_membre WHERE id_equipe = :team_id AND id_agent = :agent_id' + ); + $stmt->execute(['team_id' => $teamId, 'agent_id' => $agentId]); + return $stmt->rowCount() > 0; + } + + public function setStatus(int $teamId, string $status): void + { + $stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id'); + $stmt->execute(['statut' => $status, 'id' => $teamId]); + } + + private function members(int $teamId): array + { + $stmt = $this->pdo->prepare( + "SELECT em.id_agent, em.fonction_equipe, em.commentaire, + a.matricule, a.nom, a.prenom, a.est_diplome, + a.diplome_libelle, a.adresse, a.id_structure, + po.code AS poste_code, po.libelle AS poste_libelle, + s.nom AS structure_principale_nom, + sw.type_souhait, sw.priorite, sw.distance_km, + (SELECT COUNT(*) + FROM agent_contrainte ac + WHERE ac.id_agent = a.id_agent + AND ac.actif = TRUE + AND ac.date_debut <= e.date_fin + AND COALESCE(ac.date_fin, '9999-12-31') >= e.date_debut) AS contraintes_actives + FROM equipe_pta_membre em + JOIN agent a ON a.id_agent = em.id_agent + LEFT JOIN poste_agent po ON po.id_poste = a.id_poste + LEFT JOIN structure s ON s.id_structure = a.id_structure + LEFT JOIN equipe_pta e ON e.id_equipe = em.id_equipe + LEFT JOIN agent_structure_souhait sw + ON sw.id_agent = a.id_agent + AND sw.id_structure = e.id_structure + AND sw.actif = TRUE + WHERE em.id_equipe = :team_id + ORDER BY FIELD(em.fonction_equipe, 'RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN'), a.nom, a.prenom" + ); + $stmt->execute(['team_id' => $teamId]); + return $stmt->fetchAll(); + } + + private function controls(array $team, array $members): array + { + $controls = []; + $required = (int) $team['agents_requis']; + $assigned = (int) $team['agents_affectes']; + if ($assigned < $required) { + $controls[] = [ + 'niveau' => 'ERREUR', + 'code' => 'ENCADREMENT_INSUFFISANT', + 'message' => sprintf('%d agent(s) affecté(s) pour %d requis.', $assigned, $required), + ]; + } + + $qualified = (int) $team['agents_diplomes']; + $qualifiedRate = $assigned > 0 ? round(($qualified / $assigned) * 100, 2) : 0; + if ($qualifiedRate < (float) $team['pourcentage_diplomes_min']) { + $controls[] = [ + 'niveau' => 'ERREUR', + 'code' => 'QUALIFICATION_INSUFFISANTE', + 'message' => sprintf('%.2f %% d’agents diplômés pour un minimum paramétré à %.2f %%.', $qualifiedRate, (float) $team['pourcentage_diplomes_min']), + ]; + } + + if ((int) $team['responsables'] < 1) { + $controls[] = [ + 'niveau' => 'ALERTE', + 'code' => 'RESPONSABLE_MANQUANT', + 'message' => 'Aucun responsable n’est identifié dans l’équipe.', + ]; + } + + foreach ($members as $member) { + if ((int) ($member['contraintes_actives'] ?? 0) > 0) { + $controls[] = [ + 'niveau' => 'ALERTE', + 'code' => 'CONTRAINTE_PRIORITAIRE', + 'message' => sprintf('%s %s possède une préconisation médicale ou un temps partiel thérapeutique actif sur la période.', $member['prenom'], $member['nom']), + ]; + } + if (($member['type_souhait'] ?? null) === 'PREFERENCE') { + $controls[] = [ + 'niveau' => 'INFO', + 'code' => 'PREFERENCE_RESPECTEE', + 'message' => sprintf('%s %s a exprimé une préférence pour ce lieu.', $member['prenom'], $member['nom']), + ]; + } + } + + if ($controls === []) { + $controls[] = ['niveau' => 'INFO', 'code' => 'OK', 'message' => 'Équipe conforme aux paramètres enregistrés.']; + } + return $controls; + } + + private function teamDates(int $teamId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT id_structure, date_debut, date_fin FROM equipe_pta WHERE id_equipe = :id LIMIT 1' + ); + $stmt->execute(['id' => $teamId]); + return $stmt->fetch() ?: null; + } +} diff --git a/app/Models/VacationModel.php b/app/Models/VacationModel.php new file mode 100644 index 0000000..44d975e --- /dev/null +++ b/app/Models/VacationModel.php @@ -0,0 +1,131 @@ +pdo->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + public function saveMany(array $periods): int + { + if ($periods === []) { + return 0; + } + + $sql = "INSERT INTO periode_vacance + (libelle, date_debut, date_fin, annee_scolaire, academie, zone, source, actif) + VALUES + (:libelle, :date_debut, :date_fin, :annee_scolaire, :academie, :zone, :source, TRUE) + ON DUPLICATE KEY UPDATE + libelle = VALUES(libelle), + zone = VALUES(zone), + source = VALUES(source), + actif = TRUE"; + $stmt = $this->pdo->prepare($sql); + + $this->pdo->beginTransaction(); + try { + foreach ($periods as $period) { + $stmt->execute([ + 'libelle' => $period['libelle'], + 'date_debut' => $period['date_debut'], + 'date_fin' => $period['date_fin'], + 'annee_scolaire' => $period['annee_scolaire'], + 'academie' => $period['academie'], + 'zone' => $period['zone'], + 'source' => $period['source'], + ]); + } + $this->pdo->commit(); + } catch (\Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + + return count($periods); + } + + public function delete(int $id): bool + { + $stmt = $this->pdo->prepare('DELETE FROM periode_vacance WHERE id_periode = :id'); + $stmt->execute(['id' => $id]); + return $stmt->rowCount() > 0; + } + + public function periodsBetween(string $startDate, string $endDate): array + { + $stmt = $this->pdo->prepare( + "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire, academie, zone, source + FROM periode_vacance + WHERE actif = TRUE + AND date_debut <= :date_fin + AND date_fin >= :date_debut + ORDER BY date_debut" + ); + $stmt->execute([ + 'date_debut' => $startDate, + 'date_fin' => $endDate, + ]); + return $stmt->fetchAll(); + } + + /** + * Retourne le type de période pour les cinq jours ouvrés d'une semaine ISO. + * Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE, + * sinon elle est PERISCOLAIRE. + */ + public function typesForIsoWeek(int $year, int $week): array + { + $monday = (new DateTimeImmutable())->setISODate($year, $week, 1)->setTime(0, 0); + $friday = $monday->modify('+4 days'); + $periods = $this->periodsBetween($monday->format('Y-m-d'), $friday->format('Y-m-d')); + $days = []; + + for ($offset = 0; $offset < 5; $offset++) { + $date = $monday->modify('+' . $offset . ' days')->format('Y-m-d'); + $matchingPeriod = null; + foreach ($periods as $period) { + if ($date >= $period['date_debut'] && $date <= $period['date_fin']) { + $matchingPeriod = $period; + break; + } + } + + $days[] = [ + 'date' => $date, + 'type_affectation' => $matchingPeriod ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE', + 'vacances' => $matchingPeriod ? [ + 'id_periode' => (int) $matchingPeriod['id_periode'], + 'libelle' => (string) $matchingPeriod['libelle'], + 'annee_scolaire' => (string) $matchingPeriod['annee_scolaire'], + ] : null, + ]; + } + + return $days; + } +} diff --git a/app/Models/WeekTemplateModel.php b/app/Models/WeekTemplateModel.php new file mode 100644 index 0000000..6e04698 --- /dev/null +++ b/app/Models/WeekTemplateModel.php @@ -0,0 +1,861 @@ +pdo->prepare( + "SELECT m.id_modele, m.nom, m.date_creation, m.date_modification, + COUNT(c.id_modele_creneau) AS nombre_creneaux, + COUNT(DISTINCT c.id_structure) AS nombre_lieux + FROM modele_semaine_agent m + LEFT JOIN modele_semaine_creneau c ON c.id_modele = m.id_modele + WHERE m.id_agent = :agent_id + GROUP BY m.id_modele, m.nom, m.date_creation, m.date_modification + ORDER BY m.nom" + ); + $stmt->execute(['agent_id' => $agentId]); + return $stmt->fetchAll(); + } + + public function findForAgent(int $templateId, int $agentId): ?array + { + $stmt = $this->pdo->prepare( + "SELECT id_modele, id_agent, nom, date_creation, date_modification + FROM modele_semaine_agent + WHERE id_modele = :template_id AND id_agent = :agent_id + LIMIT 1" + ); + $stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]); + return $stmt->fetch() ?: null; + } + + public function entries(int $templateId): array + { + $stmt = $this->pdo->prepare( + "SELECT c.id_modele_creneau, c.jour_semaine, c.id_structure, c.id_motif, + TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin, + s.nom AS structure_nom, + s.type_affectation AS structure_type_affectation, + s.actif AS structure_actif, + m.libelle AS motif_libelle, + m.compte_dans_quota, + m.actif AS motif_actif + FROM modele_semaine_creneau c + INNER JOIN structure s ON s.id_structure = c.id_structure + INNER JOIN motif_planning m ON m.id_motif = c.id_motif + WHERE c.id_modele = :template_id + ORDER BY c.jour_semaine, c.heure_debut, c.heure_fin, s.nom" + ); + $stmt->execute(['template_id' => $templateId]); + return $stmt->fetchAll(); + } + + public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array + { + $name = trim(preg_replace('/\s+/u', ' ', $name) ?? ''); + if ($name === '' || strlen($name) > 240) { + throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.'); + } + + $sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber); + if (!$sourceEntries) { + throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.'); + } + + try { + $this->pdo->beginTransaction(); + + $existingStmt = $this->pdo->prepare( + 'SELECT id_modele FROM modele_semaine_agent WHERE id_agent = :agent_id AND nom = :nom FOR UPDATE' + ); + $existingStmt->execute(['agent_id' => $agentId, 'nom' => $name]); + $existingId = (int) ($existingStmt->fetchColumn() ?: 0); + + if ($existingId > 0 && !$replaceExisting) { + throw new DomainException('Une semaine type porte déjà ce nom pour cet agent.'); + } + + if ($existingId > 0) { + $templateId = $existingId; + $delete = $this->pdo->prepare('DELETE FROM modele_semaine_creneau WHERE id_modele = :template_id'); + $delete->execute(['template_id' => $templateId]); + $touch = $this->pdo->prepare('UPDATE modele_semaine_agent SET date_modification = CURRENT_TIMESTAMP WHERE id_modele = :template_id'); + $touch->execute(['template_id' => $templateId]); + } else { + $insertTemplate = $this->pdo->prepare( + 'INSERT INTO modele_semaine_agent (id_agent, nom) VALUES (:agent_id, :nom)' + ); + $insertTemplate->execute(['agent_id' => $agentId, 'nom' => $name]); + $templateId = (int) $this->pdo->lastInsertId(); + } + + $insertEntry = $this->pdo->prepare( + "INSERT INTO modele_semaine_creneau + (id_modele, jour_semaine, id_structure, id_motif, heure_debut, heure_fin) + VALUES + (:template_id, :weekday, :structure_id, :motif_id, :start, :end)" + ); + + foreach ($sourceEntries as $entry) { + $insertEntry->execute([ + 'template_id' => $templateId, + 'weekday' => (int) $entry['jour_semaine'], + 'structure_id' => (int) $entry['id_structure'], + 'motif_id' => (int) $entry['id_motif'], + 'start' => $entry['heure_debut'], + 'end' => $entry['heure_fin'], + ]); + } + + $this->pdo->commit(); + return [ + 'id_modele' => $templateId, + 'nom' => $name, + 'nombre_creneaux' => count($sourceEntries), + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function applyToWeek(int $templateId, int $agentId, int $year, int $weekNumber, string $mode): array + { + if (!in_array($mode, ['merge', 'replace'], true)) { + throw new DomainException('Mode d’import invalide.'); + } + + $template = $this->findForAgent($templateId, $agentId); + if ($template === null) { + throw new DomainException('Semaine type introuvable pour cet agent.'); + } + + $templateEntries = $this->entries($templateId); + if (!$templateEntries) { + throw new DomainException('Cette semaine type ne contient aucun créneau.'); + } + foreach ($templateEntries as $entry) { + if (!(bool) $entry['structure_actif']) { + throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom'])); + } + if (!(bool) $entry['motif_actif']) { + throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle'])); + } + } + + $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); + $weekEnd = $weekStart->modify('+6 days'); + + $targetEntries = array_map(static function (array $entry) use ($weekStart): array { + $date = $weekStart->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d'); + return [ + 'date' => $date, + 'id_structure' => (int) $entry['id_structure'], + 'id_motif' => (int) $entry['id_motif'], + 'heure_debut' => $entry['heure_debut'], + 'heure_fin' => $entry['heure_fin'], + 'structure_nom' => $entry['structure_nom'], + ]; + }, $templateEntries); + + $this->assertTemplateNoOverlap($targetEntries); + + try { + $this->pdo->beginTransaction(); + $weekId = $this->ensureWeek($year, $weekNumber, $weekStart, $weekEnd); + + $existingPlanningsStmt = $this->pdo->prepare( + "SELECT id_planning, id_structure, statut + FROM planning + WHERE id_agent = :agent_id AND id_semaine = :week_id + FOR UPDATE" + ); + $existingPlanningsStmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]); + $existingPlannings = $existingPlanningsStmt->fetchAll(); + + $reopened = []; + if ($mode === 'replace') { + foreach ($existingPlannings as $planning) { + if ($planning['statut'] === 'VALIDE') { + $reopened[] = (int) $planning['id_planning']; + } + } + if ($existingPlannings) { + $ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)"); + $delete->execute($ids); + $reopen = $this->pdo->prepare("UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)"); + $reopen->execute($ids); + } + } else { + $this->assertNoExistingOverlap($agentId, $weekId, $targetEntries); + } + + $planningByStructure = []; + foreach ($existingPlannings as $planning) { + $planningByStructure[(int) $planning['id_structure']] = [ + 'id_planning' => (int) $planning['id_planning'], + 'statut' => (string) $planning['statut'], + ]; + } + + $selectPlanning = $this->pdo->prepare( + "SELECT id_planning, statut + FROM planning + WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id + FOR UPDATE" + ); + $insertPlanning = $this->pdo->prepare( + "INSERT INTO planning (id_agent, id_semaine, id_structure, statut) + VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')" + ); + $reopenPlanning = $this->pdo->prepare( + "UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id" + ); + $insertEntry = $this->pdo->prepare( + "INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin) + VALUES (:planning_id, :motif_id, :date_jour, :start, :end)" + ); + + $inserted = 0; + foreach ($targetEntries as $entry) { + $structureId = (int) $entry['id_structure']; + if (!isset($planningByStructure[$structureId])) { + $selectPlanning->execute([ + 'agent_id' => $agentId, + 'week_id' => $weekId, + 'structure_id' => $structureId, + ]); + $planning = $selectPlanning->fetch(); + if ($planning) { + $planningByStructure[$structureId] = [ + 'id_planning' => (int) $planning['id_planning'], + 'statut' => (string) $planning['statut'], + ]; + } else { + $insertPlanning->execute([ + 'agent_id' => $agentId, + 'week_id' => $weekId, + 'structure_id' => $structureId, + ]); + $planningByStructure[$structureId] = [ + 'id_planning' => (int) $this->pdo->lastInsertId(), + 'statut' => 'BROUILLON', + ]; + } + } + + $planningInfo = &$planningByStructure[$structureId]; + if ($planningInfo['statut'] === 'VALIDE') { + $reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]); + $reopened[] = $planningInfo['id_planning']; + $planningInfo['statut'] = 'BROUILLON'; + } + + $insertEntry->execute([ + 'planning_id' => $planningInfo['id_planning'], + 'motif_id' => $entry['id_motif'], + 'date_jour' => $entry['date'], + 'start' => $entry['heure_debut'], + 'end' => $entry['heure_fin'], + ]); + $inserted++; + unset($planningInfo); + } + + $this->pdo->commit(); + return [ + 'template' => $template, + 'inserted' => $inserted, + 'mode' => $mode, + 'reopened_planning_ids' => array_values(array_unique($reopened)), + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function previewPeriod( + int $templateId, + int $agentId, + string $startDate, + string $endDate, + string $mode, + string $periodFilter = 'all' + ): array { + return $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, true); + } + + public function applyToPeriod( + int $templateId, + int $agentId, + string $startDate, + string $endDate, + string $mode, + string $periodFilter = 'all' + ): array { + $plan = $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, false); + $targetEntries = $plan['target_entries']; + + if ($targetEntries === []) { + throw new DomainException( + 'Aucun créneau de cette semaine type ne correspond au filtre de calendrier sélectionné sur cette période. Vérifiez les dates et les vacances enregistrées dans PTA.' + ); + } + + $targetDates = array_values(array_unique(array_column($targetEntries, 'date'))); + $targetDateLookup = array_fill_keys($targetDates, true); + $reopened = []; + $deleted = 0; + + try { + $this->pdo->beginTransaction(); + + $existing = $this->existingEntriesBetween($agentId, $startDate, $endDate, true); + if ($mode === 'merge') { + $conflicts = $this->findConflicts($targetEntries, $existing); + if ($conflicts !== []) { + $first = $conflicts[0]; + throw new DomainException(sprintf( + 'Application impossible en mode « Compléter » : %d conflit%s détecté%s. Premier conflit le %s entre %s-%s et un créneau existant %s-%s à « %s ». Utilisez l’aperçu puis le mode « Remplacer les jours concernés » ou corrigez le planning existant.', + count($conflicts), + count($conflicts) > 1 ? 's' : '', + count($conflicts) > 1 ? 's' : '', + $first['date'], + substr($first['template_start'], 0, 5), + substr($first['template_end'], 0, 5), + substr($first['existing_start'], 0, 5), + substr($first['existing_end'], 0, 5), + $first['existing_structure'] + )); + } + } else { + $entriesToDelete = array_values(array_filter( + $existing, + static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']]) + )); + + if ($entriesToDelete !== []) { + $entryIds = array_map(static fn(array $entry): int => (int) $entry['id_creneau'], $entriesToDelete); + $planningIds = []; + foreach ($entriesToDelete as $entry) { + if ((string) $entry['statut'] === 'VALIDE') { + $planningIds[(int) $entry['id_planning']] = true; + } + } + + $placeholders = implode(',', array_fill(0, count($entryIds), '?')); + $delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_creneau IN ($placeholders)"); + $delete->execute($entryIds); + $deleted = count($entryIds); + + if ($planningIds !== []) { + $ids = array_keys($planningIds); + $planningPlaceholders = implode(',', array_fill(0, count($ids), '?')); + $reopen = $this->pdo->prepare( + "UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($planningPlaceholders)" + ); + $reopen->execute($ids); + $reopened = array_merge($reopened, $ids); + } + } + } + + $weekIds = []; + $planningCache = []; + $selectPlanning = $this->pdo->prepare( + "SELECT id_planning, statut + FROM planning + WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id + FOR UPDATE" + ); + $insertPlanning = $this->pdo->prepare( + "INSERT INTO planning (id_agent, id_semaine, id_structure, statut) + VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')" + ); + $reopenPlanning = $this->pdo->prepare( + "UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id" + ); + $insertEntry = $this->pdo->prepare( + "INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin) + VALUES (:planning_id, :motif_id, :date_jour, :start, :end)" + ); + + $inserted = 0; + foreach ($targetEntries as $entry) { + $date = new DateTimeImmutable($entry['date']); + $isoYear = (int) $date->format('o'); + $weekNumber = (int) $date->format('W'); + $weekKey = sprintf('%04d-W%02d', $isoYear, $weekNumber); + + if (!isset($weekIds[$weekKey])) { + $weekStart = (new DateTimeImmutable())->setISODate($isoYear, $weekNumber, 1)->setTime(0, 0); + $weekIds[$weekKey] = $this->ensureWeek( + $isoYear, + $weekNumber, + $weekStart, + $weekStart->modify('+6 days') + ); + } + + $weekId = $weekIds[$weekKey]; + $structureId = (int) $entry['id_structure']; + $planningKey = $weekId . ':' . $structureId; + + if (!isset($planningCache[$planningKey])) { + $selectPlanning->execute([ + 'agent_id' => $agentId, + 'week_id' => $weekId, + 'structure_id' => $structureId, + ]); + $planning = $selectPlanning->fetch(); + if ($planning) { + $planningCache[$planningKey] = [ + 'id_planning' => (int) $planning['id_planning'], + 'statut' => (string) $planning['statut'], + ]; + } else { + $insertPlanning->execute([ + 'agent_id' => $agentId, + 'week_id' => $weekId, + 'structure_id' => $structureId, + ]); + $planningCache[$planningKey] = [ + 'id_planning' => (int) $this->pdo->lastInsertId(), + 'statut' => 'BROUILLON', + ]; + } + } + + $planningInfo = &$planningCache[$planningKey]; + if ($planningInfo['statut'] === 'VALIDE') { + $reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]); + $reopened[] = $planningInfo['id_planning']; + $planningInfo['statut'] = 'BROUILLON'; + } + + $insertEntry->execute([ + 'planning_id' => $planningInfo['id_planning'], + 'motif_id' => (int) $entry['id_motif'], + 'date_jour' => $entry['date'], + 'start' => $entry['heure_debut'], + 'end' => $entry['heure_fin'], + ]); + $inserted++; + unset($planningInfo); + } + + $this->pdo->commit(); + + return [ + 'template' => $plan['template'], + 'inserted' => $inserted, + 'deleted' => $deleted, + 'mode' => $mode, + 'period_filter' => $plan['period_filter'], + 'start_date' => $startDate, + 'end_date' => $endDate, + 'weeks_touched' => count($weekIds), + 'days_touched' => count($targetDates), + 'skipped_count' => $plan['summary']['skipped_count'], + 'skipped_vacation_count' => $plan['summary']['skipped_vacation_count'], + 'skipped_school_count' => $plan['summary']['skipped_school_count'], + 'reopened_planning_ids' => array_values(array_unique(array_map('intval', $reopened))), + 'years' => $plan['summary']['years'], + ]; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + + public function delete(int $templateId, int $agentId): bool + { + $stmt = $this->pdo->prepare( + 'DELETE FROM modele_semaine_agent WHERE id_modele = :template_id AND id_agent = :agent_id' + ); + $stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]); + return $stmt->rowCount() === 1; + } + + private function sourceWeekEntries(int $agentId, int $year, int $weekNumber): array + { + $stmt = $this->pdo->prepare( + "SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine, + p.id_structure, c.id_motif, + TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin + FROM planning p + INNER JOIN semaine s ON s.id_semaine = p.id_semaine + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + WHERE p.id_agent = :agent_id + AND s.annee = :annee + AND s.numero_semaine = :week_number + AND c.date_jour BETWEEN s.date_debut AND s.date_fin + AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4 + ORDER BY c.date_jour, c.heure_debut, p.id_structure" + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'annee' => $year, + 'week_number' => $weekNumber, + ]); + return $stmt->fetchAll(); + } + + private function buildPeriodPlan( + int $templateId, + int $agentId, + string $startDate, + string $endDate, + string $mode, + string $periodFilter, + bool $includeExistingAnalysis + ): array { + if (!in_array($mode, ['merge', 'replace'], true)) { + throw new DomainException('Mode d’application invalide.'); + } + if (!in_array($periodFilter, ['all', 'school', 'extra'], true)) { + throw new DomainException('Filtre de calendrier invalide.'); + } + + $start = $this->parseStrictDate($startDate, 'date de début'); + $end = $this->parseStrictDate($endDate, 'date de fin'); + if ($end < $start) { + throw new DomainException('La date de fin doit être postérieure ou égale à la date de début.'); + } + if ((int) $start->diff($end)->days > 730) { + throw new DomainException('La période ne peut pas dépasser deux ans.'); + } + + $template = $this->findForAgent($templateId, $agentId); + if ($template === null) { + throw new DomainException('Semaine type introuvable pour cet agent.'); + } + + $templateEntries = $this->entries($templateId); + if ($templateEntries === []) { + throw new DomainException('Cette semaine type ne contient aucun créneau.'); + } + foreach ($templateEntries as $entry) { + if (!(bool) $entry['structure_actif']) { + throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom'])); + } + if (!(bool) $entry['motif_actif']) { + throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle'])); + } + } + + $entriesByWeekday = []; + foreach ($templateEntries as $entry) { + $entriesByWeekday[(int) $entry['jour_semaine']][] = $entry; + } + + $vacationPeriods = (new VacationModel($this->pdo))->periodsBetween($startDate, $endDate); + $targetEntries = []; + $skippedEntries = []; + $days = []; + $weeks = []; + $years = []; + + for ($date = $start; $date <= $end; $date = $date->modify('+1 day')) { + $weekday = (int) $date->format('N'); + if ($weekday > 5) { + continue; + } + + $dateValue = $date->format('Y-m-d'); + $vacation = $this->vacationForDate($dateValue, $vacationPeriods); + $periodType = $vacation ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE'; + $dayEntries = $entriesByWeekday[$weekday] ?? []; + $eligibleCount = 0; + $skippedCount = 0; + + foreach ($dayEntries as $entry) { + $dateIsEligible = match ($periodFilter) { + 'school' => $periodType === 'PERISCOLAIRE', + 'extra' => $periodType === 'EXTRASCOLAIRE', + default => true, + }; + + if (!$dateIsEligible) { + $skippedCount++; + $skippedEntries[] = [ + 'date' => $dateValue, + 'reason' => $periodType === 'EXTRASCOLAIRE' ? 'VACANCES' : 'HORS_VACANCES', + 'period_type' => $periodType, + 'vacation_label' => $vacation['libelle'] ?? null, + 'structure_name' => (string) $entry['structure_nom'], + 'start' => (string) $entry['heure_debut'], + 'end' => (string) $entry['heure_fin'], + ]; + continue; + } + + $eligibleCount++; + $targetEntries[] = [ + 'date' => $dateValue, + 'id_structure' => (int) $entry['id_structure'], + 'id_motif' => (int) $entry['id_motif'], + 'heure_debut' => (string) $entry['heure_debut'], + 'heure_fin' => (string) $entry['heure_fin'], + 'structure_nom' => (string) $entry['structure_nom'], + 'motif_libelle' => (string) $entry['motif_libelle'], + 'period_type' => $periodType, + 'vacation_label' => $vacation['libelle'] ?? null, + ]; + $weeks[$date->format('o-W')] = true; + $years[(int) $date->format('Y')] = true; + } + + if ($dayEntries !== []) { + $days[$dateValue] = [ + 'date' => $dateValue, + 'weekday' => $weekday, + 'period_type' => $periodType, + 'vacation_label' => $vacation['libelle'] ?? null, + 'template_entry_count' => count($dayEntries), + 'eligible_count' => $eligibleCount, + 'skipped_count' => $skippedCount, + 'conflict_count' => 0, + ]; + } + } + + $this->assertTemplateNoOverlap($targetEntries); + + $existing = []; + $conflicts = []; + $existingOnTargetDates = []; + if ($includeExistingAnalysis && $targetEntries !== []) { + $existing = $this->existingEntriesBetween($agentId, $startDate, $endDate); + $conflicts = $this->findConflicts($targetEntries, $existing); + $targetDateLookup = array_fill_keys(array_unique(array_column($targetEntries, 'date')), true); + $existingOnTargetDates = array_values(array_filter( + $existing, + static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']]) + )); + + foreach ($conflicts as $conflict) { + if (isset($days[$conflict['date']])) { + $days[$conflict['date']]['conflict_count']++; + } + } + } + + $skippedVacationCount = count(array_filter( + $skippedEntries, + static fn(array $entry): bool => $entry['reason'] === 'VACANCES' + )); + $skippedSchoolCount = count($skippedEntries) - $skippedVacationCount; + + return [ + 'template' => [ + 'id' => (int) $template['id_modele'], + 'name' => (string) $template['nom'], + ], + 'start_date' => $startDate, + 'end_date' => $endDate, + 'mode' => $mode, + 'period_filter' => $periodFilter, + 'target_entries' => $targetEntries, + 'skipped_entries' => $skippedEntries, + 'conflicts' => $conflicts, + 'days' => array_values($days), + 'vacation_periods' => array_map(static fn(array $period): array => [ + 'id' => (int) $period['id_periode'], + 'label' => (string) $period['libelle'], + 'start_date' => (string) $period['date_debut'], + 'end_date' => (string) $period['date_fin'], + 'school_year' => (string) $period['annee_scolaire'], + ], $vacationPeriods), + 'summary' => [ + 'entry_count' => count($targetEntries), + 'target_day_count' => count(array_unique(array_column($targetEntries, 'date'))), + 'week_count' => count($weeks), + 'skipped_count' => count($skippedEntries), + 'skipped_vacation_count' => $skippedVacationCount, + 'skipped_school_count' => $skippedSchoolCount, + 'conflict_count' => count($conflicts), + 'existing_entry_count_on_target_dates' => count($existingOnTargetDates), + 'years' => array_values(array_map('intval', array_keys($years))), + 'vacation_period_count' => count($vacationPeriods), + ], + ]; + } + + private function parseStrictDate(string $value, string $label): DateTimeImmutable + { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = DateTimeImmutable::getLastErrors(); + $hasErrors = is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0); + if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) { + throw new DomainException(sprintf('La %s est invalide.', $label)); + } + return $date; + } + + private function vacationForDate(string $date, array $periods): ?array + { + foreach ($periods as $period) { + if ($date >= $period['date_debut'] && $date <= $period['date_fin']) { + return $period; + } + } + return null; + } + + private function existingEntriesBetween(int $agentId, string $startDate, string $endDate, bool $forUpdate = false): array + { + $sql = "SELECT c.id_creneau, c.id_planning, p.statut, c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin, + s.nom AS structure_nom + FROM planning p + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN structure s ON s.id_structure = p.id_structure + WHERE p.id_agent = :agent_id + AND c.date_jour BETWEEN :start_date AND :end_date + ORDER BY c.date_jour, c.heure_debut"; + if ($forUpdate) { + $sql .= ' FOR UPDATE'; + } + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([ + 'agent_id' => $agentId, + 'start_date' => $startDate, + 'end_date' => $endDate, + ]); + return $stmt->fetchAll(); + } + + private function findConflicts(array $targetEntries, array $existingEntries): array + { + $conflicts = []; + foreach ($targetEntries as $target) { + foreach ($existingEntries as $existing) { + if ($target['date'] !== $existing['date_jour']) { + continue; + } + if ($target['heure_debut'] < $existing['heure_fin'] && $target['heure_fin'] > $existing['heure_debut']) { + $conflicts[] = [ + 'date' => $target['date'], + 'template_start' => $target['heure_debut'], + 'template_end' => $target['heure_fin'], + 'template_structure' => $target['structure_nom'], + 'existing_entry_id' => (int) $existing['id_creneau'], + 'existing_start' => $existing['heure_debut'], + 'existing_end' => $existing['heure_fin'], + 'existing_structure' => $existing['structure_nom'], + ]; + } + } + } + return $conflicts; + } + + private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int + { + $stmt = $this->pdo->prepare( + "INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut) + VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE') + ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)" + ); + $stmt->execute([ + 'annee' => $year, + 'numero' => $weekNumber, + 'date_debut' => $weekStart->format('Y-m-d'), + 'date_fin' => $weekEnd->format('Y-m-d'), + ]); + $weekId = (int) $this->pdo->lastInsertId(); + if ($weekId > 0) { + return $weekId; + } + + $find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero'); + $find->execute(['annee' => $year, 'numero' => $weekNumber]); + $weekId = (int) $find->fetchColumn(); + if ($weekId <= 0) { + throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.'); + } + return $weekId; + } + + private function assertTemplateNoOverlap(array $entries): void + { + for ($i = 0, $count = count($entries); $i < $count; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + $first = $entries[$i]; + $second = $entries[$j]; + if ($first['date'] === $second['date'] + && $first['heure_debut'] < $second['heure_fin'] + && $first['heure_fin'] > $second['heure_debut']) { + throw new DomainException(sprintf( + 'La semaine type contient un chevauchement le %s entre %s-%s (%s) et %s-%s (%s).', + $first['date'], + substr($first['heure_debut'], 0, 5), + substr($first['heure_fin'], 0, 5), + $first['structure_nom'], + substr($second['heure_debut'], 0, 5), + substr($second['heure_fin'], 0, 5), + $second['structure_nom'] + )); + } + } + } + } + + private function assertNoExistingOverlap(int $agentId, int $weekId, array $entries): void + { + $stmt = $this->pdo->prepare( + "SELECT c.date_jour, + TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut, + TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin, + s.nom AS structure_nom + FROM planning p + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + INNER JOIN structure s ON s.id_structure = p.id_structure + WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id + ORDER BY c.date_jour, c.heure_debut" + ); + $stmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]); + $existing = $stmt->fetchAll(); + + foreach ($entries as $entry) { + foreach ($existing as $current) { + if ($entry['date'] === $current['date_jour'] + && $entry['heure_debut'] < $current['heure_fin'] + && $entry['heure_fin'] > $current['heure_debut']) { + throw new DomainException(sprintf( + 'Import impossible en mode « compléter » : le modèle %s-%s chevauche un créneau existant %s-%s à « %s » le %s.', + substr($entry['heure_debut'], 0, 5), + substr($entry['heure_fin'], 0, 5), + substr($current['heure_debut'], 0, 5), + substr($current['heure_fin'], 0, 5), + $current['structure_nom'], + $entry['date'] + )); + } + } + } + } +} diff --git a/app/Services/AgentHoursContractPdfService.php b/app/Services/AgentHoursContractPdfService.php new file mode 100644 index 0000000..3a34844 --- /dev/null +++ b/app/Services/AgentHoursContractPdfService.php @@ -0,0 +1,208 @@ +drawHeader($pdf, $agent, $periodLabel, $generatedAt); + $this->drawAgentIdentity($pdf, $agent); + $this->drawQuotaSummary($pdf, $quota); + $this->drawValidationClause($pdf, $periodLabel, $generatedAt); + $this->drawSignatures($pdf); + + $pdf->text( + self::MARGIN, + 813, + sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')), + 7 + ); + + return $pdf->output(); + } + + private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void + { + $pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true); + $pdf->text(self::MARGIN, 71, 'Validation du quota annuel de travail', 12, true); + $pdf->text(self::MARGIN, 92, 'Période de référence : ' . $periodLabel, 10); + $pdf->text(self::MARGIN + 315, 92, 'Édité le : ' . $generatedAt->format('d/m/Y'), 10); + $pdf->line(self::MARGIN, 108, self::PAGE_WIDTH - self::MARGIN, 108, 0.35); + + $pdf->text( + self::MARGIN, + 130, + sprintf('%s %s', (string) ($agent['prenom'] ?? ''), (string) ($agent['nom'] ?? '')), + 14, + true + ); + } + + private function drawAgentIdentity(SimplePdf $pdf, array $agent): void + { + $x = self::MARGIN; + $y = 150.0; + $w = self::PAGE_WIDTH - (self::MARGIN * 2); + $h = 140.0; + + $pdf->fillRect($x, $y, $w, 28, 0.93); + $pdf->rect($x, $y, $w, $h, 0.65); + $pdf->text($x + 12, $y + 19, 'Informations de l\'agent', 11, true); + + $leftX = $x + 14; + $rightX = $x + 278; + $row1 = $y + 52; + $row2 = $y + 78; + $row3 = $y + 102; + $row4 = $y + 128; + + $qualification = !empty($agent['est_diplome']) + ? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' - ' . $agent['diplome_libelle'] : '') + : 'Non diplômé'; + + $pdf->text($leftX, $row1, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9); + $pdf->text($rightX, $row1, 'E-mail : ' . (($agent['email'] ?? '') ?: 'Non renseigné'), 9); + $pdf->text($leftX, $row2, 'Téléphone : ' . (($agent['telephone'] ?? '') ?: 'Non renseigné'), 9); + $pdf->text($rightX, $row2, 'Qualification : ' . $qualification, 9); + $pdf->text($leftX, $row3, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9); + $pdf->text($rightX, $row3, 'Début du contrat : ' . (!empty($agent['date_debut_contrat']) ? (new DateTimeImmutable((string) $agent['date_debut_contrat']))->format('d/m/Y') : 'Non renseigné'), 9); + $address = (string) (($agent['adresse'] ?? '') ?: 'Non renseignée'); + $addressLines = $pdf->wrap('Adresse : ' . $address, $w - 28, 8.5); + $pdf->text($leftX, $row4, (string) ($addressLines[0] ?? 'Adresse : Non renseignée'), 8.5); + $pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5); + } + + private function drawQuotaSummary(SimplePdf $pdf, array $quota): void + { + $x = self::MARGIN; + $y = 318.0; + $w = self::PAGE_WIDTH - (self::MARGIN * 2); + $headerH = 30.0; + $rowH = 31.0; + + $rows = [ + ['Quota annuel de référence à 100 %', $this->minutesLabel((int) ($quota['quota_reference_minutes'] ?? 0))], + ['Quotité de travail applicable', $this->percentLabel((float) ($quota['quotite_travail'] ?? 100))], + ['Quota annuel cible de l\'agent', (string) ($quota['quota_cible']['libelle'] ?? $this->minutesLabel((int) ($quota['quota_cible_minutes'] ?? 0)))], + ['Heures validées dans PTA à la date d\'édition', (string) ($quota['valides']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_valides'] ?? 0)))], + ['Heures encore en brouillon', (string) ($quota['brouillons']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_brouillon'] ?? 0)))], + ['Solde restant à planifier / affecter', (string) ($quota['restantes']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_restantes'] ?? 0)))], + ]; + + $pdf->fillRect($x, $y, $w, $headerH, 0.90); + $pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.60); + $pdf->text($x + 12, $y + 20, 'Synthèse du quota horaire', 11, true); + + $labelWidth = 360.0; + $valueWidth = $w - $labelWidth; + $cursorY = $y + $headerH; + + foreach ($rows as $index => [$label, $value]) { + if ($index % 2 === 1) { + $pdf->fillRect($x, $cursorY, $w, $rowH, 0.975); + } + $pdf->line($x, $cursorY, $x + $w, $cursorY, 0.82); + $pdf->line($x + $labelWidth, $cursorY, $x + $labelWidth, $cursorY + $rowH, 0.82); + $pdf->text($x + 12, $cursorY + 20, (string) $label, 8.6, $index === 2); + $pdf->text($x + $labelWidth + 12, $cursorY + 20, (string) $value, 9.2, $index === 2); + $cursorY += $rowH; + } + } + + private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void + { + $x = self::MARGIN; + $y = 557.0; + $w = self::PAGE_WIDTH - (self::MARGIN * 2); + + $pdf->text($x, $y, 'Validation', 11, true); + + $paragraphs = [ + sprintf( + 'Le présent document fixe et récapitule le quota annuel de travail applicable à l\'agent pour la période %s. La signature de l\'agent et la contre-signature du responsable valent validation du quota annuel indiqué ci-dessus.', + $periodLabel + ), + 'Les heures validées et les heures en brouillon constituent un état de suivi à la date d\'édition. Toute modification ultérieure du planning doit faire l\'objet d\'une nouvelle validation dans PTA lorsque cela est nécessaire.', + ]; + + $cursorY = $y + 22; + foreach ($paragraphs as $paragraph) { + foreach ($pdf->wrap($paragraph, $w, 9) as $line) { + $pdf->text($x, $cursorY, $line, 9); + $cursorY += 13; + } + $cursorY += 8; + } + + $pdf->text($x, 638, 'Fait à : ____________________________________', 9); + $pdf->text($x + 286, 638, 'Le : ____ / ____ / ________', 9); + $pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8); + } + + private function drawSignatures(SimplePdf $pdf): void + { + $x = self::MARGIN; + $y = 680.0; + $gap = 18.0; + $w = (self::PAGE_WIDTH - (self::MARGIN * 2) - $gap) / 2; + $h = 120.0; + + $pdf->rect($x, $y, $w, $h, 0.55); + $pdf->rect($x + $w + $gap, $y, $w, $h, 0.55); + + $pdf->fillRect($x, $y, $w, 28, 0.94); + $pdf->fillRect($x + $w + $gap, $y, $w, 28, 0.94); + + $pdf->text($x + 10, $y + 19, 'Signature de l\'agent', 10, true); + $pdf->text($x + $w + $gap + 10, $y + 19, 'Contre-signature du responsable', 10, true); + + $pdf->text($x + 10, $y + 48, 'Nom : ______________________________', 8.5); + $pdf->text($x + 10, $y + 68, 'Mention « Lu et approuvé » :', 8.5); + $pdf->text($x + 10, $y + 101, 'Signature :', 8.5); + + $rightX = $x + $w + $gap + 10; + $pdf->text($rightX, $y + 48, 'Nom / qualité : _____________________', 8.5); + $pdf->text($rightX, $y + 68, 'Mention « Bon pour validation » :', 8.5); + $pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5); + } + + private function typeLabel(string $type): string + { + return match (strtoupper($type)) { + 'EXTRASCOLAIRE' => 'Extrascolaire', + 'PERISCOLAIRE' => 'Périscolaire', + default => 'Non renseigné', + }; + } + + private function percentLabel(float $rate): string + { + $formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ','); + return $formatted . ' %'; + } + + private function minutesLabel(int $minutes): string + { + $sign = $minutes < 0 ? '-' : ''; + $absolute = abs($minutes); + return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60); + } +} diff --git a/app/Services/AgentTimeSummaryPdfService.php b/app/Services/AgentTimeSummaryPdfService.php new file mode 100644 index 0000000..d444a81 --- /dev/null +++ b/app/Services/AgentTimeSummaryPdfService.php @@ -0,0 +1,184 @@ +normalizeTotals($rows); + + $this->drawHeader($pdf, $agent, $periodLabel, $generatedAt); + $this->drawIdentity($pdf, $agent); + $this->drawBreakdown($pdf, $totals); + $this->drawResult($pdf, $quota, $totals); + $this->drawFooter($pdf, $agent, $generatedAt); + + return $pdf->output(); + } + + private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void + { + $pdf->text(self::MARGIN, 34, 'PTA', 10, true); + $pdf->text(self::MARGIN, 58, 'Compte-rendu du temps de l\'agent', 17, true); + $pdf->text(self::MARGIN, 79, 'Synthèse des heures planifiées et décomptées pour la période ' . $periodLabel, 9.5); + $pdf->text(self::PAGE_WIDTH - 190, 34, 'Édité le ' . $generatedAt->format('d/m/Y'), 8); + $pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72); + } + + private function drawIdentity(SimplePdf $pdf, array $agent): void + { + $x = self::MARGIN; + $y = 118.0; + $w = self::PAGE_WIDTH - (self::MARGIN * 2); + $h = 90.0; + + $pdf->fillRect($x, $y, $w, 28, 0.93); + $pdf->rect($x, $y, $w, $h, 0.68); + $pdf->text($x + 12, $y + 19, 'Agent', 10.5, true); + $pdf->text($x + 12, $y + 50, 'Nom : ' . (($agent['nom'] ?? '') ?: '-'), 9.2); + $pdf->text($x + 270, $y + 50, 'Prénom : ' . (($agent['prenom'] ?? '') ?: '-'), 9.2); + $pdf->text($x + 12, $y + 73, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9.2); + $pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2); + } + + private function drawBreakdown(SimplePdf $pdf, array $totals): void + { + $x = self::MARGIN; + $y = 232.0; + $w = self::PAGE_WIDTH - (self::MARGIN * 2); + $headerH = 31.0; + $rowH = 43.0; + + $rows = [ + ['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']], + ['MALADIE', 'Absence', $totals['MALADIE']], + ['FORMATION', 'Formation', $totals['FORMATION']], + ['CONGE', 'Congé', $totals['CONGE']], + ['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']], + ]; + + $pdf->fillRect($x, $y, $w, $headerH, 0.92); + $pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.65); + $pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true); + + $cursorY = $y + $headerH; + foreach ($rows as [$code, $label, $minutes]) { + $palette = $this->palette($code); + $pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83); + $pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']); + $pdf->text($x + 32, $cursorY + 25, $label, 9.4, true); + $pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true); + $cursorY += $rowH; + } + } + + private function drawResult(SimplePdf $pdf, array $quota, array $totals): void + { + $counted = $totals['TRAVAIL'] + $totals['MALADIE'] + $totals['FORMATION'] + $totals['CONGE'] + $totals['OTHER_COUNTED']; + $target = (int) ($quota['quota_cible_minutes'] ?? 0); + $remaining = $target - $counted; + $validated = (int) ($quota['minutes_valides'] ?? 0); + $draft = (int) ($quota['minutes_brouillon'] ?? 0); + + $x = self::MARGIN; + $y = 507.0; + $w = self::PAGE_WIDTH - (self::MARGIN * 2); + + $pdf->line($x, $y, $x + $w, $y, 0.55); + $pdf->text($x, $y + 32, 'Résultat - heures décomptées', 12, true); + $pdf->text($x + $w - 150, $y + 32, $this->minutesLabel($counted), 13, true); + $pdf->text($x, $y + 55, sprintf('Dont %s validées et %s en brouillon.', $this->minutesLabel($validated), $this->minutesLabel($draft)), 8.5); + + $boxY = $y + 82; + $boxH = 126.0; + $pdf->fillRect($x, $boxY, $w, $boxH, 0.965); + $pdf->rect($x, $boxY, $w, $boxH, 0.65); + $pdf->text($x + 14, $boxY + 27, 'Quota à effectuer sur la période', 10, true); + $pdf->text($x + $w - 160, $boxY + 27, $this->minutesLabel($target), 11, true); + $pdf->line($x + 12, $boxY + 43, $x + $w - 12, $boxY + 43, 0.85); + $pdf->text($x + 14, $boxY + 72, 'Heures décomptées à ce jour', 10, true); + $pdf->text($x + $w - 160, $boxY + 72, $this->minutesLabel($counted), 11, true); + $pdf->line($x + 12, $boxY + 88, $x + $w - 12, $boxY + 88, 0.85); + $pdf->text($x + 14, $boxY + 114, $remaining >= 0 ? 'Heures restant à effectuer' : 'Dépassement du quota', 10.5, true); + $pdf->textColor($x + $w - 160, $boxY + 114, $this->minutesLabel(abs($remaining)), $remaining >= 0 ? '#1D4ED8' : '#B91C1C', 12, true); + + $noteY = $boxY + $boxH + 27; + foreach ($pdf->wrap('Le résultat ci-dessus additionne uniquement les motifs qui décomptent le quota annuel. Les absences non décomptées apparaissent dans le détail mais ne réduisent pas le nombre d’heures restant à effectuer.', $w, 8.5) as $line) { + $pdf->text($x, $noteY, $line, 8.5); + $noteY += 12; + } + } + + private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void + { + $pdf->text( + self::MARGIN, + 812, + sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')), + 7 + ); + } + + private function normalizeTotals(array $rows): array + { + $totals = [ + 'TRAVAIL' => 0, + 'MALADIE' => 0, + 'FORMATION' => 0, + 'CONGE' => 0, + 'AUTRE' => 0, + 'OTHER_COUNTED' => 0, + ]; + + foreach ($rows as $row) { + $code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE')); + $minutes = (int) ($row['minutes_total'] ?? 0); + if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') { + $totals[$code] += $minutes; + continue; + } + if ((int) ($row['compte_dans_quota'] ?? 0) === 1) { + $totals['OTHER_COUNTED'] += $minutes; + } else { + $totals['AUTRE'] += $minutes; + } + } + + return $totals; + } + + /** @return array{accent:string} */ + private function palette(string $code): array + { + return match (strtoupper($code)) { + 'TRAVAIL' => ['accent' => '#2563EB'], + 'FORMATION' => ['accent' => '#7C3AED'], + 'CONGE' => ['accent' => '#059669'], + 'MALADIE' => ['accent' => '#DC2626'], + default => ['accent' => '#64748B'], + }; + } + + private function minutesLabel(int $minutes): string + { + $sign = $minutes < 0 ? '-' : ''; + $absolute = abs($minutes); + return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60); + } +} diff --git a/app/Services/PlanningPdfService.php b/app/Services/PlanningPdfService.php new file mode 100644 index 0000000..a423630 --- /dev/null +++ b/app/Services/PlanningPdfService.php @@ -0,0 +1,426 @@ + 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi']; + + /** + * Compatibilité avec l'ancien export d'une seule semaine. + */ + public function agentWeek(array $agent, array $entries, array $week, array $quota): string + { + return $this->agentWeeks($agent, $entries, [$week], $quota); + } + + /** + * Génère un planning compact de 1 à 7 semaines. Avec 7 semaines, le rendu + * tient volontairement sur deux pages A4 paysage maximum (4 + 3 semaines). + */ + public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string + { + $weeks = array_values(array_slice($weeks, 0, 7)); + if ($weeks === []) { + throw new \InvalidArgumentException('Au moins une semaine est nécessaire pour générer le planning.'); + } + + $pdf = new SimplePdf(); + $entriesByDate = $this->entriesByDate($entries); + $chunks = array_chunk($weeks, 4); + + foreach ($chunks as $pageIndex => $pageWeeks) { + if ($pageIndex > 0) { + $pdf->addPage(); + } + + $this->drawAgentMultiWeekHeader($pdf, $agent, $weeks, $quota, $pageIndex + 1, count($chunks)); + + $sectionY = 96.0; + foreach ($pageWeeks as $week) { + $this->drawCompactWeek($pdf, $week, $entriesByDate, $sectionY); + $sectionY += 112.0; + } + + $this->drawLegend($pdf, self::MARGIN, 563); + $pdf->text( + self::MARGIN, + 584, + sprintf('Document genere depuis PTA - %d semaine%s affichee%s.', count($weeks), count($weeks) > 1 ? 's' : '', count($weeks) > 1 ? 's' : ''), + 7 + ); + } + + return $pdf->output(); + } + + public function structureWeek(array $structure, array $agents, array $week): string + { + $pdf = new SimplePdf(); + $this->drawDocumentHeader( + $pdf, + 'Planning hebdomadaire du lieu d\'affectation', + (string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')), + $this->weekLabel($week) + ); + + $weekDays = $this->weekDays($week); + $margin = self::MARGIN; + $tableY = 100.0; + $agentWidth = 126.0; + $quotaWidth = 84.0; + $usableWidth = $pdf->width() - ($margin * 2); + $dayWidth = ($usableWidth - $agentWidth - $quotaWidth) / 5; + $headerHeight = 38.0; + $bottomLimit = 548.0; + + $drawHeader = function () use ($pdf, $weekDays, $margin, $tableY, $agentWidth, $quotaWidth, $dayWidth, $headerHeight): void { + $x = $margin; + $headers = [['Agent', $agentWidth]]; + foreach ($weekDays as $day) { + $headers[] = [$day['label'] . ' ' . $day['display'], $dayWidth]; + } + $headers[] = ['Quota restant', $quotaWidth]; + + foreach ($headers as [$label, $width]) { + $pdf->fillRect($x, $tableY, $width, $headerHeight, 0.92); + $pdf->rect($x, $tableY, $width, $headerHeight, 0.72); + $lines = $pdf->wrap((string) $label, $width - 10, 8); + $lineY = $tableY + 15; + foreach (array_slice($lines, 0, 2) as $line) { + $pdf->text($x + 5, $lineY, $line, 8, true); + $lineY += 10; + } + $x += $width; + } + }; + + $drawHeader(); + $y = $tableY + $headerHeight; + + foreach ($agents as $agent) { + $entriesByDate = $this->entriesByDate($agent['entries'] ?? []); + $dayBlocks = []; + $maxCellHeight = 34.0; + + foreach ($weekDays as $day) { + $blocks = []; + foreach ($entriesByDate[$day['date']] ?? [] as $entry) { + $labelLines = $this->structureEntryLines($pdf, $entry, $dayWidth - 17); + $blockHeight = max(16.0, 6.0 + (count($labelLines) * 8.0)); + $blocks[] = [ + 'entry' => $entry, + 'lines' => $labelLines, + 'height' => $blockHeight, + ]; + } + $dayBlocks[] = $blocks; + + $cellHeight = 8.0; + foreach ($blocks as $block) { + $cellHeight += $block['height'] + 4.0; + } + $maxCellHeight = max($maxCellHeight, $cellHeight); + } + + $agentLines = $pdf->wrap( + sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']), + $agentWidth - 10, + 7.5 + ); + $rowHeight = max(44.0, $maxCellHeight, 14.0 + (count($agentLines) * 9.0)); + + if ($y + $rowHeight > $bottomLimit) { + $pdf->addPage(); + $this->drawDocumentHeader( + $pdf, + 'Planning hebdomadaire du lieu d\'affectation - suite', + (string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')), + $this->weekLabel($week) + ); + $drawHeader(); + $y = $tableY + $headerHeight; + } + + $x = $margin; + $widths = [$agentWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $quotaWidth]; + foreach ($widths as $width) { + $pdf->rect($x, $y, $width, $rowHeight, 0.80); + $x += $width; + } + + $lineY = $y + 13; + foreach ($agentLines as $index => $line) { + $pdf->text($margin + 5, $lineY, $line, 7.5, $index === 0); + $lineY += 9; + } + + foreach ($dayBlocks as $dayIndex => $blocks) { + $cellX = $margin + $agentWidth + ($dayIndex * $dayWidth); + if ($blocks === []) { + $pdf->text($cellX + 6, $y + 18, '-', 7.2); + continue; + } + + $blockY = $y + 5; + foreach ($blocks as $block) { + $entry = $block['entry']; + $palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE')); + $blockHeight = (float) $block['height']; + $pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']); + $pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']); + + $textY = $blockY + 10; + foreach ($block['lines'] as $lineIndex => $line) { + $isFirst = $lineIndex === 0; + $pdf->text($cellX + 10, $textY, $line, 7.0, $isFirst); + $textY += 8; + } + $blockY += $blockHeight + 4; + } + } + + $quotaText = (string) ($agent['quota']['restantes']['libelle'] ?? '-'); + $pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 18, $quotaText, 8, true); + $pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 31, (($agent['lieu_principal'] ?? false) ? 'Principal' : 'Ponctuel'), 7); + $y += $rowHeight; + } + + if ($agents === []) { + $pdf->text($margin, $y + 24, 'Aucun agent rattache ou planifie sur ce lieu pour cette semaine.', 10); + } + + $this->drawLegend($pdf, self::MARGIN, 562); + $pdf->text(self::MARGIN, 584, 'Document genere depuis PTA - Planning du lundi au vendredi.', 7); + return $pdf->output(); + } + + private function drawAgentMultiWeekHeader( + SimplePdf $pdf, + array $agent, + array $weeks, + array $quota, + int $page, + int $pageCount + ): void { + $first = $weeks[0]; + $last = $weeks[count($weeks) - 1]; + $periodStart = (new DateTimeImmutable((string) $first['date_debut']))->format('d/m/Y'); + $periodEnd = (new DateTimeImmutable((string) $last['date_debut']))->modify('+4 days')->format('d/m/Y'); + $principalLocation = (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigne'); + $principalType = $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')); + + $pdf->text(self::MARGIN, 25, 'PTA', 9, true); + $pdf->text(self::MARGIN, 44, 'Planning de l\'agent - vue multi-semaines', 16, true); + $pdf->text(self::MARGIN, 60, sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']), 9.5, true); + $pdf->text(self::MARGIN, 74, 'Lieu principal : ' . $principalLocation . ' - ' . $principalType, 8); + $pdf->text($pdf->width() - 260, 44, sprintf('Du %s au %s', $periodStart, $periodEnd), 9.5, true); + $pdf->text($pdf->width() - 260, 60, 'Quota restant : ' . ($quota['restantes']['libelle'] ?? '-'), 8.5, true); + if ($pageCount > 1) { + $pdf->text($pdf->width() - 260, 74, sprintf('Page %d / %d', $page, $pageCount), 8); + } + $pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72); + } + + /** @param array> $entriesByDate */ + private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void + { + $weekDays = $this->weekDays($week); + $usableWidth = $pdf->width() - (self::MARGIN * 2); + $colWidth = $usableWidth / 5; + $weekTitleHeight = 16.0; + $dayHeaderHeight = 16.0; + $bodyHeight = 77.0; + + $pdf->fillRect(self::MARGIN, $y, $usableWidth, $weekTitleHeight, 0.965); + $pdf->rect(self::MARGIN, $y, $usableWidth, $weekTitleHeight + $dayHeaderHeight + $bodyHeight, 0.78); + $pdf->text(self::MARGIN + 6, $y + 11, $this->weekLabel($week), 8.5, true); + + $dayHeaderY = $y + $weekTitleHeight; + $bodyY = $dayHeaderY + $dayHeaderHeight; + + foreach ($weekDays as $index => $day) { + $colX = self::MARGIN + ($index * $colWidth); + $pdf->fillRect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight, 0.92); + $pdf->rect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight + $bodyHeight, 0.82); + $pdf->text($colX + 5, $dayHeaderY + 11, $this->shortDayLabel($day['label']) . ' ' . substr($day['display'], 0, 5), 7.2, true); + + $dayEntries = $entriesByDate[$day['date']] ?? []; + if ($dayEntries === []) { + $pdf->text($colX + 6, $bodyY + 17, 'Aucune affectation', 6.5); + continue; + } + + $cursorY = $bodyY + 4; + $rendered = 0; + foreach ($dayEntries as $entryIndex => $entry) { + $lines = $this->compactAgentEntryLines($pdf, $entry, $colWidth - 18); + $cardHeight = max(18.0, 5.0 + (count($lines) * 7.2)); + if ($cursorY + $cardHeight > $bodyY + $bodyHeight - 4) { + $remaining = count($dayEntries) - $rendered; + if ($remaining > 0) { + $pdf->text($colX + 7, $bodyY + $bodyHeight - 7, '+' . $remaining . ' autre(s) affectation(s)', 6.2, true); + } + break; + } + + $palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE')); + $pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']); + $pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']); + + $lineY = $cursorY + 9; + foreach ($lines as $lineIndex => $line) { + $bold = $lineIndex === 0; + $pdf->text($colX + 10, $lineY, $line, 6.5, $bold); + $lineY += 7.2; + } + + $cursorY += $cardHeight + 3.0; + $rendered++; + } + } + } + + /** @return string[] */ + private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array + { + $lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])]; + $motifLabel = $this->motifDisplayLabel($entry); + if ($motifLabel !== '') { + foreach ($pdf->wrap($motifLabel, $width, 6.3) as $line) { + $lines[] = $line; + } + } + + $location = (string) ($entry['structure_nom'] ?? 'Lieu non renseigne'); + foreach ($pdf->wrap('Lieu : ' . $location, $width, 6.1) as $line) { + $lines[] = $line; + } + + if (($entry['statut'] ?? 'VALIDE') === 'BROUILLON') { + $lines[] = 'Brouillon'; + } + + return $lines; + } + + /** @return string[] */ + private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array + { + $first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']); + $motif = $this->motifDisplayLabel($entry); + if ($motif !== '') { + $first .= ' ' . $motif; + } + return $pdf->wrap($first, $width, 7.0); + } + + private function motifDisplayLabel(array $entry): string + { + return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL' + ? '' + : (string) ($entry['motif_libelle'] ?? 'Affectation'); + } + + /** @return array{accent:string,background:string,label:string} */ + private function motifPalette(string $code): array + { + return match (strtoupper($code)) { + 'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'], + 'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'], + 'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'], + 'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'], + default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'], + }; + } + + private function drawLegend(SimplePdf $pdf, float $x, float $y): void + { + $items = [ + ['TRAVAIL', 'Travail'], + ['FORMATION', 'Formation'], + ['CONGE', 'Conge'], + ['MALADIE', 'Absence'], + ['AUTRE', 'Autre absence'], + ]; + + foreach ($items as [$code, $label]) { + $palette = $this->motifPalette($code); + $pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']); + $pdf->text($x + 12, $y, $label, 6.5); + $x += 76; + } + } + + /** @return array> */ + private function entriesByDate(array $entries): array + { + $grouped = []; + foreach ($entries as $entry) { + $grouped[(string) $entry['date_jour']][] = $entry; + } + foreach ($grouped as &$dayEntries) { + usort($dayEntries, static fn (array $a, array $b): int => strcmp((string) $a['heure_debut'], (string) $b['heure_debut'])); + } + unset($dayEntries); + return $grouped; + } + + private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void + { + $pdf->text(self::MARGIN, 34, 'PTA', 10, true); + $pdf->text(self::MARGIN, 55, $title, 18, true); + $pdf->text(self::MARGIN, 72, $subtitle, 10); + $pdf->text($pdf->width() - 250, 55, $weekLabel, 10, true); + $pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72); + } + + /** @return array */ + private function weekDays(array $week): array + { + $start = new DateTimeImmutable((string) $week['date_debut']); + $days = []; + for ($dayNumber = 1; $dayNumber <= 5; $dayNumber++) { + $date = $start->modify('+' . ($dayNumber - 1) . ' days'); + $days[] = [ + 'date' => $date->format('Y-m-d'), + 'label' => self::WEEK_DAYS[$dayNumber], + 'display' => $date->format('d/m/Y'), + ]; + } + return $days; + } + + private function weekLabel(array $week): string + { + $start = new DateTimeImmutable((string) $week['date_debut']); + $end = $start->modify('+4 days'); + return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y')); + } + + private function shortDayLabel(string $label): string + { + return match ($label) { + 'Lundi' => 'Lun.', + 'Mardi' => 'Mar.', + 'Mercredi' => 'Mer.', + 'Jeudi' => 'Jeu.', + 'Vendredi' => 'Ven.', + default => $label, + }; + } + + private function typeLabel(string $type): string + { + return match (strtoupper($type)) { + 'EXTRASCOLAIRE' => 'Extrascolaire', + 'PERISCOLAIRE' => 'Periscolaire', + default => 'Non renseigne', + }; + } +} diff --git a/app/Services/SchoolHolidayApiService.php b/app/Services/SchoolHolidayApiService.php new file mode 100644 index 0000000..ff0c454 --- /dev/null +++ b/app/Services/SchoolHolidayApiService.php @@ -0,0 +1,484 @@ + 'Zone B', + 'amiens' => 'Zone B', + 'besancon' => 'Zone A', + 'bordeaux' => 'Zone A', + 'clermont-ferrand' => 'Zone A', + 'creteil' => 'Zone C', + 'dijon' => 'Zone A', + 'grenoble' => 'Zone A', + 'lille' => 'Zone B', + 'limoges' => 'Zone A', + 'lyon' => 'Zone A', + 'montpellier' => 'Zone C', + 'nancy-metz' => 'Zone B', + 'nantes' => 'Zone B', + 'nice' => 'Zone B', + 'normandie' => 'Zone B', + 'orleans-tours' => 'Zone B', + 'paris' => 'Zone C', + 'poitiers' => 'Zone A', + 'reims' => 'Zone B', + 'rennes' => 'Zone B', + 'strasbourg' => 'Zone B', + 'toulouse' => 'Zone C', + 'versailles' => 'Zone C', + ]; + + /** + * Compatibilité avec le code existant : retourne uniquement les périodes. + */ + public function previewCalendarYear(string $academy, int $calendarYear): array + { + return $this->previewCalendarYearDetailed($academy, $calendarYear)['periods']; + } + + /** + * Retourne toutes les périodes recouvrant une année civile, accompagnées + * d'avertissements non bloquants. Une année civile chevauche deux années + * scolaires. L'absence de publication de l'une d'elles ne doit donc plus + * empêcher l'affichage des dates déjà disponibles. + * + * @return array{periods: array, warnings: array, school_years: array} + */ + public function previewCalendarYearDetailed(string $academy, int $calendarYear): array + { + if ($calendarYear < 2000 || $calendarYear > 2100) { + throw new RuntimeException('Année civile invalide.'); + } + + $academy = trim($academy); + if ($academy === '') { + throw new RuntimeException('Académie manquante.'); + } + + $schoolYears = [ + ($calendarYear - 1) . '-' . $calendarYear, + $calendarYear . '-' . ($calendarYear + 1), + ]; + + $all = []; + $warnings = []; + $successfulQueries = 0; + + foreach ($schoolYears as $schoolYear) { + try { + $periods = $this->previewSchoolYear($academy, $schoolYear); + $successfulQueries++; + + if ($periods === []) { + $warnings[] = sprintf( + 'Aucune date publiée pour l’académie %s et l’année scolaire %s.', + $academy, + $schoolYear + ); + } + + foreach ($periods as $period) { + $key = $period['libelle'] . '|' . $period['date_debut'] . '|' . $period['date_fin']; + $all[$key] = $period; + } + } catch (Throwable $e) { + $warnings[] = sprintf( + 'Les dates de l’année scolaire %s n’ont pas pu être chargées : %s', + $schoolYear, + $e->getMessage() + ); + } + } + + if ($successfulQueries === 0) { + throw new RuntimeException(implode(' ', $warnings)); + } + + $yearStart = sprintf('%04d-01-01', $calendarYear); + $yearEnd = sprintf('%04d-12-31', $calendarYear); + + $periods = array_values(array_filter( + $all, + static fn(array $period): bool => $period['date_debut'] <= $yearEnd && $period['date_fin'] >= $yearStart + )); + + usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut'])); + + return [ + 'periods' => $periods, + 'warnings' => array_values(array_unique($warnings)), + 'school_years' => $schoolYears, + ]; + } + + /** + * Compatibilité avec l'ancienne API interne V5.17. + */ + public function preview(string $academy, string $schoolYear): array + { + return $this->previewSchoolYear($academy, $schoolYear); + } + + private function previewSchoolYear(string $academy, string $schoolYear): array + { + $errors = []; + + // 1. Recherche par académie. On essaie plusieurs graphies afin de ne + // pas rendre la recherche dépendante des accents ou du type de tiret. + foreach ($this->academyVariants($academy) as $academyVariant) { + try { + $records = $this->fetchExploreRecords([ + 'limit' => 100, + 'lang' => 'fr', + 'timezone' => 'Europe/Paris', + 'order_by' => 'start_date', + 'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear), + 'refine' => 'location:' . $this->quoteWhereValue($academyVariant), + ]); + + $periods = $this->normalize($records, $academy, $schoolYear, false); + if ($periods !== []) { + return $periods; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + // 2. Repli par zone. Il est utile lorsque le portail fournit une + // période commune à toute la zone, sans ligne spécifique à l'académie. + $zone = $this->zoneForAcademy($academy); + if ($zone !== null) { + try { + $records = $this->fetchExploreRecords([ + 'limit' => 100, + 'lang' => 'fr', + 'timezone' => 'Europe/Paris', + 'order_by' => 'start_date', + 'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear), + 'refine' => 'zones:' . $this->quoteWhereValue($zone), + ]); + + $periods = $this->normalize($records, $academy, $schoolYear, true); + if ($periods !== []) { + return $periods; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + // 3. Dernier repli sur l'ancienne API Opendatasoft. Elle utilise un + // format JSON différent, normalisé dans fetchLegacyRecords(). + foreach ($this->academyVariants($academy) as $academyVariant) { + try { + $records = $this->fetchLegacyRecords($academyVariant, $schoolYear); + $periods = $this->normalize($records, $academy, $schoolYear, false); + if ($periods !== []) { + return $periods; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($errors !== []) { + throw new RuntimeException(end($errors) ?: 'Erreur inconnue de l’API du calendrier scolaire.'); + } + + return []; + } + + private function fetchExploreRecords(array $parameters): array + { + $query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); + $lastError = null; + + foreach (self::EXPLORE_ENDPOINTS as $endpoint) { + try { + $payload = $this->fetchJson($endpoint . '?' . $query); + $records = $payload['results'] ?? null; + if (!is_array($records)) { + throw new RuntimeException('Réponse inattendue de l’API Explore.'); + } + return $records; + } catch (RuntimeException $e) { + $lastError = $e; + } + } + + throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.'); + } + + private function fetchLegacyRecords(string $academy, string $schoolYear): array + { + $query = http_build_query([ + 'dataset' => self::DATASET, + 'rows' => 100, + 'sort' => 'start_date', + 'refine.location' => $academy, + 'refine.annee_scolaire' => $schoolYear, + ], '', '&', PHP_QUERY_RFC3986); + + $payload = $this->fetchJson(self::LEGACY_ENDPOINT . '?' . $query); + $records = $payload['records'] ?? null; + if (!is_array($records)) { + throw new RuntimeException('Réponse inattendue de l’ancienne API du calendrier scolaire.'); + } + + return array_values(array_filter(array_map( + static fn(mixed $record): ?array => is_array($record) && is_array($record['fields'] ?? null) + ? $record['fields'] + : null, + $records + ))); + } + + private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array + { + $periods = []; + $requestedAcademy = $this->normalizeAcademy($academy); + + foreach ($records as $record) { + if (!is_array($record)) { + continue; + } + + // Certaines vacances communes sont publiées avec population "-". + // En filtrant uniquement "Élèves", elles disparaissaient du résultat. + if (!$this->isStudentPopulation($record['population'] ?? '')) { + continue; + } + + $recordAcademy = trim($this->stringValue($record['location'] ?? '')); + if (!$zoneFallback && $recordAcademy !== '' && $this->normalizeAcademy($recordAcademy) !== $requestedAcademy) { + continue; + } + + $description = trim($this->stringValue($record['description'] ?? '')); + if (!$this->isVacationDescription($description)) { + continue; + } + + $startRaw = $this->stringValue($record['start_date'] ?? ''); + $endRaw = $this->stringValue($record['end_date'] ?? ''); + $start = $this->dateOnly($startRaw); + $returnDate = $this->dateOnly($endRaw); + if ($start === null || $returnDate === null) { + continue; + } + + // L'API donne la date de reprise. PTA utilise des bornes inclusives, + // donc le dernier jour de vacances est la veille de la reprise. + $end = (new DateTimeImmutable($returnDate))->modify('-1 day')->format('Y-m-d'); + if ($end < $start) { + $end = $start; + } + + $zone = trim($this->stringValue($record['zones'] ?? ($record['zone'] ?? ''))); + $recordSchoolYear = trim($this->stringValue($record['annee_scolaire'] ?? $schoolYear)); + + $key = $description . '|' . $start . '|' . $end; + $periods[$key] = [ + 'libelle' => $description, + 'date_debut' => $start, + 'date_fin' => $end, + 'date_reprise_api' => $returnDate, + 'annee_scolaire' => $recordSchoolYear !== '' ? $recordSchoolYear : $schoolYear, + 'academie' => $recordAcademy !== '' && !$zoneFallback ? $recordAcademy : $academy, + 'zone' => $zone !== '' ? $zone : ($this->zoneForAcademy($academy) ?? ''), + 'source' => 'DATA_GOUV', + ]; + } + + $periods = array_values($periods); + usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut'])); + return $periods; + } + + private function isVacationDescription(string $description): bool + { + $normalized = $this->normalizeText($description); + if ($normalized === '') { + return false; + } + if (str_contains($normalized, 'rentree') || str_contains($normalized, 'prerentree')) { + return false; + } + + foreach (['vacance', 'conge', 'pont', 'ete austral', 'hiver austral'] as $keyword) { + if (str_contains($normalized, $keyword)) { + return true; + } + } + return false; + } + + private function academyVariants(string $academy): array + { + $academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy); + $variants = [$academy]; + $variants[] = str_replace(['–', '—', '‑'], '-', $academy); + $variants[] = $this->stripAccents($academy); + $variants[] = str_replace('-', ' ', $academy); + $variants[] = str_replace('-', ' ', $this->stripAccents($academy)); + + return array_values(array_unique(array_filter(array_map('trim', $variants)))); + } + + private function zoneForAcademy(string $academy): ?string + { + $key = $this->normalizeAcademy($academy); + return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null; + } + + private function fetchJson(string $url): array + { + $body = false; + $status = 0; + + if (function_exists('curl_init')) { + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Impossible d’initialiser cURL.'); + } + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_TIMEOUT => 20, + CURLOPT_ENCODING => '', + CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.3'], + ]); + $body = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($body === false) { + throw new RuntimeException($error ?: 'Erreur réseau cURL.'); + } + } else { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 20, + 'ignore_errors' => true, + 'header' => "Accept: application/json\r\nUser-Agent: PTA/7.3\r\n", + ], + ]); + $body = @file_get_contents($url, false, $context); + if ($body === false) { + throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.'); + } + foreach ($http_response_header ?? [] as $header) { + if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) { + $status = (int) $matches[1]; + break; + } + } + } + + if ($status >= 400) { + throw new RuntimeException('L’API a répondu avec le code HTTP ' . $status . '.'); + } + + $data = json_decode((string) $body, true); + if (!is_array($data)) { + throw new RuntimeException('Le contenu reçu depuis l’API n’est pas un JSON valide.'); + } + return $data; + } + + private function quoteWhereValue(string $value): string + { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; + } + + private function dateOnly(string $value): ?string + { + if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) { + return null; + } + return $matches[1]; + } + + private function stringValue(mixed $value): string + { + if (is_array($value)) { + return implode(', ', array_map(static fn(mixed $item): string => (string) $item, $value)); + } + return (string) $value; + } + + + private function isStudentPopulation(mixed $value): bool + { + $values = is_array($value) ? $value : [$value]; + foreach ($values as $item) { + $population = $this->normalizeText((string) $item); + if ($population === '' || in_array($population, ['-', 'eleves', 'tous', 'tout public'], true)) { + return true; + } + } + return false; + } + + private function normalizeAcademy(string $value): string + { + $value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value); + return $this->normalizeText($value); + } + + private function normalizeText(string $value): string + { + $value = $this->stripAccents($value); + $value = str_replace(['–', '—', '‑', '_'], '-', $value); + $value = strtolower($value); + $value = preg_replace('/[^a-z0-9-]+/', ' ', $value) ?? $value; + $value = preg_replace('/\s+/', ' ', $value) ?? $value; + return trim($value); + } + + private function stripAccents(string $value): string + { + return strtr($value, [ + 'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A', + 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', + 'Ç' => 'C', 'ç' => 'c', + 'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', + 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', + 'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I', + 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', + 'Ñ' => 'N', 'ñ' => 'n', + 'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O', + 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', + 'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U', + 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', + 'Ý' => 'Y', 'Ÿ' => 'Y', 'ý' => 'y', 'ÿ' => 'y', + 'Œ' => 'OE', 'œ' => 'oe', 'Æ' => 'AE', 'æ' => 'ae', + ]); + } +} diff --git a/app/Services/SimplePdf.php b/app/Services/SimplePdf.php new file mode 100644 index 0000000..4381236 --- /dev/null +++ b/app/Services/SimplePdf.php @@ -0,0 +1,291 @@ + */ + private array $pages = []; + private string $currentPage = ''; + + public function __construct( + private float $width = self::A4_LANDSCAPE_WIDTH, + private float $height = self::A4_LANDSCAPE_HEIGHT, + ) { + $this->addPage(); + } + + public function width(): float + { + return $this->width; + } + + public function height(): float + { + return $this->height; + } + + public function addPage(): void + { + if ($this->currentPage !== '') { + $this->pages[] = $this->currentPage; + } + $this->currentPage = ''; + } + + public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void + { + $font = $bold ? 'F2' : 'F1'; + $encoded = $this->escapeText($text); + $pdfY = $this->height - $y; + $this->currentPage .= sprintf( + "BT /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET\n", + $font, + $size, + $x, + $pdfY, + $encoded + ); + } + + public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void + { + $this->currentPage .= sprintf( + "%.3F G %.2F %.2F m %.2F %.2F l S\n", + $gray, + $x1, + $this->height - $y1, + $x2, + $this->height - $y2 + ); + } + + public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void + { + $this->currentPage .= sprintf( + "%.3F G %.2F %.2F %.2F %.2F re S\n", + $strokeGray, + $x, + $this->height - $y - $h, + $w, + $h + ); + } + + public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void + { + $this->currentPage .= sprintf( + "%.3F g %.2F %.2F %.2F %.2F re f 0 g\n", + $gray, + $x, + $this->height - $y - $h, + $w, + $h + ); + } + + public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void + { + [$r, $g, $b] = $this->hexToRgb($hex); + $this->currentPage .= sprintf( + "%.3F %.3F %.3F rg %.2F %.2F %.2F %.2F re f 0 g\n", + $r, + $g, + $b, + $x, + $this->height - $y - $h, + $w, + $h + ); + } + + public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void + { + [$r, $g, $b] = $this->hexToRgb($hex); + $this->currentPage .= sprintf( + "%.3F %.3F %.3F RG %.2F w %.2F %.2F %.2F %.2F re S 0 G 1 w\n", + $r, + $g, + $b, + $lineWidth, + $x, + $this->height - $y - $h, + $w, + $h + ); + } + + public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void + { + [$r, $g, $b] = $this->hexToRgb($hex); + $font = $bold ? 'F2' : 'F1'; + $encoded = $this->escapeText($text); + $pdfY = $this->height - $y; + $this->currentPage .= sprintf( + "BT %.3F %.3F %.3F rg /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET 0 g\n", + $r, + $g, + $b, + $font, + $size, + $x, + $pdfY, + $encoded + ); + } + + /** + * @return string[] + */ + public function wrap(string $text, float $maxWidth, float $fontSize = 9): array + { + $text = trim(preg_replace('/\s+/u', ' ', $text) ?? $text); + if ($text === '') { + return ['']; + } + + // Helvetica moyenne : environ 0,52 em par caractère. Une estimation + // conservatrice évite les débordements sans embarquer les métriques AFM. + $maxChars = max(4, (int) floor($maxWidth / max(1.0, $fontSize * 0.52))); + $words = preg_split('/\s+/u', $text) ?: [$text]; + $lines = []; + $line = ''; + + foreach ($words as $word) { + $candidate = $line === '' ? $word : $line . ' ' . $word; + if ($this->stringLength($candidate) <= $maxChars) { + $line = $candidate; + continue; + } + + if ($line !== '') { + $lines[] = $line; + } + + while ($this->stringLength($word) > $maxChars) { + $lines[] = $this->stringSlice($word, 0, $maxChars); + $word = $this->stringSlice($word, $maxChars); + } + $line = $word; + } + + if ($line !== '') { + $lines[] = $line; + } + + return $lines ?: ['']; + } + + public function output(): string + { + if ($this->currentPage !== '' || $this->pages === []) { + $this->pages[] = $this->currentPage; + $this->currentPage = ''; + } + + $objects = []; + $objects[1] = '<< /Type /Catalog /Pages 2 0 R >>'; + $objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'; + $objects[4] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>'; + + $pageObjectIds = []; + $nextObjectId = 5; + + foreach ($this->pages as $content) { + $pageObjectId = $nextObjectId++; + $contentObjectId = $nextObjectId++; + $pageObjectIds[] = $pageObjectId; + + $objects[$pageObjectId] = sprintf( + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>', + $this->width, + $this->height, + $contentObjectId + ); + $objects[$contentObjectId] = "<< /Length " . strlen($content) . ">>\nstream\n" . $content . "endstream"; + } + + $kids = implode(' ', array_map(static fn (int $id): string => $id . ' 0 R', $pageObjectIds)); + $objects[2] = sprintf('<< /Type /Pages /Kids [%s] /Count %d >>', $kids, count($pageObjectIds)); + ksort($objects); + + $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; + $offsets = [0 => 0]; + + foreach ($objects as $id => $body) { + $offsets[$id] = strlen($pdf); + $pdf .= $id . " 0 obj\n" . $body . "\nendobj\n"; + } + + $xrefOffset = strlen($pdf); + $maxObjectId = max(array_keys($objects)); + $pdf .= "xref\n0 " . ($maxObjectId + 1) . "\n"; + $pdf .= "0000000000 65535 f \n"; + for ($id = 1; $id <= $maxObjectId; $id++) { + $offset = $offsets[$id] ?? 0; + $pdf .= sprintf('%010d 00000 n ', $offset) . "\n"; + } + + $pdf .= "trailer\n<< /Size " . ($maxObjectId + 1) . " /Root 1 0 R >>\n"; + $pdf .= "startxref\n" . $xrefOffset . "\n%%EOF"; + + return $pdf; + } + + private function stringLength(string $value): int + { + return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value); + } + + private function stringSlice(string $value, int $start, ?int $length = null): string + { + if (function_exists('mb_substr')) { + return mb_substr($value, $start, $length, 'UTF-8'); + } + return $length === null ? substr($value, $start) : substr($value, $start, $length); + } + + /** @return array{0:float,1:float,2:float} */ + private function hexToRgb(string $hex): array + { + $hex = ltrim(trim($hex), '#'); + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) { + $hex = '000000'; + } + + return [ + hexdec(substr($hex, 0, 2)) / 255, + hexdec(substr($hex, 2, 2)) / 255, + hexdec(substr($hex, 4, 2)) / 255, + ]; + } + + private function escapeText(string $text): string + { + $converted = function_exists('iconv') + ? iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text) + : false; + if ($converted === false) { + $converted = preg_replace('/[^\x20-\x7E]/', '?', $text) ?? $text; + } + + return str_replace( + ['\\', '(', ')', "\r", "\n"], + ['\\\\', '\\(', '\\)', '', ' '], + $converted + ); + } +} diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php new file mode 100644 index 0000000..346ab0e --- /dev/null +++ b/app/Views/layouts/footer.php @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php new file mode 100644 index 0000000..deb7fae --- /dev/null +++ b/app/Views/layouts/header.php @@ -0,0 +1,63 @@ + ['planning.php', 'Saisie'], + 'pending' => ['pending.php', 'À valider'], + 'coverage' => ['coverage.php', 'Couverture'], + 'structures' => ['structures.php', 'Par lieu'], + 'agents' => ['agents.php', ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Par agent'], + 'annual' => ['annual.php', 'Vue annuelle'], + 'pta' => ['pta.php', 'PTA détaillé'], + 'teams' => ['teams.php', 'Équipes'], + 'vacations' => ['vacations.php', 'Vacances'], + 'administration' => ['administration.php', 'Administration'], +]; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> — PTA + + + +
+ + + +
Cette page n’est pas disponible avec votre rôle actuel.
+ + + diff --git a/app/Views/pages/administration.php b/app/Views/pages/administration.php new file mode 100644 index 0000000..7e55cb3 --- /dev/null +++ b/app/Views/pages/administration.php @@ -0,0 +1,4 @@ + + + + + + + Choisir votre accès — PTA + + + +
+
+

PTA

+

Quel est votre rôle ?

+

Votre choix détermine les pages visibles et les actions autorisées pendant cette session.

+ +
+ Profil actuel : + — +
+ + +
+ +
+ +
+ + +
+ + + + + +
+ + + + + +
+ +
+
+ + +
+ + + diff --git a/app/Views/pages/structures.php b/app/Views/pages/structures.php new file mode 100644 index 0000000..bfa5130 --- /dev/null +++ b/app/Views/pages/structures.php @@ -0,0 +1,4 @@ + +
+
+
+
+

Ajouter un lieu d’affectation

+

Chaque lieu possède son propre type : périscolaire ou extrascolaire.

+
+
+
+
+ + + + +
+
+
+ +
+
+
+ +
+
+
+

Ajouter un agent

+

L’agent reçoit un lieu principal à sa création, mais peut être intégré aussi bien aux équipes périscolaires qu’extrascolaires et travailler ponctuellement sur d’autres lieux.

+
+
+
+
+ + + + + + + + + + + + + + + + +
+

Le quota PTA est suivi sur l’année civile, du 1er janvier au 31 décembre. À 100 %, la référence est de 1 607 h. La date de début du contrat reste une information RH mais ne décale pas l’année de référence.

+
+
+ +
+
+
+
+ +
+
+
+

Paramètres des lieux d’affectation

+

Le type d’affectation est défini au niveau du lieu : par exemple « Crèche Matusalème — Périscolaire ».

+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
LieuCodeType d’affectationAction
+ +
+
+
+ +
+
+
+

Horaires de couverture des lieux

+

Définissez les périodes pendant lesquelles un nombre minimum d’agents doit être physiquement présent. Seules les affectations « Heure de travail » comptent comme présence.

+
+
+
+ +
+ +
+
+
+
+
Choisissez un lieu pour configurer les plages où une présence minimale est obligatoire.
+
+
+ +
+
+ +
+
+
+

Informations des agents

+

Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
AgentMatriculePosteQualificationTéléphoneDébut contratLieu principalAction
+ +
+
+
+ + + + diff --git a/app/Views/partials/agent_overview.php b/app/Views/partials/agent_overview.php new file mode 100644 index 0000000..7b6fdb4 --- /dev/null +++ b/app/Views/partials/agent_overview.php @@ -0,0 +1,136 @@ +
+
+
+
+

+

+
+
+
+ + + +
+
+ +
+
+
Choisissez un agent et un mois.
+ +
+
+ + + + + + + + + diff --git a/app/Views/partials/annual_overview.php b/app/Views/partials/annual_overview.php new file mode 100644 index 0000000..a2e7ceb --- /dev/null +++ b/app/Views/partials/annual_overview.php @@ -0,0 +1,101 @@ + +
+
+
+
+

Prévisionnel annuel de l’agent

+

Vue de contrôle de janvier à décembre, inspirée du tableau d’annualisation utilisé par le service.

+
+
+
+ + +
+ + + +
+
+
+
+ + +
diff --git a/app/Views/partials/coverage_alerts.php b/app/Views/partials/coverage_alerts.php new file mode 100644 index 0000000..626f2e9 --- /dev/null +++ b/app/Views/partials/coverage_alerts.php @@ -0,0 +1,73 @@ +
+
+
+
+

Contrôle de couverture des lieux

+

Repérez les plages où le nombre d’agents en « Heure de travail » est inférieur au minimum attendu.

+
+ +
+ +
+ + +
+ +
+
+ Créneaux insuffisamment couverts + 0 + plage(s) à traiter +
+
+ Lieux concernés + 0 + avec un manque +
+
+ Temps non couvert + 0 h 00 + durée cumulée +
+
+ Couvertures non configurées + 0 + lieu(x) +
+
+
+ +
+
+ + + + +
+
diff --git a/app/Views/partials/pending_validation.php b/app/Views/partials/pending_validation.php new file mode 100644 index 0000000..461caa6 --- /dev/null +++ b/app/Views/partials/pending_validation.php @@ -0,0 +1,51 @@ +
+
+
+
+

Plannings en attente de validation

+

Retrouvez tous les brouillons contenant des affectations. Ouvrez un planning pour le contrôler puis le valider directement dans l’écran de saisie.

+
+ +
+ +
+
+ À valider + 0 + planning(s) +
+
+ Volume en attente + 0 h 00 + toutes affectations +
+
+ Heures décomptées + 0 h 00 + impact sur les quotas +
+
+
+ +
+
+ + + +
+
diff --git a/app/Views/partials/planning_editor.php b/app/Views/partials/planning_editor.php new file mode 100644 index 0000000..bb7a583 --- /dev/null +++ b/app/Views/partials/planning_editor.php @@ -0,0 +1,330 @@ +
+ +
+ Vous pouvez saisir et corriger les horaires et motifs de votre lieu. Les modifications sont enregistrées en brouillon ; seul le Service Enfance peut les valider. +
+ +
+

1. Sélection du planning

+
+ + + + + +
+
+ + + +
+
+
+

2. Saisir ou modifier les affectations

+

Chaque plage horaire possède son propre motif et sa propre règle de décompte du quota annuel.

+
+
+
+

Les horaires déjà enregistrés sont rechargés automatiquement. Modifiez-les directement, puis appliquez les horaires avant d’enregistrer.

+
+ +
+ + + + + + + + + + + + +
JourAffectations et plages horaires
Sélectionnez d’abord un lieu d’affectation, un agent et une semaine.
+
+ +
+ +
+

Chaque plage peut avoir un motif différent : par exemple Travail le matin puis Formation l’après-midi. Les heures sont saisies par pas de 15 minutes. Les créneaux déjà affectés à l’agent dans un autre lieu apparaissent dans le planning global et bloquent toute nouvelle saisie qui les chevaucherait.

+
+ +
+
+
+

3. Planning global de l’agent

+

Toutes les affectations de l’agent sur la semaine, quel que soit le lieu d’affectation. Cliquez sur un créneau pour le déplacer ou le dupliquer, ou glissez-le directement vers un autre jour.

+
+
+
+ Total semaine décompté + 0 h 00 +
+ + + + + + +
+
+ +
Aucune affectation pour le moment.
+ +
+ +
+
+
+ + + + + + + +
+
+
+ + + + + + + + + + + diff --git a/app/Views/partials/pta.php b/app/Views/partials/pta.php new file mode 100644 index 0000000..01a8755 --- /dev/null +++ b/app/Views/partials/pta.php @@ -0,0 +1,133 @@ +
+
+
+
+

PTA annuel de l’agent

+

Synthèse de l’année civile, répartition des 1 607 heures proratisées et contrôles métier. Un même agent peut intervenir en périscolaire et en extrascolaire.

+
+
+
+ + +
+ +
+
+
+
+ + +
diff --git a/app/Views/partials/structure_overview.php b/app/Views/partials/structure_overview.php new file mode 100644 index 0000000..ec4b986 --- /dev/null +++ b/app/Views/partials/structure_overview.php @@ -0,0 +1,35 @@ +
+
+
+
+

+

Les agents rattachés par défaut à ce lieu et ceux qui y sont effectivement planifiés sur la semaine. Cette vue est en lecture seule.

+
+
+
+ + +
+ + +
+
+
+ +
+
Choisissez un lieu d’affectation et une semaine.
+ +
+
+ diff --git a/app/Views/partials/teams.php b/app/Views/partials/teams.php new file mode 100644 index 0000000..0a21cee --- /dev/null +++ b/app/Views/partials/teams.php @@ -0,0 +1,54 @@ +
+
+
+
+

Élaborer les équipes

+

Équipes périscolaires, mercredis scolaires et périodes extrascolaires, avec contrôle de l’effectif et de la qualification. Un même agent peut appartenir à plusieurs types d’équipes au cours de l’année.

+
+
+
+
+ + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+
+

Équipes enregistrées

+

Les seuils d’encadrement et de qualification sont paramétrables pour chaque équipe avant validation.

+
+
+
+ + +
+
+
+
Chargez les équipes pour commencer.
+
+ + +
diff --git a/app/Views/partials/vacation_calendar.php b/app/Views/partials/vacation_calendar.php new file mode 100644 index 0000000..cfed2d0 --- /dev/null +++ b/app/Views/partials/vacation_calendar.php @@ -0,0 +1,144 @@ += 8 ? $currentYear : $currentYear - 1; +$defaultSchoolYear = $schoolYearStart . '-' . ($schoolYearStart + 1); +$defaultCalendarYear = $currentYear; +?> +
+
+
+
+

Vacances scolaires

+

Consultez toutes les vacances d’une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.

+
+
+ +
+ Principe : une journée comprise dans une période de vacances enregistrée est considérée comme extrascolaire. En dehors de ces périodes, elle est considérée comme périscolaire. +
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+

Ajouter une période manuellement

+

Utilisez ce formulaire pour corriger le calendrier ou ajouter une période qui n’apparaît pas dans l’API.

+
+
+
+
+ + + + + + +
+
+
+ +
+
+
+ +
+
+
+

Périodes enregistrées

+

Ces périodes sont celles réellement utilisées par PTA pour distinguer périscolaire et extrascolaire.

+
+ +
+
+
+ + + + + + + + + + + + + + +
PériodeDatesAnnée scolaireAcadémie / zoneSourceAction
Chargement des périodes enregistrées…
+
+
+
diff --git a/app/autoload.php b/app/autoload.php new file mode 100644 index 0000000..d57a25e --- /dev/null +++ b/app/autoload.php @@ -0,0 +1,17 @@ +assertCurrent(); + +return $pdo; diff --git a/assets/js/administration.js b/assets/js/administration.js new file mode 100644 index 0000000..b19bad4 --- /dev/null +++ b/assets/js/administration.js @@ -0,0 +1,482 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant administration.js'); + + const { structureSelect } = PTA.elements; + const { + escapeHtml, + readJsonResponse, + apiErrorMessage, + formatDuration, + } = PTA.utils; + + const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire'; + + async function saveAgentStructure(button) { + const row = button.closest('tr[data-agent-id]'); + const select = row?.querySelector('.agent-structure-select'); + const message = document.querySelector('#assignment-message'); + if (!row || !select || !message) return; + + button.disabled = true; + message.className = 'message'; + message.textContent = ''; + + try { + const response = await fetch('api/agent_assignments.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + agent_id: Number(row.dataset.agentId), + structure_id: select.value ? Number(select.value) : null, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier le lieu principal de l’agent.')); + + message.className = 'message message-success'; + message.textContent = data.message; + + if (structureSelect?.value) await PTA.modules.planning?.loadAgents?.(); + if (document.querySelector('#overview-structure')?.value) await PTA.modules.structureOverview?.load?.(); + if (document.querySelector('#overview-agent')?.value) await PTA.modules.agentOverview?.load?.(); + } catch (error) { + message.className = 'message message-error'; + message.textContent = error.message; + } finally { + button.disabled = false; + } + } + + async function saveStructureType(button) { + const row = button.closest('tr[data-structure-id]'); + const select = row?.querySelector('.structure-type-select'); + const message = document.querySelector('#structure-settings-message'); + if (!row || !select || !message) return; + + button.disabled = true; + message.className = 'message'; + message.textContent = ''; + + try { + const response = await fetch('api/update_structure.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + structure_id: Number(row.dataset.structureId), + type_affectation: select.value, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier le type du lieu.')); + + updateStructureLabels(Number(row.dataset.structureId), select.value); + message.className = 'message message-success'; + message.textContent = data.message; + + if (structureSelect?.value) await PTA.modules.planning?.loadAgents?.(); + if (document.querySelector('#overview-structure')?.value === String(row.dataset.structureId)) { + await PTA.modules.structureOverview?.load?.(); + } + if (document.querySelector('#overview-agent')?.value) await PTA.modules.agentOverview?.load?.(); + } catch (error) { + message.className = 'message message-error'; + message.textContent = error.message; + } finally { + button.disabled = false; + } + } + + function addOptionIfMissing(select, value, label, selected = false) { + if (!select) return; + let option = Array.from(select.options).find((item) => item.value === String(value)); + if (!option) { + option = document.createElement('option'); + option.value = String(value); + select.appendChild(option); + } + option.textContent = label; + if (selected) option.selected = true; + } + + function structureOptionLabel(structure) { + return `${structure.nom} — ${typeLabel(structure.type_affectation)}`; + } + + function addStructureEverywhere(structure) { + const id = Number(structure.id_structure); + const label = structureOptionLabel(structure); + + [ + document.querySelector('#structure'), + document.querySelector('#overview-structure'), + document.querySelector('#new-agent-structure'), + document.querySelector('#edit-agent-entry-structure'), + document.querySelector('#edit-agent-profile-structure'), + document.querySelector('#coverage-structure-filter'), + document.querySelector('#coverage-rules-structure'), + ].forEach((select) => addOptionIfMissing(select, id, label)); + + document.querySelectorAll('.agent-structure-select').forEach((select) => { + addOptionIfMissing(select, id, label); + }); + } + + function updateStructureLabels(structureId, type) { + const source = document.querySelector(`#structure-settings-body tr[data-structure-id="${Number(structureId)}"]`); + const structureName = source?.querySelector('td strong')?.textContent?.trim(); + if (!structureName) return; + const label = `${structureName} — ${typeLabel(type)}`; + + document.querySelectorAll('select').forEach((select) => { + const option = Array.from(select.options).find((item) => item.value === String(structureId)); + if (option && ( + select.matches('#structure, #overview-structure, #new-agent-structure, #edit-agent-entry-structure, #edit-agent-profile-structure, #coverage-structure-filter, #coverage-rules-structure, .agent-structure-select') + )) { + option.textContent = label; + } + }); + } + + function structureOptionsHtml(selectedStructureId = null) { + const source = document.querySelector('#new-agent-structure'); + if (!source) return ''; + + const options = ['']; + Array.from(source.options).forEach((option) => { + if (!option.value) return; + const selected = Number(option.value) === Number(selectedStructureId) ? ' selected' : ''; + options.push(``); + }); + return options.join(''); + } + + function addStructureSettingsRow(structure) { + const body = document.querySelector('#structure-settings-body'); + if (!body || body.querySelector(`tr[data-structure-id="${Number(structure.id_structure)}"]`)) return; + + const row = document.createElement('tr'); + row.dataset.structureId = String(Number(structure.id_structure)); + row.innerHTML = ` + ${escapeHtml(structure.nom)} + ${escapeHtml(structure.code)} + + + + + `; + body.appendChild(row); + } + + function qualificationLabel(agent) { + if (!Number(agent.est_diplome)) return 'Non diplômé'; + return agent.diplome_libelle ? `Diplômé — ${agent.diplome_libelle}` : 'Diplômé'; + } + + function formatContractDate(value) { + if (!value) return 'Non renseignée'; + const [year, month, day] = String(value).split('-'); + return year && month && day ? `${day}/${month}/${year}` : value; + } + + function agentRowHtml(agent) { + const structureLabel = agent.structure_nom || agent.structure_principale_nom || 'Sans lieu principal'; + return ` + ${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)} + ${escapeHtml(agent.matricule)} + ${escapeHtml(agent.poste_libelle || 'Non renseigné')} + ${escapeHtml(qualificationLabel(agent))} + ${escapeHtml(agent.telephone || 'Non renseigné')} + ${escapeHtml(formatContractDate(agent.date_debut_contrat))} + ${escapeHtml(structureLabel)} + + `; + } + + function setAgentRowData(row, agent) { + row.dataset.agentId = String(Number(agent.id_agent)); + row.dataset.email = agent.email || ''; + row.dataset.telephone = agent.telephone || ''; + row.dataset.adresse = agent.adresse || ''; + row.dataset.estDiplome = Number(agent.est_diplome) ? '1' : '0'; + row.dataset.diplomeLibelle = agent.diplome_libelle || ''; + row.dataset.dateDebutContrat = agent.date_debut_contrat || ''; + row.dataset.dateFinContrat = agent.date_fin_contrat || ''; + row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : ''; + row.dataset.typeContrat = agent.type_contrat || 'PERMANENT'; + row.dataset.formationRepartitionAnnuelle = Number(agent.formation_repartition_annuelle) ? '1' : '0'; + row.dataset.commentairePta = agent.commentaire_pta || ''; + row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : ''; + } + + function addAgentEverywhere(agent) { + const overviewAgent = document.querySelector('#overview-agent'); + addOptionIfMissing( + overviewAgent, + Number(agent.id_agent), + `${agent.prenom} ${agent.nom} (${agent.matricule})` + ); + + const body = document.querySelector('#agent-assignment-body'); + if (body && !body.querySelector(`tr[data-agent-id="${Number(agent.id_agent)}"]`)) { + const row = document.createElement('tr'); + setAgentRowData(row, agent); + row.innerHTML = agentRowHtml(agent); + body.appendChild(row); + } + } + + function toggleDiplomaField(select, input) { + if (!select || !input) return; + const qualified = select.value === '1'; + input.disabled = !qualified; + if (!qualified) input.value = ''; + } + + function openAgentProfileModal(row) { + const modal = document.querySelector('#agent-profile-modal'); + if (!modal || !row) return; + + document.querySelector('#edit-agent-profile-id').value = row.dataset.agentId || ''; + document.querySelector('#agent-profile-modal-subtitle').textContent = row.querySelector('.agent-row-name')?.textContent?.trim() || ''; + document.querySelector('#edit-agent-profile-email').value = row.dataset.email || ''; + document.querySelector('#edit-agent-profile-phone').value = row.dataset.telephone || ''; + document.querySelector('#edit-agent-profile-address').value = row.dataset.adresse || ''; + document.querySelector('#edit-agent-profile-qualified').value = row.dataset.estDiplome === '1' ? '1' : '0'; + document.querySelector('#edit-agent-profile-diploma').value = row.dataset.diplomeLibelle || ''; + document.querySelector('#edit-agent-profile-contract-start').value = row.dataset.dateDebutContrat || ''; + document.querySelector('#edit-agent-profile-contract-end').value = row.dataset.dateFinContrat || ''; + document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || ''; + document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT'; + document.querySelector('#edit-agent-profile-training-planned').checked = row.dataset.formationRepartitionAnnuelle === '1'; + document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || ''; + document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || ''; + toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma')); + + const message = document.querySelector('#agent-profile-message'); + if (message) { + message.className = 'message'; + message.textContent = ''; + } + modal.hidden = false; + } + + function closeAgentProfileModal() { + const modal = document.querySelector('#agent-profile-modal'); + if (modal) modal.hidden = true; + } + + async function saveAgentProfile(event) { + event.preventDefault(); + const form = event.currentTarget; + const message = document.querySelector('#agent-profile-message'); + const submit = form.querySelector('button[type="submit"]'); + const agentId = Number(document.querySelector('#edit-agent-profile-id').value); + if (!agentId || !message || !submit) return; + + submit.disabled = true; + message.className = 'message'; + message.textContent = ''; + + try { + const structureValue = document.querySelector('#edit-agent-profile-structure').value; + const response = await fetch('api/update_agent.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + agent_id: agentId, + email: document.querySelector('#edit-agent-profile-email').value, + telephone: document.querySelector('#edit-agent-profile-phone').value, + adresse: document.querySelector('#edit-agent-profile-address').value, + est_diplome: document.querySelector('#edit-agent-profile-qualified').value === '1', + diplome_libelle: document.querySelector('#edit-agent-profile-diploma').value, + date_debut_contrat: document.querySelector('#edit-agent-profile-contract-start').value, + date_fin_contrat: document.querySelector('#edit-agent-profile-contract-end').value, + id_poste: Number(document.querySelector('#edit-agent-profile-post').value), + type_contrat: document.querySelector('#edit-agent-profile-contract-type').value, + formation_repartition_annuelle: document.querySelector('#edit-agent-profile-training-planned').checked, + commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value, + structure_id: structureValue ? Number(structureValue) : null, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier les informations de l’agent.')); + + const row = document.querySelector(`#agent-assignment-body tr[data-agent-id="${agentId}"]`); + if (row) { + setAgentRowData(row, data.agent); + row.innerHTML = agentRowHtml({ + ...data.agent, + structure_nom: data.agent.structure_principale_nom, + }); + } + + message.className = 'message message-success'; + message.textContent = data.message; + + if (structureSelect?.value) await PTA.modules.planning?.loadAgents?.(); + if (document.querySelector('#overview-structure')?.value) await PTA.modules.structureOverview?.load?.(); + if (document.querySelector('#overview-agent')?.value === String(agentId)) await PTA.modules.agentOverview?.load?.(); + + setTimeout(closeAgentProfileModal, 500); + } catch (error) { + message.className = 'message message-error'; + message.textContent = error.message; + } finally { + submit.disabled = false; + } + } + + async function createStructure(event) { + event.preventDefault(); + const form = event.currentTarget; + const message = document.querySelector('#create-structure-message'); + const submit = form.querySelector('button[type="submit"]'); + if (!message || !submit) return; + + message.className = 'message'; + message.textContent = ''; + submit.disabled = true; + + try { + const response = await fetch('api/create_structure.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + code: document.querySelector('#new-structure-code').value, + nom: document.querySelector('#new-structure-name').value, + adresse: document.querySelector('#new-structure-address').value, + type_affectation: document.querySelector('#new-structure-type').value, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de créer le lieu d’affectation.')); + + addStructureEverywhere(data.structure); + addStructureSettingsRow(data.structure); + form.reset(); + document.querySelector('#new-structure-type').value = 'PERISCOLAIRE'; + message.className = 'message message-success'; + message.textContent = data.message; + PTA.modules.coverage?.refresh?.({ silent: true }); + } catch (error) { + message.className = 'message message-error'; + message.textContent = error.message; + } finally { + submit.disabled = false; + } + } + + async function createAgent(event) { + event.preventDefault(); + const form = event.currentTarget; + const message = document.querySelector('#create-agent-message'); + const submit = form.querySelector('button[type="submit"]'); + if (!message || !submit) return; + + message.className = 'message'; + message.textContent = ''; + submit.disabled = true; + + try { + const response = await fetch('api/create_agent.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + matricule: document.querySelector('#new-agent-matricule').value, + nom: document.querySelector('#new-agent-name').value, + prenom: document.querySelector('#new-agent-firstname').value, + email: document.querySelector('#new-agent-email').value, + telephone: document.querySelector('#new-agent-phone').value, + adresse: document.querySelector('#new-agent-address').value, + est_diplome: document.querySelector('#new-agent-qualified').value === '1', + diplome_libelle: document.querySelector('#new-agent-diploma').value, + structure_id: Number(document.querySelector('#new-agent-structure').value), + quotite_travail: Number(document.querySelector('#new-agent-rate').value), + date_debut_contrat: document.querySelector('#new-agent-contract-start').value, + date_fin_contrat: document.querySelector('#new-agent-contract-end').value, + id_poste: Number(document.querySelector('#new-agent-post').value), + type_contrat: document.querySelector('#new-agent-contract-type').value, + formation_repartition_annuelle: document.querySelector('#new-agent-training-planned').checked, + commentaire_pta: document.querySelector('#new-agent-pta-comment').value, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de créer l’agent.')); + + addAgentEverywhere(data.agent); + const createdStructureId = String(data.agent.id_structure); + form.reset(); + document.querySelector('#new-agent-rate').value = '100'; + document.querySelector('#new-agent-contract-type').value = 'PERMANENT'; + document.querySelector('#new-agent-training-planned').checked = false; + document.querySelector('#new-agent-qualified').value = '0'; + toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma')); + + message.className = 'message message-success'; + message.textContent = `${data.message} Quota annuel : ${formatDuration(Number(data.agent.quota_cible_minutes))}, suivi du 1er janvier au 31 décembre.`; + + if (structureSelect?.value === createdStructureId) { + await PTA.modules.planning?.loadAgents?.(); + } + if (document.querySelector('#overview-structure')?.value === createdStructureId) { + await PTA.modules.structureOverview?.load?.(); + } + } catch (error) { + message.className = 'message message-error'; + message.textContent = error.message; + } finally { + submit.disabled = false; + } + } + + function init() { + document.querySelector('#agent-assignment-body')?.addEventListener('click', (event) => { + const button = event.target.closest('.edit-agent-profile'); + if (button) openAgentProfileModal(button.closest('tr[data-agent-id]')); + }); + + document.querySelector('#structure-settings-body')?.addEventListener('click', (event) => { + const button = event.target.closest('.save-structure-type'); + if (button) saveStructureType(button); + }); + + document.querySelector('#new-agent-qualified')?.addEventListener('change', (event) => { + toggleDiplomaField(event.currentTarget, document.querySelector('#new-agent-diploma')); + }); + document.querySelector('#edit-agent-profile-qualified')?.addEventListener('change', (event) => { + toggleDiplomaField(event.currentTarget, document.querySelector('#edit-agent-profile-diploma')); + }); + + document.querySelector('#close-agent-profile-modal')?.addEventListener('click', closeAgentProfileModal); + document.querySelector('#cancel-agent-profile')?.addEventListener('click', closeAgentProfileModal); + document.querySelector('#agent-profile-modal')?.addEventListener('click', (event) => { + if (event.target.id === 'agent-profile-modal') closeAgentProfileModal(); + }); + document.querySelector('#agent-profile-form')?.addEventListener('submit', saveAgentProfile); + + document.querySelector('#create-structure-form')?.addEventListener('submit', createStructure); + document.querySelector('#create-agent-form')?.addEventListener('submit', createAgent); + + toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma')); + } + + PTA.modules.administration = { + init, + saveAgentStructure, + saveStructureType, + addStructureEverywhere, + addAgentEverywhere, + openAgentProfileModal, + }; +})(); diff --git a/assets/js/agent-overview.js b/assets/js/agent-overview.js new file mode 100644 index 0000000..b8c1b76 --- /dev/null +++ b/assets/js/agent-overview.js @@ -0,0 +1,535 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant agent-overview.js'); + + const permissions = window.PTA_CONFIG?.permissions || {}; + const accessProfile = window.PTA_CONFIG?.access || {}; + const canEditDraft = Boolean(permissions.can_edit_draft); + const canEditCrossStructure = Boolean(permissions.can_edit_cross_structure); + const canCopyFullWeek = Boolean(permissions.can_copy_full_week); + + const { shared } = PTA; + const { + escapeHtml, + readJsonResponse, + apiErrorMessage, + formatDate, + timeToMinutes, + formatDuration, + } = PTA.utils; + + let draggedEntry = null; + let suppressClickUntil = 0; + let activeSourceWeek = ''; + + const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire'; + + function setMessage(element, text = '', type = 'info') { + if (!element) return; + element.className = text ? `message message-${type}` : 'message'; + element.textContent = text; + } + + function canEditEntry(entry) { + if (!canEditDraft) return false; + if (canEditCrossStructure) return true; + return Number(entry.id_structure) === Number(accessProfile.structure_id || 0); + } + + function entryBadge(entry) { + const editable = canEditEntry(entry); + const interactionAttributes = editable + ? `draggable="true" tabindex="0" role="button" data-editable="1" title="Glissez vers un autre jour pour déplacer. Ctrl/⌘ + glisser pour dupliquer."` + : 'data-editable="0"'; + return ` +
+ ${editable ? '' : ''} +
+ ${escapeHtml(entry.heure_debut)}–${escapeHtml(entry.heure_fin)} + ${escapeHtml(entry.motif_libelle)} + 📍 ${escapeHtml(entry.structure_nom)} · ${typeLabel(entry.structure_type_affectation)} + ${entry.statut === 'BROUILLON' ? 'Brouillon' : ''} +
+
+ ${editable + ? `` + : 'Lecture seule'} +
+
+ `; + } + + function countedMinutes(entries) { + return entries.reduce((sum, entry) => { + if (!Number(entry.compte_dans_quota)) return sum; + return sum + (timeToMinutes(entry.heure_fin) - timeToMinutes(entry.heure_debut)); + }, 0); + } + + function render(data) { + const container = document.querySelector('#agent-overview-content'); + const empty = document.querySelector('#agent-overview-empty'); + if (!container || !empty) return; + + shared.lastAgentOverviewData = data; + empty.hidden = true; + container.hidden = false; + + const monthMinutes = countedMinutes(data.entries || []); + const weekBlocks = (data.weeks || []).map((week) => { + const days = week.days.map((day) => { + const entries = (data.entries || []).filter((entry) => entry.date_jour === day.date); + return ` +
+
+ ${escapeHtml(day.label)} + ${escapeHtml(day.display)} +
+
+ ${entries.length ? entries.map(entryBadge).join('') : '

Aucune affectation

'} +
+
+ `; + }).join(''); + + return ` +
+
+
+ Semaine ${Number(week.number)} + du ${formatDate(week.date_debut)} au ${formatDate(week.date_fin)} +
+ ${canCopyFullWeek ? `` : ''} +
+
${days}
+
+ `; + }).join(''); + + container.innerHTML = ` +
+
+

${escapeHtml(data.agent.prenom)} ${escapeHtml(data.agent.nom)}

+

${escapeHtml(data.agent.matricule)}${data.agent.structure_principale_nom ? ` · lieu principal : ${escapeHtml(data.agent.structure_principale_nom)} (${typeLabel(data.agent.structure_principale_type_affectation)})` : ''} · ${escapeHtml(data.month.label)}

+

${Number(data.agent.est_diplome) ? `🎓 Diplômé${data.agent.diplome_libelle ? ` — ${escapeHtml(data.agent.diplome_libelle)}` : ''}` : 'Non diplômé'}${data.agent.telephone ? ` · 📞 ${escapeHtml(data.agent.telephone)}` : ''}${data.agent.adresse ? ` · 📍 ${escapeHtml(data.agent.adresse)}` : ''}${data.agent.date_debut_contrat ? ` · Contrat depuis le ${escapeHtml(formatDate(data.agent.date_debut_contrat))}` : ' · ⚠ Date de début de contrat non renseignée'}

+
+
+ + +
+
+
+
Quotité${Number(data.quota.quotite_travail).toLocaleString('fr-FR')} %
+
Quota de la période${escapeHtml(data.quota.quota_cible.libelle)}${escapeHtml(data.quota.periode_libelle || '')}
+
Décompté sur le mois affiché${escapeHtml(formatDuration(monthMinutes))}Travail + motifs comptant dans le quota
+
Reste à affecter${escapeHtml(data.quota.restantes.libelle)}Calcul sur l’année civile du 1er janvier au 31 décembre.
+
+

${canEditDraft + ? (canCopyFullWeek + ? 'Glissez un créneau modifiable vers un autre jour pour le déplacer. Maintenez Ctrl (ou sur Mac) pour le dupliquer. La duplication complète d’une semaine est réservée au Service Enfance.' + : 'Vous pouvez modifier les créneaux de votre lieu. Les créneaux saisis sur un autre lieu restent en lecture seule et toute modification est enregistrée en brouillon.') + : 'Cette vue est en lecture seule. Vous pouvez consulter votre planning et télécharger les documents disponibles.'}

+
${weekBlocks}
+ `; + + bindMonthInteractions(); + } + + function findEntry(entryId) { + return shared.lastAgentOverviewData?.entries?.find((item) => Number(item.id_creneau) === Number(entryId)) || null; + } + + async function persistEntryMove(entryId, targetDate, duplicate) { + const response = await fetch('api/move_or_duplicate_entry.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + entry_id: Number(entryId), + target_date: targetDate, + action: duplicate ? 'duplicate' : 'move', + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de déplacer ce créneau.')); + return data; + } + + function clearDropTargets() { + document.querySelectorAll('#agent-overview-content .agent-month-day-card').forEach((card) => { + card.classList.remove('is-drop-target', 'is-copy-target'); + }); + } + + function bindMonthInteractions() { + const container = document.querySelector('#agent-overview-content'); + if (!container) return; + + container.querySelectorAll('.agent-month-entry[data-editable="1"]').forEach((item) => { + item.addEventListener('dragstart', (event) => { + draggedEntry = findEntry(Number(item.dataset.entryId)); + item.classList.add('is-dragging'); + event.dataTransfer.effectAllowed = 'copyMove'; + event.dataTransfer.setData('text/plain', item.dataset.entryId || ''); + }); + item.addEventListener('dragend', () => { + item.classList.remove('is-dragging'); + draggedEntry = null; + suppressClickUntil = Date.now() + 250; + clearDropTargets(); + }); + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openModal(Number(item.dataset.entryId)); + } + }); + item.addEventListener('click', (event) => { + if (Date.now() < suppressClickUntil || event.target.closest('button')) return; + openModal(Number(item.dataset.entryId)); + }); + }); + + if (!canEditDraft) return; + container.querySelectorAll('.agent-month-day-card').forEach((card) => { + card.addEventListener('dragover', (event) => { + if (!draggedEntry) return; + event.preventDefault(); + const duplicate = event.ctrlKey || event.metaKey; + event.dataTransfer.dropEffect = duplicate ? 'copy' : 'move'; + clearDropTargets(); + card.classList.add('is-drop-target'); + if (duplicate) card.classList.add('is-copy-target'); + }); + card.addEventListener('dragleave', (event) => { + if (!card.contains(event.relatedTarget)) card.classList.remove('is-drop-target', 'is-copy-target'); + }); + card.addEventListener('drop', async (event) => { + if (!draggedEntry) return; + event.preventDefault(); + const entry = draggedEntry; + const targetDate = card.dataset.date || ''; + const duplicate = event.ctrlKey || event.metaKey; + clearDropTargets(); + try { + const result = await persistEntryMove(entry.id_creneau, targetDate, duplicate); + await load(); + setMessage(document.querySelector('#agent-overview-message'), result.message || (duplicate ? 'Créneau dupliqué.' : 'Créneau déplacé.'), 'success'); + } catch (error) { + setMessage(document.querySelector('#agent-overview-message'), error.message, 'error'); + } + }); + }); + } + + function openHoursContractPdf() { + const data = shared.lastAgentOverviewData; + const agentId = data?.agent?.id_agent || document.querySelector('#overview-agent')?.value; + const date = data?.month?.date_debut; + if (!agentId || !date) return; + const params = new URLSearchParams({ agent_id: String(agentId), date: String(date) }); + window.open(`api/agent_hours_contract_pdf.php?${params.toString()}`, '_blank', 'noopener'); + } + + function openTimeSummaryPdf() { + const data = shared.lastAgentOverviewData; + const agentId = data?.agent?.id_agent || document.querySelector('#overview-agent')?.value; + const date = data?.month?.date_debut; + if (!agentId || !date) return; + const params = new URLSearchParams({ agent_id: String(agentId), date: String(date) }); + window.open(`api/agent_time_summary_pdf.php?${params.toString()}`, '_blank', 'noopener'); + } + + function closeModal() { + const modal = document.querySelector('#agent-entry-modal'); + const form = document.querySelector('#agent-entry-edit-form'); + const message = document.querySelector('#agent-entry-edit-message'); + if (!modal) return; + modal.hidden = true; + document.body.classList.remove('modal-open'); + form?.reset(); + setMessage(message); + } + + function openModal(entryId) { + const entry = findEntry(entryId); + if (!entry || !canEditEntry(entry)) return; + const modal = document.querySelector('#agent-entry-modal'); + if (!entry || !modal) return; + + document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau)); + document.querySelector('#edit-agent-entry-date').value = entry.date_jour; + document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure)); + document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif)); + document.querySelector('#edit-agent-entry-start').value = entry.heure_debut; + document.querySelector('#edit-agent-entry-end').value = entry.heure_fin; + setMessage(document.querySelector('#agent-entry-edit-message')); + modal.hidden = false; + document.body.classList.add('modal-open'); + document.querySelector('#edit-agent-entry-motif')?.focus(); + } + + async function deleteEntry(entryId) { + const entry = findEntry(entryId); + if (!entry || !canEditEntry(entry)) return; + if (!entry) return; + + const details = `${entry.motif_libelle} · ${entry.heure_debut}–${entry.heure_fin} · ${entry.structure_nom}`; + if (!window.confirm(`Supprimer définitivement ce créneau ?\n\n${details}\n${formatDate(entry.date_jour)}`)) return; + + const button = document.querySelector('#delete-agent-entry-edit'); + const message = document.querySelector('#agent-entry-edit-message'); + if (button) button.disabled = true; + setMessage(message, 'Suppression du créneau en cours…', 'info'); + + try { + const response = await fetch('api/delete_agent_entry.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + entry_id: Number(entryId), + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de supprimer ce créneau.')); + + await load(); + closeModal(); + setMessage(document.querySelector('#agent-overview-message'), data.message || 'Créneau supprimé.', 'success'); + + // La suppression peut modifier la file de validation et créer un trou de couverture. + PTA.modules.pendingValidation?.refresh?.({ silent: true }); + PTA.modules.coverage?.refresh?.({ silent: true }); + } catch (error) { + setMessage(message, error.message, 'error'); + } finally { + if (button) button.disabled = false; + } + } + + async function saveEntry(event) { + event.preventDefault(); + if (!canEditDraft) return; + const submit = document.querySelector('#save-agent-entry-edit'); + const message = document.querySelector('#agent-entry-edit-message'); + if (!submit || !message) return; + + submit.disabled = true; + setMessage(message); + + try { + const response = await fetch('api/update_agent_entry.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + entry_id: Number(document.querySelector('#edit-agent-entry-id').value), + structure_id: Number(document.querySelector('#edit-agent-entry-structure').value), + motif_id: Number(document.querySelector('#edit-agent-entry-motif').value), + date: document.querySelector('#edit-agent-entry-date').value, + start: document.querySelector('#edit-agent-entry-start').value, + end: document.querySelector('#edit-agent-entry-end').value, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier ce créneau.')); + + setMessage(message, data.message || 'Créneau modifié.', 'success'); + await load(); + setTimeout(closeModal, 450); + } catch (error) { + setMessage(message, error.message, 'error'); + } finally { + submit.disabled = false; + } + } + + function isoWeekValueFromDate(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; + } + + function weekMonday(weekValue) { + const match = /^(\d{4})-W(\d{2})$/.exec(weekValue); + if (!match) return null; + const year = Number(match[1]); + const week = Number(match[2]); + const jan4 = new Date(year, 0, 4); + const day = jan4.getDay() || 7; + const monday = new Date(jan4); + monday.setDate(jan4.getDate() - day + 1 + ((week - 1) * 7)); + return monday; + } + + function openWeekCopyModal(sourceWeek) { + if (!canCopyFullWeek) return; + const modal = document.querySelector('#agent-week-copy-modal'); + const sourceInput = document.querySelector('#agent-week-copy-source'); + const targetInput = document.querySelector('#agent-week-copy-target'); + const summary = document.querySelector('#agent-week-copy-summary'); + if (!modal || !sourceInput || !targetInput) return; + + activeSourceWeek = sourceWeek; + sourceInput.value = sourceWeek; + const monday = weekMonday(sourceWeek); + if (monday) { + monday.setDate(monday.getDate() + 7); + targetInput.value = isoWeekValueFromDate(monday); + } + if (summary) summary.textContent = `La semaine ${sourceWeek.replace('-W', ' / S')} sera recopiée avec ses lieux, motifs et horaires.`; + setMessage(document.querySelector('#agent-week-copy-message')); + modal.hidden = false; + document.body.classList.add('modal-open'); + targetInput.focus(); + } + + function closeWeekCopyModal() { + const modal = document.querySelector('#agent-week-copy-modal'); + if (!modal) return; + modal.hidden = true; + document.body.classList.remove('modal-open'); + activeSourceWeek = ''; + document.querySelector('#agent-week-copy-form')?.reset(); + setMessage(document.querySelector('#agent-week-copy-message')); + } + + async function copyWeek(event) { + event.preventDefault(); + if (!canCopyFullWeek) return; + const agentId = Number(document.querySelector('#overview-agent')?.value || 0); + const sourceWeek = document.querySelector('#agent-week-copy-source')?.value || activeSourceWeek; + const targetWeek = document.querySelector('#agent-week-copy-target')?.value || ''; + const mode = document.querySelector('input[name="agent-week-copy-mode"]:checked')?.value || 'merge'; + const message = document.querySelector('#agent-week-copy-message'); + const submit = document.querySelector('#confirm-agent-week-copy'); + + if (!agentId || !sourceWeek || !targetWeek) { + setMessage(message, 'Sélectionnez une semaine cible.', 'warning'); + return; + } + if (sourceWeek === targetWeek) { + setMessage(message, 'La semaine cible doit être différente de la semaine source.', 'warning'); + return; + } + if (mode === 'replace' && !window.confirm('Les créneaux existants de la semaine cible seront supprimés avant la copie. Continuer ?')) return; + + if (submit) submit.disabled = true; + setMessage(message, 'Duplication de la semaine en cours...', 'info'); + try { + const response = await fetch('api/copy_agent_week.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + agent_id: agentId, + source_week: sourceWeek, + target_week: targetWeek, + mode, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de dupliquer cette semaine.')); + setMessage(message, data.message || 'Semaine dupliquée.', 'success'); + await load(); + setMessage(document.querySelector('#agent-overview-message'), data.message || 'Semaine dupliquée.', 'success'); + setTimeout(closeWeekCopyModal, 500); + } catch (error) { + setMessage(message, error.message, 'error'); + } finally { + if (submit) submit.disabled = false; + } + } + + async function load() { + const agentId = document.querySelector('#overview-agent')?.value; + const month = document.querySelector('#agent-overview-month')?.value; + const container = document.querySelector('#agent-overview-content'); + const empty = document.querySelector('#agent-overview-empty'); + if (!container || !empty) return; + + if (!agentId || !month) { + empty.hidden = false; + empty.textContent = 'Choisissez un agent et un mois.'; + container.hidden = true; + return; + } + + setMessage(document.querySelector('#agent-overview-message')); + empty.hidden = false; + empty.textContent = 'Chargement du planning mensuel de l’agent…'; + container.hidden = true; + + try { + const params = new URLSearchParams({ agent_id: agentId, month }); + const response = await fetch(`api/agent_month_overview.php?${params.toString()}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue mensuelle de l’agent.')); + render(data); + } catch (error) { + empty.hidden = false; + empty.textContent = error.message; + } + } + + function init() { + document.querySelector('#load-agent-overview')?.addEventListener('click', load); + document.querySelector('#overview-agent')?.addEventListener('change', load); + document.querySelector('#agent-overview-month')?.addEventListener('change', load); + + document.querySelector('#agent-overview-content')?.addEventListener('click', (event) => { + if (event.target.closest('.agent-hours-contract-pdf')) { + openHoursContractPdf(); + return; + } + if (event.target.closest('.agent-time-summary-pdf')) { + openTimeSummaryPdf(); + return; + } + const copyButton = event.target.closest('.agent-copy-week'); + if (copyButton && canCopyFullWeek) { + openWeekCopyModal(copyButton.dataset.sourceWeek || ''); + return; + } + const editButton = event.target.closest('.overview-entry-edit'); + if (editButton && canEditDraft) openModal(Number(editButton.dataset.entryId)); + }); + + if (canEditDraft) { + document.querySelector('#agent-entry-edit-form')?.addEventListener('submit', saveEntry); + document.querySelector('#delete-agent-entry-edit')?.addEventListener('click', () => { + const entryId = Number(document.querySelector('#edit-agent-entry-id')?.value || 0); + if (entryId) deleteEntry(entryId); + }); + document.querySelector('#close-agent-entry-modal')?.addEventListener('click', closeModal); + document.querySelector('#cancel-agent-entry-edit')?.addEventListener('click', closeModal); + document.querySelector('#agent-entry-modal')?.addEventListener('click', (event) => { + if (event.target.id === 'agent-entry-modal') closeModal(); + }); + } + + if (canCopyFullWeek) { + document.querySelector('#agent-week-copy-form')?.addEventListener('submit', copyWeek); + document.querySelector('#close-agent-week-copy-modal')?.addEventListener('click', closeWeekCopyModal); + document.querySelector('#cancel-agent-week-copy')?.addEventListener('click', closeWeekCopyModal); + document.querySelector('#agent-week-copy-modal')?.addEventListener('click', (event) => { + if (event.target.id === 'agent-week-copy-modal') closeWeekCopyModal(); + }); + } + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + if (!document.querySelector('#agent-entry-modal')?.hidden) closeModal(); + if (!document.querySelector('#agent-week-copy-modal')?.hidden) closeWeekCopyModal(); + }); + } + + PTA.modules.agentOverview = { init, load }; +})(); diff --git a/assets/js/annual-overview.js b/assets/js/annual-overview.js new file mode 100644 index 0000000..4920079 --- /dev/null +++ b/assets/js/annual-overview.js @@ -0,0 +1,306 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) return; + const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils; + const el = (id) => document.getElementById(id); + const escapeAttr = (value) => String(value ?? '') + .replaceAll('&', '&') + .replaceAll('\"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); + let current = null; + + function show(text, type = 'info') { + const box = el('annual-message'); + if (!box) return; + box.className = `message message-${type}`; + box.textContent = text; + } + + function formatGenerated(value) { + const date = new Date(String(value).replace(' ', 'T')); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' }); + } + + function formatContract(value) { + if (value === 'PERMANENT') return 'Permanent'; + if (value === 'TEMPORAIRE') return 'Temporaire'; + return value || 'Non renseigné'; + } + + function buildPlanningUrl(day) { + const agentId = Number(current?.agent?.id_agent || 0); + const structureId = Number(day.structure_id || current?.agent?.id_structure || 0); + const week = `${Number(day.week_year)}-W${String(day.week).padStart(2, '0')}`; + const params = new URLSearchParams({ agent_id: String(agentId), week, focus: 'annual' }); + if (structureId) params.set('structure_id', String(structureId)); + params.set('notice', `Ouverture depuis la vue annuelle — ${formatDate(day.date)}.`); + return `planning.php?${params.toString()}`; + } + + function cellHtml(cell, day) { + const content = cell.display ? escapeHtml(cell.display) : ' '; + const title = cell.title ? ` title="${escapeAttr(cell.title)}"` : ''; + const draftClass = day.has_draft && cell.minutes > 0 ? ' annual-cell-draft' : ''; + return `${content}`; + } + + function monthHtml(month) { + const rows = month.days.map((day) => { + const classes = [ + day.is_weekend ? 'annual-weekend' : '', + day.is_vacation ? 'annual-vacation-row' : '', + day.has_entries ? 'annual-has-entry' : '', + ].filter(Boolean).join(' '); + const vacationTitle = day.vacation_label ? ` title="${escapeAttr(day.vacation_label)}"` : ''; + const weekText = day.show_week ? String(day.week).padStart(2, '0') : ''; + return ` + + ${escapeHtml(day.weekday_short)}${day.day} + ${weekText} + ${cellHtml(day.cells.AP, day)} + ${cellHtml(day.cells.PP, day)} + ${cellHtml(day.cells.AE, day)} + ${cellHtml(day.cells.PE, day)} + `; + }).join(''); + + return ` +
+ + + + + + ${rows} + + + +
${escapeHtml(month.name)}
JourSAPPPAEPE
Total${escapeHtml(month.totals.AP.compact || '0h')}${escapeHtml(month.totals.PP.compact || '0h')}${escapeHtml(month.totals.AE.compact || '0h')}${escapeHtml(month.totals.PE.compact || '0h')}
+
`; + } + + function quotaPeriodsHtml(periods) { + return periods.map((period) => ` +
+ ${escapeHtml(formatDate(period.date_debut_periode))} → ${escapeHtml(formatDate(period.date_fin_periode))} + ${escapeHtml(period.affectees?.libelle || '0 h 00')} affectées / ${escapeHtml(period.quota_cible?.libelle || '0 h 00')} + Reste : ${escapeHtml(period.restantes?.libelle || '0 h 00')} +
`).join(''); + } + + function render(data) { + current = data; + const agent = data.agent || {}; + const periods = data.quota_periods || []; + const referencePeriod = periods[0] || {}; + + el('annual-content').hidden = false; + el('annual-title').textContent = `ANNUALISATION ${data.year}`; + el('annual-updated').textContent = `Mise à jour : ${formatGenerated(data.generated_at)}`; + el('annual-agent-name').textContent = `${agent.prenom || ''} ${agent.nom || ''}`.trim() || '—'; + el('annual-agent-number').textContent = agent.matricule || '—'; + el('annual-rate').textContent = referencePeriod.quotite_travail !== undefined ? `${Number(referencePeriod.quotite_travail).toLocaleString('fr-FR')} %` : '—'; + el('annual-target').textContent = referencePeriod.quota_cible?.libelle || '—'; + el('annual-contract').textContent = formatContract(agent.type_contrat); + el('annual-location').textContent = agent.structure_principale_nom || 'Sans lieu principal'; + el('annual-post').textContent = agent.poste_libelle || 'Poste non renseigné'; + el('annual-remaining').textContent = referencePeriod.restantes?.libelle || '—'; + el('annual-quota-periods').innerHTML = quotaPeriodsHtml(periods); + el('annual-calendar-total').textContent = data.calendar_totals?.duration || '0 h 00'; + el('annual-validated-total').textContent = data.calendar_totals?.validated || '0 h 00'; + el('annual-draft-total').textContent = data.calendar_totals?.draft || '0 h 00'; + el('annual-year-label').textContent = `01/01/${data.year} → 31/12/${data.year}`; + el('annual-months').innerHTML = (data.months || []).map(monthHtml).join(''); + el('annual-legend').innerHTML = (data.legend || []).map((item) => ` + ${escapeHtml(item.code)} ${escapeHtml(item.label)} + `).join(''); + + el('annual-open-pta').href = `pta.php?agent_id=${Number(agent.id_agent)}&date=${data.year}-07-01`; + el('annual-open-month').href = `agents.php?agent_id=${Number(agent.id_agent)}&month=${data.year}-01`; + el('annual-print').disabled = false; + el('annual-fullscreen').disabled = false; + if (el('annual-validate-year')) el('annual-validate-year').disabled = false; + } + + async function load(options = {}) { + const agentId = Number(el('annual-agent')?.value || 0); + const year = Number(el('annual-year')?.value || 0); + if (!agentId || year < 2000 || year > 2100) { + show('Choisissez un agent et une année valide.', 'error'); + return; + } + + if (options.clearValidation !== false && el('annual-validation-result')) { + el('annual-validation-result').innerHTML = ''; + } + show('Construction de la vue annuelle…'); + el('annual-content').hidden = true; + try { + const response = await fetch(`api/annual_overview.php?agent_id=${agentId}&year=${year}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue annuelle.')); + render(data); + show('Vue annuelle actualisée.', 'success'); + } catch (error) { + show(error.message, 'error'); + } + } + + function validationPlanningUrl(planning) { + const agentId = Number(current?.agent?.id_agent || el('annual-agent')?.value || 0); + const week = `${Number(planning.annee)}-W${String(planning.numero_semaine).padStart(2, '0')}`; + const params = new URLSearchParams({ + agent_id: String(agentId), + structure_id: String(Number(planning.id_structure)), + week, + focus: 'annual-validation', + notice: `Correction demandée depuis la vue annuelle — semaine ${String(planning.numero_semaine).padStart(2, '0')}.`, + }); + return `planning.php?${params.toString()}`; + } + + function renderValidationIssues(data) { + const container = el('annual-validation-result'); + if (!container) return; + const invalid = (data.plannings || []).filter((planning) => planning.has_errors); + container.innerHTML = ` +
+ Aucune semaine n’a été validée.
+ ${escapeHtml(data.error || 'Des erreurs doivent être corrigées avant la validation annuelle.')} +
${invalid.length} planning(s) à corriger.
+
+
+ ${invalid.map((planning) => { + const errors = (planning.controls || []).filter((control) => control.niveau === 'ERREUR'); + return ` +
+
+

Semaine ${String(planning.numero_semaine).padStart(2, '0')} — ${escapeHtml(planning.structure_nom || 'Lieu non renseigné')}

+ Corriger cette semaine +
+
${escapeHtml(formatDate(planning.date_debut))} → ${escapeHtml(formatDate(planning.date_fin))}
+
    ${errors.map((control) => `
  • ${escapeHtml(control.message)}
  • `).join('')}
+
`; + }).join('')} +
`; + } + + function renderValidationWarning(data) { + const container = el('annual-validation-result'); + if (!container) return; + container.innerHTML = ` +
+ Avertissement avant validation.
+ ${escapeHtml(data.message || 'Une confirmation est nécessaire.')} +
`; + } + + function renderValidationSuccess(data) { + const container = el('annual-validation-result'); + if (!container) return; + container.innerHTML = ` +
+ Validation terminée.
+ ${escapeHtml(data.message || 'Les plannings ont été validés.')} +
`; + } + + async function validateWholeYear(forceQuota = false) { + const agentId = Number(el('annual-agent')?.value || 0); + const year = Number(el('annual-year')?.value || 0); + if (!agentId || !year || !current || Number(current.agent?.id_agent) !== agentId || Number(current.year) !== year) { + show('Affichez d’abord la vue annuelle de l’agent à contrôler.', 'error'); + return; + } + + const button = el('annual-validate-year'); + if (button) button.disabled = true; + const container = el('annual-validation-result'); + if (container) { + container.innerHTML = '
Contrôle de toutes les semaines en brouillon…
'; + } + + try { + const response = await fetch('api/annual_validate.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: PTA.config.csrfToken, + agent_id: agentId, + year, + force_quota: Boolean(forceQuota), + }), + }); + const data = await readJsonResponse(response); + + if (response.status === 422 && data.status === 'errors') { + renderValidationIssues(data); + show('Des semaines doivent être corrigées avant la validation annuelle.', 'error'); + return; + } + + if (response.status === 409 && data.status === 'warning') { + renderValidationWarning(data); + const confirmed = window.confirm(`${data.message || 'Le quota annuel présente un avertissement.'}\n\nValider malgré cet avertissement ?`); + if (confirmed) await validateWholeYear(true); + return; + } + + if (!response.ok) { + throw new Error(apiErrorMessage(data, 'Impossible de valider l’année.')); + } + + renderValidationSuccess(data); + show(data.message || 'Validation annuelle terminée.', 'success'); + await load({ clearValidation: false }); + } catch (error) { + if (container) { + container.innerHTML = `
${escapeHtml(error.message)}
`; + } + show(error.message, 'error'); + } finally { + if (button) button.disabled = false; + } + } + + function handleCalendarClick(event) { + const row = event.target.closest('tr[data-url]'); + if (!row) return; + window.location.href = row.dataset.url; + } + + async function toggleFullscreen() { + const target = el('annual-content'); + if (!target) return; + try { + if (!document.fullscreenElement) { + await target.requestFullscreen?.(); + } else { + await document.exitFullscreen?.(); + } + } catch (error) { + show('Le mode plein écran n’est pas disponible dans ce navigateur.', 'error'); + } + } + + function init() { + el('load-annual')?.addEventListener('click', load); + el('annual-print')?.addEventListener('click', () => window.print()); + el('annual-fullscreen')?.addEventListener('click', toggleFullscreen); + el('annual-validate-year')?.addEventListener('click', () => validateWholeYear(false)); + el('annual-months')?.addEventListener('click', handleCalendarClick); + + const params = window.PTA_PAGE_CONFIG?.params || {}; + if (params.agentId) el('annual-agent').value = String(params.agentId); + if (params.year) el('annual-year').value = String(params.year); + if (params.agentId) load(); + } + + PTA.modules.annualOverview = { init, load }; +})(); diff --git a/assets/js/core.js b/assets/js/core.js new file mode 100644 index 0000000..91d03ff --- /dev/null +++ b/assets/js/core.js @@ -0,0 +1,175 @@ +(() => { + 'use strict'; + + const elements = { + structureSelect: document.querySelector('#structure'), + agentSelect: document.querySelector('#agent'), + weekInput: document.querySelector('#week'), + weeklyEntryBody: document.querySelector('#weekly-entry-body'), + addButton: document.querySelector('#add-entry'), + saveButton: document.querySelector('#save-draft'), + validateButton: document.querySelector('#validate-planning'), + modifyButton: document.querySelector('#modify-planning'), + planningBoard: document.querySelector('#planning-board'), + emptyState: document.querySelector('#empty-state'), + weekLabel: document.querySelector('#week-label'), + workTotal: document.querySelector('#work-total'), + statusBadge: document.querySelector('#planning-status'), + messageBox: document.querySelector('#message'), + openAgentPdfButton: document.querySelector('#open-agent-pdf'), + openStructurePdfButton: document.querySelector('#open-structure-pdf'), + quotaPanel: document.querySelector('#editor-quota'), + quotaRate: document.querySelector('#quota-rate'), + quotaTarget: document.querySelector('#quota-target'), + quotaPeriod: document.querySelector('#quota-period'), + quotaUsed: document.querySelector('#quota-used'), + quotaUsedDetail: document.querySelector('#quota-used-detail'), + quotaRemaining: document.querySelector('#quota-remaining'), + }; + + const state = { + entries: [], + otherStructureEntries: [], + currentStructure: null, + planningId: null, + planningStatus: null, + weekDays: [], + quota: null, + savedCurrentCountedMinutes: 0, + }; + + const shared = { + lastAgentOverviewData: null, + }; + + const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi']; + + function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = String(value ?? ''); + return div.innerHTML; + } + + function showMessage(text, type = 'info') { + if (!elements.messageBox) return; + elements.messageBox.className = `message message-${type}`; + elements.messageBox.textContent = text; + } + + function clearMessage() { + if (!elements.messageBox) return; + elements.messageBox.className = 'message'; + elements.messageBox.textContent = ''; + } + + async function readJsonResponse(response) { + const raw = await response.text(); + + if (!raw.trim()) { + throw new Error(`Le serveur a renvoyé une réponse vide (HTTP ${response.status}).`); + } + + try { + return JSON.parse(raw); + } catch (error) { + const readable = raw + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/&/gi, '&') + .replace(/\s+/g, ' ') + .trim(); + + throw new Error( + `Le serveur PHP n'a pas renvoyé du JSON valide. ${readable.slice(0, 500) || 'Consultez les logs PHP.'}` + ); + } + } + + function apiErrorMessage(data, fallback) { + const base = data?.error || fallback; + return data?.details ? `${base} Détail : ${data.details}` : base; + } + + function toIsoDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + function parseIsoWeek(value) { + const match = /^(\d{4})-W(\d{2})$/.exec(value); + if (!match) return []; + + const year = Number(match[1]); + const week = Number(match[2]); + const jan4 = new Date(year, 0, 4); + const jan4Day = jan4.getDay() || 7; + const monday = new Date(jan4); + monday.setDate(jan4.getDate() - jan4Day + 1 + (week - 1) * 7); + + return Array.from({ length: 5 }, (_, index) => { + const date = new Date(monday); + date.setDate(monday.getDate() + index); + return { + date: toIsoDate(date), + label: dayNames[index], + display: date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }), + }; + }); + } + + function timeToMinutes(time) { + const [hours, minutes] = String(time).split(':').map(Number); + return hours * 60 + minutes; + } + + function formatDuration(minutes) { + const sign = minutes < 0 ? '-' : ''; + const absolute = Math.abs(Math.round(minutes)); + const hours = Math.floor(absolute / 60); + const mins = absolute % 60; + return `${sign}${hours} h ${String(mins).padStart(2, '0')}`; + } + + function formatDate(value) { + if (!value) return ''; + const [year, month, day] = String(value).split('-'); + return `${day}/${month}/${year}`; + } + + function motifInfo(id) { + const motif = (window.PTA_CONFIG?.motifs || []).find((item) => Number(item.id) === Number(id)); + return motif ? { + id: Number(motif.id), + code: motif.code, + label: motif.label, + quota: Boolean(motif.quota), + } : null; + } + + window.PTA = { + config: window.PTA_CONFIG || {}, + elements, + state, + shared, + modules: {}, + utils: { + escapeHtml, + showMessage, + clearMessage, + readJsonResponse, + apiErrorMessage, + parseIsoWeek, + timeToMinutes, + formatDuration, + formatDate, + motifInfo, + }, + }; +})(); diff --git a/assets/js/coverage.js b/assets/js/coverage.js new file mode 100644 index 0000000..9d5700f --- /dev/null +++ b/assets/js/coverage.js @@ -0,0 +1,284 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant coverage.js'); + + const { elements, utils } = PTA; + const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils; + const badge = document.querySelector('#coverage-tab-badge'); + const weekInput = document.querySelector('#coverage-week'); + const structureFilter = document.querySelector('#coverage-structure-filter'); + const refreshButton = document.querySelector('#refresh-coverage-alerts'); + const loadingElement = document.querySelector('#coverage-loading'); + const emptyElement = document.querySelector('#coverage-empty'); + const contentElement = document.querySelector('#coverage-content'); + const bodyElement = document.querySelector('#coverage-alerts-body'); + const messageElement = document.querySelector('#coverage-message'); + const unconfiguredElement = document.querySelector('#coverage-unconfigured'); + + const ruleStructureSelect = document.querySelector('#coverage-rules-structure'); + const ruleEditor = document.querySelector('#coverage-rules-editor'); + const ruleMessage = document.querySelector('#coverage-rules-message'); + const loadRulesButton = document.querySelector('#load-coverage-rules'); + const saveRulesButton = document.querySelector('#save-coverage-rules'); + + const dayNames = { + 1: 'Lundi', + 2: 'Mardi', + 3: 'Mercredi', + 4: 'Jeudi', + 5: 'Vendredi', + }; + + let alerts = []; + let loading = false; + + function setBadge(count) { + if (!badge) return; + badge.textContent = String(count); + badge.hidden = count === 0; + badge.setAttribute('aria-label', `${count} plage${count > 1 ? 's' : ''} avec couverture insuffisante`); + } + + function setMessage(text = '', type = 'info') { + if (!messageElement) return; + messageElement.className = text ? `message message-${type}` : 'message'; + messageElement.textContent = text; + } + + function renderAlerts(data) { + alerts = Array.isArray(data.alerts) ? data.alerts : []; + const summary = data.summary || {}; + const unconfigured = Array.isArray(data.unconfigured_structures) ? data.unconfigured_structures : []; + + setBadge(alerts.length); + document.querySelector('#coverage-gap-count').textContent = String(Number(summary.gap_count || 0)); + document.querySelector('#coverage-structure-count').textContent = String(Number(summary.affected_structure_count || 0)); + document.querySelector('#coverage-gap-hours').textContent = formatDuration(Number(summary.gap_minutes || 0)); + document.querySelector('#coverage-unconfigured-count').textContent = String(Number(summary.unconfigured_structure_count || 0)); + + if (loadingElement) loadingElement.hidden = true; + if (emptyElement) emptyElement.hidden = alerts.length !== 0; + if (contentElement) contentElement.hidden = alerts.length === 0; + + if (bodyElement) { + bodyElement.innerHTML = alerts.map((alert, index) => { + const missing = Number(alert.agents_manquants || 0); + return ` + + + ${escapeHtml(alert.structure_nom)} + ${escapeHtml(alert.structure_code || '')} + + + ${escapeHtml(alert.jour_libelle)} + ${formatDate(alert.date)} + + ${escapeHtml(alert.heure_debut)}–${escapeHtml(alert.heure_fin)} + ${Number(alert.minimum_agents)} + ${Number(alert.agents_planifies)} + ${missing} agent${missing > 1 ? 's' : ''} + + `; + }).join(''); + } + + if (unconfiguredElement) { + unconfiguredElement.hidden = unconfigured.length === 0; + unconfiguredElement.innerHTML = unconfigured.length ? ` + Couverture non configurée pour ${unconfigured.length} lieu${unconfigured.length > 1 ? 'x' : ''} +

${unconfigured.map((item) => escapeHtml(item.nom)).join(', ')}. Configurez les plages attendues dans la page Administration pour pouvoir détecter les créneaux vides.

+ ` : ''; + } + } + + async function refresh(options = {}) { + if (loading) return; + const week = weekInput?.value; + if (!week) return; + + loading = true; + if (!options.silent) setMessage(''); + if (loadingElement) loadingElement.hidden = false; + if (refreshButton) refreshButton.disabled = true; + + try { + const params = new URLSearchParams({ week }); + if (structureFilter?.value) params.set('structure_id', structureFilter.value); + const response = await fetch(`api/coverage_alerts.php?${params.toString()}`, { headers: { Accept: 'application/json' } }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’analyser la couverture.')); + renderAlerts(data); + } catch (error) { + if (loadingElement) loadingElement.hidden = true; + setMessage(error.message, 'error'); + } finally { + loading = false; + if (refreshButton) refreshButton.disabled = false; + } + } + + function openGap(alert) { + if (!alert) return; + const params = new URLSearchParams({ + structure_id: String(alert.id_structure), + week: weekInput?.value || '', + notice: `Couverture insuffisante le ${alert.jour_libelle.toLowerCase()} ${formatDate(alert.date)} de ${alert.heure_debut} à ${alert.heure_fin} sur ${alert.structure_nom}. Choisissez un agent à affecter.`, + }); + window.location.href = `planning.php?${params.toString()}`; + } + + function coverageRuleRow(rule = {}) { + return ` +
+ + + + +
`; + } + + function renderRuleEditor(rules = []) { + if (!ruleEditor) return; + const grouped = new Map(); + for (let day = 1; day <= 5; day += 1) grouped.set(day, []); + rules.forEach((rule) => grouped.get(Number(rule.jour_semaine))?.push(rule)); + + ruleEditor.innerHTML = Array.from(grouped.entries()).map(([day, dayRules]) => ` +
+
+ ${dayNames[day]} + +
+
+ ${dayRules.length ? dayRules.map(coverageRuleRow).join('') : '

Aucune couverture obligatoire configurée.

'} +
+
`).join(''); + + if (saveRulesButton) saveRulesButton.disabled = false; + } + + async function loadRules() { + const structureId = ruleStructureSelect?.value; + if (!structureId || !ruleEditor) { + if (ruleEditor) ruleEditor.innerHTML = '
Choisissez un lieu d’affectation.
'; + if (saveRulesButton) saveRulesButton.disabled = true; + return; + } + if (ruleMessage) { + ruleMessage.className = 'message'; + ruleMessage.textContent = ''; + } + ruleEditor.innerHTML = '
Chargement des règles de couverture…
'; + + try { + const response = await fetch(`api/structure_coverage_rules.php?structure_id=${encodeURIComponent(structureId)}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les règles de couverture.')); + renderRuleEditor(data.rules || []); + } catch (error) { + ruleEditor.innerHTML = ''; + if (ruleMessage) { + ruleMessage.className = 'message message-error'; + ruleMessage.textContent = error.message; + } + } + } + + function collectRules() { + const rules = []; + ruleEditor?.querySelectorAll('.coverage-rule-day').forEach((daySection) => { + const day = Number(daySection.dataset.day); + daySection.querySelectorAll('.coverage-rule-row').forEach((row) => { + const start = row.querySelector('.coverage-rule-start')?.value || ''; + const end = row.querySelector('.coverage-rule-end')?.value || ''; + const minimum = Number(row.querySelector('.coverage-rule-minimum')?.value || 1); + if (!start && !end) return; + rules.push({ jour_semaine: day, heure_debut: start, heure_fin: end, minimum_agents: minimum }); + }); + }); + return rules; + } + + async function saveRules() { + const structureId = ruleStructureSelect?.value; + if (!structureId || !saveRulesButton || !ruleMessage) return; + + saveRulesButton.disabled = true; + ruleMessage.className = 'message'; + ruleMessage.textContent = ''; + try { + const response = await fetch('api/save_structure_coverage_rules.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + structure_id: Number(structureId), + rules: collectRules(), + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer les règles de couverture.')); + renderRuleEditor(data.rules || []); + ruleMessage.className = 'message message-success'; + ruleMessage.textContent = data.message; + await refresh({ silent: true }); + if (document.querySelector('#overview-structure')?.value === String(structureId)) { + await PTA.modules.structureOverview?.load?.(); + } + } catch (error) { + ruleMessage.className = 'message message-error'; + ruleMessage.textContent = error.message; + } finally { + saveRulesButton.disabled = false; + } + } + + function init() { + refreshButton?.addEventListener('click', () => refresh()); + weekInput?.addEventListener('change', () => refresh()); + structureFilter?.addEventListener('change', () => refresh()); + bodyElement?.addEventListener('click', (event) => { + const button = event.target.closest('.open-coverage-gap'); + if (!button) return; + openGap(alerts[Number(button.dataset.alertIndex)]); + }); + + ruleStructureSelect?.addEventListener('change', loadRules); + loadRulesButton?.addEventListener('click', loadRules); + saveRulesButton?.addEventListener('click', saveRules); + ruleEditor?.addEventListener('click', (event) => { + const addButton = event.target.closest('.add-coverage-rule'); + if (addButton) { + const day = addButton.closest('.coverage-rule-day'); + const ranges = day?.querySelector('.coverage-rule-day-ranges'); + if (!ranges) return; + ranges.querySelector('.coverage-rule-empty')?.remove(); + ranges.insertAdjacentHTML('beforeend', coverageRuleRow()); + return; + } + const removeButton = event.target.closest('.remove-coverage-rule'); + if (removeButton) { + const ranges = removeButton.closest('.coverage-rule-day-ranges'); + removeButton.closest('.coverage-rule-row')?.remove(); + if (ranges && !ranges.querySelector('.coverage-rule-row')) { + ranges.innerHTML = '

Aucune couverture obligatoire configurée.

'; + } + } + }); + + refresh({ silent: true }); + } + + PTA.modules.coverage = { init, refresh, loadRules }; +})(); diff --git a/assets/js/page-bootstrap.js b/assets/js/page-bootstrap.js new file mode 100644 index 0000000..67a6993 --- /dev/null +++ b/assets/js/page-bootstrap.js @@ -0,0 +1,80 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) return; + + const page = window.PTA_PAGE_CONFIG || {}; + const modules = Array.isArray(page.modules) ? page.modules : []; + const params = page.params || {}; + + modules.forEach((name) => PTA.modules[name]?.init?.()); + + async function initialisePlanning() { + const planning = PTA.modules.planning; + if (!planning) return; + + const { structureSelect, agentSelect, weekInput, validateButton } = PTA.elements; + if (params.structureId && structureSelect) { + structureSelect.value = String(params.structureId); + await planning.loadAgents(); + if (params.structureLocked) structureSelect.disabled = true; + } + if (params.agentId && agentSelect) { + agentSelect.value = String(params.agentId); + if (weekInput) weekInput.disabled = false; + } + if (params.week && weekInput) { + weekInput.disabled = !agentSelect?.value; + weekInput.value = params.week; + if (agentSelect?.value) await planning.handleWeekSelection(); + } + if (params.notice) PTA.utils.showMessage(params.notice, 'info'); + if (params.focus === 'validation') { + window.setTimeout(() => { + document.querySelector('#editor-view .actions-bar')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + validateButton?.focus({ preventScroll: true }); + }, 150); + } + } + + async function initialiseStructureOverview() { + if (!params.structureId) return; + const select = document.querySelector('#overview-structure'); + if (select) { + select.value = String(params.structureId); + if (params.structureLocked) select.disabled = true; + } + await PTA.modules.structureOverview?.load?.(); + } + + async function initialiseAgentOverview() { + if (params.agentId) { + const select = document.querySelector('#overview-agent'); + if (select) { + select.value = String(params.agentId); + if (params.agentLocked) select.disabled = true; + } + } + if (params.month) { + const input = document.querySelector('#agent-overview-month'); + if (input) input.value = params.month; + } + if (params.agentId) await PTA.modules.agentOverview?.load?.(); + } + + function initialiseCoverage() { + if (!params.structureId) return; + const select = document.querySelector('#coverage-structure-filter'); + if (select) { + select.value = String(params.structureId); + if (params.structureLocked) select.disabled = true; + PTA.modules.coverage?.refresh?.(); + } + } + + initialisePlanning(); + initialiseStructureOverview(); + initialiseAgentOverview(); + initialiseCoverage(); +})(); diff --git a/assets/js/pending-validation.js b/assets/js/pending-validation.js new file mode 100644 index 0000000..fe0ffa8 --- /dev/null +++ b/assets/js/pending-validation.js @@ -0,0 +1,165 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant pending-validation.js'); + + const { utils } = PTA; + const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils; + + const badge = document.querySelector('#pending-validation-tab-badge'); + const refreshButton = document.querySelector('#refresh-pending-validation'); + const countElement = document.querySelector('#pending-validation-count'); + const hoursElement = document.querySelector('#pending-validation-hours'); + const countedHoursElement = document.querySelector('#pending-validation-counted-hours'); + const loadingElement = document.querySelector('#pending-validation-loading'); + const emptyElement = document.querySelector('#pending-validation-empty'); + const contentElement = document.querySelector('#pending-validation-content'); + const bodyElement = document.querySelector('#pending-validation-body'); + const messageElement = document.querySelector('#pending-validation-message'); + + let items = []; + let loading = false; + + function weekValue(item) { + return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`; + } + + function formatDateTime(value) { + if (!value) return '—'; + const normalized = String(value).replace(' ', 'T'); + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) return escapeHtml(value); + return date.toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + } + + function structureTypeLabel(value) { + return value === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire'; + } + + function setBadge(count) { + if (!badge) return; + badge.textContent = String(count); + badge.hidden = count === 0; + badge.setAttribute('aria-label', `${count} planning${count > 1 ? 's' : ''} en attente de validation`); + } + + function setMessage(text = '', type = 'info') { + if (!messageElement) return; + messageElement.className = text ? `message message-${type}` : 'message'; + messageElement.textContent = text; + } + + function render(data) { + items = Array.isArray(data.plannings) ? data.plannings : []; + const summary = data.summary || {}; + const count = Number(summary.count ?? items.length); + + setBadge(count); + if (countElement) countElement.textContent = String(count); + if (hoursElement) hoursElement.textContent = formatDuration(Number(summary.total_minutes || 0)); + if (countedHoursElement) countedHoursElement.textContent = formatDuration(Number(summary.counted_minutes || 0)); + + if (loadingElement) loadingElement.hidden = true; + if (emptyElement) emptyElement.hidden = items.length !== 0; + if (contentElement) contentElement.hidden = items.length === 0; + if (!bodyElement) return; + + bodyElement.innerHTML = items.map((item) => { + const week = weekValue(item); + const rowId = Number(item.id_planning); + return ` + + + ${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)} + ${escapeHtml(item.matricule || '')} + + + ${escapeHtml(item.structure_nom)} + ${escapeHtml(structureTypeLabel(item.structure_type_affectation))} + + + Semaine ${Number(item.numero_semaine)} + ${formatDate(item.date_debut)} → ${formatDate(item.date_fin_ouvrable || item.date_fin)} + + ${Number(item.nombre_creneaux)} + + ${formatDuration(Number(item.total_minutes || 0))} + ${formatDuration(Number(item.minutes_decomptees || 0))} décomptées + + ${formatDateTime(item.date_modification)} + + + + `; + }).join(''); + } + + async function refresh(options = {}) { + if (loading) return; + loading = true; + if (!options.silent) setMessage(''); + if (loadingElement) { + loadingElement.hidden = false; + if (emptyElement) emptyElement.hidden = true; + if (contentElement) contentElement.hidden = true; + } + if (refreshButton) refreshButton.disabled = true; + + try { + const response = await fetch('api/pending_plannings.php', { headers: { Accept: 'application/json' } }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les plannings en attente de validation.')); + render(data); + } catch (error) { + if (loadingElement) loadingElement.hidden = true; + setMessage(error.message, 'error'); + } finally { + loading = false; + if (refreshButton) refreshButton.disabled = false; + } + } + + function openPlanning(item) { + if (!item) return; + const params = new URLSearchParams({ + structure_id: String(item.id_structure), + agent_id: String(item.id_agent), + week: weekValue(item), + focus: 'validation', + notice: `Planning de ${item.agent_prenom} ${item.agent_nom} chargé depuis la file de validation.`, + }); + window.location.href = `planning.php?${params.toString()}`; + } + + function findItemFromEventTarget(target) { + const trigger = target.closest('[data-planning-id]'); + if (!trigger) return null; + const id = Number(trigger.dataset.planningId); + return items.find((item) => Number(item.id_planning) === id) || null; + } + + function init() { + refreshButton?.addEventListener('click', () => refresh()); + bodyElement?.addEventListener('click', (event) => { + const item = findItemFromEventTarget(event.target); + if (item) openPlanning(item); + }); + bodyElement?.addEventListener('keydown', (event) => { + if (!['Enter', ' '].includes(event.key)) return; + if (event.target.closest('button')) return; + const row = event.target.closest('.pending-validation-row'); + if (!row) return; + event.preventDefault(); + const item = findItemFromEventTarget(row); + if (item) openPlanning(item); + }); + + refresh({ silent: true }); + } + + PTA.modules.pendingValidation = { init, refresh, openPlanning }; +})(); diff --git a/assets/js/planning-editor.js b/assets/js/planning-editor.js new file mode 100644 index 0000000..336304f --- /dev/null +++ b/assets/js/planning-editor.js @@ -0,0 +1,1130 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant planning-editor.js'); + + const { + structureSelect, + agentSelect, + weekInput, + weeklyEntryBody, + addButton, + saveButton, + validateButton, + modifyButton, + planningBoard, + emptyState, + weekLabel, + workTotal, + statusBadge, + openAgentPdfButton, + quotaPanel, + quotaRate, + quotaTarget, + quotaPeriod, + quotaUsed, + quotaUsedDetail, + quotaRemaining, + } = PTA.elements; + + const permissions = window.PTA_CONFIG?.permissions || {}; + const accessProfile = window.PTA_CONFIG?.access || {}; + const canEditDraft = Boolean(permissions.can_edit_draft); + const canValidate = Boolean(permissions.can_validate); + const canEditCrossStructure = Boolean(permissions.can_edit_cross_structure); + + const state = PTA.state; + const { + escapeHtml, + showMessage, + clearMessage, + readJsonResponse, + apiErrorMessage, + parseIsoWeek, + timeToMinutes, + formatDuration, + motifInfo, + } = PTA.utils; + + function isPlanningContextReady() { + return Boolean(structureSelect?.value && agentSelect?.value && weekInput?.value); + } + + function updateAgentPdfButton() { + if (openAgentPdfButton) { + openAgentPdfButton.disabled = !(agentSelect?.value && weekInput?.value); + } + } + + function openAgentPlanningPdf() { + if (!agentSelect?.value || !weekInput?.value) { + showMessage('Sélectionnez un agent et une semaine avant d’ouvrir le PDF.', 'warning'); + return; + } + + const params = new URLSearchParams({ + agent_id: agentSelect.value, + week: weekInput.value, + weeks: '7', + }); + window.open(`api/agent_planning_pdf.php?${params.toString()}`, '_blank', 'noopener'); + } + + function setStatus(status) { + state.planningStatus = status; + if (!statusBadge) return; + + statusBadge.className = 'status-badge'; + + if (status === 'VALIDE') { + statusBadge.classList.add('status-valid'); + statusBadge.textContent = 'Planning validé'; + } else if (status === 'BROUILLON') { + statusBadge.classList.add('status-draft'); + statusBadge.textContent = 'Brouillon'; + } else { + statusBadge.classList.add('status-neutral'); + statusBadge.textContent = 'Nouveau planning'; + } + + const locked = status === 'VALIDE'; + if (modifyButton) { + modifyButton.hidden = !canEditDraft || !locked; + modifyButton.disabled = !canEditDraft || !locked || !state.planningId; + } + if (saveButton) { + saveButton.hidden = !canEditDraft || locked; + saveButton.disabled = !canEditDraft || locked || !isPlanningContextReady(); + } + if (validateButton) { + validateButton.hidden = !canValidate || locked; + validateButton.disabled = !canValidate || locked || !isPlanningContextReady() || state.entries.length === 0; + } + if (addButton) { + addButton.hidden = !canEditDraft; + addButton.disabled = !canEditDraft || locked || !isPlanningContextReady() || state.weekDays.length === 0; + } + + updateEntryControlsState(); + updateAgentPdfButton(); + } + + function motifOptionsHtml(selectedMotifId = null) { + return (window.PTA_CONFIG?.motifs || []) + .map((motif) => { + const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : ''; + const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota'; + return ``; + }) + .join(''); + } + + function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) { + const suffix = `${dayLabel} plage ${index + 1}`; + return ` +
+ + + + + +
`; + } + + function renderWeeklyEntryRows() { + if (!weeklyEntryBody) return; + weeklyEntryBody.innerHTML = ''; + + if (!state.weekDays.length) { + weeklyEntryBody.innerHTML = 'Sélectionnez d’abord un lieu d’affectation, un agent et une semaine.'; + return; + } + + state.weekDays.forEach((day) => { + const row = document.createElement('tr'); + row.className = 'weekly-entry-row'; + row.dataset.date = day.date; + row.innerHTML = ` + + ${escapeHtml(day.label)} + ${escapeHtml(day.display)} + ${day.type_affectation ? `${day.type_affectation === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire'}` : ''} + ${day.vacances?.libelle ? `${escapeHtml(day.vacances.libelle)}` : ''} + + +
+ ${timeRangeHtml(day.label, 0)} +
+ + + `; + weeklyEntryBody.appendChild(row); + }); + + updateEntryControlsState(); + } + + function fillWeeklyEntryRowsFromEntries() { + if (!state.weekDays.length || !weeklyEntryBody) return; + + renderWeeklyEntryRows(); + + state.weekDays.forEach((day) => { + const row = weeklyEntryBody.querySelector(`.weekly-entry-row[data-date="${day.date}"]`); + if (!row) return; + + const entries = state.entries + .filter((entry) => entry.date === day.date) + .sort((a, b) => a.start.localeCompare(b.start)); + + if (!entries.length) return; + + const rangesContainer = row.querySelector('.weekly-time-ranges'); + if (!rangesContainer) return; + + rangesContainer.innerHTML = ''; + entries.forEach((entry, index) => { + rangesContainer.insertAdjacentHTML('beforeend', timeRangeHtml(day.label, index, entry.motif_id)); + const range = rangesContainer.lastElementChild; + range.querySelector('.weekly-range-start').value = entry.start; + range.querySelector('.weekly-range-end').value = entry.end; + }); + }); + + updateEntryControlsState(); + } + + function updateEntryControlsState() { + if (!weeklyEntryBody) return; + const locked = state.planningStatus === 'VALIDE'; + const enabled = canEditDraft && !locked && isPlanningContextReady() && state.weekDays.length > 0; + weeklyEntryBody.querySelectorAll('.weekly-time, .weekly-range-motif, .add-time-range, .remove-time-range').forEach((control) => { + control.disabled = !enabled; + }); + } + + function populateDays() { + state.weekDays = parseIsoWeek(weekInput?.value || ''); + renderWeeklyEntryRows(); + + if (state.weekDays.length) { + if (weekLabel) { + weekLabel.textContent = `Du ${state.weekDays[0].display} au ${state.weekDays[state.weekDays.length - 1].display} · tous lieux d’affectation`; + } + return true; + } + + if (weekLabel) weekLabel.textContent = 'Sélectionnez une semaine valide.'; + return false; + } + + function countedMinutes(entries = state.entries) { + return entries.reduce((total, entry) => { + const motif = motifInfo(entry.motif_id) ?? { quota: Boolean(entry.compte_dans_quota) }; + if (!motif.quota) return total; + return total + (timeToMinutes(entry.end) - timeToMinutes(entry.start)); + }, 0); + } + + function updateQuotaPanel() { + if (!quotaPanel) return; + if (!state.quota) { + quotaPanel.hidden = true; + return; + } + + const currentMinutes = countedMinutes(); + const baseAllocated = Number(state.quota.minutes_affectees) - Number(state.savedCurrentCountedMinutes || 0); + const projectedAllocated = baseAllocated + currentMinutes; + const projectedRemaining = Number(state.quota.quota_cible_minutes) - projectedAllocated; + + quotaPanel.hidden = false; + if (quotaRate) quotaRate.textContent = `${Number(state.quota.quotite_travail).toLocaleString('fr-FR')} %`; + if (quotaTarget) quotaTarget.textContent = formatDuration(Number(state.quota.quota_cible_minutes)); + if (quotaPeriod) { + quotaPeriod.textContent = state.quota.periode_libelle || 'Année civile'; + } + if (quotaUsed) quotaUsed.textContent = formatDuration(projectedAllocated); + if (quotaUsedDetail) { + quotaUsedDetail.textContent = `${formatDuration(Number(state.quota.minutes_valides))} validées · ${formatDuration(Math.max(0, projectedAllocated - Number(state.quota.minutes_valides)))} en brouillon`; + } + if (quotaRemaining) { + quotaRemaining.textContent = formatDuration(projectedRemaining); + quotaRemaining.classList.toggle('quota-negative', projectedRemaining < 0); + } + } + + + let draggedEntry = null; + let activeEntryAction = null; + let suppressEntryClickUntil = 0; + + function sameEntry(a, b) { + if (!a || !b) return false; + if (a.id_creneau && b.id_creneau) return Number(a.id_creneau) === Number(b.id_creneau); + return String(a.local_id || '') === String(b.local_id || ''); + } + + function allGlobalEntries() { + const currentStructureName = state.currentStructure?.nom + || structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim() + || 'Lieu d’affectation sélectionné'; + + return [ + ...state.entries.map((entry) => ({ + ...entry, + is_external: false, + structure_name: currentStructureName, + structure_id: Number(structureSelect?.value || 0), + })), + ...state.otherStructureEntries.map((entry) => ({ ...entry, is_external: true })), + ]; + } + + function findEntryConflict(entry, targetDate, duplicate) { + const startMinutes = timeToMinutes(entry.start); + const endMinutes = timeToMinutes(entry.end); + + return allGlobalEntries().find((candidate) => { + if (!duplicate && sameEntry(candidate, entry)) return false; + return candidate.date === targetDate + && startMinutes < timeToMinutes(candidate.end) + && endMinutes > timeToMinutes(candidate.start); + }) || null; + } + + function entryActionSummary(entry) { + const motif = motifInfo(entry.motif_id) ?? { label: entry.motif_label || 'Affectation' }; + const location = entry.structure_name + || state.currentStructure?.nom + || structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim() + || 'Lieu non précisé'; + return `${motif.label} · ${entry.start}–${entry.end} · ${location}`; + } + + function closeEntryActionModal() { + const modal = document.querySelector('#planning-entry-action-modal'); + const message = document.querySelector('#planning-entry-action-message'); + if (modal) modal.hidden = true; + if (message) { + message.className = 'message'; + message.textContent = ''; + } + activeEntryAction = null; + if (!document.querySelector('.modal-backdrop:not([hidden])')) { + document.body.classList.remove('modal-open'); + } + } + + function openEntryActionModal(entry) { + const modal = document.querySelector('#planning-entry-action-modal'); + const summary = document.querySelector('#planning-entry-action-summary'); + const targetSelect = document.querySelector('#planning-entry-target-date'); + const moveRadio = document.querySelector('input[name="planning-entry-action"][value="move"]'); + if (!modal || !targetSelect) return; + + activeEntryAction = entry; + if (summary) summary.textContent = entryActionSummary(entry); + targetSelect.innerHTML = state.weekDays.map((day) => ( + `` + )).join(''); + if (moveRadio) moveRadio.checked = true; + + const message = document.querySelector('#planning-entry-action-message'); + if (message) { + message.className = 'message'; + message.textContent = ''; + } + modal.hidden = false; + document.body.classList.add('modal-open'); + targetSelect.focus(); + } + + function applyLocalEntryDayAction(entry, targetDate, duplicate) { + const conflict = findEntryConflict(entry, targetDate, duplicate); + if (conflict) { + const location = conflict.structure_name || 'un autre lieu'; + showMessage(`Action impossible : un créneau ${conflict.start}–${conflict.end} existe déjà à ${location} ce jour-là.`, 'warning'); + return false; + } + + if (duplicate) { + const clone = { + ...entry, + local_id: crypto.randomUUID(), + id_creneau: null, + date: targetDate, + }; + delete clone.is_external; + delete clone.structure_name; + delete clone.structure_id; + delete clone.structure_type_affectation; + delete clone.status; + state.entries.push(clone); + } else { + const source = state.entries.find((candidate) => sameEntry(candidate, entry)); + if (!source) { + showMessage('Le créneau à déplacer n’est plus présent dans le formulaire. Rechargez le planning.', 'warning'); + return false; + } + source.date = targetDate; + } + + fillWeeklyEntryRowsFromEntries(); + render(); + setStatus(state.planningStatus ?? 'BROUILLON'); + showMessage( + duplicate + ? 'Le créneau a été dupliqué dans le formulaire. Enregistrez le brouillon pour confirmer la duplication.' + : 'Le créneau a été déplacé dans le formulaire. Enregistrez le brouillon pour confirmer le changement de jour.', + 'success' + ); + return true; + } + + async function persistEntryDayAction(entry, targetDate, duplicate) { + if (!entry.id_creneau) { + return applyLocalEntryDayAction(entry, targetDate, duplicate); + } + + if (entry.is_external && isPlanningContextReady() && state.planningStatus !== 'VALIDE') { + if (!syncEntriesFromWeeklyForm({ silent: true })) return false; + if (state.entries.length > 0 || state.planningId) { + const saved = await saveDraft({ silent: true }); + if (!saved) return false; + } + } + + try { + const response = await fetch('api/move_or_duplicate_entry.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + entry_id: Number(entry.id_creneau), + target_date: targetDate, + action: duplicate ? 'duplicate' : 'move', + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier ce créneau.')); + + await loadPlanning(); + showMessage(data.message || (duplicate ? 'Créneau dupliqué.' : 'Créneau déplacé.'), 'success'); + return true; + } catch (error) { + showMessage(error.message, 'error'); + return false; + } + } + + async function applyEntryDayAction(entry, targetDate, duplicate = false) { + if (!entry || !targetDate) return false; + if (!duplicate && entry.date === targetDate) { + showMessage('Le créneau se trouve déjà sur ce jour.', 'info'); + return true; + } + + const conflict = findEntryConflict(entry, targetDate, duplicate); + if (conflict) { + const location = conflict.structure_name || 'un autre lieu'; + showMessage(`Action impossible : un créneau ${conflict.start}–${conflict.end} existe déjà à ${location} ce jour-là.`, 'warning'); + return false; + } + + const directServerAction = Boolean(entry.id_creneau) && (entry.is_external || state.planningStatus === 'VALIDE'); + return directServerAction + ? persistEntryDayAction(entry, targetDate, duplicate) + : applyLocalEntryDayAction(entry, targetDate, duplicate); + } + + async function submitEntryActionModal(event) { + event.preventDefault(); + if (!activeEntryAction) return; + + const targetDate = document.querySelector('#planning-entry-target-date')?.value || ''; + const action = document.querySelector('input[name="planning-entry-action"]:checked')?.value || 'move'; + const button = document.querySelector('#confirm-planning-entry-action'); + const message = document.querySelector('#planning-entry-action-message'); + if (button) button.disabled = true; + + try { + const success = await applyEntryDayAction(activeEntryAction, targetDate, action === 'duplicate'); + if (success) closeEntryActionModal(); + else if (message) { + message.className = 'message message-warning'; + message.textContent = 'La modification n’a pas été appliquée. Vérifiez les messages affichés dans la page.'; + } + } finally { + if (button) button.disabled = false; + } + } + + function clearDropTargets() { + planningBoard?.querySelectorAll('.day-column').forEach((column) => { + column.classList.remove('is-drop-target', 'is-copy-target'); + }); + } + + function makeEntryInteractive(item, entry) { + if (!canEditDraft || (entry.is_external && !canEditCrossStructure)) return; + item.classList.add('planning-entry-interactive'); + item.draggable = true; + item.tabIndex = 0; + item.setAttribute('role', 'button'); + item.setAttribute('aria-label', `${entryActionSummary(entry)}. Cliquez pour déplacer ou dupliquer ce créneau.`); + item.title = 'Cliquez pour déplacer ou dupliquer. Glissez vers un autre jour ; Ctrl/⌘ + glisser pour dupliquer.'; + + item.addEventListener('dragstart', (event) => { + draggedEntry = entry; + item.classList.add('is-dragging'); + event.dataTransfer.effectAllowed = 'copyMove'; + event.dataTransfer.setData('text/plain', String(entry.id_creneau || entry.local_id || 'entry')); + }); + item.addEventListener('dragend', () => { + item.classList.remove('is-dragging'); + draggedEntry = null; + suppressEntryClickUntil = Date.now() + 250; + clearDropTargets(); + }); + item.addEventListener('click', (event) => { + if (Date.now() < suppressEntryClickUntil || event.target.closest('button')) return; + openEntryActionModal(entry); + }); + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openEntryActionModal(entry); + } + }); + } + + function makeDayDropTarget(column, day) { + column.dataset.date = day.date; + if (!canEditDraft) return; + column.addEventListener('dragover', (event) => { + if (!draggedEntry) return; + event.preventDefault(); + const copy = event.ctrlKey || event.metaKey; + event.dataTransfer.dropEffect = copy ? 'copy' : 'move'; + clearDropTargets(); + column.classList.add('is-drop-target'); + if (copy) column.classList.add('is-copy-target'); + }); + column.addEventListener('dragleave', (event) => { + if (!column.contains(event.relatedTarget)) { + column.classList.remove('is-drop-target', 'is-copy-target'); + } + }); + column.addEventListener('drop', async (event) => { + if (!draggedEntry) return; + event.preventDefault(); + const entry = draggedEntry; + const duplicate = event.ctrlKey || event.metaKey; + clearDropTargets(); + await applyEntryDayAction(entry, day.date, duplicate); + }); + } + + function render() { + if (!planningBoard || !emptyState) return; + planningBoard.innerHTML = ''; + const visibleDates = new Set(state.weekDays.map((day) => day.date)); + const currentStructureName = state.currentStructure?.nom + || structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim() + || 'Lieu d’affectation sélectionné'; + + const localEntries = state.entries.map((entry) => ({ + ...entry, + is_external: false, + structure_name: currentStructureName, + structure_id: Number(structureSelect?.value || 0), + structure_type_affectation: state.currentStructure?.type_affectation || '', + })); + const externalEntries = state.otherStructureEntries.map((entry) => ({ + ...entry, + is_external: true, + })); + const globalEntries = [...localEntries, ...externalEntries] + .filter((entry) => visibleDates.has(entry.date)); + + const hasEntries = globalEntries.length > 0; + emptyState.hidden = hasEntries; + planningBoard.hidden = !hasEntries; + + let totalCountedMinutes = 0; + + state.weekDays.forEach((day) => { + const entries = globalEntries + .filter((entry) => entry.date === day.date) + .sort((a, b) => a.start.localeCompare(b.start)); + + const column = document.createElement('section'); + column.className = 'day-column'; + column.innerHTML = ` +
+ ${day.label} + ${day.display} +
+
+ `; + + makeDayDropTarget(column, day); + const entriesContainer = column.querySelector('.day-entries'); + + if (entries.length === 0) { + entriesContainer.innerHTML = '

Aucune affectation

'; + } else { + entries.forEach((entry) => { + const motif = motifInfo(entry.motif_id) ?? { + label: entry.motif_label || 'Motif', + quota: Boolean(entry.compte_dans_quota), + }; + + const duration = timeToMinutes(entry.end) - timeToMinutes(entry.start); + if (motif.quota) totalCountedMinutes += duration; + + const item = document.createElement('article'); + item.className = `planning-entry motif-${String(entry.motif_code || motif.code || 'autre').toLowerCase()}${entry.is_external ? ' planning-entry-external' : ''}`; + item.innerHTML = ` + +
+ ${escapeHtml(motif.label)} + ${escapeHtml(entry.start)} – ${escapeHtml(entry.end)} + 📍 ${escapeHtml(entry.structure_name || 'Lieu non précisé')}${entry.structure_type_affectation ? ` · ${escapeHtml(structureTypeLabel(entry.structure_type_affectation))}` : ''} + ${formatDuration(duration)}${motif.quota ? ' · quota' : ''}${entry.is_external ? ` · ${escapeHtml(String(entry.status || '').toLowerCase())}` : ''} +
+ ${entry.is_external + ? 'Autre lieu' + : state.planningStatus === 'VALIDE' + ? 'Validé' + : canEditDraft + ? '' + : 'Lecture seule'} + `; + + makeEntryInteractive(item, entry); + + const removeButton = item.querySelector('.remove-entry'); + if (removeButton) { + removeButton.addEventListener('click', (event) => { + event.stopPropagation(); + if (state.planningStatus === 'VALIDE') return; + state.entries = state.entries.filter((candidate) => candidate.local_id !== entry.local_id); + fillWeeklyEntryRowsFromEntries(); + render(); + setStatus(state.planningStatus); + }); + } + + entriesContainer.appendChild(item); + }); + } + + planningBoard.appendChild(column); + }); + + if (workTotal) workTotal.textContent = formatDuration(totalCountedMinutes); + if (validateButton) { + validateButton.disabled = state.planningStatus === 'VALIDE' || !isPlanningContextReady() || state.entries.length === 0; + } + updateQuotaPanel(); + PTA.modules.weekTemplates?.refreshButtons?.(); + } + + function resetPlanningState() { + state.entries = []; + state.otherStructureEntries = []; + state.currentStructure = null; + state.planningId = null; + state.quota = null; + state.savedCurrentCountedMinutes = 0; + state.weekDays = []; + renderWeeklyEntryRows(); + setStatus(null); + render(); + clearMessage(); + } + + const structureTypeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : type === 'PERISCOLAIRE' ? 'Périscolaire' : ''; + + async function loadAgents() { + resetPlanningState(); + + if (!agentSelect || !weekInput || !structureSelect) return; + agentSelect.disabled = true; + weekInput.disabled = true; + agentSelect.innerHTML = ''; + + if (!structureSelect.value) { + agentSelect.innerHTML = ''; + return; + } + + try { + const response = await fetch(`api/agents.php?structure_id=${encodeURIComponent(structureSelect.value)}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Erreur de chargement des agents.')); + + agentSelect.innerHTML = ''; + data.agents.forEach((agent) => { + const option = document.createElement('option'); + option.value = agent.id_agent; + const defaultLabel = agent.structure_nom ? ` · principal : ${agent.structure_nom}` : ' · sans lieu principal'; + const principalType = agent.structure_type_affectation === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire'; + const typeSuffix = agent.structure_nom ? ` · ${principalType}` : ''; + option.textContent = `${agent.prenom} ${agent.nom} (${agent.matricule})${defaultLabel}${typeSuffix}`; + agentSelect.appendChild(option); + }); + agentSelect.disabled = false; + + if (data.agents.length === 0) { + showMessage('Aucun agent actif n’est disponible.', 'warning'); + } + } catch (error) { + showMessage(error.message, 'error'); + agentSelect.innerHTML = ''; + } + } + + async function loadPlanning() { + if (!isPlanningContextReady()) return; + if (!populateDays()) { + setStatus(null); + return; + } + + state.entries = []; + state.otherStructureEntries = []; + state.currentStructure = null; + state.planningId = null; + state.savedCurrentCountedMinutes = 0; + setStatus(null); + render(); + clearMessage(); + + try { + const params = new URLSearchParams({ + structure_id: structureSelect.value, + agent_id: agentSelect.value, + week: weekInput.value, + }); + const response = await fetch(`api/planning.php?${params.toString()}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le planning.')); + + state.quota = data.quota || null; + state.currentStructure = data.current_structure || null; + const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day])); + state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) })); + state.otherStructureEntries = (data.other_entries || []).map((entry) => ({ + local_id: `external-${entry.id_creneau}`, + id_creneau: Number(entry.id_creneau), + date: entry.date_jour, + motif_id: Number(entry.id_motif), + motif_code: entry.motif_code, + motif_label: entry.motif_libelle, + compte_dans_quota: Number(entry.compte_dans_quota) === 1, + start: entry.heure_debut, + end: entry.heure_fin, + structure_id: Number(entry.id_structure), + structure_name: entry.structure_nom, + structure_type_affectation: entry.structure_type_affectation, + status: entry.statut, + })); + + if (data.planning) { + state.planningId = Number(data.planning.id_planning); + state.entries = data.entries.map((entry) => ({ + local_id: crypto.randomUUID(), + id_creneau: Number(entry.id_creneau), + date: entry.date_jour, + motif_id: Number(entry.id_motif), + motif_code: entry.motif_code, + motif_label: entry.motif_libelle, + compte_dans_quota: Number(entry.compte_dans_quota) === 1, + start: entry.heure_debut, + end: entry.heure_fin, + })); + state.savedCurrentCountedMinutes = countedMinutes(state.entries); + setStatus(data.planning.statut); + } else { + setStatus(null); + } + + fillWeeklyEntryRowsFromEntries(); + render(); + if (state.otherStructureEntries.length > 0) { + const count = state.otherStructureEntries.length; + showMessage(`${count} affectation${count > 1 ? 's' : ''} déjà saisie${count > 1 ? 's' : ''} sur d’autres lieux ${count > 1 ? 'sont' : 'est'} affichée${count > 1 ? 's' : ''} en lecture seule.`, 'info'); + } + } catch (error) { + showMessage(error.message, 'error'); + } + } + + function hasLocalOverlap(date, start, end, additionalEntries = [], includeCurrentEntries = true) { + const startMinutes = timeToMinutes(start); + const endMinutes = timeToMinutes(end); + const candidates = [ + ...(includeCurrentEntries ? state.entries : []), + ...state.otherStructureEntries, + ...additionalEntries, + ]; + + return candidates.find((entry) => ( + entry.date === date + && startMinutes < timeToMinutes(entry.end) + && endMinutes > timeToMinutes(entry.start) + )) || null; + } + + function syncEntriesFromWeeklyForm({ silent = false } = {}) { + clearMessage(); + + const newEntries = []; + const errors = []; + + weeklyEntryBody?.querySelectorAll('.weekly-entry-row').forEach((row) => { + const day = state.weekDays.find((item) => item.date === row.dataset.date); + const ranges = Array.from(row.querySelectorAll('.weekly-time-range')); + + const filledRanges = ranges.map((range, index) => ({ + index, + motifId: Number(range.querySelector('.weekly-range-motif')?.value || 0), + start: range.querySelector('.weekly-range-start')?.value || '', + end: range.querySelector('.weekly-range-end')?.value || '', + })).filter((range) => range.start || range.end); + + filledRanges.forEach((range) => { + const label = `plage ${range.index + 1}`; + const { motifId, start, end } = range; + const motif = motifInfo(motifId); + + if (!motifId || !motif) { + errors.push(`${day?.label || row.dataset.date} ${label} : sélectionnez un motif.`); + return; + } + + if (!start || !end) { + errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`); + return; + } + + if (timeToMinutes(end) <= timeToMinutes(start)) { + errors.push(`${day?.label || row.dataset.date} ${label} : l’heure de fin doit être postérieure à l’heure de début.`); + return; + } + + const conflict = hasLocalOverlap(row.dataset.date, start, end, newEntries, false); + if (conflict) { + const location = conflict.structure_name + || state.currentStructure?.nom + || structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim() + || 'le lieu sélectionné'; + errors.push(`${day?.label || row.dataset.date} ${label} : chevauchement avec ${conflict.start}-${conflict.end} à ${location}.`); + return; + } + + newEntries.push({ + local_id: crypto.randomUUID(), + date: row.dataset.date, + motif_id: motifId, + motif_code: motif.code, + motif_label: motif.label, + compte_dans_quota: motif.quota, + start, + end, + }); + }); + }); + + if (errors.length) { + showMessage(errors.join('\n'), 'warning'); + return false; + } + + state.entries = newEntries; + render(); + setStatus(state.planningStatus ?? 'BROUILLON'); + + if (!silent) { + if (newEntries.length === 0) { + showMessage('Toutes les plages ont été supprimées du formulaire. Enregistrez le brouillon pour confirmer la suppression.', 'info'); + } else { + showMessage(`${newEntries.length} affectation${newEntries.length > 1 ? 's' : ''} mise${newEntries.length > 1 ? 's' : ''} à jour dans le planning. Pensez à enregistrer le brouillon.`, 'success'); + } + } + return true; + } + + function addEntry() { + syncEntriesFromWeeklyForm(); + } + + async function saveDraft({ silent = false } = {}) { + if (!canEditDraft) { + showMessage('Votre rôle ne permet pas de modifier ce planning.', 'warning'); + return null; + } + if (!isPlanningContextReady()) { + showMessage('Sélectionnez un lieu d’affectation, un agent et une semaine.', 'warning'); + return null; + } + + if (!syncEntriesFromWeeklyForm({ silent: true })) { + return null; + } + + if (saveButton) saveButton.disabled = true; + if (validateButton) validateButton.disabled = true; + + try { + const response = await fetch('save_planning.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + structure_id: Number(structureSelect.value), + agent_id: Number(agentSelect.value), + week: weekInput.value, + entries: state.entries.map((entry) => ({ + date: entry.date, + motif_id: entry.motif_id, + start: entry.start, + end: entry.end, + })), + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Enregistrement impossible.')); + + state.planningId = Number(data.planning_id); + if (data.quota) state.quota = data.quota; + state.savedCurrentCountedMinutes = countedMinutes(); + setStatus('BROUILLON'); + render(); + if (!silent) showMessage(data.message, 'success'); + PTA.modules.pendingValidation?.refresh?.({ silent: true }); + PTA.modules.coverage?.refresh?.({ silent: true }); + return state.planningId; + } catch (error) { + showMessage(error.message, 'error'); + setStatus(state.planningStatus); + return null; + } + } + + function controlsToText(controls) { + return controls + .filter((control) => control.niveau !== 'INFO') + .map((control) => `• ${control.message}`) + .join('\n'); + } + + async function validatePlanning(forceQuota = false) { + if (!canValidate) { + showMessage('Seul le Service Enfance peut valider un planning.', 'warning'); + return; + } + clearMessage(); + const planningId = await saveDraft({ silent: true }); + if (!planningId) return; + + try { + const response = await fetch('validate_planning.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + planning_id: planningId, + force_quota: forceQuota, + }), + }); + const data = await readJsonResponse(response); + + if (response.status === 409 && data.status === 'warning') { + const detail = controlsToText(data.controls || []); + const confirmed = window.confirm(`${data.message}\n\n${detail}\n\nVoulez-vous valider malgré cette alerte ?`); + if (confirmed) await validatePlanning(true); + return; + } + + if (!response.ok) { + const detail = controlsToText(data.controls || []); + throw new Error(detail || apiErrorMessage(data, 'Validation impossible.')); + } + + if (data.quota) state.quota = data.quota; + state.savedCurrentCountedMinutes = countedMinutes(); + setStatus('VALIDE'); + showMessage(data.message, 'success'); + render(); + PTA.modules.pendingValidation?.refresh?.({ silent: true }); + PTA.modules.coverage?.refresh?.({ silent: true }); + } catch (error) { + showMessage(error.message, 'error'); + setStatus('BROUILLON'); + } + } + + async function reopenPlanningForEdit() { + if (!canEditDraft) return; + clearMessage(); + + if (!state.planningId || state.planningStatus !== 'VALIDE') { + showMessage('Aucun planning validé à modifier.', 'warning'); + return; + } + + const confirmed = window.confirm( + 'Le planning va repasser en brouillon afin de pouvoir être corrigé.\n\n' + + 'Il devra être validé à nouveau après les modifications. Continuer ?' + ); + if (!confirmed) return; + + if (modifyButton) modifyButton.disabled = true; + + try { + const response = await fetch('reopen_planning.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + planning_id: state.planningId, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier ce planning.')); + + if (data.quota) state.quota = data.quota; + setStatus('BROUILLON'); + render(); + showMessage(data.message || 'Le planning est à nouveau modifiable.', 'success'); + PTA.modules.pendingValidation?.refresh?.({ silent: true }); + PTA.modules.coverage?.refresh?.({ silent: true }); + } catch (error) { + showMessage(error.message, 'error'); + setStatus('VALIDE'); + } + } + + async function handleWeekSelection() { + clearMessage(); + updateAgentPdfButton(); + const hasValidWeek = populateDays(); + setStatus(state.planningStatus); + render(); + + if (!hasValidWeek || !isPlanningContextReady()) return; + await loadPlanning(); + } + + function handleAgentChange() { + if (!weekInput) return; + weekInput.disabled = !agentSelect?.value; + state.entries = []; + state.otherStructureEntries = []; + state.currentStructure = null; + state.planningId = null; + state.weekDays = []; + state.quota = null; + state.savedCurrentCountedMinutes = 0; + renderWeeklyEntryRows(); + setStatus(null); + render(); + clearMessage(); + + updateAgentPdfButton(); + if (agentSelect?.value && weekInput.value) { + handleWeekSelection(); + } + } + + function handleWeeklyEntryClick(event) { + const addRangeButton = event.target.closest('.add-time-range'); + if (addRangeButton) { + const row = addRangeButton.closest('.weekly-entry-row'); + const container = row?.querySelector('.weekly-time-ranges'); + const day = state.weekDays.find((item) => item.date === row?.dataset.date); + if (!row || !container || !day || state.planningStatus === 'VALIDE') return; + const index = container.querySelectorAll('.weekly-time-range').length; + container.insertAdjacentHTML('beforeend', timeRangeHtml(day.label, index)); + updateEntryControlsState(); + return; + } + + const removeRangeButton = event.target.closest('.remove-time-range'); + if (removeRangeButton) { + const row = removeRangeButton.closest('.weekly-entry-row'); + const ranges = row?.querySelectorAll('.weekly-time-range'); + if (!row || !ranges || state.planningStatus === 'VALIDE') return; + if (ranges.length === 1) { + ranges[0].querySelectorAll('input, select').forEach((control) => { control.value = ''; }); + } else { + removeRangeButton.closest('.weekly-time-range')?.remove(); + } + } + } + + function init() { + structureSelect?.addEventListener('change', loadAgents); + agentSelect?.addEventListener('change', handleAgentChange); + weekInput?.addEventListener('input', handleWeekSelection); + weekInput?.addEventListener('change', handleWeekSelection); + if (canEditDraft) { + weeklyEntryBody?.addEventListener('click', handleWeeklyEntryClick); + addButton?.addEventListener('click', addEntry); + saveButton?.addEventListener('click', () => saveDraft()); + modifyButton?.addEventListener('click', reopenPlanningForEdit); + } + if (canValidate) validateButton?.addEventListener('click', () => validatePlanning(false)); + openAgentPdfButton?.addEventListener('click', openAgentPlanningPdf); + document.querySelector('#planning-entry-action-form')?.addEventListener('submit', submitEntryActionModal); + document.querySelector('#close-planning-entry-action-modal')?.addEventListener('click', closeEntryActionModal); + document.querySelector('#cancel-planning-entry-action')?.addEventListener('click', closeEntryActionModal); + document.querySelector('#planning-entry-action-modal')?.addEventListener('click', (event) => { + if (event.target.id === 'planning-entry-action-modal') closeEntryActionModal(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !document.querySelector('#planning-entry-action-modal')?.hidden) { + closeEntryActionModal(); + } + }); + + updateAgentPdfButton(); + renderWeeklyEntryRows(); + setStatus(null); + render(); + } + + PTA.modules.planning = { + init, + loadAgents, + loadPlanning, + render, + setStatus, + renderWeeklyEntryRows, + handleWeekSelection, + updateAgentPdfButton, + applyEntryDayAction, + openEntryActionModal, + saveDraft, + syncEntriesFromWeeklyForm, + isPlanningContextReady, + countedMinutes, + }; +})(); diff --git a/assets/js/pta.js b/assets/js/pta.js new file mode 100644 index 0000000..6f778ac --- /dev/null +++ b/assets/js/pta.js @@ -0,0 +1,184 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) return; + const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = PTA.utils; + let current = null; + + const el = (id) => document.getElementById(id); + const selectedAgentId = () => Number(el('pta-agent')?.value || 0); + + function show(text, type = 'info') { + const box = el('pta-message'); + if (!box) return; + box.className = `message message-${type}`; + box.textContent = text; + } + + function card(label, value, detail = '') { + return `
${escapeHtml(label)}${escapeHtml(value)}${detail ? `${escapeHtml(detail)}` : ''}
`; + } + + function renderSummary(data) { + current = data; + const agent = data.agent || {}; + const quota = data.quota || {}; + const counters = data.counters || {}; + el('pta-content').hidden = false; + el('pta-agent-name').textContent = `${agent.prenom || ''} ${agent.nom || ''}`.trim(); + el('pta-agent-meta').textContent = [ + agent.matricule, + agent.poste_libelle || 'Poste non renseigné', + agent.structure_principale_nom || 'Sans lieu principal', + agent.type_contrat === 'PERMANENT' ? 'Contrat permanent' : 'Contrat non permanent', + ].filter(Boolean).join(' — '); + + el('pta-summary-cards').innerHTML = [ + card('Année civile PTA', `${formatDate(quota.date_debut_periode)} → ${formatDate(quota.date_fin_periode)}`), + card('Quotité', `${Number(quota.quotite_travail || 0).toLocaleString('fr-FR')} %`, `Référence : ${formatDuration(Number(quota.quota_reference_minutes || 96420))}`), + card('Quota à réaliser', quota.quota_cible || formatDuration(Number(quota.quota_cible_minutes || 0))), + card('Déjà affecté', quota.affectees || formatDuration(Number(quota.minutes_affectees || 0))), + card('Reste à affecter', quota.restantes || formatDuration(Number(quota.minutes_restantes || 0))), + card('Formation', `${counters.formation_duree || '0 h 00'} / ${counters.formation_cible_duree || '0 h 00'}`), + card('Congés annuels', `${Number(counters.conges_annuels_jours || 0)} / ${Number(counters.conges_annuels_cible || 25)} jours`), + card('Fractionnement', `${Number(counters.jours_fractionnement || 0)} / ${Number(counters.jours_fractionnement_cible || 2)} jours`), + ].join(''); + + el('pta-category-body').innerHTML = (data.categories || []).map((category) => ` + ${escapeHtml(category.libelle)}${escapeHtml(category.duree)}${Number(category.jours || 0)} + `).join('') || 'Aucune affectation sur la période.'; + + el('pta-controls').innerHTML = (data.controls || []).map((control) => { + const level = String(control.niveau || 'INFO').toLowerCase(); + return `
${escapeHtml(control.niveau || 'INFO')}${escapeHtml(control.message || '')}
`; + }).join('') || '
Aucun contrôle disponible.
'; + + renderConstraints(data.constraints || []); + renderWishes(data.wishes || []); + + const refDate = el('pta-reference-date').value; + el('pta-open-agent-view').href = `agents.php?agent_id=${Number(agent.id_agent)}&month=${encodeURIComponent(refDate.slice(0, 7))}`; + el('pta-open-planning').href = `planning.php?agent_id=${Number(agent.id_agent)}`; + el('pta-constraint-start').value ||= quota.date_debut_periode || ''; + el('pta-constraint-end').value ||= quota.date_fin_periode || ''; + } + + function renderConstraints(items) { + el('pta-constraint-list').innerHTML = items.map((item) => ` +
+
${item.type_contrainte === 'TEMPS_PARTIEL_THERAPEUTIQUE' ? 'Temps partiel thérapeutique' : 'Préconisation médicale'} +

${escapeHtml(formatDate(item.date_debut))} → ${item.date_fin ? escapeHtml(formatDate(item.date_fin)) : 'sans fin'} — ${escapeHtml(item.commentaire)}

+ +
+ `).join('') || '
Aucune contrainte active sur cette période.
'; + } + + function renderWishes(items) { + el('pta-wish-list').innerHTML = items.map((item) => ` +
+
${item.type_souhait === 'INTERDICTION' ? 'Interdiction' : 'Préférence'} — ${escapeHtml(item.structure_nom)} +

Priorité ${Number(item.priorite)}${item.distance_km !== null ? ` — ${Number(item.distance_km).toLocaleString('fr-FR')} km` : ''}${item.commentaire ? ` — ${escapeHtml(item.commentaire)}` : ''}

+ +
+ `).join('') || '
Aucune préférence ou interdiction enregistrée.
'; + } + + async function load() { + const agentId = selectedAgentId(); + const date = el('pta-reference-date')?.value; + if (!agentId || !date) { + show('Choisissez un agent et une date de référence.', 'error'); + return; + } + show('Calcul du PTA en cours…'); + try { + const response = await fetch(`api/pta_summary.php?agent_id=${agentId}&date=${encodeURIComponent(date)}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le PTA.')); + renderSummary(data); + show('PTA actualisé.', 'success'); + } catch (error) { + show(error.message, 'error'); + } + } + + async function post(url, payload) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, ...payload }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Opération impossible.')); + return data; + } + + async function saveConstraint(event) { + event.preventDefault(); + if (!selectedAgentId()) return; + try { + await post('api/pta_constraint.php', { + agent_id: selectedAgentId(), + type_contrainte: el('pta-constraint-type').value, + date_debut: el('pta-constraint-start').value, + date_fin: el('pta-constraint-end').value, + quotite_temporaire: el('pta-constraint-rate').value, + maximum_heures_jour: el('pta-constraint-day-max').value, + maximum_heures_semaine: el('pta-constraint-week-max').value, + interdit_matin: el('pta-forbid-morning').checked, + interdit_midi: el('pta-forbid-midday').checked, + interdit_soir: el('pta-forbid-evening').checked, + commentaire: el('pta-constraint-comment').value, + }); + event.currentTarget.reset(); + await load(); + } catch (error) { show(error.message, 'error'); } + } + + async function saveWish(event) { + event.preventDefault(); + if (!selectedAgentId()) return; + try { + await post('api/pta_wish.php', { + agent_id: selectedAgentId(), + structure_id: Number(el('pta-wish-structure').value), + type_souhait: el('pta-wish-type').value, + priorite: Number(el('pta-wish-priority').value), + distance_km: el('pta-wish-distance').value, + commentaire: el('pta-wish-comment').value, + }); + event.currentTarget.reset(); + el('pta-wish-priority').value = '1'; + await load(); + } catch (error) { show(error.message, 'error'); } + } + + async function handleDelete(event) { + const constraint = event.target.closest('.pta-delete-constraint'); + const wish = event.target.closest('.pta-delete-wish'); + if (!constraint && !wish) return; + if (!window.confirm('Confirmer la suppression ?')) return; + try { + if (constraint) { + await post('api/pta_constraint.php', { action: 'DELETE', agent_id: selectedAgentId(), constraint_id: Number(constraint.dataset.id) }); + } else { + await post('api/pta_wish.php', { action: 'DELETE', agent_id: selectedAgentId(), wish_id: Number(wish.dataset.id) }); + } + await load(); + } catch (error) { show(error.message, 'error'); } + } + + function init() { + el('load-pta')?.addEventListener('click', load); + el('pta-constraint-form')?.addEventListener('submit', saveConstraint); + el('pta-wish-form')?.addEventListener('submit', saveWish); + el('pta-content')?.addEventListener('click', handleDelete); + const params = window.PTA_PAGE_CONFIG?.params || {}; + if (params.agentId) el('pta-agent').value = String(params.agentId); + if (params.date) el('pta-reference-date').value = params.date; + if (params.agentId) load(); + } + + PTA.modules.pta = { init, load }; +})(); diff --git a/assets/js/structure-overview.js b/assets/js/structure-overview.js new file mode 100644 index 0000000..f6ad730 --- /dev/null +++ b/assets/js/structure-overview.js @@ -0,0 +1,177 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant structure-overview.js'); + + const { openStructurePdfButton } = PTA.elements; + const { escapeHtml, readJsonResponse, apiErrorMessage, parseIsoWeek, formatDate } = PTA.utils; + const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire'; + + function entryBadge(entry) { + return ` +
+ ${escapeHtml(entry.heure_debut)}–${escapeHtml(entry.heure_fin)} + ${escapeHtml(entry.motif_libelle)} + ${entry.statut === 'BROUILLON' ? 'Brouillon' : ''} +
+ `; + } + + function coveragePanel(coverage) { + if (!coverage?.configured) { + return ` +
+ Couverture non configurée + Définissez les horaires de présence minimale dans Administration pour détecter automatiquement les créneaux vides. +
`; + } + + const gaps = Array.isArray(coverage.gaps) ? coverage.gaps : []; + if (!gaps.length) { + return ` +
+ Couverture complète + Aucun manque d’agent détecté sur les plages de couverture configurées. +
`; + } + + const items = gaps.slice(0, 8).map((gap) => ` +
  • ${escapeHtml(gap.jour_libelle)} ${escapeHtml(gap.heure_debut)}–${escapeHtml(gap.heure_fin)} : ${Number(gap.agents_manquants)} agent${Number(gap.agents_manquants) > 1 ? 's' : ''} manquant${Number(gap.agents_manquants) > 1 ? 's' : ''}
  • + `).join(''); + const more = gaps.length > 8 ? `
  • … et ${gaps.length - 8} autre${gaps.length - 8 > 1 ? 's' : ''} plage${gaps.length - 8 > 1 ? 's' : ''}.
  • ` : ''; + return ` +
    + ⚠ ${gaps.length} plage${gaps.length > 1 ? 's' : ''} insuffisamment couverte${gaps.length > 1 ? 's' : ''} +
      ${items}${more}
    +
    `; + } + + function render(data) { + const container = document.querySelector('#structure-overview-content'); + const empty = document.querySelector('#structure-overview-empty'); + const days = parseIsoWeek(data.week.value); + if (!container || !empty) return; + + empty.hidden = true; + container.hidden = false; + + if (!data.agents.length) { + container.innerHTML = ` +
    +
    +

    ${escapeHtml(data.structure.nom)}

    +

    ${typeLabel(data.structure.type_affectation)} · semaine du ${formatDate(days[0]?.date)} au ${formatDate(days[days.length - 1]?.date)}

    +
    + 0 agent +
    + ${coveragePanel(data.coverage)} +
    Aucun agent n’est rattaché ou planifié sur ce lieu pour cette semaine.
    `; + return; + } + + const headerDays = days.map((day) => `${day.label}${day.display}`).join(''); + const rows = data.agents.map((agent) => { + const cells = days.map((day) => { + const entries = agent.entries.filter((entry) => entry.date_jour === day.date); + return `${entries.length ? entries.map((entry) => entryBadge(entry)).join('') : ''}`; + }).join(''); + + return ` + + + ${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)} + ${escapeHtml(agent.matricule)} + ${agent.lieu_principal ? 'Lieu principal' : 'Affectation ponctuelle'} + + ${cells} + + ${escapeHtml(agent.quota.restantes.libelle)} + sur ${escapeHtml(agent.quota.quota_cible.libelle)} + ${Number(agent.quota.quotite_travail).toLocaleString('fr-FR')} % + + + `; + }).join(''); + + container.innerHTML = ` +
    +
    +

    ${escapeHtml(data.structure.nom)}

    +

    ${typeLabel(data.structure.type_affectation)} · semaine du ${formatDate(days[0]?.date)} au ${formatDate(days[days.length - 1]?.date)}

    +
    + ${data.agents.length} agent${data.agents.length > 1 ? 's' : ''} +
    + ${coveragePanel(data.coverage)} +
    + + + + + ${headerDays} + + + + ${rows} +
    AgentQuota restant
    +
    + `; + } + + function updatePdfButton() { + if (!openStructurePdfButton) return; + const structureId = document.querySelector('#overview-structure')?.value; + const week = document.querySelector('#overview-week')?.value; + openStructurePdfButton.disabled = !(structureId && week); + } + + function openPdf() { + const structureId = document.querySelector('#overview-structure')?.value; + const week = document.querySelector('#overview-week')?.value; + if (!structureId || !week) return; + + const params = new URLSearchParams({ structure_id: structureId, week }); + window.open(`api/structure_planning_pdf.php?${params.toString()}`, '_blank', 'noopener'); + } + + async function load() { + const structureId = document.querySelector('#overview-structure')?.value; + const week = document.querySelector('#overview-week')?.value; + const container = document.querySelector('#structure-overview-content'); + const empty = document.querySelector('#structure-overview-empty'); + updatePdfButton(); + if (!container || !empty) return; + + if (!structureId || !week) { + empty.hidden = false; + empty.textContent = 'Choisissez un lieu d’affectation et une semaine.'; + container.hidden = true; + return; + } + + empty.hidden = false; + empty.textContent = 'Chargement du planning du lieu…'; + container.hidden = true; + + try { + const params = new URLSearchParams({ structure_id: structureId, week }); + const response = await fetch(`api/structure_overview.php?${params.toString()}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue du lieu.')); + render(data); + } catch (error) { + empty.hidden = false; + empty.textContent = error.message; + } + } + + function init() { + document.querySelector('#load-structure-overview')?.addEventListener('click', load); + document.querySelector('#overview-structure')?.addEventListener('change', load); + document.querySelector('#overview-week')?.addEventListener('change', load); + openStructurePdfButton?.addEventListener('click', openPdf); + updatePdfButton(); + } + + PTA.modules.structureOverview = { init, load, render, updatePdfButton }; +})(); diff --git a/assets/js/teams.js b/assets/js/teams.js new file mode 100644 index 0000000..eac3181 --- /dev/null +++ b/assets/js/teams.js @@ -0,0 +1,158 @@ +(() => { + 'use strict'; + const PTA = window.PTA; + if (!PTA) return; + const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils; + const el = (id) => document.getElementById(id); + + function message(text, type = 'info') { + const box = el('team-list-message'); + if (!box) return; + box.className = `message message-${type}`; + box.textContent = text; + } + + async function api(url, options = {}) { + const response = await fetch(url, options); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Opération impossible.')); + return data; + } + + function controlHtml(control) { + const level = String(control.niveau || 'INFO').toLowerCase(); + return `
    ${escapeHtml(control.niveau)}${escapeHtml(control.message)}
    `; + } + + function teamHtml(team) { + const options = el('team-agent-options')?.innerHTML || ''; + const members = (team.members || []).map((member) => ` + + ${escapeHtml(member.prenom)} ${escapeHtml(member.nom)}
    ${escapeHtml(member.poste_libelle || 'Poste non renseigné')} + ${member.est_diplome ? `Diplômé${member.diplome_libelle ? ` — ${escapeHtml(member.diplome_libelle)}` : ''}` : 'Non diplômé'} + ${escapeHtml(member.fonction_equipe)} + ${escapeHtml(member.structure_principale_nom || 'Sans lieu principal')} + + + `).join('') || 'Aucun agent affecté.'; + return ` +
    +
    +

    ${escapeHtml(team.libelle)}

    ${escapeHtml(team.structure_nom)} — ${formatDate(team.date_debut)} au ${formatDate(team.date_fin)} — ${escapeHtml(team.type_equipe)}

    + +
    +
    +
    Agents requis${Number(team.agents_requis)}
    +
    Agents affectés${Number(team.agents_affectes)}
    +
    Diplômés${Number(team.agents_diplomes || 0)}
    +
    Enfants${Number(team.nombre_enfants_moins_6) + Number(team.nombre_enfants_6_plus)}
    +
    +
    ${(team.controls || []).map(controlHtml).join('')}
    +
    + + + + +
    +
    ${members}
    AgentQualificationFonctionLieu principalAction
    +
    `; + } + + async function load() { + const start = el('team-filter-start')?.value || ''; + const end = el('team-filter-end')?.value || ''; + const query = new URLSearchParams(); + if (start) query.set('date_debut', start); + if (end) query.set('date_fin', end); + message('Chargement des équipes…'); + try { + const data = await api(`api/teams.php?${query.toString()}`); + el('team-list').innerHTML = (data.teams || []).map(teamHtml).join('') || '
    Aucune équipe sur cette période.
    '; + message(`${(data.teams || []).length} équipe(s) affichée(s).`, 'success'); + } catch (error) { message(error.message, 'error'); } + } + + async function create(event) { + event.preventDefault(); + const box = el('team-create-message'); + try { + const data = await api('api/teams.php', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + structure_id: Number(el('team-structure').value), + type_equipe: el('team-type').value, + libelle: el('team-label').value, + date_debut: el('team-start').value, + date_fin: el('team-end').value, + nombre_enfants_moins_6: Number(el('team-under-six').value), + nombre_enfants_6_plus: Number(el('team-over-six').value), + ratio_moins_6: Number(el('team-ratio-under-six').value), + ratio_6_plus: Number(el('team-ratio-over-six').value), + minimum_agents: Number(el('team-min-agents').value), + pourcentage_diplomes_min: Number(el('team-qualified-rate').value), + commentaire: el('team-comment').value, + }), + }); + box.className = 'message message-success'; box.textContent = data.message; + event.currentTarget.reset(); + el('team-ratio-under-six').value = '8'; el('team-ratio-over-six').value = '12'; el('team-min-agents').value = '1'; el('team-qualified-rate').value = '50'; + await load(); + } catch (error) { box.className = 'message message-error'; box.textContent = error.message; } + } + + async function handle(event) { + const card = event.target.closest('.team-card'); + if (!card) return; + const teamId = Number(card.dataset.teamId); + if (event.type === 'submit' && event.target.matches('.team-member-form')) { + event.preventDefault(); + try { + await api('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, team_id: teamId, + agent_id: Number(card.querySelector('.team-member-agent').value), + fonction_equipe: card.querySelector('.team-member-function').value, + commentaire: card.querySelector('.team-member-comment').value, + }) }); + await load(); + } catch (error) { message(error.message, 'error'); } + return; + } + const remove = event.target.closest('.remove-team-member'); + if (remove) { + if (!window.confirm('Retirer cet agent de l’équipe ?')) return; + try { + await api('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, action: 'REMOVE', team_id: teamId, agent_id: Number(remove.dataset.agentId), + }) }); + await load(); + } catch (error) { message(error.message, 'error'); } + } + } + + async function status(event) { + const select = event.target.closest('.team-status'); + if (!select) return; + const card = select.closest('.team-card'); + try { + await api('api/team_status.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, team_id: Number(card.dataset.teamId), statut: select.value, + }) }); + await load(); + } catch (error) { message(error.message, 'error'); } + } + + function init() { + const params = window.PTA_PAGE_CONFIG?.params || {}; + if (params.dateDebut) el('team-filter-start').value = params.dateDebut; + if (params.dateFin) el('team-filter-end').value = params.dateFin; + el('team-create-form')?.addEventListener('submit', create); + el('load-teams')?.addEventListener('click', load); + el('team-list')?.addEventListener('submit', handle); + el('team-list')?.addEventListener('click', handle); + el('team-list')?.addEventListener('change', status); + load(); + } + + PTA.modules.teams = { init, load }; +})(); diff --git a/assets/js/vacation-calendar.js b/assets/js/vacation-calendar.js new file mode 100644 index 0000000..04eeb99 --- /dev/null +++ b/assets/js/vacation-calendar.js @@ -0,0 +1,263 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant vacation-calendar.js'); + + const { escapeHtml, readJsonResponse, apiErrorMessage, formatDate } = PTA.utils; + let previewPeriods = []; + + const elements = () => ({ + academy: document.querySelector('#vacation-academy'), + calendarYear: document.querySelector('#vacation-calendar-year'), + previewButton: document.querySelector('#preview-vacations'), + previewMessage: document.querySelector('#vacation-preview-message'), + previewPanel: document.querySelector('#vacation-preview-panel'), + previewBody: document.querySelector('#vacation-preview-body'), + selectAll: document.querySelector('#vacation-select-all'), + saveSelected: document.querySelector('#save-selected-vacations'), + savedBody: document.querySelector('#vacation-saved-body'), + savedMessage: document.querySelector('#vacation-saved-message'), + refresh: document.querySelector('#refresh-vacations'), + manualForm: document.querySelector('#manual-vacation-form'), + manualMessage: document.querySelector('#manual-vacation-message'), + }); + + function setMessage(element, text = '', type = 'info') { + if (!element) return; + element.className = text ? `message message-${type}` : 'message'; + element.textContent = text; + } + + function sourceLabel(source) { + return source === 'DATA_GOUV' ? 'API officielle' : 'Saisie manuelle'; + } + + function renderPreview() { + const el = elements(); + if (!el.previewBody || !el.previewPanel) return; + + if (!previewPeriods.length) { + el.previewBody.innerHTML = '
    Aucune période proposée.
    '; + el.previewPanel.hidden = false; + return; + } + + el.previewBody.innerHTML = previewPeriods.map((period, index) => ` + + + + + ${escapeHtml(period.annee_scolaire || '')} + + + + + ${period.date_reprise_api ? `Reprise API : ${escapeHtml(formatDate(period.date_reprise_api))}` : ''} + + ${escapeHtml([period.zone, period.academie].filter(Boolean).join(' · ') || 'Non précisé')} + + `).join(''); + el.previewPanel.hidden = false; + if (el.selectAll) el.selectAll.checked = false; + } + + async function preview() { + const el = elements(); + const academy = el.academy?.value.trim() || ''; + const calendarYear = String(el.calendarYear?.value || '').trim(); + if (!academy || !/^\d{4}$/.test(calendarYear)) { + setMessage(el.previewMessage, 'Renseignez une académie et une année civile au format 2026.', 'warning'); + return; + } + + el.previewButton.disabled = true; + setMessage(el.previewMessage, 'Consultation de l’API officielle en cours…', 'info'); + if (el.previewPanel) el.previewPanel.hidden = true; + + try { + const params = new URLSearchParams({ academy, year: calendarYear }); + const response = await fetch(`api/preview_school_holidays.php?${params.toString()}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de consulter le calendrier scolaire.')); + + previewPeriods = Array.isArray(data.periods) ? data.periods : []; + renderPreview(); + const warnings = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : []; + const baseMessage = data.message || `${previewPeriods.length} période(s) proposée(s). Aucune donnée n’a encore été enregistrée.`; + const fullMessage = warnings.length ? `${baseMessage} Attention : ${warnings.join(' ')}` : baseMessage; + setMessage(el.previewMessage, fullMessage, warnings.length ? 'warning' : (previewPeriods.length ? 'success' : 'warning')); + } catch (error) { + previewPeriods = []; + setMessage(el.previewMessage, error.message, 'error'); + } finally { + el.previewButton.disabled = false; + } + } + + function selectedPreviewPeriods() { + const el = elements(); + return Array.from(el.previewBody?.querySelectorAll('.vacation-preview-row') || []) + .filter((row) => row.querySelector('.vacation-import-check')?.checked) + .map((row) => { + const source = previewPeriods[Number(row.dataset.index)] || {}; + return { + libelle: row.querySelector('.vacation-preview-label')?.value.trim() || '', + date_debut: row.querySelector('.vacation-preview-start')?.value || '', + date_fin: row.querySelector('.vacation-preview-end')?.value || '', + annee_scolaire: source.annee_scolaire || '', + academie: source.academie || elements().academy?.value.trim() || '', + zone: source.zone || '', + source: 'DATA_GOUV', + }; + }); + } + + async function savePeriods(periods, messageElement) { + const response = await fetch('api/save_vacation_periods.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + periods, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer les périodes.')); + setMessage(messageElement, data.message, 'success'); + await loadSaved(); + return data; + } + + async function saveSelected() { + const el = elements(); + const periods = selectedPreviewPeriods(); + if (!periods.length) { + setMessage(el.previewMessage, 'Cochez au moins une période avant de l’enregistrer.', 'warning'); + return; + } + + el.saveSelected.disabled = true; + try { + await savePeriods(periods, el.previewMessage); + el.previewBody?.querySelectorAll('.vacation-import-check').forEach((checkbox) => { checkbox.checked = false; }); + if (el.selectAll) el.selectAll.checked = false; + } catch (error) { + setMessage(el.previewMessage, error.message, 'error'); + } finally { + el.saveSelected.disabled = false; + } + } + + async function saveManual(event) { + event.preventDefault(); + const el = elements(); + const submit = el.manualForm?.querySelector('button[type="submit"]'); + const period = { + libelle: document.querySelector('#manual-vacation-label')?.value.trim() || '', + date_debut: document.querySelector('#manual-vacation-start')?.value || '', + date_fin: document.querySelector('#manual-vacation-end')?.value || '', + annee_scolaire: document.querySelector('#manual-vacation-school-year')?.value.trim() || '', + academie: document.querySelector('#manual-vacation-academy')?.value.trim() || '', + zone: document.querySelector('#manual-vacation-zone')?.value.trim() || '', + source: 'MANUEL', + }; + + if (period.date_debut && period.date_fin && period.date_fin < period.date_debut) { + setMessage(el.manualMessage, 'La date de fin doit être postérieure ou égale à la date de début.', 'warning'); + return; + } + + if (submit) submit.disabled = true; + try { + await savePeriods([period], el.manualMessage); + document.querySelector('#manual-vacation-label').value = ''; + document.querySelector('#manual-vacation-start').value = ''; + document.querySelector('#manual-vacation-end').value = ''; + document.querySelector('#manual-vacation-zone').value = ''; + } catch (error) { + setMessage(el.manualMessage, error.message, 'error'); + } finally { + if (submit) submit.disabled = false; + } + } + + function renderSaved(periods) { + const el = elements(); + if (!el.savedBody) return; + if (!periods.length) { + el.savedBody.innerHTML = '
    Aucune période de vacances n’est encore enregistrée.
    '; + return; + } + + el.savedBody.innerHTML = periods.map((period) => ` + + ${escapeHtml(period.libelle)} + ${escapeHtml(formatDate(period.date_debut))} → ${escapeHtml(formatDate(period.date_fin))} + ${escapeHtml(period.annee_scolaire || '')} + ${escapeHtml([period.academie, period.zone].filter(Boolean).join(' · ') || 'Non précisé')} + ${escapeHtml(sourceLabel(period.source))} + + + `).join(''); + } + + async function loadSaved() { + const el = elements(); + try { + const response = await fetch('api/vacation_periods.php'); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les périodes enregistrées.')); + renderSaved(Array.isArray(data.periods) ? data.periods : []); + setMessage(el.savedMessage, '', 'info'); + } catch (error) { + setMessage(el.savedMessage, error.message, 'error'); + } + } + + async function deletePeriod(button) { + const row = button.closest('tr[data-period-id]'); + const id = Number(row?.dataset.periodId || 0); + if (!id || !confirm('Supprimer cette période de vacances ? Les journées concernées seront à nouveau considérées comme périscolaires.')) return; + + button.disabled = true; + try { + const response = await fetch('api/delete_vacation_period.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + period_id: id, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Suppression impossible.')); + setMessage(elements().savedMessage, data.message, 'success'); + await loadSaved(); + } catch (error) { + setMessage(elements().savedMessage, error.message, 'error'); + button.disabled = false; + } + } + + function init() { + const el = elements(); + el.previewButton?.addEventListener('click', preview); + el.saveSelected?.addEventListener('click', saveSelected); + el.refresh?.addEventListener('click', loadSaved); + el.manualForm?.addEventListener('submit', saveManual); + el.selectAll?.addEventListener('change', () => { + el.previewBody?.querySelectorAll('.vacation-import-check').forEach((checkbox) => { + checkbox.checked = el.selectAll.checked; + }); + }); + el.savedBody?.addEventListener('click', (event) => { + const button = event.target.closest('.delete-vacation-period'); + if (button) deletePeriod(button); + }); + + loadSaved(); + } + + PTA.modules.vacationCalendar = { init, loadSaved, preview }; +})(); diff --git a/assets/js/week-templates.js b/assets/js/week-templates.js new file mode 100644 index 0000000..ee0f90b --- /dev/null +++ b/assets/js/week-templates.js @@ -0,0 +1,691 @@ +(() => { + 'use strict'; + + const PTA = window.PTA; + if (!PTA) throw new Error('PTA core doit être chargé avant week-templates.js'); + + const { state } = PTA; + const { agentSelect, weekInput } = PTA.elements; + const { showMessage, readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils; + + const saveButton = document.querySelector('#save-week-template'); + const applyButton = document.querySelector('#apply-week-template'); + + const saveModal = document.querySelector('#save-week-template-modal'); + const saveForm = document.querySelector('#save-week-template-form'); + const saveNameInput = document.querySelector('#week-template-name'); + const saveContext = document.querySelector('#save-week-template-context'); + const saveMessage = document.querySelector('#save-week-template-message'); + + const applyModal = document.querySelector('#apply-week-template-modal'); + const applyForm = document.querySelector('#apply-week-template-form'); + const templateSelect = document.querySelector('#week-template-select'); + const templateDetails = document.querySelector('#week-template-details'); + const replaceWarning = document.querySelector('#week-template-replace-warning'); + const deleteButton = document.querySelector('#delete-week-template'); + const applyMessage = document.querySelector('#apply-week-template-message'); + + const periodButton = document.querySelector('#apply-week-template-period'); + const periodModal = document.querySelector('#apply-week-template-period-modal'); + const periodForm = document.querySelector('#apply-week-template-period-form'); + const periodTemplateSelect = document.querySelector('#week-template-period-select'); + const periodStartInput = document.querySelector('#week-template-period-start'); + const periodEndInput = document.querySelector('#week-template-period-end'); + const periodReplaceWarning = document.querySelector('#week-template-period-replace-warning'); + const periodPreviewButton = document.querySelector('#preview-week-template-period'); + const periodPreviewPanel = document.querySelector('#week-template-period-preview'); + const periodConfirmButton = document.querySelector('#confirm-apply-week-template-period'); + const periodMessage = document.querySelector('#apply-week-template-period-message'); + + let templates = []; + let lastPeriodPreview = null; + let lastPeriodPreviewSignature = ''; + + function contextReady() { + return Boolean(agentSelect?.value && weekInput?.value); + } + + function globalEntryCount() { + return (state.entries?.length || 0) + (state.otherStructureEntries?.length || 0); + } + + function refreshButtons() { + const ready = contextReady(); + if (saveButton) saveButton.disabled = !ready; + if (applyButton) applyButton.disabled = !ready; + if (periodButton) periodButton.disabled = !ready; + } + + function setInlineMessage(element, text = '', type = 'info') { + if (!element) return; + element.className = text ? `message message-${type}` : 'message'; + element.textContent = text; + } + + function updateBodyModalState() { + const anyOpen = Array.from(document.querySelectorAll('.modal-backdrop')) + .some((modal) => !modal.hidden); + document.body.classList.toggle('modal-open', anyOpen); + } + + function closeModal(modal) { + if (!modal) return; + modal.hidden = true; + updateBodyModalState(); + } + + function openModal(modal) { + if (!modal) return; + modal.hidden = false; + document.body.classList.add('modal-open'); + } + + function agentLabel() { + return agentSelect?.options[agentSelect.selectedIndex]?.textContent?.trim() || 'Agent sélectionné'; + } + + async function persistCurrentEditorIfNeeded() { + const planning = PTA.modules.planning; + if (!planning || state.planningStatus === 'VALIDE') return true; + if (!planning.isPlanningContextReady?.()) return true; + + const planningId = await planning.saveDraft?.({ silent: true }); + return planningId !== null && planningId !== undefined; + } + + async function openSaveModal() { + if (!contextReady()) { + showMessage('Sélectionnez un agent et une semaine avant d’enregistrer une semaine type.', 'warning'); + return; + } + if (state.planningStatus !== 'VALIDE') { + const synced = PTA.modules.planning?.syncEntriesFromWeeklyForm?.({ silent: true }); + if (synced === false) return; + } + + if (globalEntryCount() === 0) { + showMessage('La semaine ne contient aucune affectation à enregistrer comme modèle.', 'warning'); + return; + } + + if (saveNameInput) saveNameInput.value = ''; + if (saveContext) { + saveContext.innerHTML = ` + ${escapeHtml(agentLabel())} + Semaine ${escapeHtml(weekInput.value)} · ${globalEntryCount()} créneau${globalEntryCount() > 1 ? 'x' : ''} actuellement affiché${globalEntryCount() > 1 ? 's' : ''} + `; + } + setInlineMessage(saveMessage); + openModal(saveModal); + setTimeout(() => saveNameInput?.focus(), 0); + } + + async function saveTemplate(replace = false) { + const name = saveNameInput?.value?.trim() || ''; + if (!name) { + setInlineMessage(saveMessage, 'Saisissez un nom pour la semaine type.', 'warning'); + saveNameInput?.focus(); + return; + } + + const submitButton = saveForm?.querySelector('button[type="submit"]'); + if (submitButton) submitButton.disabled = true; + setInlineMessage(saveMessage, 'Enregistrement du modèle...', 'info'); + + try { + const persisted = await persistCurrentEditorIfNeeded(); + if (!persisted) { + throw new Error('Le planning courant n’a pas pu être enregistré avant la création du modèle.'); + } + + const response = await fetch('api/save_week_template.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + agent_id: Number(agentSelect.value), + week: weekInput.value, + name, + replace, + }), + }); + const data = await readJsonResponse(response); + + if (response.status === 409 && data.code === 'TEMPLATE_EXISTS' && !replace) { + const confirmed = window.confirm(`Une semaine type nommée « ${name} » existe déjà pour cet agent. Voulez-vous la remplacer par la semaine actuelle ?`); + if (confirmed) { + await saveTemplate(true); + } else { + setInlineMessage(saveMessage, 'Enregistrement annulé.', 'info'); + } + return; + } + + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer la semaine type.')); + + setInlineMessage(saveMessage, data.message, 'success'); + showMessage(data.message, 'success'); + setTimeout(() => closeModal(saveModal), 450); + } catch (error) { + setInlineMessage(saveMessage, error.message, 'error'); + } finally { + if (submitButton) submitButton.disabled = false; + } + } + + async function loadTemplates() { + if (!agentSelect?.value) return []; + + const response = await fetch(`api/week_templates.php?agent_id=${encodeURIComponent(agentSelect.value)}`); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les semaines types.')); + templates = Array.isArray(data.templates) ? data.templates : []; + return templates; + } + + function renderTemplateSelect() { + if (!templateSelect) return; + if (!templates.length) { + templateSelect.innerHTML = ''; + templateSelect.disabled = true; + if (deleteButton) deleteButton.disabled = true; + if (templateDetails) templateDetails.innerHTML = '

    Créez d’abord une semaine type depuis une semaine déjà planifiée.

    '; + return; + } + + templateSelect.disabled = false; + templateSelect.innerHTML = '' + templates.map((template) => ( + `` + )).join(''); + renderSelectedTemplateDetails(); + } + + function renderSelectedTemplateDetails() { + const selectedId = Number(templateSelect?.value || 0); + const template = templates.find((item) => Number(item.id) === selectedId); + if (deleteButton) deleteButton.disabled = !template; + + if (!templateDetails) return; + if (!template) { + templateDetails.innerHTML = '

    Sélectionnez un modèle pour l’appliquer à la semaine affichée.

    '; + return; + } + + templateDetails.innerHTML = ` +
    + ${escapeHtml(template.name)} + ${Number(template.entry_count)} créneau${Number(template.entry_count) > 1 ? 'x' : ''} · ${Number(template.location_count)} lieu${Number(template.location_count) > 1 ? 'x' : ''} +
    + `; + } + + async function openApplyModal() { + if (!contextReady()) { + showMessage('Sélectionnez un agent et la semaine cible avant d’importer une semaine type.', 'warning'); + return; + } + + setInlineMessage(applyMessage, 'Chargement des modèles...', 'info'); + if (templateSelect) { + templateSelect.disabled = true; + templateSelect.innerHTML = ''; + } + openModal(applyModal); + + try { + await loadTemplates(); + renderTemplateSelect(); + setInlineMessage(applyMessage); + } catch (error) { + setInlineMessage(applyMessage, error.message, 'error'); + } + } + + async function applyTemplate() { + const templateId = Number(templateSelect?.value || 0); + const mode = applyForm?.querySelector('input[name="week-template-mode"]:checked')?.value || 'merge'; + const template = templates.find((item) => Number(item.id) === templateId); + + if (!templateId || !template) { + setInlineMessage(applyMessage, 'Sélectionnez une semaine type.', 'warning'); + return; + } + + if (mode === 'replace') { + const confirmed = window.confirm( + `Le mode « Remplacer » va supprimer toutes les affectations actuelles de ${agentLabel()} pour ${weekInput.value}, tous lieux confondus, puis appliquer « ${template.name} ».\n\nContinuer ?` + ); + if (!confirmed) return; + } + + const submitButton = applyForm?.querySelector('button[type="submit"]'); + if (submitButton) submitButton.disabled = true; + if (deleteButton) deleteButton.disabled = true; + setInlineMessage(applyMessage, 'Import de la semaine type...', 'info'); + + try { + // En mode compléter, persister d'abord les éventuelles modifications locales non encore enregistrées. + if (mode === 'merge') { + const persisted = await persistCurrentEditorIfNeeded(); + if (!persisted) { + throw new Error('Le planning courant n’a pas pu être enregistré avant l’import.'); + } + } + + const response = await fetch('api/apply_week_template.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + template_id: templateId, + agent_id: Number(agentSelect.value), + week: weekInput.value, + mode, + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’importer la semaine type.')); + + if (data.quota) state.quota = data.quota; + await PTA.modules.planning?.loadPlanning?.(); + setInlineMessage(applyMessage, data.message, 'success'); + showMessage(data.message, 'success'); + setTimeout(() => closeModal(applyModal), 550); + } catch (error) { + setInlineMessage(applyMessage, error.message, 'error'); + } finally { + if (submitButton) submitButton.disabled = false; + renderSelectedTemplateDetails(); + } + } + + function periodMode() { + return periodForm?.querySelector('input[name="week-template-period-mode"]:checked')?.value || 'merge'; + } + + function periodFilter() { + return periodForm?.querySelector('input[name="week-template-period-filter"]:checked')?.value || 'all'; + } + + function weekMonday(weekValue) { + const match = /^(\d{4})-W(\d{2})$/.exec(weekValue || ''); + if (!match) return null; + const year = Number(match[1]); + const week = Number(match[2]); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4Day = jan4.getUTCDay() || 7; + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7); + return monday; + } + + function dateInputValue(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ''; + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + function formatDateFr(value) { + if (!value) return ''; + const date = new Date(`${value}T12:00:00`); + return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date); + } + + function invalidatePeriodPreview() { + lastPeriodPreview = null; + lastPeriodPreviewSignature = ''; + if (periodConfirmButton) periodConfirmButton.disabled = true; + if (periodPreviewPanel) { + periodPreviewPanel.hidden = true; + periodPreviewPanel.innerHTML = ''; + } + setInlineMessage(periodMessage); + updatePeriodReplaceWarning(); + } + + function currentPeriodSignature() { + return JSON.stringify({ + template_id: Number(periodTemplateSelect?.value || 0), + start_date: periodStartInput?.value || '', + end_date: periodEndInput?.value || '', + mode: periodMode(), + period_filter: periodFilter(), + }); + } + + function renderPeriodTemplateSelect() { + if (!periodTemplateSelect) return; + if (!templates.length) { + periodTemplateSelect.innerHTML = ''; + periodTemplateSelect.disabled = true; + return; + } + periodTemplateSelect.disabled = false; + periodTemplateSelect.innerHTML = '' + templates.map((template) => ( + `` + )).join(''); + } + + function setDefaultPeriodDates() { + const monday = weekMonday(weekInput?.value); + if (!monday) return; + if (periodStartInput && !periodStartInput.value) periodStartInput.value = dateInputValue(monday); + if (periodEndInput && !periodEndInput.value) { + const friday = new Date(monday); + friday.setUTCDate(monday.getUTCDate() + 4); + periodEndInput.value = dateInputValue(friday); + } + } + + async function openPeriodModal() { + if (!contextReady()) { + showMessage('Sélectionnez un agent et une semaine avant d’appliquer une semaine type sur une période.', 'warning'); + return; + } + + invalidatePeriodPreview(); + setDefaultPeriodDates(); + if (periodTemplateSelect) { + periodTemplateSelect.disabled = true; + periodTemplateSelect.innerHTML = ''; + } + openModal(periodModal); + setInlineMessage(periodMessage, 'Chargement des modèles...', 'info'); + + try { + await loadTemplates(); + renderPeriodTemplateSelect(); + setInlineMessage(periodMessage); + } catch (error) { + setInlineMessage(periodMessage, error.message, 'error'); + } + } + + function updatePeriodReplaceWarning() { + if (periodReplaceWarning) periodReplaceWarning.hidden = periodMode() !== 'replace'; + } + + function renderPeriodPreview(preview) { + if (!periodPreviewPanel) return; + const summary = preview.summary || {}; + const conflicts = Array.isArray(preview.conflicts) ? preview.conflicts : []; + const days = Array.isArray(preview.days) ? preview.days : []; + const vacations = Array.isArray(preview.vacation_periods) ? preview.vacation_periods : []; + const mode = preview.mode || periodMode(); + const filter = preview.period_filter || periodFilter(); + const filterLabel = filter === 'school' + ? 'Périodes scolaires uniquement' + : filter === 'extra' + ? 'Périodes extrascolaires uniquement' + : 'Toutes les semaines'; + + const vacationHtml = vacations.length + ? `
    Vacances prises en compte
      ${vacations.map((item) => `
    • ${escapeHtml(item.label)} : ${formatDateFr(item.start_date)} → ${formatDateFr(item.end_date)}
    • `).join('')}
    ` + : filter === 'extra' + ? '
    Aucune période de vacances enregistrée sur cette plage. Aucun jour ne pourra être considéré comme extrascolaire tant que le calendrier n’est pas renseigné.
    ' + : '
    Aucune période de vacances enregistrée sur cette plage. Les dates seront considérées comme scolaires.
    '; + + const relevantDays = days.filter((day) => Number(day.eligible_count) > 0 || Number(day.skipped_count) > 0 || Number(day.conflict_count) > 0); + const dayRows = relevantDays.slice(0, 80).map((day) => { + const periodLabel = day.period_type === 'EXTRASCOLAIRE' + ? `Extrascolaire${day.vacation_label ? ` · ${escapeHtml(day.vacation_label)}` : ''}` + : 'Périscolaire'; + return ` + ${formatDateFr(day.date)} + ${periodLabel} + ${Number(day.eligible_count)} + ${Number(day.skipped_count)} + ${Number(day.conflict_count) || '—'} + `; + }).join(''); + + const conflictHtml = conflicts.length + ? `
    ${conflicts.length} conflit${conflicts.length > 1 ? 's' : ''} détecté${conflicts.length > 1 ? 's' : ''}. ${mode === 'merge' ? 'Le mode « Compléter » ne peut pas être appliqué tant que ces conflits existent. Vous pouvez choisir « Remplacer les jours concernés » après vérification.' : 'Ces créneaux existants seront remplacés sur les jours concernés.'}
    ` + : '
    Aucun chevauchement détecté.
    '; + + periodPreviewPanel.innerHTML = ` +
    +
    Périmètre${escapeHtml(filterLabel)}
    +
    Créneaux à créer${Number(summary.entry_count || 0)}
    +
    Jours concernés${Number(summary.target_day_count || 0)}
    +
    Semaines touchées${Number(summary.week_count || 0)}
    +
    Ignorés (vacances)${Number(summary.skipped_vacation_count || 0)}
    +
    Ignorés (hors vacances)${Number(summary.skipped_school_count || 0)}
    +
    ${mode === 'replace' ? 'Créneaux existants remplacés' : 'Conflits'}${mode === 'replace' ? Number(summary.existing_entry_count_on_target_dates || 0) : Number(summary.conflict_count || 0)}
    +
    + ${vacationHtml} + ${conflictHtml} +
    + + + ${dayRows || ''} +
    DateContexteCréésIgnorésConflits
    Aucune journée du modèle ne correspond à cette période.
    +
    + ${relevantDays.length > 80 ? `

    Aperçu limité aux 80 premières journées concernées sur ${relevantDays.length}.

    ` : ''} + `; + periodPreviewPanel.hidden = false; + + const canApply = Number(summary.entry_count || 0) > 0 + && (mode !== 'merge' || Number(summary.conflict_count || 0) === 0); + if (periodConfirmButton) periodConfirmButton.disabled = !canApply; + } + + async function previewPeriodApplication() { + const templateId = Number(periodTemplateSelect?.value || 0); + const startDate = periodStartInput?.value || ''; + const endDate = periodEndInput?.value || ''; + if (!templateId || !startDate || !endDate) { + setInlineMessage(periodMessage, 'Sélectionnez une semaine type et renseignez les deux dates.', 'warning'); + return; + } + if (endDate < startDate) { + setInlineMessage(periodMessage, 'La date de fin doit être postérieure ou égale à la date de début.', 'warning'); + return; + } + + if (periodPreviewButton) periodPreviewButton.disabled = true; + if (periodConfirmButton) periodConfirmButton.disabled = true; + setInlineMessage(periodMessage, 'Analyse de la période et des vacances...', 'info'); + + try { + const response = await fetch('api/preview_week_template_period.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + template_id: templateId, + agent_id: Number(agentSelect.value), + start_date: startDate, + end_date: endDate, + mode: periodMode(), + period_filter: periodFilter(), + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de prévisualiser l’application sur la période.')); + + lastPeriodPreview = data.preview; + lastPeriodPreviewSignature = currentPeriodSignature(); + renderPeriodPreview(lastPeriodPreview); + setInlineMessage(periodMessage, 'Aperçu calculé. Vérifiez les dates avant de confirmer.', 'success'); + } catch (error) { + lastPeriodPreview = null; + lastPeriodPreviewSignature = ''; + if (periodPreviewPanel) periodPreviewPanel.hidden = true; + setInlineMessage(periodMessage, error.message, 'error'); + } finally { + if (periodPreviewButton) periodPreviewButton.disabled = false; + } + } + + async function applyTemplatePeriod() { + if (!lastPeriodPreview || lastPeriodPreviewSignature !== currentPeriodSignature()) { + setInlineMessage(periodMessage, 'La période a changé. Relancez la prévisualisation avant d’appliquer le modèle.', 'warning'); + if (periodConfirmButton) periodConfirmButton.disabled = true; + return; + } + + const templateId = Number(periodTemplateSelect?.value || 0); + const template = templates.find((item) => Number(item.id) === templateId); + const mode = periodMode(); + const summary = lastPeriodPreview.summary || {}; + + if (mode === 'replace' && Number(summary.existing_entry_count_on_target_dates || 0) > 0) { + const confirmed = window.confirm( + `${Number(summary.existing_entry_count_on_target_dates)} créneau(x) existant(s) seront supprimé(s) sur les jours réellement concernés avant d’appliquer « ${template?.name || 'la semaine type'} ».\n\nLes dates exclues par le filtre de calendrier ne seront pas modifiées. Continuer ?` + ); + if (!confirmed) return; + } + + if (periodConfirmButton) periodConfirmButton.disabled = true; + if (periodPreviewButton) periodPreviewButton.disabled = true; + setInlineMessage(periodMessage, 'Application de la semaine type sur la période...', 'info'); + + try { + const response = await fetch('api/apply_week_template_period.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + template_id: templateId, + agent_id: Number(agentSelect.value), + start_date: periodStartInput.value, + end_date: periodEndInput.value, + mode, + period_filter: periodFilter(), + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’appliquer la semaine type sur la période.')); + + await PTA.modules.planning?.loadPlanning?.(); + PTA.modules.pendingValidation?.refresh?.({ silent: true }); + PTA.modules.coverage?.refresh?.({ silent: true }); + showMessage(data.message, 'success'); + setInlineMessage(periodMessage, data.message, 'success'); + lastPeriodPreview = null; + lastPeriodPreviewSignature = ''; + setTimeout(() => closeModal(periodModal), 700); + } catch (error) { + setInlineMessage(periodMessage, error.message, 'error'); + // Le planning peut avoir changé depuis l'aperçu : imposer une nouvelle prévisualisation. + lastPeriodPreview = null; + lastPeriodPreviewSignature = ''; + if (periodConfirmButton) periodConfirmButton.disabled = true; + } finally { + if (periodPreviewButton) periodPreviewButton.disabled = false; + } + } + + async function deleteSelectedTemplate() { + const templateId = Number(templateSelect?.value || 0); + const template = templates.find((item) => Number(item.id) === templateId); + if (!template) return; + + const confirmed = window.confirm(`Supprimer définitivement la semaine type « ${template.name} » ?`); + if (!confirmed) return; + + if (deleteButton) deleteButton.disabled = true; + try { + const response = await fetch('api/delete_week_template.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + template_id: templateId, + agent_id: Number(agentSelect.value), + }), + }); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de supprimer la semaine type.')); + + setInlineMessage(applyMessage, data.message, 'success'); + await loadTemplates(); + renderTemplateSelect(); + } catch (error) { + setInlineMessage(applyMessage, error.message, 'error'); + renderSelectedTemplateDetails(); + } + } + + function updateReplaceWarning() { + const mode = applyForm?.querySelector('input[name="week-template-mode"]:checked')?.value || 'merge'; + if (replaceWarning) replaceWarning.hidden = mode !== 'replace'; + } + + function init() { + saveButton?.addEventListener('click', openSaveModal); + applyButton?.addEventListener('click', openApplyModal); + periodButton?.addEventListener('click', openPeriodModal); + + saveForm?.addEventListener('submit', (event) => { + event.preventDefault(); + saveTemplate(false); + }); + applyForm?.addEventListener('submit', (event) => { + event.preventDefault(); + applyTemplate(); + }); + periodForm?.addEventListener('submit', (event) => { + event.preventDefault(); + applyTemplatePeriod(); + }); + periodPreviewButton?.addEventListener('click', previewPeriodApplication); + + document.querySelector('#close-save-week-template-modal')?.addEventListener('click', () => closeModal(saveModal)); + document.querySelector('#cancel-save-week-template')?.addEventListener('click', () => closeModal(saveModal)); + document.querySelector('#close-apply-week-template-modal')?.addEventListener('click', () => closeModal(applyModal)); + document.querySelector('#cancel-apply-week-template')?.addEventListener('click', () => closeModal(applyModal)); + document.querySelector('#close-apply-week-template-period-modal')?.addEventListener('click', () => closeModal(periodModal)); + document.querySelector('#cancel-apply-week-template-period')?.addEventListener('click', () => closeModal(periodModal)); + + saveModal?.addEventListener('click', (event) => { + if (event.target === saveModal) closeModal(saveModal); + }); + applyModal?.addEventListener('click', (event) => { + if (event.target === applyModal) closeModal(applyModal); + }); + periodModal?.addEventListener('click', (event) => { + if (event.target === periodModal) closeModal(periodModal); + }); + + templateSelect?.addEventListener('change', renderSelectedTemplateDetails); + applyForm?.querySelectorAll('input[name="week-template-mode"]').forEach((input) => { + input.addEventListener('change', updateReplaceWarning); + }); + periodForm?.querySelectorAll('input[name="week-template-period-mode"]').forEach((input) => { + input.addEventListener('change', invalidatePeriodPreview); + }); + periodForm?.querySelectorAll('input[name="week-template-period-filter"]').forEach((input) => { + input.addEventListener('change', invalidatePeriodPreview); + }); + [periodTemplateSelect, periodStartInput, periodEndInput].forEach((input) => { + input?.addEventListener('change', invalidatePeriodPreview); + input?.addEventListener('input', invalidatePeriodPreview); + }); + deleteButton?.addEventListener('click', deleteSelectedTemplate); + + agentSelect?.addEventListener('change', refreshButtons); + weekInput?.addEventListener('input', refreshButtons); + weekInput?.addEventListener('change', refreshButtons); + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + if (saveModal && !saveModal.hidden) closeModal(saveModal); + if (applyModal && !applyModal.hidden) closeModal(applyModal); + if (periodModal && !periodModal.hidden) closeModal(periodModal); + }); + + updateReplaceWarning(); + updatePeriodReplaceWarning(); + refreshButtons(); + } + + PTA.modules.weekTemplates = { + init, + refreshButtons, + loadTemplates, + }; +})(); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..38ffca7 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,2555 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #17202a; + background: #f4f6f8; + font-synthesis: none; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + background: #f4f6f8; +} + +button, input, select { font: inherit; } + +.app-shell { + width: min(1440px, calc(100% - 32px)); + margin: 0 auto; + padding: 32px 0 48px; +} + +.page-header, +.section-heading, +.actions-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.page-header { margin-bottom: 24px; } + +.eyebrow { + margin: 0 0 4px; + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #546e7a; +} + +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 6px; font-size: clamp(1.8rem, 4vw, 2.6rem); } +h2 { margin-bottom: 18px; font-size: 1.15rem; } +.subtitle, .section-subtitle, .hint { color: #66737d; } +.subtitle, .section-subtitle { margin-bottom: 0; } +.hint { margin: 14px 0 0; font-size: 0.88rem; } + +.card { + background: #fff; + border: 1px solid #e4e8eb; + border-radius: 16px; + padding: 22px; + margin-bottom: 20px; + box-shadow: 0 6px 24px rgba(23, 32, 42, 0.05); +} + +.selection-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.entry-grid { + display: grid; + grid-template-columns: 1.5fr 1.5fr 1fr 1fr auto; + align-items: end; + gap: 14px; +} + +.field { + display: flex; + flex-direction: column; + gap: 7px; + font-weight: 700; + font-size: 0.9rem; +} + +.field strong { color: #c62828; } + +select, input { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid #cfd8dc; + border-radius: 10px; + background: #fff; + color: inherit; + outline: none; +} + +select:focus, input:focus { + border-color: #607d8b; + box-shadow: 0 0 0 3px rgba(96, 125, 139, 0.14); +} + +select:disabled, input:disabled { + background: #f3f5f6; + color: #9aa4aa; + cursor: not-allowed; +} + +.button { + border: 0; + border-radius: 10px; + min-height: 44px; + padding: 10px 18px; + font-weight: 800; + cursor: pointer; + transition: transform 0.1s ease, opacity 0.15s ease; +} + +.button:not(:disabled):active { transform: translateY(1px); } +.button:disabled { opacity: 0.45; cursor: not-allowed; } +.button-primary { background: #263238; color: #fff; } +.button-secondary { background: #e8ecef; color: #263238; } +.button-success { background: #256d3b; color: #fff; } +.button-warning { background: #b06a00; color: #fff; } +.button-add { white-space: nowrap; } + +.status-badge { + padding: 8px 12px; + border-radius: 999px; + font-size: 0.86rem; + font-weight: 800; + white-space: nowrap; +} +.status-neutral { background: #e9eef1; color: #52616b; } +.status-draft { background: #fff4d6; color: #7d5c00; } +.status-valid { background: #dff3e5; color: #256d3b; } + +.totals { + min-width: 150px; + padding: 12px 16px; + border-radius: 12px; + background: #f4f7f8; + text-align: right; +} +.totals span { display: block; color: #65737b; font-size: 0.78rem; } +.totals strong { font-size: 1.15rem; } + +.planning-board { + display: grid; + grid-template-columns: repeat(5, minmax(150px, 1fr)); + gap: 12px; + overflow-x: auto; + padding-bottom: 4px; +} + +.day-column { + min-width: 150px; + border: 1px solid #e1e6e9; + border-radius: 12px; + overflow: hidden; + background: #fafbfb; +} + +.day-header { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 11px 12px; + background: #eef2f4; + border-bottom: 1px solid #e1e6e9; +} +.day-header span { color: #68757d; font-size: 0.8rem; } +.day-entries { padding: 10px; } +.day-empty { margin: 8px 0; color: #98a1a7; font-size: 0.83rem; text-align: center; } + +.planning-entry { + display: flex; + gap: 8px; + justify-content: space-between; + align-items: flex-start; + padding: 10px; + margin-bottom: 8px; + background: #fff; + border: 1px solid #dde3e6; + border-radius: 10px; +} +.planning-entry:last-child { margin-bottom: 0; } +.entry-content { display: flex; flex-direction: column; gap: 2px; } +.entry-motif { font-size: 0.77rem; font-weight: 800; color: #546e7a; } +.entry-content strong { font-size: 0.9rem; } +.entry-content small { color: #7b878e; } +.remove-entry { + width: 26px; + height: 26px; + border: 0; + border-radius: 50%; + background: #f1f3f4; + color: #7f1d1d; + font-size: 1.1rem; + cursor: pointer; +} + +.empty-state { + padding: 36px 18px; + border: 1px dashed #cfd8dc; + border-radius: 12px; + text-align: center; + color: #7b878e; +} + +.actions-bar { + position: sticky; + bottom: 12px; + padding: 16px 18px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid #dfe5e8; + border-radius: 14px; + box-shadow: 0 10px 34px rgba(23, 32, 42, 0.13); + backdrop-filter: blur(8px); +} +.action-buttons { display: flex; gap: 10px; } +.message { flex: 1; white-space: pre-line; font-size: 0.9rem; } +.message-success { color: #256d3b; } +.message-warning { color: #8a6100; } +.message-error { color: #b3261e; } + +@media (max-width: 980px) { + .selection-grid { grid-template-columns: 1fr; } + .entry-grid { grid-template-columns: repeat(2, 1fr); } + .button-add { grid-column: 1 / -1; } +} + +@media (max-width: 640px) { + .app-shell { width: min(100% - 20px, 1440px); padding-top: 18px; } + .page-header, .section-heading, .actions-bar { align-items: stretch; flex-direction: column; } + .entry-grid { grid-template-columns: 1fr; } + .action-buttons { display: grid; grid-template-columns: 1fr; } + .status-badge { align-self: flex-start; } + .totals { text-align: left; } +} + +/* ============================================================ + V4 - Navigation, quota et vues d'ensemble + ============================================================ */ +.view-tabs { + display: flex; + gap: 0.65rem; + margin: 0 0 1.25rem; + padding: 0.35rem; + border: 1px solid #dbe3ee; + border-radius: 14px; + background: #f8fafc; + overflow-x: auto; +} + +.view-tab { + border: 0; + border-radius: 10px; + padding: 0.8rem 1rem; + background: transparent; + color: #4b5563; + font: inherit; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} + +.view-tab.is-active { + background: #ffffff; + color: #172033; + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.08); +} + +.app-view[hidden] { + display: none !important; +} + +.quota-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.quota-card { + display: flex; + min-height: 100px; + flex-direction: column; + justify-content: center; + gap: 0.3rem; + padding: 1rem 1.15rem; + border: 1px solid #dbe3ee; + border-radius: 14px; + background: #ffffff; +} + +.quota-card span, +.quota-card small { + color: #6b7280; +} + +.quota-card strong { + color: #172033; + font-size: 1.35rem; +} + +.quota-card-emphasis { + border-color: #b9c7ff; + background: #f4f6ff; +} + +.quota-negative { + color: #b42318 !important; +} + +.overview-filters { + align-items: end; +} + +.filter-button { + min-height: 46px; +} + +.overview-card { + min-height: 280px; +} + +.overview-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.overview-title-row h3 { + margin: 0; + font-size: 1.35rem; +} + +.overview-title-row p { + margin: 0.25rem 0 0; + color: #6b7280; +} + +.overview-count { + display: inline-flex; + padding: 0.45rem 0.75rem; + border-radius: 999px; + background: #eef2ff; + color: #3730a3; + font-weight: 700; +} + +.table-scroll { + overflow-x: auto; + border: 1px solid #e5e7eb; + border-radius: 14px; +} + +.planning-table { + width: 100%; + min-width: 1280px; + border-collapse: collapse; + background: #ffffff; +} + +.planning-table th, +.planning-table td { + padding: 0.75rem; + border-right: 1px solid #edf0f4; + border-bottom: 1px solid #edf0f4; + vertical-align: top; + text-align: left; +} + +.planning-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: #f8fafc; + color: #334155; + font-size: 0.82rem; +} + +.planning-table thead th span { + display: block; + margin-top: 0.15rem; + color: #94a3b8; + font-weight: 500; +} + +.agent-cell { + min-width: 155px; + background: #fcfcfd; +} + +.agent-cell strong, +.agent-cell span, +.quota-cell strong, +.quota-cell span, +.quota-cell small { + display: block; +} + +.agent-cell span, +.quota-cell span, +.quota-cell small { + margin-top: 0.2rem; + color: #6b7280; + font-size: 0.78rem; +} + +.quota-cell { + min-width: 135px; +} + +.overview-entry { + display: flex; + flex-direction: column; + gap: 0.12rem; + min-width: 112px; + margin-bottom: 0.45rem; + padding: 0.55rem 0.6rem; + border-left: 4px solid #64748b; + border-radius: 8px; + background: #f8fafc; + font-size: 0.78rem; +} + +.overview-entry strong { + font-size: 0.8rem; +} + +.overview-entry span, +.overview-entry small { + color: #475569; +} + +.overview-entry em { + margin-top: 0.15rem; + color: #92400e; + font-size: 0.7rem; + font-style: normal; + font-weight: 700; +} + +.motif-travail { border-left-color: #2563eb; } +.motif-formation { border-left-color: #7c3aed; } +.motif-conge { border-left-color: #059669; } +.motif-maladie { border-left-color: #dc2626; } +.motif-autre { border-left-color: #64748b; } + +.cell-empty { + color: #cbd5e1; +} + +.agent-week-grid { + display: grid; + grid-template-columns: repeat(7, minmax(150px, 1fr)); + gap: 0.75rem; + overflow-x: auto; + padding-bottom: 0.25rem; +} + +.agent-day-card { + min-width: 150px; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #ffffff; + overflow: hidden; +} + +.agent-day-card > header { + display: flex; + justify-content: space-between; + gap: 0.5rem; + padding: 0.7rem; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; +} + +.agent-day-card > header span { + color: #6b7280; + font-size: 0.8rem; +} + +.agent-day-body { + padding: 0.65rem; +} + +.overview-quota-strip { + margin-bottom: 1.25rem; +} + +@media (max-width: 900px) { + .quota-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .quota-strip { + grid-template-columns: 1fr; + } + + .view-tabs { + width: 100%; + } +} + +/* ============================================================ + V4.2 - Saisie hebdomadaire des affectations du lundi au vendredi + ============================================================ */ +.weekly-entry-toolbar { + display: flex; + align-items: end; + gap: 24px; + margin-bottom: 18px; +} + +.weekly-motif-field { + width: min(420px, 100%); + flex: 0 0 min(420px, 100%); +} + +.weekly-entry-help { + margin: 0 0 4px; + color: #66737d; + font-size: 0.9rem; + line-height: 1.45; +} + +.weekly-entry-scroll { + overflow-x: auto; + border: 1px solid #e1e6e9; + border-radius: 12px; +} + +.weekly-entry-table { + width: 100%; + min-width: 860px; + border-collapse: collapse; + background: #fff; +} + +.weekly-entry-table th, +.weekly-entry-table td { + padding: 11px 12px; + border-right: 1px solid #e7ebed; + border-bottom: 1px solid #e7ebed; + text-align: center; + vertical-align: middle; +} + +.weekly-entry-table tr:last-child th, +.weekly-entry-table tr:last-child td { + border-bottom: 0; +} + +.weekly-entry-table th:last-child, +.weekly-entry-table td:last-child { + border-right: 0; +} + +.weekly-entry-table thead th { + background: #eef2f4; + color: #42515a; + font-size: 0.84rem; +} + +.weekly-entry-table thead tr:first-child th { + padding-bottom: 7px; + font-size: 0.9rem; +} + +.weekly-entry-subhead th { + padding-top: 7px !important; + font-weight: 700; + color: #6b7780 !important; +} + +.weekly-day-cell { + width: 150px; + min-width: 150px; + background: #fafbfb; + text-align: left !important; +} + +.weekly-day-cell strong, +.weekly-day-cell span { + display: block; +} + +.weekly-day-cell span { + margin-top: 2px; + color: #7b878e; + font-size: 0.8rem; + font-weight: 500; +} + +.weekly-entry-table input[type="time"] { + min-width: 116px; + min-height: 40px; + padding: 8px 9px; +} + +.weekly-motif-select { + min-width: 190px; + min-height: 40px; + padding: 8px 10px; +} + +.weekly-entry-placeholder { + padding: 30px 18px !important; + color: #7b878e; +} + +.weekly-entry-actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +/* Le planning affiché couvre désormais uniquement les cinq jours ouvrés. */ +.planning-board { + grid-template-columns: repeat(5, minmax(170px, 1fr)); +} + +@media (max-width: 760px) { + .weekly-entry-toolbar { + align-items: stretch; + flex-direction: column; + gap: 10px; + } + + .weekly-motif-field { + width: 100%; + flex-basis: auto; + } + + .weekly-entry-actions .button { + width: 100%; + } +} + +/* ============================================================ + V4.4 - Planning global agent et contrôle inter-structures + ============================================================ */ +.agent-week-grid { + grid-template-columns: repeat(5, minmax(170px, 1fr)); +} + +.entry-location { + display: block; + margin-top: 3px; + color: #334155; + font-size: 0.78rem; + font-weight: 700; +} + +.planning-entry-external { + background: #f8fafc; + border-style: dashed; +} + +.entry-locked { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 7px; + border-radius: 999px; + background: #e2e8f0; + color: #475569; + font-size: 0.68rem; + font-weight: 800; + white-space: nowrap; +} +.message-info { color: #1d4ed8; } + +/* ============================================================ + V4.5 - Association unique agent / structure + ============================================================ */ +.assignment-table .agent-structure-select { + min-width: 260px; + width: 100%; +} + +.assignment-table td:last-child { + width: 150px; + text-align: right; +} + +#assignment-message:not(:empty) { + margin-bottom: 1rem; +} + +/* ============================================================ + V4.8 - Administration des structures et des agents + ============================================================ */ +.admin-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.admin-card { + min-width: 0; +} + +.admin-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.admin-field-wide { + grid-column: 1 / -1; +} + +.admin-form-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 18px; +} + +.admin-form-actions .message { + flex: 1; + margin: 0; +} + +.input-suffix { + display: flex; + align-items: stretch; +} + +.input-suffix input { + flex: 1; + min-width: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-suffix span { + display: inline-flex; + align-items: center; + padding: 0 12px; + border: 1px solid #cbd5e1; + border-left: 0; + border-radius: 0 8px 8px 0; + background: #f8fafc; + font-weight: 700; + color: #475569; +} + +@media (max-width: 900px) { + .admin-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 620px) { + .admin-form-grid { + grid-template-columns: 1fr; + } + + .admin-field-wide { + grid-column: auto; + } + + .admin-form-actions { + align-items: stretch; + flex-direction: column; + } + + .admin-form-actions .button { + width: 100%; + } +} + + +/* ============================================================ + V4.9 - Lieux d’affectation et plages horaires dynamiques + ============================================================ */ +.weekly-ranges-cell { + min-width: 720px; + text-align: left !important; +} + +.weekly-time-ranges { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; +} + +.weekly-time-range { + display: grid; + flex: 0 0 auto; + grid-template-columns: minmax(190px, 240px) 120px auto 120px 38px; + align-items: end; + gap: 8px; + padding: 8px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #f8fafc; +} + +.weekly-time-range label { + display: grid; + gap: 4px; +} + +.weekly-range-motif { + min-width: 190px; + min-height: 40px; + padding: 8px 10px; +} + +.weekly-time-range label span { + color: #64748b; + font-size: 0.72rem; + font-weight: 700; +} + +.weekly-time-separator { + padding-bottom: 10px; + color: #64748b; + font-weight: 800; +} + +.add-time-range { + margin-top: 8px; +} + +.remove-time-range { + min-width: 36px; + min-height: 40px; + padding: 0; + font-size: 1.2rem; +} + +.assignment-table .agent-type-select { + min-width: 180px; + width: 100%; +} + +@media (max-width: 760px) { + .weekly-time-range { + grid-template-columns: 1fr 1fr 1fr 38px; + } + + .weekly-time-separator { + display: none; + } +} + +.weekly-mixed-motif-warning { + display: block; + margin-top: 0.5rem; + font-size: 0.8rem; + line-height: 1.35; +} + +/* --------------------------------------------------------- + Édition directe d'un créneau depuis la vue agent + --------------------------------------------------------- */ +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.55); + overflow-y: auto; +} + +.modal-backdrop[hidden] { + display: none; +} + +.modal-card { + width: min(760px, 100%); + max-height: calc(100vh - 48px); + overflow-y: auto; + background: #fff; + border-radius: 18px; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28); + padding: 24px; +} + +.modal-heading { + align-items: flex-start; +} + +.modal-close { + min-width: 42px; + min-height: 42px; + padding: 6px 12px; + font-size: 1.5rem; + line-height: 1; +} + +.edit-time-range-field { + align-self: end; +} + +.edit-time-range { + display: flex; + align-items: center; + gap: 10px; +} + +.edit-time-range input { + min-width: 0; + flex: 1; +} + +.modal-actions { + justify-content: flex-end; + gap: 10px; +} + +.overview-entry-actions { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.overview-entry-edit { + padding: 5px 9px; + font-size: 0.78rem; +} + +body.modal-open { + overflow: hidden; +} + +@media (max-width: 640px) { + .modal-backdrop { + padding: 12px; + align-items: end; + } + + .modal-card { + max-height: 92vh; + border-radius: 18px 18px 0 0; + padding: 18px; + } + + .edit-time-range { + flex-wrap: wrap; + } +} + + +/* ============================================================ + V5.3 - Actions d'export PDF + ============================================================ */ +.planning-summary-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.button-pdf { + white-space: nowrap; +} + +.overview-filter-actions { + display: flex; + align-items: stretch; + gap: 0.6rem; +} + +.overview-filter-actions .button { + flex: 1; +} + +@media (max-width: 640px) { + .planning-summary-actions, + .overview-filter-actions { + width: 100%; + flex-direction: column; + align-items: stretch; + } +} + +/* ============================================================ + V5.6 - Contrat horaire PDF depuis la vue agent + ============================================================ */ +.agent-overview-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +@media (max-width: 760px) { + .overview-title-row { + align-items: stretch; + flex-direction: column; + } + + .agent-overview-actions, + .agent-overview-actions .button { + width: 100%; + } +} + +/* ============================================================ + V5.8 - Déplacement / duplication des créneaux par glisser-déposer + ============================================================ */ +.planning-entry-interactive { + position: relative; + cursor: grab; + user-select: none; + transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +.planning-entry-interactive:hover, +.planning-entry-interactive:focus-visible { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); + outline: none; +} + +.planning-entry-interactive:active { + cursor: grabbing; +} + +.planning-entry-interactive.is-dragging { + opacity: 0.45; + transform: scale(0.98); +} + +.entry-drag-hint { + flex: 0 0 auto; + align-self: center; + color: #94a3b8; + font-size: 1rem; + font-weight: 900; + letter-spacing: -0.25rem; + writing-mode: vertical-rl; + pointer-events: none; +} + +.day-column.is-drop-target { + outline: 3px solid #2563eb; + outline-offset: 2px; + background: #eff6ff; +} + +.day-column.is-copy-target { + outline-style: dashed; + background: #f5f3ff; +} + +.day-column.is-drop-target .day-header::after { + content: 'Déplacer ici'; + margin-left: auto; + color: #1d4ed8; + font-size: 0.68rem; + font-weight: 800; +} + +.day-column.is-copy-target .day-header::after { + content: 'Dupliquer ici'; + color: #6d28d9; +} + +.planning-entry-action-card { + width: min(620px, 100%); +} + +.entry-action-mode { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 18px; + padding: 0; + border: 0; +} + +.entry-action-mode legend { + grid-column: 1 / -1; + margin-bottom: 4px; + color: #475569; + font-size: 0.85rem; + font-weight: 800; +} + +.entry-action-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px; + border: 1px solid #dbe3e8; + border-radius: 12px; + background: #f8fafc; + cursor: pointer; +} + +.entry-action-option:has(input:checked) { + border-color: #2563eb; + background: #eff6ff; +} + +.entry-action-option input { + margin-top: 3px; +} + +.entry-action-option span, +.entry-action-option strong, +.entry-action-option small { + display: block; +} + +.entry-action-option small { + margin-top: 3px; + color: #64748b; +} + +@media (max-width: 640px) { + .entry-action-mode { + grid-template-columns: 1fr; + } +} + +/* ============================================================ + V5.9 - Semaines types par agent + ============================================================ */ +.week-template-modal-card { + width: min(720px, 100%); +} + +.week-template-context, +.week-template-details { + display: grid; + gap: 6px; + padding: 12px 14px; + border: 1px solid #dbe3e8; + border-radius: 12px; + background: #f8fafc; +} + +.week-template-context strong, +.week-template-context span, +.week-template-detail-card strong, +.week-template-detail-card span { + display: block; +} + +.week-template-context span, +.week-template-detail-card span { + margin-top: 3px; + color: #64748b; + font-size: 0.85rem; +} + +.week-template-mode { + margin-top: 4px; +} + +.week-template-danger { + padding: 12px 14px; + border: 1px solid #fecaca; + border-radius: 12px; + background: #fef2f2; + color: #991b1b; + font-size: 0.88rem; + font-weight: 700; +} + +.week-template-modal-actions { + align-items: center; +} + +.modal-action-spacer { + flex: 1; +} + +.button-danger { + border-color: #dc2626; + background: #fff; + color: #b91c1c; +} + +.button-danger:hover:not(:disabled) { + background: #fef2f2; +} + +@media (max-width: 760px) { + .week-template-modal-actions { + flex-direction: column; + align-items: stretch; + } + + .modal-action-spacer { + display: none; + } +} + +/* ============================================================ + V5.11 - Vue agent mensuelle, glisser-déposer inter-semaines + ============================================================ */ +.agent-month-title-row { + align-items: flex-start; +} + +.agent-month-planner { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.agent-month-help { + margin: 0.9rem 0 0; + color: #64748b; + font-size: 0.84rem; +} + +.agent-month-week { + border: 1px solid #dbe3ec; + border-radius: 14px; + overflow: hidden; + background: #fff; +} + +.agent-month-week-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 0.85rem; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.agent-month-week-header > div { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.agent-month-week-header span { + color: #64748b; + font-size: 0.78rem; +} + +.agent-month-week-grid { + display: grid; + grid-template-columns: repeat(5, minmax(165px, 1fr)); + min-width: 825px; +} + +.agent-month-week { + overflow-x: auto; +} + +.agent-month-day-card { + min-height: 150px; + border-right: 1px solid #e2e8f0; + background: #fff; + transition: background 0.15s ease, outline 0.15s ease; +} + +.agent-month-day-card:last-child { + border-right: 0; +} + +.agent-month-day-card.is-outside-month { + background: #f8fafc; +} + +.agent-month-day-card.is-outside-month .agent-month-day-header { + opacity: 0.65; +} + +.agent-month-day-header { + display: flex; + justify-content: space-between; + gap: 0.5rem; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid #edf2f7; + background: rgba(248, 250, 252, 0.85); +} + +.agent-month-day-header span { + color: #64748b; + font-size: 0.78rem; +} + +.agent-month-day-body { + display: grid; + align-content: start; + gap: 0.5rem; + padding: 0.55rem; + min-height: 105px; +} + +.agent-month-entry { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.45rem; + padding: 0.5rem 0.55rem; + border-left-width: 4px; +} + +.agent-month-entry .overview-entry-edit { + grid-column: 1 / -1; + justify-self: end; +} + +.agent-month-entry-content { + display: flex; + flex-direction: column; + gap: 0.12rem; + min-width: 0; +} + +.agent-month-entry-content small { + overflow-wrap: anywhere; +} + +.agent-month-day-card.is-drop-target { + outline: 3px solid #2563eb; + outline-offset: -3px; + background: #eff6ff; +} + +.agent-month-day-card.is-copy-target { + outline-style: dashed; + outline-color: #7c3aed; + background: #f5f3ff; +} + +.agent-month-day-card.is-drop-target .agent-month-day-header::after { + content: 'Déplacer ici'; + margin-left: auto; + color: #1d4ed8; + font-size: 0.66rem; + font-weight: 800; +} + +.agent-month-day-card.is-copy-target .agent-month-day-header::after { + content: 'Dupliquer ici'; + color: #6d28d9; +} + +@media (max-width: 900px) { + .agent-month-week-header { + align-items: flex-start; + flex-direction: column; + } + + .agent-month-week-header .button { + width: 100%; + } +} + + +/* ============================================================ + V5.12 - File de validation des plannings + ============================================================ */ +.pending-validation-tab { + display: inline-flex; + align-items: center; + gap: 0.55rem; +} + +.validation-tab-badge { + display: inline-grid; + min-width: 1.55rem; + height: 1.55rem; + place-items: center; + padding: 0 0.4rem; + border-radius: 999px; + background: #b42318; + color: #fff; + font-size: 0.75rem; + font-weight: 900; + line-height: 1; +} + +.pending-validation-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.25rem; +} + +.pending-summary-card { + padding: 1rem 1.1rem; + border: 1px solid #dbe3ee; + border-radius: 14px; + background: #f8fafc; +} + +.pending-summary-card span, +.pending-summary-card small { + display: block; + color: #6b7280; +} + +.pending-summary-card strong { + display: block; + margin: 0.15rem 0; + color: #172033; + font-size: 1.45rem; +} + +.pending-validation-table-wrap { + overflow-x: auto; +} + +.pending-validation-table { + width: 100%; + min-width: 1040px; + border-collapse: collapse; +} + +.pending-validation-table th, +.pending-validation-table td { + padding: 0.9rem 0.8rem; + border-bottom: 1px solid #e5e7eb; + text-align: left; + vertical-align: middle; +} + +.pending-validation-table th { + background: #f8fafc; + color: #4b5563; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.pending-validation-table td strong, +.pending-validation-table td small { + display: block; +} + +.pending-validation-table td small { + margin-top: 0.2rem; + color: #6b7280; +} + +.pending-validation-row { + cursor: pointer; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.pending-validation-row:hover, +.pending-validation-row:focus { + background: #fffaf0; + outline: none; + box-shadow: inset 4px 0 0 #b06a00; +} + +.pending-count-chip { + display: inline-grid; + min-width: 2rem; + height: 2rem; + place-items: center; + padding: 0 0.45rem; + border-radius: 999px; + background: #fff4d6; + color: #7d5c00; + font-weight: 900; +} + +.pending-validation-action-cell { + text-align: right !important; +} + +.pending-validation-action-cell .button { + white-space: nowrap; +} + +@media (max-width: 760px) { + .pending-validation-summary { + grid-template-columns: 1fr; + } +} + +/* ============================================================ + V5.13 - Contrôle de couverture des lieux + ============================================================ */ +.coverage-tab { + display: inline-flex; + align-items: center; + gap: 0.55rem; +} + +.coverage-tab-badge { + background: #b42318; +} + +.coverage-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.25rem; +} + +.coverage-summary-card { + padding: 1rem 1.1rem; + border: 1px solid #dbe3ee; + border-radius: 14px; + background: #f8fafc; +} + +.coverage-summary-card span, +.coverage-summary-card small { + display: block; + color: #6b7280; +} + +.coverage-summary-card strong { + display: block; + margin: 0.15rem 0; + color: #172033; + font-size: 1.45rem; +} + +.coverage-summary-danger { + border-color: #fecaca; + background: #fff7f7; +} + +.coverage-summary-danger strong { + color: #b42318; +} + +.coverage-table-wrap { + overflow-x: auto; +} + +.coverage-table { + width: 100%; + min-width: 1050px; + border-collapse: collapse; +} + +.coverage-table th, +.coverage-table td { + padding: 0.9rem 0.8rem; + border-bottom: 1px solid #e5e7eb; + text-align: left; + vertical-align: middle; +} + +.coverage-table th { + background: #f8fafc; + color: #4b5563; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.coverage-table td strong, +.coverage-table td small { + display: block; +} + +.coverage-table td small { + margin-top: 0.2rem; + color: #6b7280; +} + +.coverage-alert-row { + background: #fffafa; +} + +.coverage-missing-chip { + display: inline-flex; + padding: 0.35rem 0.65rem; + border-radius: 999px; + background: #fee2e2; + color: #991b1b; + font-weight: 800; +} + +.coverage-action-cell { + text-align: right !important; +} + +.coverage-unconfigured { + margin-top: 1rem; + padding: 0.9rem 1rem; + border: 1px solid #f6d58b; + border-radius: 12px; + background: #fffbeb; + color: #7c5b00; +} + +.coverage-unconfigured strong, +.coverage-unconfigured p { + display: block; + margin: 0; +} + +.coverage-unconfigured p { + margin-top: 0.35rem; + font-size: 0.88rem; +} + +.coverage-inline-notice { + margin-bottom: 1rem; + padding: 0.85rem 1rem; + border: 1px solid; + border-radius: 12px; +} + +.coverage-inline-notice strong, +.coverage-inline-notice span { + display: block; +} + +.coverage-inline-notice span { + margin-top: 0.25rem; + font-size: 0.88rem; +} + +.coverage-inline-notice ul { + margin: 0.55rem 0 0 1.1rem; + padding: 0; +} + +.coverage-inline-info { + border-color: #bfdbfe; + background: #eff6ff; + color: #1e40af; +} + +.coverage-inline-success { + border-color: #bbf7d0; + background: #f0fdf4; + color: #166534; +} + +.coverage-inline-danger { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; +} + +.coverage-rules-editor { + display: grid; + gap: 0.85rem; + margin-top: 1rem; +} + +.coverage-rule-day { + border: 1px solid #dbe3ee; + border-radius: 14px; + overflow: hidden; + background: #fff; +} + +.coverage-rule-day-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid #e5e7eb; + background: #f8fafc; +} + +.coverage-rule-day-ranges { + display: grid; + gap: 0.7rem; + padding: 0.85rem; +} + +.coverage-rule-row { + display: grid; + grid-template-columns: minmax(130px, 1fr) minmax(130px, 1fr) minmax(110px, 0.7fr) auto; + gap: 0.7rem; + align-items: end; +} + +.coverage-rule-empty { + margin: 0; + color: #7b8794; + font-size: 0.88rem; +} + +.coverage-rules-actions { + display: flex; + justify-content: flex-end; + margin-top: 1rem; +} + +@media (max-width: 900px) { + .coverage-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .coverage-rule-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .coverage-summary, + .coverage-rule-row { + grid-template-columns: 1fr; + } + + .coverage-rule-day-header { + align-items: stretch; + flex-direction: column; + } +} + +/* V5.14 - actions directes sur les créneaux de la vue par agent */ +.agent-month-entry-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + justify-content: flex-end; +} + +.agent-month-entry-actions .button { + padding: 0.45rem 0.65rem; + font-size: 0.78rem; +} + + +/* V5.15 - suppression déplacée dans la modale de modification de la vue agent */ +.agent-entry-modal-actions { + align-items: center; +} + +@media (max-width: 760px) { + .agent-entry-modal-actions { + flex-direction: column; + align-items: stretch; + } + + .agent-entry-modal-actions .modal-action-spacer { + display: none; + } +} + +/* ============================================================ + V5.16 - Informations complémentaires des agents + ============================================================ */ +button, input, select, textarea { font: inherit; } + +textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid #cfd8dc; + border-radius: 10px; + background: #fff; + color: inherit; + outline: none; + resize: vertical; +} + +textarea:focus { + border-color: #607d8b; + box-shadow: 0 0 0 3px rgba(96, 125, 139, 0.14); +} + +textarea:disabled { + background: #f3f5f6; + color: #9aa4aa; + cursor: not-allowed; +} + +.agent-information-table .agent-row-qualification, +.agent-information-table .agent-row-phone, +.agent-information-table .agent-row-structure { + min-width: 150px; +} + +.agent-information-table td:last-child { + width: 120px; + text-align: right; +} + +/* PTA V5.17 - Calendrier des vacances scolaires */ +.vacation-api-note { + margin: 0 0 1rem; + padding: .9rem 1rem; + border: 1px solid var(--border, #d9dee7); + border-radius: 10px; + background: #f7f9fc; + line-height: 1.5; +} + +.vacation-filters { + align-items: end; +} + +.vacation-preview-panel { + margin-top: 1rem; +} + +.compact-heading { + gap: 1rem; + align-items: center; +} + +.vacation-select-all { + display: inline-flex; + gap: .5rem; + align-items: center; + white-space: nowrap; + font-weight: 600; +} + +.vacation-table input[type="text"], +.vacation-table input[type="date"] { + min-width: 150px; + width: 100%; +} + +.vacation-table td small { + display: block; + margin-top: .3rem; + color: #667085; +} + +.vacation-source-badge, +.weekly-period-badge { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 999px; + padding: .2rem .55rem; + font-size: .74rem; + font-weight: 700; + line-height: 1.2; +} + +.vacation-source-badge { + background: #eef2f7; + color: #344054; +} + +.weekly-period-badge.is-peri { + background: #e8f1ff; + color: #174ea6; +} + +.weekly-period-badge.is-extra { + background: #fff1dc; + color: #9a4d00; +} + +.weekly-period-detail { + display: block; + max-width: 150px; + color: #667085; + font-size: .72rem; + line-height: 1.25; +} + +.button-danger { + border-color: #d92d20; + color: #b42318; + background: #fff; +} + +.button-danger:hover { + background: #fff1f0; +} + +@media (max-width: 760px) { + .compact-heading { + align-items: flex-start; + flex-direction: column; + } +} + +/* ============================================================ + V5.19 - Application d'une semaine type sur une période + ============================================================ */ +.week-template-period-modal-card { + width: min(1080px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + overflow: auto; +} + +.week-template-period-grid { + display: grid; + grid-template-columns: minmax(240px, 1.5fr) repeat(2, minmax(170px, 0.8fr)); + gap: 1rem; +} + +.week-template-period-rule-note { + padding: 0.9rem 1rem; + border: 1px solid #cbd5e1; + border-radius: 12px; + background: #f8fafc; + color: #334155; + line-height: 1.5; +} + +.week-template-period-preview-actions { + display: flex; + justify-content: flex-start; +} + +.week-template-period-preview { + display: grid; + gap: 1rem; + padding-top: 0.25rem; +} + +.week-template-period-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.week-template-period-summary article { + display: grid; + gap: 0.25rem; + padding: 0.8rem 0.9rem; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; +} + +.week-template-period-summary span { + color: #64748b; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.week-template-period-summary strong { + font-size: 1.25rem; +} + +.week-template-period-vacations, +.week-template-period-alert { + padding: 0.85rem 1rem; + border-radius: 12px; + border: 1px solid #dbe4ee; + background: #f8fafc; +} + +.week-template-period-vacations ul { + margin: 0.55rem 0 0; + padding-left: 1.2rem; +} + +.week-template-period-alert.is-success { + border-color: #a7d7b7; + background: #effaf2; + color: #166534; +} + +.week-template-period-alert.is-warning { + border-color: #f2d38a; + background: #fff9e8; + color: #854d0e; +} + +.week-template-period-alert.is-error { + border-color: #f0a8a8; + background: #fff1f1; + color: #991b1b; +} + +.week-template-period-table-wrap { + max-height: 360px; + overflow: auto; + border: 1px solid #e2e8f0; + border-radius: 12px; +} + +.week-template-period-table { + margin: 0; +} + +.week-template-period-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f8fafc; +} + +@media (max-width: 820px) { + .week-template-period-grid, + .week-template-period-summary { + grid-template-columns: 1fr; + } +} + + +/* ============================================================ + V6 - Navigation multipage + ============================================================ */ +.view-tab { + display: inline-flex; + align-items: center; + text-decoration: none; +} +.page-content { + display: block; +} +.page-content > .card:first-child { + margin-top: 0; +} + +/* PTA V7 — synthèse annuelle et équipes */ +.view-tabs { overflow-x: auto; flex-wrap: nowrap; } +.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; } +.summary-card { padding: 14px; border: 1px solid var(--border-color, #d8dee8); border-radius: 12px; background: #fff; display: grid; gap: 6px; } +.summary-card span { font-size: .82rem; color: #5f6b7a; } +.summary-card strong { font-size: 1.15rem; } +.summary-card small { color: #6b7280; } +.compact-summary { margin: 14px 0; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } +.control-list { display: grid; gap: 9px; } +.control-item { display: flex; gap: 12px; align-items: flex-start; padding: 11px 13px; border-radius: 10px; border-left: 5px solid #64748b; background: #f8fafc; } +.control-item strong { min-width: 68px; font-size: .78rem; } +.control-erreur { border-left-color: #b91c1c; background: #fef2f2; } +.control-alerte { border-left-color: #d97706; background: #fffbeb; } +.control-info { border-left-color: #2563eb; background: #eff6ff; } +.stack-list { display: grid; gap: 10px; margin-top: 16px; } +.stack-item { display: flex; justify-content: space-between; gap: 14px; align-items: center; border: 1px solid #dbe3ed; border-radius: 10px; padding: 12px; background: #fff; } +.stack-item p { margin: 4px 0 0; color: #64748b; } +.button-group { display: flex; gap: 8px; flex-wrap: wrap; } +.button-danger { background: #fff; color: #b91c1c; border: 1px solid #fecaca; } +.button-danger:hover { background: #fef2f2; } +.check-field { display: flex; align-items: center; gap: 8px; padding-top: 26px; } +.team-list { display: grid; gap: 18px; margin-top: 16px; } +.team-card { border: 1px solid #cbd5e1; } +.inline-form { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(170px, 1fr) minmax(180px, 2fr) auto; align-items: end; gap: 10px; margin: 16px 0; padding: 12px; background: #f8fafc; border-radius: 10px; } +@media (max-width: 900px) { .inline-form { grid-template-columns: 1fr; } .stack-item { align-items: flex-start; flex-direction: column; } } + +/* ============================================================ + V7.2 - Vue annuelle de contrôle type tableau d'annualisation + ============================================================ */ +.annual-overview-page { + --annual-border: #aab2b8; + --annual-month: #f3c623; + --annual-blue: #dcecf8; + --annual-grey: #d5d5d5; + --annual-dark-blue: #164a78; +} + +.annual-filters { + grid-template-columns: minmax(280px, 1.8fr) minmax(150px, 0.6fr) auto; + align-items: end; +} + +.annual-filter-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.annual-sheet { + margin-bottom: 24px; + border: 1px solid #b9c1c7; + background: #fff; + box-shadow: 0 8px 28px rgba(23, 32, 42, 0.09); +} + +.annual-sheet:fullscreen { + overflow: auto; + padding: 14px; + background: #fff; +} + +.annual-sheet-header { + border-bottom: 1px solid var(--annual-border); + background: #fff; +} + +.annual-title-row { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + min-height: 70px; + padding: 10px 18px; + border-bottom: 1px solid var(--annual-border); +} + +.annual-title-row h2 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(1.3rem, 2.5vw, 2rem); + letter-spacing: 0.04em; + text-align: center; +} + +.annual-direction, +.annual-updated { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.annual-updated { text-align: right; } + +.annual-identity-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid var(--annual-border); +} + +.annual-identity-grid > div { + min-height: 56px; + padding: 8px 12px; + border-right: 1px solid #d7dcdf; + border-bottom: 1px solid #d7dcdf; +} + +.annual-identity-grid > div:nth-child(4n) { border-right: 0; } +.annual-identity-grid > div:nth-last-child(-n + 4) { border-bottom: 0; } +.annual-identity-grid span { display: block; margin-bottom: 4px; color: #5e6a71; font-size: 0.72rem; } +.annual-identity-grid strong { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.88rem; } + +.annual-quota-periods { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 10px 14px; + background: #f6f8f9; + border-bottom: 1px solid var(--annual-border); +} + +.annual-quota-chip { + min-width: 245px; + padding: 8px 10px; + border: 1px solid #cfd6da; + border-radius: 8px; + background: #fff; +} + +.annual-quota-chip span, +.annual-quota-chip small { display: block; color: #647178; font-size: 0.7rem; } +.annual-quota-chip strong { display: block; margin: 3px 0; font-size: 0.76rem; } + +.annual-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; +} +.annual-toolbar p { margin: 0 0 0 auto; color: #66737d; font-size: 0.78rem; } + +.annual-summary-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid var(--annual-border); + background: #f5f7f8; +} +.annual-summary-strip article { padding: 10px 14px; border-right: 1px solid #d7dcdf; } +.annual-summary-strip article:last-child { border-right: 0; } +.annual-summary-strip span { display: block; color: #65737b; font-size: 0.7rem; } +.annual-summary-strip strong { font-size: 0.9rem; } + +.annual-grid-scroll { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + background: #fff; + scrollbar-color: #71808a #e9edef; + scrollbar-width: auto; +} + +.annual-months { + display: flex; + align-items: flex-start; + width: max-content; + min-width: 100%; +} + +.annual-month { + width: 280px; + flex: 0 0 280px; + border-right: 2px solid #7f8a91; +} +.annual-month:last-child { border-right: 0; } + +.annual-month table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-family: Arial, Helvetica, sans-serif; + font-size: 10px; +} + +.annual-month th, +.annual-month td { + height: 24px; + padding: 2px 3px; + border: 1px solid var(--annual-border); + text-align: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.annual-month-title { + position: sticky; + top: 0; + z-index: 3; + height: 30px !important; + background: var(--annual-month); + color: #111; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.annual-column-headings th { + position: sticky; + top: 30px; + z-index: 2; + background: #f8fafb; + font-weight: 900; +} + +.annual-column-headings th:first-child { width: 37px; } +.annual-column-headings th:nth-child(2) { width: 26px; } + +.annual-day-cell { + width: 37px; + background: #fff; + font-weight: 800; + cursor: pointer; +} +.annual-day-cell span { margin-right: 3px; color: #78848b; font-size: 8px; } +.annual-week-cell { + width: 26px; + background: #3d7fb9; + color: #fff; + font-weight: 800; +} + +.annual-data-cell { + cursor: pointer; + font-weight: 700; + transition: outline 0.1s ease, filter 0.1s ease; +} +.annual-month tbody tr:hover .annual-data-cell, +.annual-month tbody tr:hover .annual-day-cell { outline: 2px solid rgba(22, 74, 120, 0.35); outline-offset: -2px; } +.annual-month tbody tr:hover .annual-data-cell { filter: brightness(0.97); } + +.annual-weekend td, +.annual-weekend .annual-day-cell { background: var(--annual-grey); color: #6a7175; } +.annual-vacation-row .annual-day-cell { background: #c9e3f7; } +.annual-vacation-row .annual-data-cell { background: #e8f4fc; } + +.annual-work { background: #e8f4fc !important; color: #123c5d; } +.annual-extra { background: #cfe5f6 !important; color: #123c5d; } +.annual-preparation { background: #fff0a8 !important; color: #5b4b00; } +.annual-training { background: #dcc8f5 !important; color: #4d2675; } +.annual-leave { background: #cfe9c8 !important; color: #29552a; } +.annual-absence { background: #f6c4c4 !important; color: #8b1919; } +.annual-other-absence { background: #cfd5da !important; color: #37434b; } +.annual-jnt { background: #f7d7dc !important; color: #b11f35; } +.annual-fraction { background: #d52020 !important; color: #fff; } +.annual-cell-draft { box-shadow: inset 0 0 0 2px #f09b24; } + +.annual-month tfoot th, +.annual-month tfoot td { + height: 30px; + background: #f7c8d3; + font-weight: 900; +} + +.annual-sheet-footer { + padding: 12px 16px 16px; + border-top: 1px solid var(--annual-border); + background: #f8fafb; +} + +.annual-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin-bottom: 10px; +} +.annual-legend-item { display: inline-flex; align-items: center; gap: 5px; font-size: 0.72rem; } +.annual-legend-item i { width: 15px; height: 15px; border: 1px solid #9ba5aa; border-radius: 3px; } +.annual-abbreviations { display: flex; flex-wrap: wrap; gap: 8px 16px; color: #59666e; font-size: 0.72rem; } + +@media (max-width: 900px) { + .annual-filters { grid-template-columns: 1fr; } + .annual-identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .annual-identity-grid > div:nth-child(4n) { border-right: 1px solid #d7dcdf; } + .annual-identity-grid > div:nth-child(2n) { border-right: 0; } + .annual-summary-strip { grid-template-columns: repeat(2, 1fr); } + .annual-title-row { grid-template-columns: 1fr; text-align: center; } + .annual-updated { text-align: center; } + .annual-toolbar { align-items: stretch; flex-direction: column; } + .annual-toolbar p { margin-left: 0; } +} + +@media print { + @page { size: A3 landscape; margin: 7mm; } + body { background: #fff; } + .app-shell { width: 100%; max-width: none; margin: 0; padding: 0; } + .page-header, .view-tabs, .no-print { display: none !important; } + .annual-sheet { border: 0; box-shadow: none; margin: 0; } + .annual-grid-scroll { overflow: visible; } + .annual-months { display: grid; grid-template-columns: repeat(6, 1fr); width: 100%; } + .annual-month { width: auto; min-width: 0; border-right: 1px solid #555; break-inside: avoid; } + .annual-month:nth-child(6n) { border-right: 0; } + .annual-month table { font-size: 6.8px; } + .annual-month th, .annual-month td { height: 14px; padding: 0 1px; } + .annual-month-title, .annual-column-headings th { position: static; } + .annual-month-title { height: 18px !important; } + .annual-sheet-footer { padding: 5px 8px; } + .annual-legend-item, .annual-abbreviations { font-size: 6.5px; } +} + +/* ============================================================ + PTA V7.4 - Choix de rôle et contexte d'accès + ============================================================ */ +.header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.access-profile-badge { + display: grid; + gap: 1px; + min-width: 190px; + padding: 9px 12px; + border: 1px solid #d8e0e5; + border-radius: 12px; + background: #f7fafb; + line-height: 1.2; +} +.access-profile-badge span { + color: #607079; + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.access-profile-badge strong { color: #17384e; font-size: 0.88rem; } +.access-profile-badge small { color: #66767f; font-size: 0.72rem; } +.switch-role-link { white-space: nowrap; } + +.nav-count-badge { + display: inline-flex; + min-width: 19px; + height: 19px; + align-items: center; + justify-content: center; + margin-left: 5px; + padding: 0 5px; + border-radius: 999px; + background: #c72f3f; + color: #fff; + font-size: 0.68rem; + font-weight: 900; + line-height: 1; +} +.role-access-warning, +.role-page-notice { margin: 0 0 16px; } +.entry-locked { + display: inline-flex; + align-items: center; + padding: 4px 7px; + border-radius: 999px; + background: #edf1f3; + color: #69777e; + font-size: 0.68rem; + font-weight: 800; +} + +.role-page-body { + min-height: 100vh; + margin: 0; + padding: 28px 16px; + background: + radial-gradient(circle at 15% 15%, rgba(40, 121, 167, 0.15), transparent 34%), + radial-gradient(circle at 85% 85%, rgba(62, 143, 103, 0.12), transparent 30%), + #eef3f5; + color: #1f303a; +} +.role-page-shell { + width: min(980px, 100%); + margin: 0 auto; + padding: 30px; + border: 1px solid #d7e0e5; + border-radius: 22px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 18px 55px rgba(27, 58, 76, 0.13); +} +.role-intro { margin-bottom: 22px; text-align: center; } +.role-intro h1 { margin: 3px 0 8px; font-size: clamp(1.75rem, 4vw, 2.45rem); } +.role-intro > p:not(.eyebrow) { margin: 0 auto; max-width: 650px; color: #61717a; } +.role-current-profile { + display: inline-flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px; + margin-top: 14px; + padding: 8px 12px; + border-radius: 999px; + background: #edf5f8; + color: #31556a; + font-size: 0.82rem; +} +.role-selection-form { display: grid; gap: 18px; } +.role-choice-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} +.role-choice-card { position: relative; cursor: pointer; } +.role-choice-card input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} +.role-choice-content { + display: grid; + align-content: start; + min-height: 150px; + padding: 20px; + border: 2px solid #dce4e8; + border-radius: 16px; + background: #fff; + box-shadow: 0 5px 15px rgba(25, 54, 69, 0.05); + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, background 0.15s ease; +} +.role-choice-card:hover .role-choice-content { + transform: translateY(-2px); + border-color: #9cbccd; + box-shadow: 0 10px 24px rgba(25, 54, 69, 0.1); +} +.role-choice-card input:focus-visible + .role-choice-content { + outline: 3px solid rgba(25, 113, 164, 0.25); + outline-offset: 3px; +} +.role-choice-card input:checked + .role-choice-content { + border-color: #1971a4; + background: #f0f8fc; + box-shadow: 0 0 0 4px rgba(25, 113, 164, 0.1); +} +.role-choice-content strong { margin-bottom: 9px; color: #163d55; font-size: 1.03rem; } +.role-choice-content small { color: #5e6f79; font-size: 0.82rem; line-height: 1.5; } +.role-context-card { + padding: 18px; + border: 1px solid #d8e2e7; + border-radius: 14px; + background: #f8fafb; +} +.role-context-card[hidden] { display: none; } +.role-form-actions { display: flex; justify-content: center; } +.role-form-actions .button { min-width: 210px; } +.role-security-note { + margin-top: 20px; + padding: 13px 15px; + border-left: 4px solid #d9911e; + border-radius: 8px; + background: #fff8e8; + color: #664d21; + font-size: 0.79rem; + line-height: 1.5; +} + +@media (max-width: 850px) { + .page-header { align-items: flex-start; } + .header-actions { justify-content: flex-start; width: 100%; } + .access-profile-badge { flex: 1 1 220px; } + .role-choice-grid { grid-template-columns: 1fr; } + .role-choice-content { min-height: 0; } +} + +@media (max-width: 560px) { + .role-page-body { padding: 12px 8px; } + .role-page-shell { padding: 20px 14px; border-radius: 16px; } + .header-actions { align-items: stretch; } + .header-actions > * { width: 100%; } + .switch-role-link { justify-content: center; } +} + +/* PTA V7.7 - Validation annuelle depuis la vue de contrôle */ +.annual-validation-card { + margin: 18px 12px 12px; + padding: 18px; + border: 1px solid #d7dde1; + border-radius: 12px; + background: #fff; +} +.annual-validation-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} +.annual-validation-heading h3 { margin: 2px 0 4px; } +.annual-validation-heading p { margin: 0; } +.annual-validation-note { + margin: 12px 0 0; + font-size: .88rem; + color: #56616a; +} +.annual-validation-result { margin-top: 14px; } +.annual-validation-summary { + padding: 12px 14px; + border-radius: 9px; + background: #f4f7f8; + border: 1px solid #d9e0e4; +} +.annual-validation-summary.is-error { background: #fff3f2; border-color: #e7b7b2; } +.annual-validation-summary.is-success { background: #f0f8f2; border-color: #b9d8bf; } +.annual-validation-summary.is-warning { background: #fff8e8; border-color: #e5cf96; } +.annual-validation-issues { + display: grid; + gap: 10px; + margin-top: 12px; +} +.annual-validation-issue { + padding: 12px; + border: 1px solid #e0c0bc; + border-radius: 9px; + background: #fff; +} +.annual-validation-issue header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} +.annual-validation-issue h4 { margin: 0; font-size: .98rem; } +.annual-validation-issue ul { margin: 6px 0 10px 20px; padding: 0; } +.annual-validation-issue li { margin: 4px 0; } +.annual-validation-open-link::after { content: ' ↗'; } +@media (max-width: 760px) { + .annual-validation-heading, + .annual-validation-issue header { align-items: stretch; flex-direction: column; } +} +@media print { + .annual-validation-card { display: none !important; } +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..c3c95e4 --- /dev/null +++ b/config.php @@ -0,0 +1,14 @@ + [ + 'host' => getenv('PTA_DB_HOST') ?: '127.0.0.1', + 'port' => getenv('PTA_DB_PORT') ?: '3306', + 'name' => getenv('PTA_DB_NAME') ?: 'pta_test', + 'user' => getenv('PTA_DB_USER') ?: 'root', + 'pass' => getenv('PTA_DB_PASS') ?: '', + 'charset' => 'utf8mb4', + ], +]; diff --git a/coverage.php b/coverage.php new file mode 100644 index 0000000..48a3ba2 --- /dev/null +++ b/coverage.php @@ -0,0 +1,6 @@ + heure_debut), + CONSTRAINT chk_couverture_minimum CHECK (minimum_agents BETWEEN 1 AND 50), + CONSTRAINT chk_couverture_quart_heure CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ) +) ENGINE = InnoDB; + +CREATE INDEX idx_couverture_structure_jour + ON structure_couverture_horaire(id_structure, jour_semaine, heure_debut, heure_fin); + +CREATE TABLE tranche_age ( + id_tranche_age INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + libelle VARCHAR(100) NOT NULL, + age_min_mois INT UNSIGNED NULL, + age_max_mois INT UNSIGNED NULL, + CONSTRAINT chk_tranche_age + CHECK ( + age_min_mois IS NULL + OR age_max_mois IS NULL + OR age_max_mois >= age_min_mois + ) +) ENGINE = InnoDB; + +CREATE TABLE structure_capacite ( + id_structure INT UNSIGNED NOT NULL, + id_tranche_age INT UNSIGNED NOT NULL, + nombre_places INT UNSIGNED NOT NULL, + + PRIMARY KEY (id_structure, id_tranche_age), + + CONSTRAINT fk_capacite_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure), + + CONSTRAINT fk_capacite_tranche_age + FOREIGN KEY (id_tranche_age) + REFERENCES tranche_age(id_tranche_age), + + CONSTRAINT chk_nombre_places + CHECK (nombre_places > 0) +) ENGINE = InnoDB; + +-- ============================================================ +-- 2. CONTRATS ET QUOTAS +-- ============================================================ + +CREATE TABLE contrat_agent ( + id_contrat INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NULL, + + -- Quota total sur la periode du contrat, en minutes. + -- Le stockage en minutes evite les problemes d'arrondis. + quota_minutes INT UNSIGNED NOT NULL, + + type_contrat VARCHAR(100) NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT fk_contrat_agent + FOREIGN KEY (id_agent) + REFERENCES agent(id_agent), + + CONSTRAINT chk_dates_contrat + CHECK (date_fin IS NULL OR date_fin >= date_debut), + + CONSTRAINT chk_quota_contrat + CHECK (quota_minutes > 0) +) ENGINE = InnoDB; + +CREATE INDEX idx_contrat_agent_dates + ON contrat_agent(id_agent, date_debut, date_fin); + +-- ============================================================ +-- 3. SEMAINES +-- ============================================================ + +CREATE TABLE semaine ( + id_semaine INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + annee SMALLINT UNSIGNED NOT NULL, + numero_semaine TINYINT UNSIGNED NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + statut ENUM('OUVERTE', 'VALIDEE', 'VERROUILLEE') + NOT NULL DEFAULT 'OUVERTE', + + UNIQUE KEY uk_semaine_annee_numero (annee, numero_semaine), + UNIQUE KEY uk_semaine_dates (date_debut, date_fin), + + CONSTRAINT chk_numero_semaine + CHECK (numero_semaine BETWEEN 1 AND 53), + + CONSTRAINT chk_dates_semaine + CHECK (date_fin >= date_debut) +) ENGINE = InnoDB; + +-- ============================================================ +-- 4. PLANNINGS ET CRENEAUX +-- ============================================================ + +CREATE TABLE planning ( + id_planning INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + id_semaine INT UNSIGNED NOT NULL, + id_structure INT UNSIGNED NOT NULL, + + statut ENUM('BROUILLON', 'VALIDE') + NOT NULL DEFAULT 'BROUILLON', + + commentaire VARCHAR(500) NULL, + id_planning_source INT UNSIGNED NULL, + + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + date_modification DATETIME NOT NULL + DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + date_validation DATETIME NULL, + + CONSTRAINT fk_planning_agent + FOREIGN KEY (id_agent) + REFERENCES agent(id_agent), + + CONSTRAINT fk_planning_semaine + FOREIGN KEY (id_semaine) + REFERENCES semaine(id_semaine), + + CONSTRAINT fk_planning_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure), + + CONSTRAINT fk_planning_source + FOREIGN KEY (id_planning_source) + REFERENCES planning(id_planning) + ON DELETE SET NULL, + + UNIQUE KEY uk_planning_agent_semaine_structure + (id_agent, id_semaine, id_structure) +) ENGINE = InnoDB; + +CREATE INDEX idx_planning_agent_statut + ON planning(id_agent, statut); + +CREATE TABLE creneau_horaire ( + id_creneau INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_planning INT UNSIGNED NOT NULL, + date_jour DATE NOT NULL, + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + + CONSTRAINT fk_creneau_planning + FOREIGN KEY (id_planning) + REFERENCES planning(id_planning) + ON DELETE CASCADE, + + CONSTRAINT chk_creneau_ordre + CHECK (heure_fin > heure_debut), + + -- Les heures doivent tomber sur un quart d'heure exact. + CONSTRAINT chk_creneau_quart_heure + CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ) +) ENGINE = InnoDB; + +CREATE INDEX idx_creneau_planning_date + ON creneau_horaire(id_planning, date_jour, heure_debut, heure_fin); + +-- ============================================================ +-- 5. VUES DE SUIVI +-- ============================================================ + +CREATE OR REPLACE VIEW v_suivi_quota_contrat AS +SELECT + ca.id_contrat, + ca.id_agent, + a.matricule, + CONCAT(a.prenom, ' ', a.nom) AS agent, + ca.date_debut, + ca.date_fin, + ca.quota_minutes, + ROUND(ca.quota_minutes / 60, 2) AS quota_heures, + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) AS minutes_planifiees_validees, + ROUND( + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) / 60, + 2 + ) AS heures_planifiees_validees, + ROUND( + ( + ca.quota_minutes + - COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) + ) / 60, + 2 + ) AS heures_restantes +FROM contrat_agent ca +JOIN agent a + ON a.id_agent = ca.id_agent +LEFT JOIN planning p + ON p.id_agent = ca.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') +GROUP BY + ca.id_contrat, + ca.id_agent, + a.matricule, + a.prenom, + a.nom, + ca.date_debut, + ca.date_fin, + ca.quota_minutes; + +CREATE OR REPLACE VIEW v_planning_agent_semaine AS +SELECT + p.id_planning, + p.id_agent, + a.matricule, + CONCAT(a.prenom, ' ', a.nom) AS agent, + s.annee, + s.numero_semaine, + st.nom AS structure, + p.statut, + ROUND( + COALESCE( + SUM(TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 3600), + 0 + ), + 2 + ) AS heures_planifiees +FROM planning p +JOIN agent a + ON a.id_agent = p.id_agent +JOIN semaine s + ON s.id_semaine = p.id_semaine +JOIN structure st + ON st.id_structure = p.id_structure +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning +GROUP BY + p.id_planning, + p.id_agent, + a.matricule, + a.prenom, + a.nom, + s.annee, + s.numero_semaine, + st.nom, + p.statut; + +-- ============================================================ +-- 6. PROCEDURES DE CONTROLE ET DE VALIDATION +-- ============================================================ + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS sp_preparer_controles_planning$$ +CREATE PROCEDURE sp_preparer_controles_planning( + IN p_id_planning INT UNSIGNED +) +controle: BEGIN + DECLARE v_planning_existe INT DEFAULT 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_controles_planning; + + CREATE TEMPORARY TABLE tmp_controles_planning ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + niveau ENUM('ERREUR', 'ALERTE', 'INFO') NOT NULL, + code VARCHAR(100) NOT NULL, + message VARCHAR(1000) NOT NULL + ); + + SELECT COUNT(*) + INTO v_planning_existe + FROM planning + WHERE id_planning = p_id_planning; + + IF v_planning_existe = 0 THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'PLANNING_INTROUVABLE', + CONCAT('Le planning #', p_id_planning, ' n''existe pas.') + ); + + LEAVE controle; + END IF; + + -- -------------------------------------------------------- + -- Controle 1 : le planning doit contenir au moins un creneau + -- -------------------------------------------------------- + IF NOT EXISTS ( + SELECT 1 + FROM creneau_horaire + WHERE id_planning = p_id_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'AUCUN_CRENEAU', + 'Le planning ne contient aucun creneau horaire.' + ); + END IF; + + -- -------------------------------------------------------- + -- Controle 2 : chaque creneau doit etre dans la semaine + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' est en dehors de la semaine ', s.numero_semaine, + ' (', DATE_FORMAT(s.date_debut, '%d/%m/%Y'), + ' au ', DATE_FORMAT(s.date_fin, '%d/%m/%Y'), ').' + ) + FROM planning p + JOIN semaine s + ON s.id_semaine = p.id_semaine + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND c.date_jour NOT BETWEEN s.date_debut AND s.date_fin; + + -- -------------------------------------------------------- + -- Controle 3 : chaque creneau doit etre couvert par un contrat + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'HORS_CONTRAT', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' n''est couvert par aucun contrat actif pour cet agent.' + ) + FROM planning p + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND NOT EXISTS ( + SELECT 1 + FROM contrat_agent ca + WHERE ca.id_agent = p.id_agent + AND ca.actif = TRUE + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ); + + -- -------------------------------------------------------- + -- Controle 4 : chevauchements dans le meme planning + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + CONCAT( + 'Les creneaux #', c1.id_creneau, + ' et #', c2.id_creneau, + ' se chevauchent le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), + ' (', TIME_FORMAT(c1.heure_debut, '%H:%i'), '-', TIME_FORMAT(c1.heure_fin, '%H:%i'), + ' et ', TIME_FORMAT(c2.heure_debut, '%H:%i'), '-', TIME_FORMAT(c2.heure_fin, '%H:%i'), ').' + ) + FROM creneau_horaire c1 + JOIN creneau_horaire c2 + ON c2.id_planning = c1.id_planning + AND c2.date_jour = c1.date_jour + AND c2.id_creneau > c1.id_creneau + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE c1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 5 : chevauchement avec un autre planning valide + -- pour le meme agent, meme si la structure est differente. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_PLANNING', + CONCAT( + 'Le creneau #', c1.id_creneau, + ' chevauche le creneau #', c2.id_creneau, + ' du planning valide #', p2.id_planning, + ' le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), '.' + ) + FROM planning p1 + JOIN creneau_horaire c1 + ON c1.id_planning = p1.id_planning + JOIN planning p2 + ON p2.id_agent = p1.id_agent + AND p2.id_planning <> p1.id_planning + AND p2.statut = 'VALIDE' + JOIN creneau_horaire c2 + ON c2.id_planning = p2.id_planning + AND c2.date_jour = c1.date_jour + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE p1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 6 : depassement du quota du contrat + -- Le quota est ici un quota total sur la periode du contrat. + -- Il s'agit d'une ALERTE et non d'une erreur bloquante. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ALERTE', + 'QUOTA_DEPASSE', + CONCAT( + 'Le contrat #', ca.id_contrat, + ' prevoit ', ROUND(ca.quota_minutes / 60, 2), ' h. ', + 'Apres validation, ', + ROUND( + ( + -- Minutes deja validees, hors planning courant + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + -- Minutes du planning courant appartenant a ce contrat + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) / 60, + 2 + ), + ' h seraient planifiees.' + ) + FROM planning p + JOIN contrat_agent ca + ON ca.id_agent = p.id_agent + AND ca.actif = TRUE + WHERE p.id_planning = p_id_planning + AND EXISTS ( + SELECT 1 + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ) + AND ( + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) > ca.quota_minutes; + + -- Si aucun probleme n'a ete detecte, on retourne explicitement OK. + IF NOT EXISTS ( + SELECT 1 + FROM tmp_controles_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'INFO', + 'OK', + 'Aucune anomalie detectee. Le planning peut etre valide.' + ); + END IF; +END$$ + +DROP PROCEDURE IF EXISTS sp_controler_planning$$ +CREATE PROCEDURE sp_controler_planning( + IN p_id_planning INT UNSIGNED +) +BEGIN + CALL sp_preparer_controles_planning(p_id_planning); + + SELECT + niveau, + code, + message + FROM tmp_controles_planning + ORDER BY + FIELD(niveau, 'ERREUR', 'ALERTE', 'INFO'), + id; +END$$ + +DROP PROCEDURE IF EXISTS sp_valider_planning$$ +CREATE PROCEDURE sp_valider_planning( + IN p_id_planning INT UNSIGNED, + IN p_forcer_depassement_quota BOOLEAN +) +BEGIN + DECLARE v_nb_erreurs INT DEFAULT 0; + DECLARE v_nb_alertes_quota INT DEFAULT 0; + DECLARE v_message VARCHAR(1000); + + CALL sp_preparer_controles_planning(p_id_planning); + + SELECT COUNT(*) + INTO v_nb_erreurs + FROM tmp_controles_planning + WHERE niveau = 'ERREUR'; + + IF v_nb_erreurs > 0 THEN + SELECT message + INTO v_message + FROM tmp_controles_planning + WHERE niveau = 'ERREUR' + ORDER BY id + LIMIT 1; + + SET v_message = CONCAT( + 'Validation impossible. ', + v_nb_erreurs, + ' erreur(s) detectee(s). Premiere erreur : ', + v_message + ); + + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = v_message; + END IF; + + SELECT COUNT(*) + INTO v_nb_alertes_quota + FROM tmp_controles_planning + WHERE code = 'QUOTA_DEPASSE'; + + IF v_nb_alertes_quota > 0 + AND COALESCE(p_forcer_depassement_quota, FALSE) = FALSE THEN + + SET v_message = CONCAT( + 'Validation suspendue : le quota contractuel serait depasse. ', + 'Relancer avec p_forcer_depassement_quota = TRUE pour forcer la validation.' + ); + + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = v_message; + END IF; + + UPDATE planning + SET + statut = 'VALIDE', + date_validation = CURRENT_TIMESTAMP + WHERE id_planning = p_id_planning; + + SELECT + p.id_planning, + p.statut, + p.date_validation, + 'Planning valide avec succes.' AS message + FROM planning p + WHERE p.id_planning = p_id_planning; +END$$ + +DELIMITER ; + +-- ============================================================ +-- 7. JEU D'ESSAI +-- ============================================================ + +INSERT INTO agent ( + id_agent, + matricule, + nom, + prenom, + email, + telephone, + adresse, + est_diplome, + diplome_libelle, + date_debut_contrat +) +VALUES + (1, 'AG001', 'MARTIN', 'Alice', 'alice.martin@example.test', '06 10 20 30 40', '12 rue des Lilas, Orléans', TRUE, 'BAFA', '2026-07-01'), + (2, 'AG002', 'DURAND', 'Bruno', 'bruno.durand@example.test', '06 11 21 31 41', '8 avenue de Paris, Orléans', FALSE, NULL, '2026-07-01'), + (3, 'AG003', 'PETIT', 'Chloe', 'chloe.petit@example.test', '06 12 22 32 42', '5 rue Royale, Orléans', TRUE, 'CAP AEPE', '2026-07-22'); + +INSERT INTO structure ( + id_structure, + code, + nom, + adresse, + type_affectation +) +VALUES + (1, 'STR001', 'Creche des Lilas', '10 rue des Lilas, Orleans', 'PERISCOLAIRE'), + (2, 'STR002', 'Accueil Madeleine', '36 rue du Faubourg Madeleine, Orleans', 'EXTRASCOLAIRE'); + +-- Plages de couverture de test. Elles sont configurables dans l'administration. +INSERT INTO structure_couverture_horaire (id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents) +VALUES + (1, 1, '07:30:00', '09:00:00', 1), + (1, 1, '16:30:00', '18:30:00', 1), + (1, 2, '07:30:00', '09:00:00', 1), + (1, 2, '16:30:00', '18:30:00', 1), + (1, 3, '07:30:00', '09:00:00', 1), + (1, 3, '16:30:00', '18:30:00', 1), + (1, 4, '07:30:00', '09:00:00', 1), + (1, 4, '16:30:00', '18:30:00', 1), + (1, 5, '07:30:00', '09:00:00', 1), + (1, 5, '16:30:00', '18:30:00', 1), + (2, 1, '08:00:00', '18:00:00', 1), + (2, 2, '08:00:00', '18:00:00', 1), + (2, 3, '08:00:00', '18:00:00', 1), + (2, 4, '08:00:00', '18:00:00', 1), + (2, 5, '08:00:00', '18:00:00', 1); + +-- Une structure principale par agent. +UPDATE agent SET id_structure = 1 WHERE id_agent IN (1, 2); +UPDATE agent SET id_structure = 2 WHERE id_agent = 3; + +INSERT INTO tranche_age ( + id_tranche_age, + libelle, + age_min_mois, + age_max_mois +) +VALUES + (1, '0 - 3 ans', 0, 35), + (2, '3 - 6 ans', 36, 71), + (3, '6 - 11 ans', 72, 131); + +INSERT INTO structure_capacite ( + id_structure, + id_tranche_age, + nombre_places +) +VALUES + (1, 1, 20), + (1, 2, 12), + (2, 2, 18), + (2, 3, 30); + +-- Alice : 100 h sur juillet/aout pour permettre plusieurs plannings valides. +-- Bruno : 35 h au total, afin de provoquer un depassement de quota. +-- Chloe : contrat qui commence le 22/07, afin de tester un planning hors contrat. +INSERT INTO contrat_agent ( + id_contrat, + id_agent, + date_debut, + date_fin, + quota_minutes, + type_contrat +) +VALUES + (1, 1, '2026-07-01', '2026-08-31', 6000, 'PTA ete - 100 h'), + (2, 2, '2026-07-01', '2026-07-31', 2100, 'PTA juillet - 35 h'), + (3, 3, '2026-07-22', '2026-08-31', 2100, 'PTA ete - 35 h'); + +INSERT INTO semaine ( + id_semaine, + annee, + numero_semaine, + date_debut, + date_fin +) +VALUES + (1, 2026, 29, '2026-07-13', '2026-07-19'), + (2, 2026, 30, '2026-07-20', '2026-07-26'), + (3, 2026, 31, '2026-07-27', '2026-08-02'); + +-- ------------------------------------------------------------ +-- Planning #1 : Alice, semaine 29, 32 h, deja valide +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + date_validation +) +VALUES + (1, 1, 1, 1, 'VALIDE', 'Planning valide de reference - 32 h', CURRENT_TIMESTAMP); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (1, '2026-07-13', '08:00:00', '12:00:00'), + (1, '2026-07-13', '13:00:00', '17:00:00'), + (1, '2026-07-14', '08:00:00', '12:00:00'), + (1, '2026-07-14', '13:00:00', '17:00:00'), + (1, '2026-07-15', '08:00:00', '12:00:00'), + (1, '2026-07-15', '13:00:00', '17:00:00'), + (1, '2026-07-16', '08:00:00', '12:00:00'), + (1, '2026-07-16', '13:00:00', '17:00:00'); + +-- ------------------------------------------------------------ +-- Planning #2 : Alice, semaine 30, 32 h, deja valide +-- Sert de planning de reference pour tester les chevauchements. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + date_validation +) +VALUES + (2, 1, 2, 1, 'VALIDE', 'Deuxieme planning valide de reference - 32 h', CURRENT_TIMESTAMP); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (2, '2026-07-20', '08:00:00', '12:00:00'), + (2, '2026-07-20', '13:00:00', '17:00:00'), + (2, '2026-07-21', '08:00:00', '12:00:00'), + (2, '2026-07-21', '13:00:00', '17:00:00'), + (2, '2026-07-22', '08:00:00', '12:00:00'), + (2, '2026-07-22', '13:00:00', '17:00:00'), + (2, '2026-07-23', '08:00:00', '12:00:00'), + (2, '2026-07-23', '13:00:00', '17:00:00'); + +-- ------------------------------------------------------------ +-- Planning #3 : Alice, semaine 30, autre structure +-- ERREUR ATTENDUE : chevauchement avec le planning #2. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (3, 1, 2, 2, 'BROUILLON', 'Test chevauchement entre deux structures'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (3, '2026-07-20', '10:00:00', '12:00:00'); + +-- ------------------------------------------------------------ +-- Planning #4 : Bruno, semaine 29, 30 h, deja valide +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + date_validation +) +VALUES + (4, 2, 1, 1, 'VALIDE', 'Planning valide Bruno - 30 h sur quota de 35 h', CURRENT_TIMESTAMP); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (4, '2026-07-13', '08:00:00', '12:00:00'), + (4, '2026-07-13', '13:00:00', '16:00:00'), + (4, '2026-07-14', '08:00:00', '12:00:00'), + (4, '2026-07-14', '13:00:00', '16:00:00'), + (4, '2026-07-15', '08:00:00', '12:00:00'), + (4, '2026-07-15', '13:00:00', '16:00:00'), + (4, '2026-07-16', '08:00:00', '12:00:00'), + (4, '2026-07-16', '13:00:00', '16:00:00'), + (4, '2026-07-17', '08:00:00', '10:00:00'); + +-- ------------------------------------------------------------ +-- Planning #5 : Bruno, semaine 30, 10 h +-- ALERTE ATTENDUE : quota depasse (30 h deja validees + 10 h = 40 h > 35 h). +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (5, 2, 2, 1, 'BROUILLON', 'Test depassement de quota'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (5, '2026-07-20', '08:00:00', '12:00:00'), + (5, '2026-07-21', '08:00:00', '12:00:00'), + (5, '2026-07-22', '08:00:00', '10:00:00'); + +-- ------------------------------------------------------------ +-- Planning #6 : Chloe, semaine 29 +-- ERREUR ATTENDUE : hors contrat, son contrat commence le 22/07. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (6, 3, 1, 1, 'BROUILLON', 'Test creneau avant le debut du contrat'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (6, '2026-07-15', '08:00:00', '12:00:00'); + +-- ------------------------------------------------------------ +-- Planning #7 : Bruno, semaine 30, deuxieme structure +-- ERREUR ATTENDUE : deux creneaux se chevauchent dans le meme planning. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (7, 2, 2, 2, 'BROUILLON', 'Test chevauchement interne'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (7, '2026-07-23', '08:00:00', '12:00:00'), + (7, '2026-07-23', '11:00:00', '14:00:00'); + +-- ------------------------------------------------------------ +-- Planning #8 : Alice, semaine 31, 24 h +-- CAS VALIDE ATTENDU : 64 h deja validees + 24 h = 88 h sur quota de 100 h. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + id_planning_source +) +VALUES + (8, 1, 3, 1, 'BROUILLON', 'Cas nominal pouvant etre valide', 2); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (8, '2026-07-27', '08:00:00', '12:00:00'), + (8, '2026-07-27', '13:00:00', '17:00:00'), + (8, '2026-07-28', '08:00:00', '12:00:00'), + (8, '2026-07-28', '13:00:00', '17:00:00'), + (8, '2026-07-29', '08:00:00', '12:00:00'), + (8, '2026-07-29', '13:00:00', '17:00:00'); + +-- ------------------------------------------------------------ +-- Planning #9 : Chloe, semaine 30 +-- ERREUR ATTENDUE : le 27/07 appartient a la semaine 31, pas a la semaine 30. +-- Le creneau est volontairement couvert par le contrat pour isoler ce controle. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (9, 3, 2, 1, 'BROUILLON', 'Test date hors de la semaine du planning'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (9, '2026-07-27', '08:00:00', '12:00:00'); + +-- ============================================================ +-- 8. REQUETES DE TEST A EXECUTER MANUELLEMENT +-- ============================================================ + +-- CAS 1 : cas nominal, aucune anomalie attendue. +-- CALL sp_controler_planning(8); + +-- CAS 2 : validation du cas nominal. +-- CALL sp_valider_planning(8, FALSE); + +-- CAS 3 : chevauchement avec un autre planning valide. +-- CALL sp_controler_planning(3); +-- CALL sp_valider_planning(3, FALSE); + +-- CAS 4 : depassement de quota. +-- CALL sp_controler_planning(5); +-- La validation ci-dessous doit etre refusee : +-- CALL sp_valider_planning(5, FALSE); +-- La validation peut etre forcee apres confirmation utilisateur : +-- CALL sp_valider_planning(5, TRUE); + +-- CAS 5 : creneau hors contrat. +-- CALL sp_controler_planning(6); +-- CALL sp_valider_planning(6, FALSE); + +-- CAS 6 : chevauchement interne. +-- CALL sp_controler_planning(7); +-- CALL sp_valider_planning(7, FALSE); + +-- CAS 7 : date en dehors de la semaine selectionnee. +-- CALL sp_controler_planning(9); +-- CALL sp_valider_planning(9, FALSE); + +-- CAS 8 : controle direct par contrainte SQL des quarts d'heure. +-- L'INSERT suivant doit echouer a cause du CHECK chk_creneau_quart_heure : +-- INSERT INTO creneau_horaire(id_planning, date_jour, heure_debut, heure_fin) +-- VALUES (8, '2026-07-30', '08:10:00', '10:00:00'); + +-- CAS 9 : consultation du suivi de quota par contrat. +-- SELECT * FROM v_suivi_quota_contrat ORDER BY id_contrat; + +-- CAS 10 : synthese des heures par planning. +-- SELECT * FROM v_planning_agent_semaine ORDER BY id_planning; + +-- ============================================================ +-- 9. LIMITES FONCTIONNELLES VOLONTAIRES DU MVP +-- ============================================================ +-- La capacite par tranche d'age est stockee dans structure_capacite, +-- mais elle ne peut pas encore etre controlee automatiquement avec le +-- modele actuel car aucun effectif d'enfants/usagers n'est associe a un +-- planning. Pour controler la capacite, il faudra ajouter une table +-- d'effectif ou d'affectation par structure, date et tranche d'age. +-- +-- De meme, les heures ci-dessus sont des heures PLANIFIEES et non des +-- heures reellement EFFECTUEES. Pour distinguer les deux, il faudra +-- ajouter un module de pointage ou de realisation effective. +-- ============================================================ +-- Extension de la base PTA pour l'interface de gestion planning +-- À exécuter APRES pta_mysql_test.sql +-- Compatible MySQL 8+ +-- ============================================================ + +USE nfbhnajdb1; + +-- 1. Motifs d'affectation +CREATE TABLE IF NOT EXISTS motif_planning ( + id_motif INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + libelle VARCHAR(120) NOT NULL, + compte_dans_quota BOOLEAN NOT NULL DEFAULT FALSE, + ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100, + actif BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE = InnoDB; + +INSERT INTO motif_planning (code, libelle, compte_dans_quota, ordre_affichage) +VALUES + ('TRAVAIL', 'Heure de travail', TRUE, 10), + ('CONGE', 'Congé', FALSE, 20), + ('MALADIE', 'Absence', TRUE, 30), + ('FORMATION', 'Formation', TRUE, 40), + ('AUTRE', 'Autre absence (non décomptée)', FALSE, 100) +ON DUPLICATE KEY UPDATE + libelle = VALUES(libelle), + compte_dans_quota = VALUES(compte_dans_quota), + ordre_affichage = VALUES(ordre_affichage), + actif = TRUE; + +-- 2. Rattachement des agents aux structures +CREATE TABLE IF NOT EXISTS agent_structure ( + id_agent_structure INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + id_structure INT UNSIGNED NOT NULL, + date_debut DATE NULL, + date_fin DATE NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT fk_agent_structure_agent + FOREIGN KEY (id_agent) REFERENCES agent(id_agent), + + CONSTRAINT fk_agent_structure_structure + FOREIGN KEY (id_structure) REFERENCES structure(id_structure), + + CONSTRAINT chk_agent_structure_dates + CHECK (date_fin IS NULL OR date_debut IS NULL OR date_fin >= date_debut), + + UNIQUE KEY uk_agent_structure_periode (id_agent, id_structure, date_debut) +) ENGINE = InnoDB; + +-- Historique initial cohérent avec la structure principale de chaque agent. +INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) +VALUES + (1, 1, '2026-01-01', NULL, TRUE), + (2, 1, '2026-01-01', NULL, TRUE), + (3, 2, '2026-01-01', NULL, TRUE) +ON DUPLICATE KEY UPDATE + date_fin = VALUES(date_fin), + actif = VALUES(actif); + +-- 3. Ajout du motif à chaque créneau existant +SET @travail_id = ( + SELECT id_motif FROM motif_planning WHERE code = 'TRAVAIL' LIMIT 1 +); + +SET @col_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND COLUMN_NAME = 'id_motif' +); + +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE creneau_horaire ADD COLUMN id_motif INT UNSIGNED NULL AFTER id_planning', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE creneau_horaire +SET id_motif = @travail_id +WHERE id_motif IS NULL; + +SET @fk_exists = ( + SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND CONSTRAINT_NAME = 'fk_creneau_motif' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); + +SET @sql = IF( + @fk_exists = 0, + 'ALTER TABLE creneau_horaire ADD CONSTRAINT fk_creneau_motif FOREIGN KEY (id_motif) REFERENCES motif_planning(id_motif)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE creneau_horaire + MODIFY id_motif INT UNSIGNED NOT NULL; + +SET @idx_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND INDEX_NAME = 'idx_creneau_motif' +); + +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX idx_creneau_motif ON creneau_horaire(id_motif)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4. Vue de synthèse par agent en tenant compte du motif +CREATE OR REPLACE VIEW v_suivi_quota_contrat AS +SELECT + ca.id_contrat, + ca.id_agent, + a.matricule, + CONCAT(a.prenom, ' ', a.nom) AS agent, + ca.date_debut, + ca.date_fin, + ca.quota_minutes, + ROUND(ca.quota_minutes / 60, 2) AS quota_heures, + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) AS minutes_planifiees_validees, + ROUND( + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) / 60, + 2 + ) AS heures_planifiees_validees, + ROUND( + ( + ca.quota_minutes + - COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) + ) / 60, + 2 + ) AS heures_restantes +FROM contrat_agent ca +JOIN agent a ON a.id_agent = ca.id_agent +LEFT JOIN planning p ON p.id_agent = ca.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') +LEFT JOIN motif_planning m ON m.id_motif = c.id_motif +GROUP BY + ca.id_contrat, + ca.id_agent, + a.matricule, + a.prenom, + a.nom, + ca.date_debut, + ca.date_fin, + ca.quota_minutes; + + +-- 5. Mise à jour du contrôle de quota : seuls les motifs marqués +-- compte_dans_quota = TRUE sont additionnés. +DELIMITER $$ + +DROP PROCEDURE IF EXISTS sp_preparer_controles_planning$$ +CREATE PROCEDURE sp_preparer_controles_planning( + IN p_id_planning INT UNSIGNED +) +controle: BEGIN + DECLARE v_planning_existe INT DEFAULT 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_controles_planning; + + CREATE TEMPORARY TABLE tmp_controles_planning ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + niveau ENUM('ERREUR', 'ALERTE', 'INFO') NOT NULL, + code VARCHAR(100) NOT NULL, + message VARCHAR(1000) NOT NULL + ); + + SELECT COUNT(*) + INTO v_planning_existe + FROM planning + WHERE id_planning = p_id_planning; + + IF v_planning_existe = 0 THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'PLANNING_INTROUVABLE', + CONCAT('Le planning #', p_id_planning, ' n''existe pas.') + ); + + LEAVE controle; + END IF; + + -- -------------------------------------------------------- + -- Controle 1 : le planning doit contenir au moins un creneau + -- -------------------------------------------------------- + IF NOT EXISTS ( + SELECT 1 + FROM creneau_horaire + WHERE id_planning = p_id_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'AUCUN_CRENEAU', + 'Le planning ne contient aucun creneau horaire.' + ); + END IF; + + -- -------------------------------------------------------- + -- Controle 2 : chaque creneau doit etre dans la semaine + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' est en dehors de la semaine ', s.numero_semaine, + ' (', DATE_FORMAT(s.date_debut, '%d/%m/%Y'), + ' au ', DATE_FORMAT(s.date_fin, '%d/%m/%Y'), ').' + ) + FROM planning p + JOIN semaine s + ON s.id_semaine = p.id_semaine + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND c.date_jour NOT BETWEEN s.date_debut AND s.date_fin; + + -- -------------------------------------------------------- + -- Controle 3 : chaque creneau doit etre couvert par un contrat + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'HORS_CONTRAT', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' n''est couvert par aucun contrat actif pour cet agent.' + ) + FROM planning p + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND NOT EXISTS ( + SELECT 1 + FROM contrat_agent ca + WHERE ca.id_agent = p.id_agent + AND ca.actif = TRUE + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ); + + -- -------------------------------------------------------- + -- Controle 4 : chevauchements dans le meme planning + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + CONCAT( + 'Les creneaux #', c1.id_creneau, + ' et #', c2.id_creneau, + ' se chevauchent le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), + ' (', TIME_FORMAT(c1.heure_debut, '%H:%i'), '-', TIME_FORMAT(c1.heure_fin, '%H:%i'), + ' et ', TIME_FORMAT(c2.heure_debut, '%H:%i'), '-', TIME_FORMAT(c2.heure_fin, '%H:%i'), ').' + ) + FROM creneau_horaire c1 + JOIN creneau_horaire c2 + ON c2.id_planning = c1.id_planning + AND c2.date_jour = c1.date_jour + AND c2.id_creneau > c1.id_creneau + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE c1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 5 : chevauchement avec un autre planning valide + -- pour le meme agent, meme si la structure est differente. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_PLANNING', + CONCAT( + 'Le creneau #', c1.id_creneau, + ' chevauche le creneau #', c2.id_creneau, + ' du planning valide #', p2.id_planning, + ' le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), '.' + ) + FROM planning p1 + JOIN creneau_horaire c1 + ON c1.id_planning = p1.id_planning + JOIN planning p2 + ON p2.id_agent = p1.id_agent + AND p2.id_planning <> p1.id_planning + AND p2.statut = 'VALIDE' + JOIN creneau_horaire c2 + ON c2.id_planning = p2.id_planning + AND c2.date_jour = c1.date_jour + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE p1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 6 : depassement du quota du contrat + -- Le quota est ici un quota total sur la periode du contrat. + -- Il s'agit d'une ALERTE et non d'une erreur bloquante. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ALERTE', + 'QUOTA_DEPASSE', + CONCAT( + 'Le contrat #', ca.id_contrat, + ' prevoit ', ROUND(ca.quota_minutes / 60, 2), ' h. ', + 'Apres validation, ', + ROUND( + ( + -- Minutes deja validees, hors planning courant + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + JOIN motif_planning mv + ON mv.id_motif = cv.id_motif + AND mv.compte_dans_quota = TRUE + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + -- Minutes du planning courant appartenant a ce contrat + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + JOIN motif_planning mc + ON mc.id_motif = cc.id_motif + AND mc.compte_dans_quota = TRUE + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) / 60, + 2 + ), + ' h seraient planifiees.' + ) + FROM planning p + JOIN contrat_agent ca + ON ca.id_agent = p.id_agent + AND ca.actif = TRUE + WHERE p.id_planning = p_id_planning + AND EXISTS ( + SELECT 1 + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ) + AND ( + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + JOIN motif_planning mv + ON mv.id_motif = cv.id_motif + AND mv.compte_dans_quota = TRUE + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + JOIN motif_planning mc + ON mc.id_motif = cc.id_motif + AND mc.compte_dans_quota = TRUE + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) > ca.quota_minutes; + + -- Si aucun probleme n'a ete detecte, on retourne explicitement OK. + IF NOT EXISTS ( + SELECT 1 + FROM tmp_controles_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'INFO', + 'OK', + 'Aucune anomalie detectee. Le planning peut etre valide.' + ); + END IF; +END$$ + + +DELIMITER ; +-- ============================================================ +-- PTA - Quotas annuels et vues de pilotage +-- À exécuter APRÈS : +-- 1. pta_mysql_test.sql +-- 2. migration_planning_motifs.sql +-- Compatible MySQL 8+ +-- ============================================================ + +USE nfbhnajdb1; + +-- ------------------------------------------------------------ +-- 1. Quota annuel d'un agent +-- 1607 h = 96 420 minutes à 100 %. +-- La quotité permet de proratiser automatiquement le quota cible. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS quota_agent_annuel ( + id_quota INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + annee SMALLINT UNSIGNED NOT NULL, + quotite_travail DECIMAL(5,2) NOT NULL DEFAULT 100.00, + quota_reference_minutes INT UNSIGNED NOT NULL DEFAULT 96420, + quota_cible_minutes INT UNSIGNED NOT NULL, + commentaire VARCHAR(255) NULL, + + CONSTRAINT fk_quota_annuel_agent + FOREIGN KEY (id_agent) REFERENCES agent(id_agent), + + CONSTRAINT chk_quotite_travail + CHECK (quotite_travail > 0 AND quotite_travail <= 100), + + CONSTRAINT chk_quota_annuel_positif + CHECK (quota_cible_minutes > 0), + + UNIQUE KEY uk_quota_agent_annee (id_agent, annee) +) ENGINE = InnoDB; + +-- Jeu d'essai 2026 : tous les agents existants à 100 %, soit 1607 h. +INSERT INTO quota_agent_annuel ( + id_agent, + annee, + quotite_travail, + quota_reference_minutes, + quota_cible_minutes, + commentaire +) +SELECT + a.id_agent, + 2026, + 100.00, + 96420, + 96420, + 'Quota annuel de référence à 100 %' +FROM agent a +WHERE NOT EXISTS ( + SELECT 1 + FROM quota_agent_annuel q + WHERE q.id_agent = a.id_agent + AND q.annee = 2026 +); + +-- Exemple de modification pour un agent à 80 % : +-- UPDATE quota_agent_annuel +-- SET quotite_travail = 80.00, +-- quota_cible_minutes = ROUND(96420 * 0.80) +-- WHERE id_agent = 2 AND annee = 2026; + +-- ------------------------------------------------------------ +-- 2. Règle de décompte des motifs +-- L'application reste paramétrable : le booléen compte_dans_quota +-- indique si le motif consomme le quota annuel. +-- Selon le besoin exprimé, les motifs principaux ci-dessous sont +-- comptés par défaut. Il est possible de changer cette règle ensuite. +-- ------------------------------------------------------------ +UPDATE motif_planning +SET compte_dans_quota = TRUE +WHERE code IN ('TRAVAIL', 'FORMATION', 'CONGE', 'MALADIE'); + +-- ------------------------------------------------------------ +-- 3. Vue annuelle de suivi des quotas +-- Les brouillons et les plannings validés sont distingués. +-- ------------------------------------------------------------ +CREATE OR REPLACE VIEW v_suivi_quota_annuel AS +SELECT + q.id_agent, + a.matricule, + a.nom, + a.prenom, + q.annee, + q.quotite_travail, + q.quota_reference_minutes, + q.quota_cible_minutes, + ROUND(q.quota_cible_minutes / 60, 2) AS quota_cible_heures, + + COALESCE(SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_valides, + + COALESCE(SUM( + CASE + WHEN p.statut = 'BROUILLON' AND m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_brouillon, + + q.quota_cible_minutes - COALESCE(SUM( + CASE + WHEN m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_restantes + +FROM quota_agent_annuel q +INNER JOIN agent a ON a.id_agent = q.id_agent +LEFT JOIN planning p ON p.id_agent = q.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND YEAR(c.date_jour) = q.annee +LEFT JOIN motif_planning m ON m.id_motif = c.id_motif +GROUP BY + q.id_agent, + a.matricule, + a.nom, + a.prenom, + q.annee, + q.quotite_travail, + q.quota_reference_minutes, + q.quota_cible_minutes; + + +-- ============================================================ +-- PTA V5.9 - Semaines types par agent +-- ============================================================ +-- Un modèle mémorise les affectations d'une semaine de référence : +-- jour ouvré, lieu, motif et plage horaire. + +CREATE TABLE IF NOT EXISTS modele_semaine_agent ( + id_modele INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + nom VARCHAR(120) NOT NULL, + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + date_modification DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_modele_semaine_agent + FOREIGN KEY (id_agent) + REFERENCES agent(id_agent) + ON DELETE CASCADE, + + UNIQUE KEY uk_modele_semaine_agent_nom (id_agent, nom), + KEY idx_modele_semaine_agent (id_agent, date_modification) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS modele_semaine_creneau ( + id_modele_creneau INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_modele INT UNSIGNED NOT NULL, + jour_semaine TINYINT UNSIGNED NOT NULL COMMENT '1=lundi ... 5=vendredi', + id_structure INT UNSIGNED NOT NULL, + id_motif INT UNSIGNED NOT NULL, + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + + CONSTRAINT fk_modele_creneau_modele + FOREIGN KEY (id_modele) + REFERENCES modele_semaine_agent(id_modele) + ON DELETE CASCADE, + + CONSTRAINT fk_modele_creneau_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure), + + CONSTRAINT fk_modele_creneau_motif + FOREIGN KEY (id_motif) + REFERENCES motif_planning(id_motif), + + CONSTRAINT chk_modele_creneau_jour + CHECK (jour_semaine BETWEEN 1 AND 5), + + CONSTRAINT chk_modele_creneau_ordre + CHECK (heure_fin > heure_debut), + + CONSTRAINT chk_modele_creneau_quart_heure + CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ), + + KEY idx_modele_creneau_modele_jour (id_modele, jour_semaine, heure_debut) +) ENGINE = InnoDB; + + + +-- ============================================================ +-- PTA V5.17 - Périodes de vacances scolaires +-- ============================================================ +-- Les dates sont validées manuellement par l'utilisateur avant +-- d'être enregistrées, même lorsqu'elles proviennent de l'API +-- officielle du calendrier scolaire. +-- +-- date_fin est stockée de façon INCLUSIVE dans PTA. +-- Pour une proposition issue de l'API, la date de reprise fournie +-- par l'API est convertie en dernier jour de vacances (veille). + +CREATE TABLE IF NOT EXISTS periode_vacance ( + id_periode INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + libelle VARCHAR(150) NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + annee_scolaire VARCHAR(9) NOT NULL, + academie VARCHAR(100) NOT NULL DEFAULT '', + zone VARCHAR(50) NOT NULL DEFAULT '', + source ENUM('MANUEL', 'DATA_GOUV') NOT NULL DEFAULT 'MANUEL', + actif BOOLEAN NOT NULL DEFAULT TRUE, + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT chk_periode_vacance_dates + CHECK (date_fin >= date_debut), + + UNIQUE KEY uk_periode_vacance ( + date_debut, + date_fin, + annee_scolaire, + academie + ), + + KEY idx_periode_vacance_dates (date_debut, date_fin), + KEY idx_periode_vacance_annee (annee_scolaire) +) ENGINE = InnoDB; + + + +-- ============================================================ +-- Historique V5.20 - date de début de contrat +-- IMPORTANT : depuis la V7.5, le quota PTA est suivi sur l'année civile +-- (01/01 -> 31/12). La date de contrat n'est plus l'ancre du quota. +-- ============================================================ diff --git a/database/migration_agent_structure_principale.sql b/database/migration_agent_structure_principale.sql new file mode 100644 index 0000000..64d2df1 --- /dev/null +++ b/database/migration_agent_structure_principale.sql @@ -0,0 +1,70 @@ +-- ============================================================ +-- PTA V4.5 - Une structure principale par agent +-- MySQL 8+ +-- ============================================================ + +SET @col_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'agent' + AND COLUMN_NAME = 'id_structure' +); + +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE agent ADD COLUMN id_structure INT UNSIGNED NULL AFTER email', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @fk_exists = ( + SELECT COUNT(*) + FROM information_schema.REFERENTIAL_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'agent' + AND CONSTRAINT_NAME = 'fk_agent_structure_principale' +); + +SET @sql = IF( + @fk_exists = 0, + 'ALTER TABLE agent ADD CONSTRAINT fk_agent_structure_principale FOREIGN KEY (id_structure) REFERENCES structure(id_structure) ON UPDATE CASCADE ON DELETE SET NULL', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'agent' + AND INDEX_NAME = 'idx_agent_structure_principale' +); +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX idx_agent_structure_principale ON agent(id_structure)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Reprise automatique uniquement lorsqu'un agent n'a qu'un seul rattachement actif. +UPDATE agent a +JOIN ( + SELECT id_agent, MIN(id_structure) AS id_structure + FROM agent_structure + WHERE actif = TRUE + AND (date_fin IS NULL OR date_fin >= CURRENT_DATE) + GROUP BY id_agent + HAVING COUNT(DISTINCT id_structure) = 1 +) x ON x.id_agent = a.id_agent +SET a.id_structure = x.id_structure +WHERE a.id_structure IS NULL; + +-- Les agents encore sans structure doivent être affectés depuis l'onglet +-- "Gestion des agents" de l'application. diff --git a/database/migration_planning_motifs.sql b/database/migration_planning_motifs.sql new file mode 100644 index 0000000..2411d4b --- /dev/null +++ b/database/migration_planning_motifs.sql @@ -0,0 +1,449 @@ +-- ============================================================ +-- Extension de la base PTA pour l'interface de gestion planning +-- À exécuter APRES pta_mysql_test.sql +-- Compatible MySQL 8+ +-- ============================================================ + +USE pta_test; + +-- 1. Motifs d'affectation +CREATE TABLE IF NOT EXISTS motif_planning ( + id_motif INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + libelle VARCHAR(120) NOT NULL, + compte_dans_quota BOOLEAN NOT NULL DEFAULT FALSE, + ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100, + actif BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE = InnoDB; + +INSERT INTO motif_planning (code, libelle, compte_dans_quota, ordre_affichage) +VALUES + ('TRAVAIL', 'Heure de travail', TRUE, 10), + ('CONGE', 'Congé', FALSE, 20), + ('MALADIE', 'Absence', TRUE, 30), + ('FORMATION', 'Formation', TRUE, 40), + ('AUTRE', 'Autre absence (non décomptée)', FALSE, 100) +ON DUPLICATE KEY UPDATE + libelle = VALUES(libelle), + compte_dans_quota = VALUES(compte_dans_quota), + ordre_affichage = VALUES(ordre_affichage), + actif = TRUE; + +-- 2. Rattachement des agents aux structures +CREATE TABLE IF NOT EXISTS agent_structure ( + id_agent_structure INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + id_structure INT UNSIGNED NOT NULL, + date_debut DATE NULL, + date_fin DATE NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT fk_agent_structure_agent + FOREIGN KEY (id_agent) REFERENCES agent(id_agent), + + CONSTRAINT fk_agent_structure_structure + FOREIGN KEY (id_structure) REFERENCES structure(id_structure), + + CONSTRAINT chk_agent_structure_dates + CHECK (date_fin IS NULL OR date_debut IS NULL OR date_fin >= date_debut), + + UNIQUE KEY uk_agent_structure_periode (id_agent, id_structure, date_debut) +) ENGINE = InnoDB; + +-- Jeu d'essai : un seul rattachement principal par agent. +INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) +VALUES + (1, 1, '2026-01-01', NULL, TRUE), + (2, 1, '2026-01-01', NULL, TRUE), + (3, 2, '2026-01-01', NULL, TRUE) +ON DUPLICATE KEY UPDATE + date_fin = VALUES(date_fin), + actif = VALUES(actif); + +-- 3. Ajout du motif à chaque créneau existant +SET @travail_id = ( + SELECT id_motif FROM motif_planning WHERE code = 'TRAVAIL' LIMIT 1 +); + +SET @col_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND COLUMN_NAME = 'id_motif' +); + +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE creneau_horaire ADD COLUMN id_motif INT UNSIGNED NULL AFTER id_planning', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE creneau_horaire +SET id_motif = @travail_id +WHERE id_motif IS NULL; + +SET @fk_exists = ( + SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND CONSTRAINT_NAME = 'fk_creneau_motif' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); + +SET @sql = IF( + @fk_exists = 0, + 'ALTER TABLE creneau_horaire ADD CONSTRAINT fk_creneau_motif FOREIGN KEY (id_motif) REFERENCES motif_planning(id_motif)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE creneau_horaire + MODIFY id_motif INT UNSIGNED NOT NULL; + +SET @idx_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND INDEX_NAME = 'idx_creneau_motif' +); + +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX idx_creneau_motif ON creneau_horaire(id_motif)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4. Vue de synthèse par agent en tenant compte du motif +CREATE OR REPLACE VIEW v_suivi_quota_contrat AS +SELECT + ca.id_contrat, + ca.id_agent, + a.matricule, + CONCAT(a.prenom, ' ', a.nom) AS agent, + ca.date_debut, + ca.date_fin, + ca.quota_minutes, + ROUND(ca.quota_minutes / 60, 2) AS quota_heures, + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) AS minutes_planifiees_validees, + ROUND( + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) / 60, + 2 + ) AS heures_planifiees_validees, + ROUND( + ( + ca.quota_minutes + - COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) + ) / 60, + 2 + ) AS heures_restantes +FROM contrat_agent ca +JOIN agent a ON a.id_agent = ca.id_agent +LEFT JOIN planning p ON p.id_agent = ca.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') +LEFT JOIN motif_planning m ON m.id_motif = c.id_motif +GROUP BY + ca.id_contrat, + ca.id_agent, + a.matricule, + a.prenom, + a.nom, + ca.date_debut, + ca.date_fin, + ca.quota_minutes; + + +-- 5. Mise à jour du contrôle de quota : seuls les motifs marqués +-- compte_dans_quota = TRUE sont additionnés. +DELIMITER $$ + +DROP PROCEDURE IF EXISTS sp_preparer_controles_planning$$ +CREATE PROCEDURE sp_preparer_controles_planning( + IN p_id_planning INT UNSIGNED +) +controle: BEGIN + DECLARE v_planning_existe INT DEFAULT 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_controles_planning; + + CREATE TEMPORARY TABLE tmp_controles_planning ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + niveau ENUM('ERREUR', 'ALERTE', 'INFO') NOT NULL, + code VARCHAR(100) NOT NULL, + message VARCHAR(1000) NOT NULL + ); + + SELECT COUNT(*) + INTO v_planning_existe + FROM planning + WHERE id_planning = p_id_planning; + + IF v_planning_existe = 0 THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'PLANNING_INTROUVABLE', + CONCAT('Le planning #', p_id_planning, ' n''existe pas.') + ); + + LEAVE controle; + END IF; + + -- -------------------------------------------------------- + -- Controle 1 : le planning doit contenir au moins un creneau + -- -------------------------------------------------------- + IF NOT EXISTS ( + SELECT 1 + FROM creneau_horaire + WHERE id_planning = p_id_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'AUCUN_CRENEAU', + 'Le planning ne contient aucun creneau horaire.' + ); + END IF; + + -- -------------------------------------------------------- + -- Controle 2 : chaque creneau doit etre dans la semaine + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' est en dehors de la semaine ', s.numero_semaine, + ' (', DATE_FORMAT(s.date_debut, '%d/%m/%Y'), + ' au ', DATE_FORMAT(s.date_fin, '%d/%m/%Y'), ').' + ) + FROM planning p + JOIN semaine s + ON s.id_semaine = p.id_semaine + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND c.date_jour NOT BETWEEN s.date_debut AND s.date_fin; + + -- -------------------------------------------------------- + -- Controle 3 : chaque creneau doit etre couvert par un contrat + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'HORS_CONTRAT', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' n''est couvert par aucun contrat actif pour cet agent.' + ) + FROM planning p + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND NOT EXISTS ( + SELECT 1 + FROM contrat_agent ca + WHERE ca.id_agent = p.id_agent + AND ca.actif = TRUE + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ); + + -- -------------------------------------------------------- + -- Controle 4 : chevauchements dans le meme planning + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + CONCAT( + 'Les creneaux #', c1.id_creneau, + ' et #', c2.id_creneau, + ' se chevauchent le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), + ' (', TIME_FORMAT(c1.heure_debut, '%H:%i'), '-', TIME_FORMAT(c1.heure_fin, '%H:%i'), + ' et ', TIME_FORMAT(c2.heure_debut, '%H:%i'), '-', TIME_FORMAT(c2.heure_fin, '%H:%i'), ').' + ) + FROM creneau_horaire c1 + JOIN creneau_horaire c2 + ON c2.id_planning = c1.id_planning + AND c2.date_jour = c1.date_jour + AND c2.id_creneau > c1.id_creneau + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE c1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 5 : chevauchement avec un autre planning valide + -- pour le meme agent, meme si la structure est differente. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_PLANNING', + CONCAT( + 'Le creneau #', c1.id_creneau, + ' chevauche le creneau #', c2.id_creneau, + ' du planning valide #', p2.id_planning, + ' le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), '.' + ) + FROM planning p1 + JOIN creneau_horaire c1 + ON c1.id_planning = p1.id_planning + JOIN planning p2 + ON p2.id_agent = p1.id_agent + AND p2.id_planning <> p1.id_planning + AND p2.statut = 'VALIDE' + JOIN creneau_horaire c2 + ON c2.id_planning = p2.id_planning + AND c2.date_jour = c1.date_jour + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE p1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 6 : depassement du quota du contrat + -- Le quota est ici un quota total sur la periode du contrat. + -- Il s'agit d'une ALERTE et non d'une erreur bloquante. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ALERTE', + 'QUOTA_DEPASSE', + CONCAT( + 'Le contrat #', ca.id_contrat, + ' prevoit ', ROUND(ca.quota_minutes / 60, 2), ' h. ', + 'Apres validation, ', + ROUND( + ( + -- Minutes deja validees, hors planning courant + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + JOIN motif_planning mv + ON mv.id_motif = cv.id_motif + AND mv.compte_dans_quota = TRUE + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + -- Minutes du planning courant appartenant a ce contrat + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + JOIN motif_planning mc + ON mc.id_motif = cc.id_motif + AND mc.compte_dans_quota = TRUE + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) / 60, + 2 + ), + ' h seraient planifiees.' + ) + FROM planning p + JOIN contrat_agent ca + ON ca.id_agent = p.id_agent + AND ca.actif = TRUE + WHERE p.id_planning = p_id_planning + AND EXISTS ( + SELECT 1 + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ) + AND ( + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + JOIN motif_planning mv + ON mv.id_motif = cv.id_motif + AND mv.compte_dans_quota = TRUE + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + JOIN motif_planning mc + ON mc.id_motif = cc.id_motif + AND mc.compte_dans_quota = TRUE + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) > ca.quota_minutes; + + -- Si aucun probleme n'a ete detecte, on retourne explicitement OK. + IF NOT EXISTS ( + SELECT 1 + FROM tmp_controles_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'INFO', + 'OK', + 'Aucune anomalie detectee. Le planning peut etre valide.' + ); + END IF; +END$$ + + +DELIMITER ; diff --git a/database/migration_quota_annuel_vues.sql b/database/migration_quota_annuel_vues.sql new file mode 100644 index 0000000..0d6f5cb --- /dev/null +++ b/database/migration_quota_annuel_vues.sql @@ -0,0 +1,145 @@ +-- ============================================================ +-- PTA - Quotas annuels et vues de pilotage +-- À exécuter APRÈS : +-- 1. pta_mysql_test.sql +-- 2. migration_planning_motifs.sql +-- Compatible MySQL 8+ +-- ============================================================ + +USE pta_test; + +-- ------------------------------------------------------------ +-- 1. Quota annuel d'un agent +-- 1607 h = 96 420 minutes à 100 %. +-- La quotité permet de proratiser automatiquement le quota cible. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS quota_agent_annuel ( + id_quota INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + annee SMALLINT UNSIGNED NOT NULL, + quotite_travail DECIMAL(5,2) NOT NULL DEFAULT 100.00, + quota_reference_minutes INT UNSIGNED NOT NULL DEFAULT 96420, + quota_cible_minutes INT UNSIGNED NOT NULL, + commentaire VARCHAR(255) NULL, + + CONSTRAINT fk_quota_annuel_agent + FOREIGN KEY (id_agent) REFERENCES agent(id_agent), + + CONSTRAINT chk_quotite_travail + CHECK (quotite_travail > 0 AND quotite_travail <= 100), + + CONSTRAINT chk_quota_annuel_positif + CHECK (quota_cible_minutes > 0), + + UNIQUE KEY uk_quota_agent_annee (id_agent, annee) +) ENGINE = InnoDB; + +-- Jeu d'essai 2026 : tous les agents existants à 100 %, soit 1607 h. +INSERT INTO quota_agent_annuel ( + id_agent, + annee, + quotite_travail, + quota_reference_minutes, + quota_cible_minutes, + commentaire +) +SELECT + a.id_agent, + 2026, + 100.00, + 96420, + 96420, + 'Quota annuel de référence à 100 %' +FROM agent a +WHERE NOT EXISTS ( + SELECT 1 + FROM quota_agent_annuel q + WHERE q.id_agent = a.id_agent + AND q.annee = 2026 +); + +-- Exemple de modification pour un agent à 80 % : +-- UPDATE quota_agent_annuel +-- SET quotite_travail = 80.00, +-- quota_cible_minutes = ROUND(96420 * 0.80) +-- WHERE id_agent = 2 AND annee = 2026; + +-- ------------------------------------------------------------ +-- 2. Règle de décompte des motifs +-- L'application reste paramétrable : le booléen compte_dans_quota +-- indique si le motif consomme le quota annuel. +-- Selon le besoin exprimé, les motifs principaux ci-dessous sont +-- comptés par défaut. Il est possible de changer cette règle ensuite. +-- ------------------------------------------------------------ +UPDATE motif_planning +SET compte_dans_quota = TRUE +WHERE code IN ('TRAVAIL', 'FORMATION', 'CONGE', 'MALADIE'); + +-- ------------------------------------------------------------ +-- 3. Vue annuelle de suivi des quotas +-- Les brouillons et les plannings validés sont distingués. +-- ------------------------------------------------------------ +CREATE OR REPLACE VIEW v_suivi_quota_annuel AS +SELECT + q.id_agent, + a.matricule, + a.nom, + a.prenom, + q.annee, + q.quotite_travail, + q.quota_reference_minutes, + q.quota_cible_minutes, + ROUND(q.quota_cible_minutes / 60, 2) AS quota_cible_heures, + + COALESCE(SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_valides, + + COALESCE(SUM( + CASE + WHEN p.statut = 'BROUILLON' AND m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_brouillon, + + q.quota_cible_minutes - COALESCE(SUM( + CASE + WHEN m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_restantes + +FROM quota_agent_annuel q +INNER JOIN agent a ON a.id_agent = q.id_agent +LEFT JOIN planning p ON p.id_agent = q.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND YEAR(c.date_jour) = q.annee +LEFT JOIN motif_planning m ON m.id_motif = c.id_motif +GROUP BY + q.id_agent, + a.matricule, + a.nom, + a.prenom, + q.annee, + q.quotite_travail, + q.quota_reference_minutes, + q.quota_cible_minutes; diff --git a/database/migration_v4_9_type_affectation.sql b/database/migration_v4_9_type_affectation.sql new file mode 100644 index 0000000..674c891 --- /dev/null +++ b/database/migration_v4_9_type_affectation.sql @@ -0,0 +1,10 @@ +-- PTA V4.9 - Ajout du type d’affectation des agents +-- À exécuter une seule fois sur une base existante V4.8. + +ALTER TABLE agent + ADD COLUMN type_affectation ENUM('PERISCOLAIRE', 'EXTRASCOLAIRE') + NOT NULL DEFAULT 'PERISCOLAIRE' + AFTER id_structure; + +-- Valeur par défaut : les agents existants sont considérés périscolaires. +-- Modifiez ensuite individuellement les agents extrascolaires depuis l’écran Administration. diff --git a/database/migration_v5_13_couverture_structures.sql b/database/migration_v5_13_couverture_structures.sql new file mode 100644 index 0000000..247e384 --- /dev/null +++ b/database/migration_v5_13_couverture_structures.sql @@ -0,0 +1,39 @@ +-- ============================================================ +-- PTA V5.13 - Contrôle de couverture des lieux d'affectation +-- MySQL 8+ +-- ============================================================ + +CREATE TABLE IF NOT EXISTS structure_couverture_horaire ( + id_couverture INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_structure INT UNSIGNED NOT NULL, + jour_semaine TINYINT UNSIGNED NOT NULL COMMENT '1=lundi, 5=vendredi', + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + minimum_agents TINYINT UNSIGNED NOT NULL DEFAULT 1, + + CONSTRAINT fk_couverture_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure) + ON UPDATE CASCADE + ON DELETE CASCADE, + + CONSTRAINT chk_couverture_jour + CHECK (jour_semaine BETWEEN 1 AND 5), + + CONSTRAINT chk_couverture_ordre + CHECK (heure_fin > heure_debut), + + CONSTRAINT chk_couverture_minimum + CHECK (minimum_agents BETWEEN 1 AND 50), + + CONSTRAINT chk_couverture_quart_heure + CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ) +) ENGINE = InnoDB; + +CREATE INDEX idx_couverture_structure_jour + ON structure_couverture_horaire(id_structure, jour_semaine, heure_debut, heure_fin); diff --git a/database/migration_v5_16_informations_agents.sql b/database/migration_v5_16_informations_agents.sql new file mode 100644 index 0000000..4032445 --- /dev/null +++ b/database/migration_v5_16_informations_agents.sql @@ -0,0 +1,15 @@ +-- ============================================================ +-- PTA V5.16 - Diplôme et coordonnées des agents +-- MySQL 8+ +-- ============================================================ +-- À exécuter une seule fois sur une base V5.15. + +ALTER TABLE agent + ADD COLUMN telephone VARCHAR(30) NULL AFTER email, + ADD COLUMN adresse VARCHAR(255) NULL AFTER telephone, + ADD COLUMN est_diplome BOOLEAN NOT NULL DEFAULT FALSE AFTER adresse, + ADD COLUMN diplome_libelle VARCHAR(150) NULL AFTER est_diplome; + +-- Exemples de mise à jour : +-- UPDATE agent SET est_diplome = TRUE, diplome_libelle = 'BAFA' WHERE id_agent = 1; +-- UPDATE agent SET telephone = '06 00 00 00 00', adresse = '1 rue Exemple, Orléans' WHERE id_agent = 1; diff --git a/database/migration_v5_17_vacances_scolaires.sql b/database/migration_v5_17_vacances_scolaires.sql new file mode 100644 index 0000000..7fb3b78 --- /dev/null +++ b/database/migration_v5_17_vacances_scolaires.sql @@ -0,0 +1,36 @@ +-- ============================================================ +-- PTA V5.17 - Périodes de vacances scolaires +-- ============================================================ +-- Les dates sont validées manuellement par l'utilisateur avant +-- d'être enregistrées, même lorsqu'elles proviennent de l'API +-- officielle du calendrier scolaire. +-- +-- date_fin est stockée de façon INCLUSIVE dans PTA. +-- Pour une proposition issue de l'API, la date de reprise fournie +-- par l'API est convertie en dernier jour de vacances (veille). + +CREATE TABLE IF NOT EXISTS periode_vacance ( + id_periode INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + libelle VARCHAR(150) NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + annee_scolaire VARCHAR(9) NOT NULL, + academie VARCHAR(100) NOT NULL DEFAULT '', + zone VARCHAR(50) NOT NULL DEFAULT '', + source ENUM('MANUEL', 'DATA_GOUV') NOT NULL DEFAULT 'MANUEL', + actif BOOLEAN NOT NULL DEFAULT TRUE, + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT chk_periode_vacance_dates + CHECK (date_fin >= date_debut), + + UNIQUE KEY uk_periode_vacance ( + date_debut, + date_fin, + annee_scolaire, + academie + ), + + KEY idx_periode_vacance_dates (date_debut, date_fin), + KEY idx_periode_vacance_annee (annee_scolaire) +) ENGINE = InnoDB; diff --git a/database/migration_v5_20_annee_contractuelle.sql b/database/migration_v5_20_annee_contractuelle.sql new file mode 100644 index 0000000..864d491 --- /dev/null +++ b/database/migration_v5_20_annee_contractuelle.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- PTA V5.20 - Année contractuelle pour le calcul des quotas +-- MySQL 8+ +-- ============================================================ +-- À exécuter une seule fois sur une base V5.19. +-- +-- Le quota annuel (1607 h à 100 %) n'est plus consommé sur la +-- période 01/01 -> 31/12. La période de référence commence à la +-- date anniversaire du contrat de chaque agent. +-- +-- Exemple : date_debut_contrat = 2026-09-01 +-- période 1 : 2026-09-01 -> 2027-08-31 +-- période 2 : 2027-09-01 -> 2028-08-31 +-- +-- La migration ne devine volontairement PAS la date de contrat +-- des agents existants. Il faut la renseigner depuis Administration +-- > Modifier l'agent. Tant qu'elle est NULL, l'application conserve +-- temporairement le calcul par année civile et signale le fallback. + +ALTER TABLE agent + ADD COLUMN date_debut_contrat DATE NULL AFTER diplome_libelle; + +CREATE INDEX idx_agent_date_debut_contrat ON agent(date_debut_contrat); + +-- Exemples de mise à jour manuelle : +-- UPDATE agent SET date_debut_contrat = '2024-09-01' WHERE id_agent = 1; +-- UPDATE agent SET date_debut_contrat = '2026-01-15' WHERE id_agent = 2; diff --git a/database/migration_v5_5_motifs.sql b/database/migration_v5_5_motifs.sql new file mode 100644 index 0000000..00441e1 --- /dev/null +++ b/database/migration_v5_5_motifs.sql @@ -0,0 +1,14 @@ +-- PTA V5.5 - Mise à jour des motifs d'affectation +-- Compatible avec une base existante. + +UPDATE motif_planning +SET libelle = 'Absence', + compte_dans_quota = TRUE, + actif = TRUE +WHERE code = 'MALADIE'; + +UPDATE motif_planning +SET libelle = 'Autre absence (non décomptée)', + compte_dans_quota = FALSE, + actif = TRUE +WHERE code = 'AUTRE'; diff --git a/database/migration_v5_7_type_affectation_structure.sql b/database/migration_v5_7_type_affectation_structure.sql new file mode 100644 index 0000000..edf94ed --- /dev/null +++ b/database/migration_v5_7_type_affectation_structure.sql @@ -0,0 +1,33 @@ +-- ============================================================ +-- PTA V5.7 - Le type d'affectation appartient au lieu +-- ============================================================ +-- A exécuter une seule fois sur une base V5.6. +-- La migration reprend, quand c'est possible, le type majoritaire +-- des agents actuellement rattachés au lieu puis supprime la colonne +-- type_affectation de la table agent. + +ALTER TABLE structure + ADD COLUMN type_affectation ENUM('PERISCOLAIRE', 'EXTRASCOLAIRE') + NOT NULL DEFAULT 'PERISCOLAIRE' + AFTER adresse; + +UPDATE structure s +LEFT JOIN ( + SELECT + id_structure, + CASE + WHEN SUM(type_affectation = 'EXTRASCOLAIRE') > SUM(type_affectation = 'PERISCOLAIRE') + THEN 'EXTRASCOLAIRE' + ELSE 'PERISCOLAIRE' + END AS type_affectation_migre + FROM agent + WHERE id_structure IS NOT NULL + GROUP BY id_structure +) source ON source.id_structure = s.id_structure +SET s.type_affectation = COALESCE(source.type_affectation_migre, 'PERISCOLAIRE'); + +ALTER TABLE agent + DROP COLUMN type_affectation; + +-- Vérification conseillée après migration : +-- SELECT id_structure, code, nom, type_affectation FROM structure ORDER BY nom; diff --git a/database/migration_v5_9_semaines_types.sql b/database/migration_v5_9_semaines_types.sql new file mode 100644 index 0000000..3d5aa59 --- /dev/null +++ b/database/migration_v5_9_semaines_types.sql @@ -0,0 +1,60 @@ +-- ============================================================ +-- PTA V5.9 - Semaines types par agent +-- ============================================================ +-- Un modèle mémorise les affectations d'une semaine de référence : +-- jour ouvré, lieu, motif et plage horaire. + +CREATE TABLE IF NOT EXISTS modele_semaine_agent ( + id_modele INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + nom VARCHAR(120) NOT NULL, + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + date_modification DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_modele_semaine_agent + FOREIGN KEY (id_agent) + REFERENCES agent(id_agent) + ON DELETE CASCADE, + + UNIQUE KEY uk_modele_semaine_agent_nom (id_agent, nom), + KEY idx_modele_semaine_agent (id_agent, date_modification) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS modele_semaine_creneau ( + id_modele_creneau INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_modele INT UNSIGNED NOT NULL, + jour_semaine TINYINT UNSIGNED NOT NULL COMMENT '1=lundi ... 5=vendredi', + id_structure INT UNSIGNED NOT NULL, + id_motif INT UNSIGNED NOT NULL, + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + + CONSTRAINT fk_modele_creneau_modele + FOREIGN KEY (id_modele) + REFERENCES modele_semaine_agent(id_modele) + ON DELETE CASCADE, + + CONSTRAINT fk_modele_creneau_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure), + + CONSTRAINT fk_modele_creneau_motif + FOREIGN KEY (id_motif) + REFERENCES motif_planning(id_motif), + + CONSTRAINT chk_modele_creneau_jour + CHECK (jour_semaine BETWEEN 1 AND 5), + + CONSTRAINT chk_modele_creneau_ordre + CHECK (heure_fin > heure_debut), + + CONSTRAINT chk_modele_creneau_quart_heure + CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ), + + KEY idx_modele_creneau_modele_jour (id_modele, jour_semaine, heure_debut) +) ENGINE = InnoDB; diff --git a/database/migration_v7_5_quota_annee_civile.sql b/database/migration_v7_5_quota_annee_civile.sql new file mode 100644 index 0000000..d9e5c7f --- /dev/null +++ b/database/migration_v7_5_quota_annee_civile.sql @@ -0,0 +1,95 @@ +-- ============================================================================ +-- PTA V7.5 - Quota PTA sur l'année civile +-- MySQL 8+ +-- À exécuter après la V7 / V7.4. +-- ============================================================================ +-- Règle métier : +-- * 1607 h à 100 % sont planifiées sur l'année civile : 01/01 -> 31/12. +-- * la date de début du contrat reste une information RH et ne décale plus +-- la période annuelle du PTA ; +-- * un agent peut participer à des équipes périscolaires ET extrascolaires. +-- Aucun type périscolaire/extrascolaire n'est porté par l'agent. +-- +-- Cette migration ne supprime pas les anciens PTA construits sur une période +-- contractuelle. Elle crée, si besoin, une ligne civile équivalente afin de +-- conserver l'historique et d'éviter une migration destructive. +-- ============================================================================ + +START TRANSACTION; + +UPDATE quota_agent_annuel +SET commentaire = 'Quota PTA de référence - année civile' +WHERE commentaire IS NULL + OR commentaire LIKE '%contrat%' + OR commentaire LIKE '%période%'; + +-- Recrée une ligne PTA civile à partir des anciennes lignes non civiles. +-- INSERT IGNORE évite tout conflit si la ligne annuelle existe déjà. +INSERT IGNORE INTO pta_annuel ( + id_agent, + date_debut, + date_fin, + quotite_travail, + quota_reference_minutes, + quota_cible_minutes, + enveloppe_formation_minutes, + conges_annuels_jours_cible, + jours_fractionnement_cible, + statut, + commentaire, + date_validation +) +SELECT + pa.id_agent, + STR_TO_DATE(CONCAT(YEAR(pa.date_debut), '-01-01'), '%Y-%m-%d') AS date_debut, + STR_TO_DATE(CONCAT(YEAR(pa.date_debut), '-12-31'), '%Y-%m-%d') AS date_fin, + pa.quotite_travail, + pa.quota_reference_minutes, + pa.quota_cible_minutes, + pa.enveloppe_formation_minutes, + pa.conges_annuels_jours_cible, + pa.jours_fractionnement_cible, + pa.statut, + CONCAT( + COALESCE(NULLIF(pa.commentaire, ''), ''), + CASE WHEN COALESCE(NULLIF(pa.commentaire, ''), '') = '' THEN '' ELSE ' - ' END, + 'Période convertie en année civile par migration V7.5' + ), + pa.date_validation +FROM pta_annuel pa +WHERE pa.date_debut <> STR_TO_DATE(CONCAT(YEAR(pa.date_debut), '-01-01'), '%Y-%m-%d') + OR pa.date_fin <> STR_TO_DATE(CONCAT(YEAR(pa.date_debut), '-12-31'), '%Y-%m-%d'); + +-- La vue de synthèse ne présente désormais que les PTA civils. +CREATE OR REPLACE VIEW v_pta_annuel_synthese AS +SELECT + pa.id_pta, + pa.id_agent, + a.matricule, + a.nom, + a.prenom, + po.code AS poste_code, + po.libelle AS poste_libelle, + a.type_contrat, + pa.date_debut, + pa.date_fin, + pa.quotite_travail, + pa.quota_cible_minutes, + COALESCE(SUM(CASE WHEN mp.compte_dans_quota = TRUE THEN TIMESTAMPDIFF(MINUTE, CONCAT(ch.date_jour, ' ', ch.heure_debut), CONCAT(ch.date_jour, ' ', ch.heure_fin)) ELSE 0 END), 0) AS minutes_decomptees, + pa.quota_cible_minutes - COALESCE(SUM(CASE WHEN mp.compte_dans_quota = TRUE THEN TIMESTAMPDIFF(MINUTE, CONCAT(ch.date_jour, ' ', ch.heure_debut), CONCAT(ch.date_jour, ' ', ch.heure_fin)) ELSE 0 END), 0) AS minutes_restantes, + pa.statut +FROM pta_annuel pa +JOIN agent a ON a.id_agent = pa.id_agent +LEFT JOIN poste_agent po ON po.id_poste = a.id_poste +LEFT JOIN planning p ON p.id_agent = pa.id_agent +LEFT JOIN creneau_horaire ch + ON ch.id_planning = p.id_planning + AND ch.date_jour BETWEEN pa.date_debut AND pa.date_fin +LEFT JOIN motif_planning mp ON mp.id_motif = ch.id_motif +WHERE pa.date_debut = STR_TO_DATE(CONCAT(YEAR(pa.date_debut), '-01-01'), '%Y-%m-%d') + AND pa.date_fin = STR_TO_DATE(CONCAT(YEAR(pa.date_debut), '-12-31'), '%Y-%m-%d') +GROUP BY pa.id_pta, pa.id_agent, a.matricule, a.nom, a.prenom, po.code, po.libelle, + a.type_contrat, pa.date_debut, pa.date_fin, pa.quotite_travail, + pa.quota_cible_minutes, pa.statut; + +COMMIT; diff --git a/database/migration_v7_pta_annuel_equipes.sql b/database/migration_v7_pta_annuel_equipes.sql new file mode 100644 index 0000000..65dced2 --- /dev/null +++ b/database/migration_v7_pta_annuel_equipes.sql @@ -0,0 +1,313 @@ +-- ============================================================================ +-- PTA V7 - Socle métier annuel, postes, contraintes et équipes +-- MySQL 8.0.16+ +-- À exécuter après le schéma V5.20 / V6. +-- ============================================================================ + +START TRANSACTION; + +-- -------------------------------------------------------------------------- +-- 1. Postes éligibles au PTA +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS poste_agent ( + id_poste INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL, + libelle VARCHAR(150) NOT NULL, + famille ENUM('ANIMATION', 'RESTAURATION_ENTRETIEN') NOT NULL, + heures_mercredi_minutes SMALLINT UNSIGNED NOT NULL, + heures_extrascolaire_minutes SMALLINT UNSIGNED NOT NULL, + autorise_preparation BOOLEAN NOT NULL DEFAULT TRUE, + peut_assurer_animation BOOLEAN NOT NULL DEFAULT TRUE, + preparation_lundi_si_100 BOOLEAN NOT NULL DEFAULT FALSE, + actif BOOLEAN NOT NULL DEFAULT TRUE, + ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100, + CONSTRAINT uk_poste_agent_code UNIQUE (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO poste_agent ( + code, libelle, famille, heures_mercredi_minutes, heures_extrascolaire_minutes, + autorise_preparation, peut_assurer_animation, preparation_lundi_si_100, ordre_affichage +) VALUES + ('RESPONSABLE', 'Responsable d’accueil', 'ANIMATION', 570, 570, TRUE, TRUE, TRUE, 10), + ('ANIMATION_RELAIS', 'Agent d’animation relais', 'ANIMATION', 570, 570, TRUE, TRUE, TRUE, 20), + ('ANIMATION', 'Agent d’animation', 'ANIMATION', 570, 570, TRUE, TRUE, TRUE, 30), + ('RESTAURATION_ENTRETIEN', 'Agent de restauration collective et d’entretien', 'RESTAURATION_ENTRETIEN', 420, 420, FALSE, FALSE, FALSE, 40) +ON DUPLICATE KEY UPDATE + libelle = VALUES(libelle), + famille = VALUES(famille), + heures_mercredi_minutes = VALUES(heures_mercredi_minutes), + heures_extrascolaire_minutes = VALUES(heures_extrascolaire_minutes), + autorise_preparation = VALUES(autorise_preparation), + peut_assurer_animation = VALUES(peut_assurer_animation), + preparation_lundi_si_100 = VALUES(preparation_lundi_si_100), + ordre_affichage = VALUES(ordre_affichage), + actif = TRUE; + +-- -------------------------------------------------------------------------- +-- 2. Compléments métier de l’agent +-- -------------------------------------------------------------------------- +ALTER TABLE agent + ADD COLUMN id_poste INT UNSIGNED NULL AFTER date_debut_contrat, + ADD COLUMN type_contrat ENUM('PERMANENT', 'TEMPORAIRE') NOT NULL DEFAULT 'PERMANENT' AFTER id_poste, + ADD COLUMN date_fin_contrat DATE NULL AFTER type_contrat, + ADD COLUMN formation_repartition_annuelle BOOLEAN NOT NULL DEFAULT FALSE AFTER date_fin_contrat, + ADD COLUMN commentaire_pta VARCHAR(1000) NULL AFTER formation_repartition_annuelle; + +ALTER TABLE agent + ADD CONSTRAINT fk_agent_poste + FOREIGN KEY (id_poste) REFERENCES poste_agent(id_poste) + ON UPDATE CASCADE ON DELETE SET NULL, + ADD CONSTRAINT chk_agent_dates_contrat_v7 + CHECK (date_fin_contrat IS NULL OR date_debut_contrat IS NULL OR date_fin_contrat >= date_debut_contrat); + +CREATE INDEX idx_agent_poste ON agent(id_poste); +CREATE INDEX idx_agent_type_contrat ON agent(type_contrat, actif); + +-- -------------------------------------------------------------------------- +-- 3. PTA annuel de l’agent +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS pta_annuel ( + id_pta INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + quotite_travail DECIMAL(5,2) NOT NULL, + quota_reference_minutes INT UNSIGNED NOT NULL DEFAULT 96420, + quota_cible_minutes INT UNSIGNED NOT NULL, + enveloppe_formation_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 840, + conges_annuels_jours_cible DECIMAL(5,2) NOT NULL DEFAULT 25.00, + jours_fractionnement_cible TINYINT UNSIGNED NOT NULL DEFAULT 2, + statut ENUM('BROUILLON', 'A_CONTROLER', 'VALIDE') NOT NULL DEFAULT 'BROUILLON', + commentaire VARCHAR(1000) NULL, + date_validation DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_pta_annuel_agent + FOREIGN KEY (id_agent) REFERENCES agent(id_agent) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT chk_pta_annuel_dates CHECK (date_fin >= date_debut), + CONSTRAINT chk_pta_annuel_quotite CHECK (quotite_travail > 0 AND quotite_travail <= 100), + CONSTRAINT chk_pta_annuel_quota CHECK (quota_reference_minutes > 0 AND quota_cible_minutes > 0), + CONSTRAINT uk_pta_annuel_agent_periode UNIQUE (id_agent, date_debut, date_fin), + INDEX idx_pta_annuel_agent_statut (id_agent, statut), + INDEX idx_pta_annuel_periode (date_debut, date_fin) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------------------------- +-- 4. Préférences, interdictions et proximité des lieux +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS agent_structure_souhait ( + id_souhait INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + id_structure INT UNSIGNED NOT NULL, + type_souhait ENUM('PREFERENCE', 'INTERDICTION') NOT NULL, + priorite TINYINT UNSIGNED NOT NULL DEFAULT 1, + distance_km DECIMAL(7,2) NULL, + commentaire VARCHAR(500) NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_souhait_agent FOREIGN KEY (id_agent) REFERENCES agent(id_agent) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_souhait_structure FOREIGN KEY (id_structure) REFERENCES structure(id_structure) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT chk_souhait_priorite CHECK (priorite BETWEEN 1 AND 5), + CONSTRAINT chk_souhait_distance CHECK (distance_km IS NULL OR distance_km >= 0), + CONSTRAINT uk_souhait_agent_structure UNIQUE (id_agent, id_structure), + INDEX idx_souhait_agent_type (id_agent, type_souhait, actif) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------------------------- +-- 5. Contraintes médicales et temps partiels thérapeutiques +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS agent_contrainte ( + id_contrainte INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + type_contrainte ENUM('MEDICALE', 'TEMPS_PARTIEL_THERAPEUTIQUE') NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NULL, + quotite_temporaire DECIMAL(5,2) NULL, + maximum_minutes_jour SMALLINT UNSIGNED NULL, + maximum_minutes_semaine SMALLINT UNSIGNED NULL, + interdit_matin BOOLEAN NOT NULL DEFAULT FALSE, + interdit_midi BOOLEAN NOT NULL DEFAULT FALSE, + interdit_soir BOOLEAN NOT NULL DEFAULT FALSE, + commentaire VARCHAR(1000) NOT NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_contrainte_agent FOREIGN KEY (id_agent) REFERENCES agent(id_agent) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT chk_contrainte_dates CHECK (date_fin IS NULL OR date_fin >= date_debut), + CONSTRAINT chk_contrainte_quotite CHECK (quotite_temporaire IS NULL OR (quotite_temporaire > 0 AND quotite_temporaire <= 100)), + CONSTRAINT chk_contrainte_max_jour CHECK (maximum_minutes_jour IS NULL OR maximum_minutes_jour > 0), + CONSTRAINT chk_contrainte_max_semaine CHECK (maximum_minutes_semaine IS NULL OR maximum_minutes_semaine > 0), + INDEX idx_contrainte_agent_dates (id_agent, actif, date_debut, date_fin) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------------------------- +-- 6. Équipes périscolaires des mercredis et extrascolaires +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS equipe_pta ( + id_equipe INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_structure INT UNSIGNED NOT NULL, + type_equipe ENUM('MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE', 'PERISCOLAIRE_SEMAINE') NOT NULL, + libelle VARCHAR(180) NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + nombre_enfants_moins_6 SMALLINT UNSIGNED NOT NULL DEFAULT 0, + nombre_enfants_6_plus SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ratio_moins_6 TINYINT UNSIGNED NOT NULL DEFAULT 8, + ratio_6_plus TINYINT UNSIGNED NOT NULL DEFAULT 12, + minimum_agents TINYINT UNSIGNED NOT NULL DEFAULT 1, + pourcentage_diplomes_min DECIMAL(5,2) NOT NULL DEFAULT 50.00, + statut ENUM('BROUILLON', 'A_CONTROLER', 'VALIDEE') NOT NULL DEFAULT 'BROUILLON', + commentaire VARCHAR(1000) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_equipe_pta_structure FOREIGN KEY (id_structure) REFERENCES structure(id_structure) + ON UPDATE CASCADE ON DELETE RESTRICT, + CONSTRAINT chk_equipe_dates CHECK (date_fin >= date_debut), + CONSTRAINT chk_equipe_ratios CHECK (ratio_moins_6 > 0 AND ratio_6_plus > 0), + CONSTRAINT chk_equipe_minimum CHECK (minimum_agents > 0), + CONSTRAINT chk_equipe_diplomes CHECK (pourcentage_diplomes_min BETWEEN 0 AND 100), + INDEX idx_equipe_pta_type_dates (type_equipe, date_debut, date_fin), + INDEX idx_equipe_pta_structure_dates (id_structure, date_debut, date_fin) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS equipe_pta_membre ( + id_equipe_membre INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_equipe INT UNSIGNED NOT NULL, + id_agent INT UNSIGNED NOT NULL, + fonction_equipe ENUM('RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN') NOT NULL DEFAULT 'ANIMATION', + date_debut DATE NULL, + date_fin DATE NULL, + commentaire VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_equipe_membre_equipe FOREIGN KEY (id_equipe) REFERENCES equipe_pta(id_equipe) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_equipe_membre_agent FOREIGN KEY (id_agent) REFERENCES agent(id_agent) + ON UPDATE CASCADE ON DELETE RESTRICT, + CONSTRAINT chk_equipe_membre_dates CHECK (date_fin IS NULL OR date_debut IS NULL OR date_fin >= date_debut), + CONSTRAINT uk_equipe_membre UNIQUE (id_equipe, id_agent), + INDEX idx_equipe_membre_agent (id_agent, id_equipe) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------------------------- +-- 7. Références horaires des lieux pour les automatismes de préparation +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS structure_creneau_reference ( + id_reference INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_structure INT UNSIGNED NOT NULL, + code_plage ENUM('ALP_MATIN', 'RESTAURATION', 'ALP_SOIR') NOT NULL, + jour_semaine TINYINT UNSIGNED NOT NULL COMMENT '1=lundi ... 5=vendredi', + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT fk_reference_structure FOREIGN KEY (id_structure) REFERENCES structure(id_structure) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT chk_reference_jour CHECK (jour_semaine BETWEEN 1 AND 5), + CONSTRAINT chk_reference_ordre CHECK (heure_fin > heure_debut), + CONSTRAINT uk_reference_structure_plage_jour UNIQUE (id_structure, code_plage, jour_semaine), + INDEX idx_reference_structure_jour (id_structure, jour_semaine, actif) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------------------------- +-- 8. Motifs détaillés du PTA +-- -------------------------------------------------------------------------- +INSERT INTO motif_planning (code, libelle, compte_dans_quota, ordre_affichage, actif) +VALUES + ('ALP_MATIN', 'ALP matin', TRUE, 10, TRUE), + ('RESTAURATION', 'Restauration', TRUE, 20, TRUE), + ('ALP_SOIR', 'ALP soir', TRUE, 30, TRUE), + ('MERCREDI_PERISCOLAIRE', 'Mercredi périscolaire', TRUE, 40, TRUE), + ('EXTRASCOLAIRE', 'Temps extrascolaire', TRUE, 50, TRUE), + ('FORMATION', 'Formation', TRUE, 60, TRUE), + ('CONGE', 'Congé annuel (non décompté)', FALSE, 70, TRUE), + ('JOUR_FRACTIONNEMENT', 'Journée de fractionnement', TRUE, 80, TRUE), + ('PREPARATION_PERISCOLAIRE', 'Préparation périscolaire', TRUE, 90, TRUE), + ('PREPARATION_EXTRASCOLAIRE', 'Préparation extrascolaire', TRUE, 100, TRUE), + ('PREPARATION_LUNDI', 'Préparation du lundi', TRUE, 110, TRUE), + ('MENAGE_FOND', 'Ménage de fond', TRUE, 120, TRUE), + ('JNT', 'Journée non travaillée', FALSE, 130, TRUE) +ON DUPLICATE KEY UPDATE + libelle = VALUES(libelle), + compte_dans_quota = VALUES(compte_dans_quota), + ordre_affichage = VALUES(ordre_affichage), + actif = TRUE; + +-- La règle métier communiquée précise que les congés annuels ne sont pas +-- redécomptés du quota puisqu'ils sont déjà intégrés dans le calcul initial. +UPDATE motif_planning +SET libelle = 'Congé annuel (non décompté)', compte_dans_quota = FALSE +WHERE code = 'CONGE'; + +-- -------------------------------------------------------------------------- +-- 9. Vues de synthèse +-- -------------------------------------------------------------------------- +CREATE OR REPLACE VIEW v_pta_annuel_synthese AS +SELECT + pa.id_pta, + pa.id_agent, + a.matricule, + a.nom, + a.prenom, + po.code AS poste_code, + po.libelle AS poste_libelle, + a.type_contrat, + pa.date_debut, + pa.date_fin, + pa.quotite_travail, + pa.quota_cible_minutes, + COALESCE(SUM(CASE WHEN mp.compte_dans_quota = TRUE THEN TIMESTAMPDIFF(MINUTE, CONCAT(ch.date_jour, ' ', ch.heure_debut), CONCAT(ch.date_jour, ' ', ch.heure_fin)) ELSE 0 END), 0) AS minutes_decomptees, + pa.quota_cible_minutes - COALESCE(SUM(CASE WHEN mp.compte_dans_quota = TRUE THEN TIMESTAMPDIFF(MINUTE, CONCAT(ch.date_jour, ' ', ch.heure_debut), CONCAT(ch.date_jour, ' ', ch.heure_fin)) ELSE 0 END), 0) AS minutes_restantes, + pa.statut +FROM pta_annuel pa +JOIN agent a ON a.id_agent = pa.id_agent +LEFT JOIN poste_agent po ON po.id_poste = a.id_poste +LEFT JOIN planning p ON p.id_agent = pa.id_agent +LEFT JOIN creneau_horaire ch ON ch.id_planning = p.id_planning AND ch.date_jour BETWEEN pa.date_debut AND pa.date_fin +LEFT JOIN motif_planning mp ON mp.id_motif = ch.id_motif +GROUP BY pa.id_pta, pa.id_agent, a.matricule, a.nom, a.prenom, po.code, po.libelle, + a.type_contrat, pa.date_debut, pa.date_fin, pa.quotite_travail, + pa.quota_cible_minutes, pa.statut; + +CREATE OR REPLACE VIEW v_equipe_pta_controle AS +SELECT + e.id_equipe, + e.libelle, + e.type_equipe, + e.id_structure, + s.nom AS structure_nom, + e.date_debut, + e.date_fin, + e.nombre_enfants_moins_6, + e.nombre_enfants_6_plus, + GREATEST( + e.minimum_agents, + CEIL(e.nombre_enfants_moins_6 / e.ratio_moins_6) + CEIL(e.nombre_enfants_6_plus / e.ratio_6_plus) + ) AS agents_requis, + COUNT(em.id_agent) AS agents_affectes, + SUM(CASE WHEN a.est_diplome = TRUE THEN 1 ELSE 0 END) AS agents_diplomes, + CASE WHEN COUNT(em.id_agent) = 0 THEN 0 + ELSE ROUND(100 * SUM(CASE WHEN a.est_diplome = TRUE THEN 1 ELSE 0 END) / COUNT(em.id_agent), 2) + END AS pourcentage_diplomes, + e.pourcentage_diplomes_min, + SUM(CASE WHEN em.fonction_equipe = 'RESPONSABLE' THEN 1 ELSE 0 END) AS responsables, + e.statut +FROM equipe_pta e +JOIN structure s ON s.id_structure = e.id_structure +LEFT JOIN equipe_pta_membre em ON em.id_equipe = e.id_equipe +LEFT JOIN agent a ON a.id_agent = em.id_agent +GROUP BY e.id_equipe, e.libelle, e.type_equipe, e.id_structure, s.nom, + e.date_debut, e.date_fin, e.nombre_enfants_moins_6, e.nombre_enfants_6_plus, + e.ratio_moins_6, e.ratio_6_plus, e.minimum_agents, + e.pourcentage_diplomes_min, e.statut; + +COMMIT; diff --git a/database/pta_mysql_test.sql b/database/pta_mysql_test.sql new file mode 100644 index 0000000..aa85e84 --- /dev/null +++ b/database/pta_mysql_test.sql @@ -0,0 +1,1739 @@ +-- ============================================================ +-- Base de donnees de test PTA +-- Compatible MySQL 8.0.16+ +-- ============================================================ + +DROP DATABASE IF EXISTS pta_test; +CREATE DATABASE pta_test + CHARACTER SET utf8mb4 + COLLATE utf8mb4_0900_ai_ci; + +USE pta_test; + +-- ============================================================ +-- 1. REFERENTIEL +-- ============================================================ + +CREATE TABLE agent ( + id_agent INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + matricule VARCHAR(50) NOT NULL UNIQUE, + nom VARCHAR(100) NOT NULL, + prenom VARCHAR(100) NOT NULL, + email VARCHAR(255) NULL, + telephone VARCHAR(30) NULL, + adresse VARCHAR(255) NULL, + est_diplome BOOLEAN NOT NULL DEFAULT FALSE, + diplome_libelle VARCHAR(150) NULL, + date_debut_contrat DATE NULL, + id_structure INT UNSIGNED NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE = InnoDB; + +CREATE TABLE structure ( + id_structure INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + nom VARCHAR(150) NOT NULL, + adresse VARCHAR(255) NULL, + type_affectation ENUM('PERISCOLAIRE', 'EXTRASCOLAIRE') NOT NULL DEFAULT 'PERISCOLAIRE', + actif BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE = InnoDB; + +ALTER TABLE agent + ADD CONSTRAINT fk_agent_structure_principale + FOREIGN KEY (id_structure) REFERENCES structure(id_structure) + ON UPDATE CASCADE + ON DELETE SET NULL; + +CREATE INDEX idx_agent_structure_principale ON agent(id_structure); + +CREATE TABLE structure_couverture_horaire ( + id_couverture INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_structure INT UNSIGNED NOT NULL, + jour_semaine TINYINT UNSIGNED NOT NULL COMMENT '1=lundi, 5=vendredi', + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + minimum_agents TINYINT UNSIGNED NOT NULL DEFAULT 1, + + CONSTRAINT fk_couverture_structure + FOREIGN KEY (id_structure) REFERENCES structure(id_structure) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT chk_couverture_jour CHECK (jour_semaine BETWEEN 1 AND 5), + CONSTRAINT chk_couverture_ordre CHECK (heure_fin > heure_debut), + CONSTRAINT chk_couverture_minimum CHECK (minimum_agents BETWEEN 1 AND 50), + CONSTRAINT chk_couverture_quart_heure CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ) +) ENGINE = InnoDB; + +CREATE INDEX idx_couverture_structure_jour + ON structure_couverture_horaire(id_structure, jour_semaine, heure_debut, heure_fin); + +CREATE TABLE tranche_age ( + id_tranche_age INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + libelle VARCHAR(100) NOT NULL, + age_min_mois INT UNSIGNED NULL, + age_max_mois INT UNSIGNED NULL, + CONSTRAINT chk_tranche_age + CHECK ( + age_min_mois IS NULL + OR age_max_mois IS NULL + OR age_max_mois >= age_min_mois + ) +) ENGINE = InnoDB; + +CREATE TABLE structure_capacite ( + id_structure INT UNSIGNED NOT NULL, + id_tranche_age INT UNSIGNED NOT NULL, + nombre_places INT UNSIGNED NOT NULL, + + PRIMARY KEY (id_structure, id_tranche_age), + + CONSTRAINT fk_capacite_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure), + + CONSTRAINT fk_capacite_tranche_age + FOREIGN KEY (id_tranche_age) + REFERENCES tranche_age(id_tranche_age), + + CONSTRAINT chk_nombre_places + CHECK (nombre_places > 0) +) ENGINE = InnoDB; + +-- ============================================================ +-- 2. CONTRATS ET QUOTAS +-- ============================================================ + +CREATE TABLE contrat_agent ( + id_contrat INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NULL, + + -- Quota total sur la periode du contrat, en minutes. + -- Le stockage en minutes evite les problemes d'arrondis. + quota_minutes INT UNSIGNED NOT NULL, + + type_contrat VARCHAR(100) NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT fk_contrat_agent + FOREIGN KEY (id_agent) + REFERENCES agent(id_agent), + + CONSTRAINT chk_dates_contrat + CHECK (date_fin IS NULL OR date_fin >= date_debut), + + CONSTRAINT chk_quota_contrat + CHECK (quota_minutes > 0) +) ENGINE = InnoDB; + +CREATE INDEX idx_contrat_agent_dates + ON contrat_agent(id_agent, date_debut, date_fin); + +-- ============================================================ +-- 3. SEMAINES +-- ============================================================ + +CREATE TABLE semaine ( + id_semaine INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + annee SMALLINT UNSIGNED NOT NULL, + numero_semaine TINYINT UNSIGNED NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + statut ENUM('OUVERTE', 'VALIDEE', 'VERROUILLEE') + NOT NULL DEFAULT 'OUVERTE', + + UNIQUE KEY uk_semaine_annee_numero (annee, numero_semaine), + UNIQUE KEY uk_semaine_dates (date_debut, date_fin), + + CONSTRAINT chk_numero_semaine + CHECK (numero_semaine BETWEEN 1 AND 53), + + CONSTRAINT chk_dates_semaine + CHECK (date_fin >= date_debut) +) ENGINE = InnoDB; + +-- ============================================================ +-- 4. PLANNINGS ET CRENEAUX +-- ============================================================ + +CREATE TABLE planning ( + id_planning INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + id_semaine INT UNSIGNED NOT NULL, + id_structure INT UNSIGNED NOT NULL, + + statut ENUM('BROUILLON', 'VALIDE') + NOT NULL DEFAULT 'BROUILLON', + + commentaire VARCHAR(500) NULL, + id_planning_source INT UNSIGNED NULL, + + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + date_modification DATETIME NOT NULL + DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + date_validation DATETIME NULL, + + CONSTRAINT fk_planning_agent + FOREIGN KEY (id_agent) + REFERENCES agent(id_agent), + + CONSTRAINT fk_planning_semaine + FOREIGN KEY (id_semaine) + REFERENCES semaine(id_semaine), + + CONSTRAINT fk_planning_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure), + + CONSTRAINT fk_planning_source + FOREIGN KEY (id_planning_source) + REFERENCES planning(id_planning) + ON DELETE SET NULL, + + UNIQUE KEY uk_planning_agent_semaine_structure + (id_agent, id_semaine, id_structure) +) ENGINE = InnoDB; + +CREATE INDEX idx_planning_agent_statut + ON planning(id_agent, statut); + +CREATE TABLE creneau_horaire ( + id_creneau INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_planning INT UNSIGNED NOT NULL, + date_jour DATE NOT NULL, + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + + CONSTRAINT fk_creneau_planning + FOREIGN KEY (id_planning) + REFERENCES planning(id_planning) + ON DELETE CASCADE, + + CONSTRAINT chk_creneau_ordre + CHECK (heure_fin > heure_debut), + + -- Les heures doivent tomber sur un quart d'heure exact. + CONSTRAINT chk_creneau_quart_heure + CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ) +) ENGINE = InnoDB; + +CREATE INDEX idx_creneau_planning_date + ON creneau_horaire(id_planning, date_jour, heure_debut, heure_fin); + +-- ============================================================ +-- 5. VUES DE SUIVI +-- ============================================================ + +CREATE OR REPLACE VIEW v_suivi_quota_contrat AS +SELECT + ca.id_contrat, + ca.id_agent, + a.matricule, + CONCAT(a.prenom, ' ', a.nom) AS agent, + ca.date_debut, + ca.date_fin, + ca.quota_minutes, + ROUND(ca.quota_minutes / 60, 2) AS quota_heures, + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) AS minutes_planifiees_validees, + ROUND( + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) / 60, + 2 + ) AS heures_planifiees_validees, + ROUND( + ( + ca.quota_minutes + - COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) + ) / 60, + 2 + ) AS heures_restantes +FROM contrat_agent ca +JOIN agent a + ON a.id_agent = ca.id_agent +LEFT JOIN planning p + ON p.id_agent = ca.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') +GROUP BY + ca.id_contrat, + ca.id_agent, + a.matricule, + a.prenom, + a.nom, + ca.date_debut, + ca.date_fin, + ca.quota_minutes; + +CREATE OR REPLACE VIEW v_planning_agent_semaine AS +SELECT + p.id_planning, + p.id_agent, + a.matricule, + CONCAT(a.prenom, ' ', a.nom) AS agent, + s.annee, + s.numero_semaine, + st.nom AS structure, + p.statut, + ROUND( + COALESCE( + SUM(TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 3600), + 0 + ), + 2 + ) AS heures_planifiees +FROM planning p +JOIN agent a + ON a.id_agent = p.id_agent +JOIN semaine s + ON s.id_semaine = p.id_semaine +JOIN structure st + ON st.id_structure = p.id_structure +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning +GROUP BY + p.id_planning, + p.id_agent, + a.matricule, + a.prenom, + a.nom, + s.annee, + s.numero_semaine, + st.nom, + p.statut; + +-- ============================================================ +-- 6. PROCEDURES DE CONTROLE ET DE VALIDATION +-- ============================================================ + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS sp_preparer_controles_planning$$ +CREATE PROCEDURE sp_preparer_controles_planning( + IN p_id_planning INT UNSIGNED +) +controle: BEGIN + DECLARE v_planning_existe INT DEFAULT 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_controles_planning; + + CREATE TEMPORARY TABLE tmp_controles_planning ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + niveau ENUM('ERREUR', 'ALERTE', 'INFO') NOT NULL, + code VARCHAR(100) NOT NULL, + message VARCHAR(1000) NOT NULL + ); + + SELECT COUNT(*) + INTO v_planning_existe + FROM planning + WHERE id_planning = p_id_planning; + + IF v_planning_existe = 0 THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'PLANNING_INTROUVABLE', + CONCAT('Le planning #', p_id_planning, ' n''existe pas.') + ); + + LEAVE controle; + END IF; + + -- -------------------------------------------------------- + -- Controle 1 : le planning doit contenir au moins un creneau + -- -------------------------------------------------------- + IF NOT EXISTS ( + SELECT 1 + FROM creneau_horaire + WHERE id_planning = p_id_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'AUCUN_CRENEAU', + 'Le planning ne contient aucun creneau horaire.' + ); + END IF; + + -- -------------------------------------------------------- + -- Controle 2 : chaque creneau doit etre dans la semaine + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' est en dehors de la semaine ', s.numero_semaine, + ' (', DATE_FORMAT(s.date_debut, '%d/%m/%Y'), + ' au ', DATE_FORMAT(s.date_fin, '%d/%m/%Y'), ').' + ) + FROM planning p + JOIN semaine s + ON s.id_semaine = p.id_semaine + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND c.date_jour NOT BETWEEN s.date_debut AND s.date_fin; + + -- -------------------------------------------------------- + -- Controle 3 : chaque creneau doit etre couvert par un contrat + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'HORS_CONTRAT', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' n''est couvert par aucun contrat actif pour cet agent.' + ) + FROM planning p + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND NOT EXISTS ( + SELECT 1 + FROM contrat_agent ca + WHERE ca.id_agent = p.id_agent + AND ca.actif = TRUE + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ); + + -- -------------------------------------------------------- + -- Controle 4 : chevauchements dans le meme planning + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + CONCAT( + 'Les creneaux #', c1.id_creneau, + ' et #', c2.id_creneau, + ' se chevauchent le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), + ' (', TIME_FORMAT(c1.heure_debut, '%H:%i'), '-', TIME_FORMAT(c1.heure_fin, '%H:%i'), + ' et ', TIME_FORMAT(c2.heure_debut, '%H:%i'), '-', TIME_FORMAT(c2.heure_fin, '%H:%i'), ').' + ) + FROM creneau_horaire c1 + JOIN creneau_horaire c2 + ON c2.id_planning = c1.id_planning + AND c2.date_jour = c1.date_jour + AND c2.id_creneau > c1.id_creneau + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE c1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 5 : chevauchement avec un autre planning valide + -- pour le meme agent, meme si la structure est differente. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_PLANNING', + CONCAT( + 'Le creneau #', c1.id_creneau, + ' chevauche le creneau #', c2.id_creneau, + ' du planning valide #', p2.id_planning, + ' le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), '.' + ) + FROM planning p1 + JOIN creneau_horaire c1 + ON c1.id_planning = p1.id_planning + JOIN planning p2 + ON p2.id_agent = p1.id_agent + AND p2.id_planning <> p1.id_planning + AND p2.statut = 'VALIDE' + JOIN creneau_horaire c2 + ON c2.id_planning = p2.id_planning + AND c2.date_jour = c1.date_jour + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE p1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 6 : depassement du quota du contrat + -- Le quota est ici un quota total sur la periode du contrat. + -- Il s'agit d'une ALERTE et non d'une erreur bloquante. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ALERTE', + 'QUOTA_DEPASSE', + CONCAT( + 'Le contrat #', ca.id_contrat, + ' prevoit ', ROUND(ca.quota_minutes / 60, 2), ' h. ', + 'Apres validation, ', + ROUND( + ( + -- Minutes deja validees, hors planning courant + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + -- Minutes du planning courant appartenant a ce contrat + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) / 60, + 2 + ), + ' h seraient planifiees.' + ) + FROM planning p + JOIN contrat_agent ca + ON ca.id_agent = p.id_agent + AND ca.actif = TRUE + WHERE p.id_planning = p_id_planning + AND EXISTS ( + SELECT 1 + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ) + AND ( + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) > ca.quota_minutes; + + -- Si aucun probleme n'a ete detecte, on retourne explicitement OK. + IF NOT EXISTS ( + SELECT 1 + FROM tmp_controles_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'INFO', + 'OK', + 'Aucune anomalie detectee. Le planning peut etre valide.' + ); + END IF; +END$$ + +DROP PROCEDURE IF EXISTS sp_controler_planning$$ +CREATE PROCEDURE sp_controler_planning( + IN p_id_planning INT UNSIGNED +) +BEGIN + CALL sp_preparer_controles_planning(p_id_planning); + + SELECT + niveau, + code, + message + FROM tmp_controles_planning + ORDER BY + FIELD(niveau, 'ERREUR', 'ALERTE', 'INFO'), + id; +END$$ + +DROP PROCEDURE IF EXISTS sp_valider_planning$$ +CREATE PROCEDURE sp_valider_planning( + IN p_id_planning INT UNSIGNED, + IN p_forcer_depassement_quota BOOLEAN +) +BEGIN + DECLARE v_nb_erreurs INT DEFAULT 0; + DECLARE v_nb_alertes_quota INT DEFAULT 0; + DECLARE v_message VARCHAR(1000); + + CALL sp_preparer_controles_planning(p_id_planning); + + SELECT COUNT(*) + INTO v_nb_erreurs + FROM tmp_controles_planning + WHERE niveau = 'ERREUR'; + + IF v_nb_erreurs > 0 THEN + SELECT message + INTO v_message + FROM tmp_controles_planning + WHERE niveau = 'ERREUR' + ORDER BY id + LIMIT 1; + + SET v_message = CONCAT( + 'Validation impossible. ', + v_nb_erreurs, + ' erreur(s) detectee(s). Premiere erreur : ', + v_message + ); + + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = v_message; + END IF; + + SELECT COUNT(*) + INTO v_nb_alertes_quota + FROM tmp_controles_planning + WHERE code = 'QUOTA_DEPASSE'; + + IF v_nb_alertes_quota > 0 + AND COALESCE(p_forcer_depassement_quota, FALSE) = FALSE THEN + + SET v_message = CONCAT( + 'Validation suspendue : le quota contractuel serait depasse. ', + 'Relancer avec p_forcer_depassement_quota = TRUE pour forcer la validation.' + ); + + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = v_message; + END IF; + + UPDATE planning + SET + statut = 'VALIDE', + date_validation = CURRENT_TIMESTAMP + WHERE id_planning = p_id_planning; + + SELECT + p.id_planning, + p.statut, + p.date_validation, + 'Planning valide avec succes.' AS message + FROM planning p + WHERE p.id_planning = p_id_planning; +END$$ + +DELIMITER ; + +-- ============================================================ +-- 7. JEU D'ESSAI +-- ============================================================ + +INSERT INTO agent ( + id_agent, + matricule, + nom, + prenom, + email, + telephone, + adresse, + est_diplome, + diplome_libelle, + date_debut_contrat +) +VALUES + (1, 'AG001', 'MARTIN', 'Alice', 'alice.martin@example.test', '06 10 20 30 40', '12 rue des Lilas, Orléans', TRUE, 'BAFA', '2026-07-01'), + (2, 'AG002', 'DURAND', 'Bruno', 'bruno.durand@example.test', '06 11 21 31 41', '8 avenue de Paris, Orléans', FALSE, NULL, '2026-07-01'), + (3, 'AG003', 'PETIT', 'Chloe', 'chloe.petit@example.test', '06 12 22 32 42', '5 rue Royale, Orléans', TRUE, 'CAP AEPE', '2026-07-22'); + +INSERT INTO structure ( + id_structure, + code, + nom, + adresse, + type_affectation +) +VALUES + (1, 'STR001', 'Creche des Lilas', '10 rue des Lilas, Orleans', 'PERISCOLAIRE'), + (2, 'STR002', 'Accueil Madeleine', '36 rue du Faubourg Madeleine, Orleans', 'EXTRASCOLAIRE'); + +-- Plages de couverture de test. Elles sont configurables dans l'administration. +INSERT INTO structure_couverture_horaire (id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents) +VALUES + (1, 1, '07:30:00', '09:00:00', 1), + (1, 1, '16:30:00', '18:30:00', 1), + (1, 2, '07:30:00', '09:00:00', 1), + (1, 2, '16:30:00', '18:30:00', 1), + (1, 3, '07:30:00', '09:00:00', 1), + (1, 3, '16:30:00', '18:30:00', 1), + (1, 4, '07:30:00', '09:00:00', 1), + (1, 4, '16:30:00', '18:30:00', 1), + (1, 5, '07:30:00', '09:00:00', 1), + (1, 5, '16:30:00', '18:30:00', 1), + (2, 1, '08:00:00', '18:00:00', 1), + (2, 2, '08:00:00', '18:00:00', 1), + (2, 3, '08:00:00', '18:00:00', 1), + (2, 4, '08:00:00', '18:00:00', 1), + (2, 5, '08:00:00', '18:00:00', 1); + +-- Une structure principale par agent. +UPDATE agent SET id_structure = 1 WHERE id_agent IN (1, 2); +UPDATE agent SET id_structure = 2 WHERE id_agent = 3; + +INSERT INTO tranche_age ( + id_tranche_age, + libelle, + age_min_mois, + age_max_mois +) +VALUES + (1, '0 - 3 ans', 0, 35), + (2, '3 - 6 ans', 36, 71), + (3, '6 - 11 ans', 72, 131); + +INSERT INTO structure_capacite ( + id_structure, + id_tranche_age, + nombre_places +) +VALUES + (1, 1, 20), + (1, 2, 12), + (2, 2, 18), + (2, 3, 30); + +-- Alice : 100 h sur juillet/aout pour permettre plusieurs plannings valides. +-- Bruno : 35 h au total, afin de provoquer un depassement de quota. +-- Chloe : contrat qui commence le 22/07, afin de tester un planning hors contrat. +INSERT INTO contrat_agent ( + id_contrat, + id_agent, + date_debut, + date_fin, + quota_minutes, + type_contrat +) +VALUES + (1, 1, '2026-07-01', '2026-08-31', 6000, 'PTA ete - 100 h'), + (2, 2, '2026-07-01', '2026-07-31', 2100, 'PTA juillet - 35 h'), + (3, 3, '2026-07-22', '2026-08-31', 2100, 'PTA ete - 35 h'); + +INSERT INTO semaine ( + id_semaine, + annee, + numero_semaine, + date_debut, + date_fin +) +VALUES + (1, 2026, 29, '2026-07-13', '2026-07-19'), + (2, 2026, 30, '2026-07-20', '2026-07-26'), + (3, 2026, 31, '2026-07-27', '2026-08-02'); + +-- ------------------------------------------------------------ +-- Planning #1 : Alice, semaine 29, 32 h, deja valide +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + date_validation +) +VALUES + (1, 1, 1, 1, 'VALIDE', 'Planning valide de reference - 32 h', CURRENT_TIMESTAMP); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (1, '2026-07-13', '08:00:00', '12:00:00'), + (1, '2026-07-13', '13:00:00', '17:00:00'), + (1, '2026-07-14', '08:00:00', '12:00:00'), + (1, '2026-07-14', '13:00:00', '17:00:00'), + (1, '2026-07-15', '08:00:00', '12:00:00'), + (1, '2026-07-15', '13:00:00', '17:00:00'), + (1, '2026-07-16', '08:00:00', '12:00:00'), + (1, '2026-07-16', '13:00:00', '17:00:00'); + +-- ------------------------------------------------------------ +-- Planning #2 : Alice, semaine 30, 32 h, deja valide +-- Sert de planning de reference pour tester les chevauchements. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + date_validation +) +VALUES + (2, 1, 2, 1, 'VALIDE', 'Deuxieme planning valide de reference - 32 h', CURRENT_TIMESTAMP); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (2, '2026-07-20', '08:00:00', '12:00:00'), + (2, '2026-07-20', '13:00:00', '17:00:00'), + (2, '2026-07-21', '08:00:00', '12:00:00'), + (2, '2026-07-21', '13:00:00', '17:00:00'), + (2, '2026-07-22', '08:00:00', '12:00:00'), + (2, '2026-07-22', '13:00:00', '17:00:00'), + (2, '2026-07-23', '08:00:00', '12:00:00'), + (2, '2026-07-23', '13:00:00', '17:00:00'); + +-- ------------------------------------------------------------ +-- Planning #3 : Alice, semaine 30, autre structure +-- ERREUR ATTENDUE : chevauchement avec le planning #2. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (3, 1, 2, 2, 'BROUILLON', 'Test chevauchement entre deux structures'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (3, '2026-07-20', '10:00:00', '12:00:00'); + +-- ------------------------------------------------------------ +-- Planning #4 : Bruno, semaine 29, 30 h, deja valide +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + date_validation +) +VALUES + (4, 2, 1, 1, 'VALIDE', 'Planning valide Bruno - 30 h sur quota de 35 h', CURRENT_TIMESTAMP); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (4, '2026-07-13', '08:00:00', '12:00:00'), + (4, '2026-07-13', '13:00:00', '16:00:00'), + (4, '2026-07-14', '08:00:00', '12:00:00'), + (4, '2026-07-14', '13:00:00', '16:00:00'), + (4, '2026-07-15', '08:00:00', '12:00:00'), + (4, '2026-07-15', '13:00:00', '16:00:00'), + (4, '2026-07-16', '08:00:00', '12:00:00'), + (4, '2026-07-16', '13:00:00', '16:00:00'), + (4, '2026-07-17', '08:00:00', '10:00:00'); + +-- ------------------------------------------------------------ +-- Planning #5 : Bruno, semaine 30, 10 h +-- ALERTE ATTENDUE : quota depasse (30 h deja validees + 10 h = 40 h > 35 h). +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (5, 2, 2, 1, 'BROUILLON', 'Test depassement de quota'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (5, '2026-07-20', '08:00:00', '12:00:00'), + (5, '2026-07-21', '08:00:00', '12:00:00'), + (5, '2026-07-22', '08:00:00', '10:00:00'); + +-- ------------------------------------------------------------ +-- Planning #6 : Chloe, semaine 29 +-- ERREUR ATTENDUE : hors contrat, son contrat commence le 22/07. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (6, 3, 1, 1, 'BROUILLON', 'Test creneau avant le debut du contrat'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (6, '2026-07-15', '08:00:00', '12:00:00'); + +-- ------------------------------------------------------------ +-- Planning #7 : Bruno, semaine 30, deuxieme structure +-- ERREUR ATTENDUE : deux creneaux se chevauchent dans le meme planning. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (7, 2, 2, 2, 'BROUILLON', 'Test chevauchement interne'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (7, '2026-07-23', '08:00:00', '12:00:00'), + (7, '2026-07-23', '11:00:00', '14:00:00'); + +-- ------------------------------------------------------------ +-- Planning #8 : Alice, semaine 31, 24 h +-- CAS VALIDE ATTENDU : 64 h deja validees + 24 h = 88 h sur quota de 100 h. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire, + id_planning_source +) +VALUES + (8, 1, 3, 1, 'BROUILLON', 'Cas nominal pouvant etre valide', 2); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (8, '2026-07-27', '08:00:00', '12:00:00'), + (8, '2026-07-27', '13:00:00', '17:00:00'), + (8, '2026-07-28', '08:00:00', '12:00:00'), + (8, '2026-07-28', '13:00:00', '17:00:00'), + (8, '2026-07-29', '08:00:00', '12:00:00'), + (8, '2026-07-29', '13:00:00', '17:00:00'); + +-- ------------------------------------------------------------ +-- Planning #9 : Chloe, semaine 30 +-- ERREUR ATTENDUE : le 27/07 appartient a la semaine 31, pas a la semaine 30. +-- Le creneau est volontairement couvert par le contrat pour isoler ce controle. +-- ------------------------------------------------------------ +INSERT INTO planning ( + id_planning, + id_agent, + id_semaine, + id_structure, + statut, + commentaire +) +VALUES + (9, 3, 2, 1, 'BROUILLON', 'Test date hors de la semaine du planning'); + +INSERT INTO creneau_horaire (id_planning, date_jour, heure_debut, heure_fin) +VALUES + (9, '2026-07-27', '08:00:00', '12:00:00'); + +-- ============================================================ +-- 8. REQUETES DE TEST A EXECUTER MANUELLEMENT +-- ============================================================ + +-- CAS 1 : cas nominal, aucune anomalie attendue. +-- CALL sp_controler_planning(8); + +-- CAS 2 : validation du cas nominal. +-- CALL sp_valider_planning(8, FALSE); + +-- CAS 3 : chevauchement avec un autre planning valide. +-- CALL sp_controler_planning(3); +-- CALL sp_valider_planning(3, FALSE); + +-- CAS 4 : depassement de quota. +-- CALL sp_controler_planning(5); +-- La validation ci-dessous doit etre refusee : +-- CALL sp_valider_planning(5, FALSE); +-- La validation peut etre forcee apres confirmation utilisateur : +-- CALL sp_valider_planning(5, TRUE); + +-- CAS 5 : creneau hors contrat. +-- CALL sp_controler_planning(6); +-- CALL sp_valider_planning(6, FALSE); + +-- CAS 6 : chevauchement interne. +-- CALL sp_controler_planning(7); +-- CALL sp_valider_planning(7, FALSE); + +-- CAS 7 : date en dehors de la semaine selectionnee. +-- CALL sp_controler_planning(9); +-- CALL sp_valider_planning(9, FALSE); + +-- CAS 8 : controle direct par contrainte SQL des quarts d'heure. +-- L'INSERT suivant doit echouer a cause du CHECK chk_creneau_quart_heure : +-- INSERT INTO creneau_horaire(id_planning, date_jour, heure_debut, heure_fin) +-- VALUES (8, '2026-07-30', '08:10:00', '10:00:00'); + +-- CAS 9 : consultation du suivi de quota par contrat. +-- SELECT * FROM v_suivi_quota_contrat ORDER BY id_contrat; + +-- CAS 10 : synthese des heures par planning. +-- SELECT * FROM v_planning_agent_semaine ORDER BY id_planning; + +-- ============================================================ +-- 9. LIMITES FONCTIONNELLES VOLONTAIRES DU MVP +-- ============================================================ +-- La capacite par tranche d'age est stockee dans structure_capacite, +-- mais elle ne peut pas encore etre controlee automatiquement avec le +-- modele actuel car aucun effectif d'enfants/usagers n'est associe a un +-- planning. Pour controler la capacite, il faudra ajouter une table +-- d'effectif ou d'affectation par structure, date et tranche d'age. +-- +-- De meme, les heures ci-dessus sont des heures PLANIFIEES et non des +-- heures reellement EFFECTUEES. Pour distinguer les deux, il faudra +-- ajouter un module de pointage ou de realisation effective. +-- ============================================================ +-- Extension de la base PTA pour l'interface de gestion planning +-- À exécuter APRES pta_mysql_test.sql +-- Compatible MySQL 8+ +-- ============================================================ + +USE pta_test; + +-- 1. Motifs d'affectation +CREATE TABLE IF NOT EXISTS motif_planning ( + id_motif INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + libelle VARCHAR(120) NOT NULL, + compte_dans_quota BOOLEAN NOT NULL DEFAULT FALSE, + ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100, + actif BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE = InnoDB; + +INSERT INTO motif_planning (code, libelle, compte_dans_quota, ordre_affichage) +VALUES + ('TRAVAIL', 'Heure de travail', TRUE, 10), + ('CONGE', 'Congé', FALSE, 20), + ('MALADIE', 'Absence', TRUE, 30), + ('FORMATION', 'Formation', TRUE, 40), + ('AUTRE', 'Autre absence (non décomptée)', FALSE, 100) +ON DUPLICATE KEY UPDATE + libelle = VALUES(libelle), + compte_dans_quota = VALUES(compte_dans_quota), + ordre_affichage = VALUES(ordre_affichage), + actif = TRUE; + +-- 2. Rattachement des agents aux structures +CREATE TABLE IF NOT EXISTS agent_structure ( + id_agent_structure INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + id_structure INT UNSIGNED NOT NULL, + date_debut DATE NULL, + date_fin DATE NULL, + actif BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT fk_agent_structure_agent + FOREIGN KEY (id_agent) REFERENCES agent(id_agent), + + CONSTRAINT fk_agent_structure_structure + FOREIGN KEY (id_structure) REFERENCES structure(id_structure), + + CONSTRAINT chk_agent_structure_dates + CHECK (date_fin IS NULL OR date_debut IS NULL OR date_fin >= date_debut), + + UNIQUE KEY uk_agent_structure_periode (id_agent, id_structure, date_debut) +) ENGINE = InnoDB; + +-- Historique initial cohérent avec la structure principale de chaque agent. +INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) +VALUES + (1, 1, '2026-01-01', NULL, TRUE), + (2, 1, '2026-01-01', NULL, TRUE), + (3, 2, '2026-01-01', NULL, TRUE) +ON DUPLICATE KEY UPDATE + date_fin = VALUES(date_fin), + actif = VALUES(actif); + +-- 3. Ajout du motif à chaque créneau existant +SET @travail_id = ( + SELECT id_motif FROM motif_planning WHERE code = 'TRAVAIL' LIMIT 1 +); + +SET @col_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND COLUMN_NAME = 'id_motif' +); + +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE creneau_horaire ADD COLUMN id_motif INT UNSIGNED NULL AFTER id_planning', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE creneau_horaire +SET id_motif = @travail_id +WHERE id_motif IS NULL; + +SET @fk_exists = ( + SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND CONSTRAINT_NAME = 'fk_creneau_motif' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); + +SET @sql = IF( + @fk_exists = 0, + 'ALTER TABLE creneau_horaire ADD CONSTRAINT fk_creneau_motif FOREIGN KEY (id_motif) REFERENCES motif_planning(id_motif)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE creneau_horaire + MODIFY id_motif INT UNSIGNED NOT NULL; + +SET @idx_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'creneau_horaire' + AND INDEX_NAME = 'idx_creneau_motif' +); + +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX idx_creneau_motif ON creneau_horaire(id_motif)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4. Vue de synthèse par agent en tenant compte du motif +CREATE OR REPLACE VIEW v_suivi_quota_contrat AS +SELECT + ca.id_contrat, + ca.id_agent, + a.matricule, + CONCAT(a.prenom, ' ', a.nom) AS agent, + ca.date_debut, + ca.date_fin, + ca.quota_minutes, + ROUND(ca.quota_minutes / 60, 2) AS quota_heures, + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) AS minutes_planifiees_validees, + ROUND( + COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) / 60, + 2 + ) AS heures_planifiees_validees, + ROUND( + ( + ca.quota_minutes + - COALESCE( + SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIME_TO_SEC(TIMEDIFF(c.heure_fin, c.heure_debut)) / 60 + ELSE 0 + END + ), + 0 + ) + ) / 60, + 2 + ) AS heures_restantes +FROM contrat_agent ca +JOIN agent a ON a.id_agent = ca.id_agent +LEFT JOIN planning p ON p.id_agent = ca.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') +LEFT JOIN motif_planning m ON m.id_motif = c.id_motif +GROUP BY + ca.id_contrat, + ca.id_agent, + a.matricule, + a.prenom, + a.nom, + ca.date_debut, + ca.date_fin, + ca.quota_minutes; + + +-- 5. Mise à jour du contrôle de quota : seuls les motifs marqués +-- compte_dans_quota = TRUE sont additionnés. +DELIMITER $$ + +DROP PROCEDURE IF EXISTS sp_preparer_controles_planning$$ +CREATE PROCEDURE sp_preparer_controles_planning( + IN p_id_planning INT UNSIGNED +) +controle: BEGIN + DECLARE v_planning_existe INT DEFAULT 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_controles_planning; + + CREATE TEMPORARY TABLE tmp_controles_planning ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + niveau ENUM('ERREUR', 'ALERTE', 'INFO') NOT NULL, + code VARCHAR(100) NOT NULL, + message VARCHAR(1000) NOT NULL + ); + + SELECT COUNT(*) + INTO v_planning_existe + FROM planning + WHERE id_planning = p_id_planning; + + IF v_planning_existe = 0 THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'PLANNING_INTROUVABLE', + CONCAT('Le planning #', p_id_planning, ' n''existe pas.') + ); + + LEAVE controle; + END IF; + + -- -------------------------------------------------------- + -- Controle 1 : le planning doit contenir au moins un creneau + -- -------------------------------------------------------- + IF NOT EXISTS ( + SELECT 1 + FROM creneau_horaire + WHERE id_planning = p_id_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'ERREUR', + 'AUCUN_CRENEAU', + 'Le planning ne contient aucun creneau horaire.' + ); + END IF; + + -- -------------------------------------------------------- + -- Controle 2 : chaque creneau doit etre dans la semaine + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' est en dehors de la semaine ', s.numero_semaine, + ' (', DATE_FORMAT(s.date_debut, '%d/%m/%Y'), + ' au ', DATE_FORMAT(s.date_fin, '%d/%m/%Y'), ').' + ) + FROM planning p + JOIN semaine s + ON s.id_semaine = p.id_semaine + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND c.date_jour NOT BETWEEN s.date_debut AND s.date_fin; + + -- -------------------------------------------------------- + -- Controle 3 : chaque creneau doit etre couvert par un contrat + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'HORS_CONTRAT', + CONCAT( + 'Le creneau #', c.id_creneau, + ' du ', DATE_FORMAT(c.date_jour, '%d/%m/%Y'), + ' n''est couvert par aucun contrat actif pour cet agent.' + ) + FROM planning p + JOIN creneau_horaire c + ON c.id_planning = p.id_planning + WHERE p.id_planning = p_id_planning + AND NOT EXISTS ( + SELECT 1 + FROM contrat_agent ca + WHERE ca.id_agent = p.id_agent + AND ca.actif = TRUE + AND c.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ); + + -- -------------------------------------------------------- + -- Controle 4 : chevauchements dans le meme planning + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + CONCAT( + 'Les creneaux #', c1.id_creneau, + ' et #', c2.id_creneau, + ' se chevauchent le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), + ' (', TIME_FORMAT(c1.heure_debut, '%H:%i'), '-', TIME_FORMAT(c1.heure_fin, '%H:%i'), + ' et ', TIME_FORMAT(c2.heure_debut, '%H:%i'), '-', TIME_FORMAT(c2.heure_fin, '%H:%i'), ').' + ) + FROM creneau_horaire c1 + JOIN creneau_horaire c2 + ON c2.id_planning = c1.id_planning + AND c2.date_jour = c1.date_jour + AND c2.id_creneau > c1.id_creneau + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE c1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 5 : chevauchement avec un autre planning valide + -- pour le meme agent, meme si la structure est differente. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_PLANNING', + CONCAT( + 'Le creneau #', c1.id_creneau, + ' chevauche le creneau #', c2.id_creneau, + ' du planning valide #', p2.id_planning, + ' le ', DATE_FORMAT(c1.date_jour, '%d/%m/%Y'), '.' + ) + FROM planning p1 + JOIN creneau_horaire c1 + ON c1.id_planning = p1.id_planning + JOIN planning p2 + ON p2.id_agent = p1.id_agent + AND p2.id_planning <> p1.id_planning + AND p2.statut = 'VALIDE' + JOIN creneau_horaire c2 + ON c2.id_planning = p2.id_planning + AND c2.date_jour = c1.date_jour + AND c1.heure_debut < c2.heure_fin + AND c1.heure_fin > c2.heure_debut + WHERE p1.id_planning = p_id_planning; + + -- -------------------------------------------------------- + -- Controle 6 : depassement du quota du contrat + -- Le quota est ici un quota total sur la periode du contrat. + -- Il s'agit d'une ALERTE et non d'une erreur bloquante. + -- -------------------------------------------------------- + INSERT INTO tmp_controles_planning(niveau, code, message) + SELECT + 'ALERTE', + 'QUOTA_DEPASSE', + CONCAT( + 'Le contrat #', ca.id_contrat, + ' prevoit ', ROUND(ca.quota_minutes / 60, 2), ' h. ', + 'Apres validation, ', + ROUND( + ( + -- Minutes deja validees, hors planning courant + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + JOIN motif_planning mv + ON mv.id_motif = cv.id_motif + AND mv.compte_dans_quota = TRUE + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + -- Minutes du planning courant appartenant a ce contrat + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + JOIN motif_planning mc + ON mc.id_motif = cc.id_motif + AND mc.compte_dans_quota = TRUE + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) / 60, + 2 + ), + ' h seraient planifiees.' + ) + FROM planning p + JOIN contrat_agent ca + ON ca.id_agent = p.id_agent + AND ca.actif = TRUE + WHERE p.id_planning = p_id_planning + AND EXISTS ( + SELECT 1 + FROM creneau_horaire cc + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ) + AND ( + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cv.heure_fin, cv.heure_debut)) / 60 + ) + FROM planning pv + JOIN creneau_horaire cv + ON cv.id_planning = pv.id_planning + JOIN motif_planning mv + ON mv.id_motif = cv.id_motif + AND mv.compte_dans_quota = TRUE + WHERE pv.id_agent = ca.id_agent + AND pv.statut = 'VALIDE' + AND pv.id_planning <> p_id_planning + AND cv.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + + + COALESCE(( + SELECT SUM( + TIME_TO_SEC(TIMEDIFF(cc.heure_fin, cc.heure_debut)) / 60 + ) + FROM creneau_horaire cc + JOIN motif_planning mc + ON mc.id_motif = cc.id_motif + AND mc.compte_dans_quota = TRUE + WHERE cc.id_planning = p_id_planning + AND cc.date_jour BETWEEN ca.date_debut AND COALESCE(ca.date_fin, '9999-12-31') + ), 0) + ) > ca.quota_minutes; + + -- Si aucun probleme n'a ete detecte, on retourne explicitement OK. + IF NOT EXISTS ( + SELECT 1 + FROM tmp_controles_planning + ) THEN + INSERT INTO tmp_controles_planning(niveau, code, message) + VALUES ( + 'INFO', + 'OK', + 'Aucune anomalie detectee. Le planning peut etre valide.' + ); + END IF; +END$$ + + +DELIMITER ; +-- ============================================================ +-- PTA - Quotas annuels et vues de pilotage +-- À exécuter APRÈS : +-- 1. pta_mysql_test.sql +-- 2. migration_planning_motifs.sql +-- Compatible MySQL 8+ +-- ============================================================ + +USE pta_test; + +-- ------------------------------------------------------------ +-- 1. Quota annuel d'un agent +-- 1607 h = 96 420 minutes à 100 %. +-- La quotité permet de proratiser automatiquement le quota cible. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS quota_agent_annuel ( + id_quota INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + annee SMALLINT UNSIGNED NOT NULL, + quotite_travail DECIMAL(5,2) NOT NULL DEFAULT 100.00, + quota_reference_minutes INT UNSIGNED NOT NULL DEFAULT 96420, + quota_cible_minutes INT UNSIGNED NOT NULL, + commentaire VARCHAR(255) NULL, + + CONSTRAINT fk_quota_annuel_agent + FOREIGN KEY (id_agent) REFERENCES agent(id_agent), + + CONSTRAINT chk_quotite_travail + CHECK (quotite_travail > 0 AND quotite_travail <= 100), + + CONSTRAINT chk_quota_annuel_positif + CHECK (quota_cible_minutes > 0), + + UNIQUE KEY uk_quota_agent_annee (id_agent, annee) +) ENGINE = InnoDB; + +-- Jeu d'essai 2026 : tous les agents existants à 100 %, soit 1607 h. +INSERT INTO quota_agent_annuel ( + id_agent, + annee, + quotite_travail, + quota_reference_minutes, + quota_cible_minutes, + commentaire +) +SELECT + a.id_agent, + 2026, + 100.00, + 96420, + 96420, + 'Quota annuel de référence à 100 %' +FROM agent a +WHERE NOT EXISTS ( + SELECT 1 + FROM quota_agent_annuel q + WHERE q.id_agent = a.id_agent + AND q.annee = 2026 +); + +-- Exemple de modification pour un agent à 80 % : +-- UPDATE quota_agent_annuel +-- SET quotite_travail = 80.00, +-- quota_cible_minutes = ROUND(96420 * 0.80) +-- WHERE id_agent = 2 AND annee = 2026; + +-- ------------------------------------------------------------ +-- 2. Règle de décompte des motifs +-- L'application reste paramétrable : le booléen compte_dans_quota +-- indique si le motif consomme le quota annuel. +-- Selon le besoin exprimé, les motifs principaux ci-dessous sont +-- comptés par défaut. Il est possible de changer cette règle ensuite. +-- ------------------------------------------------------------ +UPDATE motif_planning +SET compte_dans_quota = TRUE +WHERE code IN ('TRAVAIL', 'FORMATION', 'CONGE', 'MALADIE'); + +-- ------------------------------------------------------------ +-- 3. Vue annuelle de suivi des quotas +-- Les brouillons et les plannings validés sont distingués. +-- ------------------------------------------------------------ +CREATE OR REPLACE VIEW v_suivi_quota_annuel AS +SELECT + q.id_agent, + a.matricule, + a.nom, + a.prenom, + q.annee, + q.quotite_travail, + q.quota_reference_minutes, + q.quota_cible_minutes, + ROUND(q.quota_cible_minutes / 60, 2) AS quota_cible_heures, + + COALESCE(SUM( + CASE + WHEN p.statut = 'VALIDE' AND m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_valides, + + COALESCE(SUM( + CASE + WHEN p.statut = 'BROUILLON' AND m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_brouillon, + + q.quota_cible_minutes - COALESCE(SUM( + CASE + WHEN m.compte_dans_quota = TRUE + THEN TIMESTAMPDIFF( + MINUTE, + CONCAT(c.date_jour, ' ', c.heure_debut), + CONCAT(c.date_jour, ' ', c.heure_fin) + ) + ELSE 0 + END + ), 0) AS minutes_restantes + +FROM quota_agent_annuel q +INNER JOIN agent a ON a.id_agent = q.id_agent +LEFT JOIN planning p ON p.id_agent = q.id_agent +LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND YEAR(c.date_jour) = q.annee +LEFT JOIN motif_planning m ON m.id_motif = c.id_motif +GROUP BY + q.id_agent, + a.matricule, + a.nom, + a.prenom, + q.annee, + q.quotite_travail, + q.quota_reference_minutes, + q.quota_cible_minutes; + + +-- ============================================================ +-- PTA V5.9 - Semaines types par agent +-- ============================================================ +-- Un modèle mémorise les affectations d'une semaine de référence : +-- jour ouvré, lieu, motif et plage horaire. + +CREATE TABLE IF NOT EXISTS modele_semaine_agent ( + id_modele INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_agent INT UNSIGNED NOT NULL, + nom VARCHAR(120) NOT NULL, + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + date_modification DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_modele_semaine_agent + FOREIGN KEY (id_agent) + REFERENCES agent(id_agent) + ON DELETE CASCADE, + + UNIQUE KEY uk_modele_semaine_agent_nom (id_agent, nom), + KEY idx_modele_semaine_agent (id_agent, date_modification) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS modele_semaine_creneau ( + id_modele_creneau INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_modele INT UNSIGNED NOT NULL, + jour_semaine TINYINT UNSIGNED NOT NULL COMMENT '1=lundi ... 5=vendredi', + id_structure INT UNSIGNED NOT NULL, + id_motif INT UNSIGNED NOT NULL, + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + + CONSTRAINT fk_modele_creneau_modele + FOREIGN KEY (id_modele) + REFERENCES modele_semaine_agent(id_modele) + ON DELETE CASCADE, + + CONSTRAINT fk_modele_creneau_structure + FOREIGN KEY (id_structure) + REFERENCES structure(id_structure), + + CONSTRAINT fk_modele_creneau_motif + FOREIGN KEY (id_motif) + REFERENCES motif_planning(id_motif), + + CONSTRAINT chk_modele_creneau_jour + CHECK (jour_semaine BETWEEN 1 AND 5), + + CONSTRAINT chk_modele_creneau_ordre + CHECK (heure_fin > heure_debut), + + CONSTRAINT chk_modele_creneau_quart_heure + CHECK ( + MOD(MINUTE(heure_debut), 15) = 0 + AND MOD(MINUTE(heure_fin), 15) = 0 + AND SECOND(heure_debut) = 0 + AND SECOND(heure_fin) = 0 + ), + + KEY idx_modele_creneau_modele_jour (id_modele, jour_semaine, heure_debut) +) ENGINE = InnoDB; + + +-- ============================================================ +-- PTA V5.17 - Périodes de vacances scolaires +-- ============================================================ +-- Les dates sont validées manuellement par l'utilisateur avant +-- d'être enregistrées, même lorsqu'elles proviennent de l'API +-- officielle du calendrier scolaire. +-- +-- date_fin est stockée de façon INCLUSIVE dans PTA. +-- Pour une proposition issue de l'API, la date de reprise fournie +-- par l'API est convertie en dernier jour de vacances (veille). + +CREATE TABLE IF NOT EXISTS periode_vacance ( + id_periode INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + libelle VARCHAR(150) NOT NULL, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + annee_scolaire VARCHAR(9) NOT NULL, + academie VARCHAR(100) NOT NULL DEFAULT '', + zone VARCHAR(50) NOT NULL DEFAULT '', + source ENUM('MANUEL', 'DATA_GOUV') NOT NULL DEFAULT 'MANUEL', + actif BOOLEAN NOT NULL DEFAULT TRUE, + date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT chk_periode_vacance_dates + CHECK (date_fin >= date_debut), + + UNIQUE KEY uk_periode_vacance ( + date_debut, + date_fin, + annee_scolaire, + academie + ), + + KEY idx_periode_vacance_dates (date_debut, date_fin), + KEY idx_periode_vacance_annee (annee_scolaire) +) ENGINE = InnoDB; + + + +-- ============================================================ +-- Historique V5.20 - date de début de contrat +-- IMPORTANT : depuis la V7.5, le quota PTA est suivi sur l'année civile +-- (01/01 -> 31/12). La date de contrat n'est plus l'ancre du quota. +-- ============================================================ diff --git a/db.php b/db.php new file mode 100644 index 0000000..68e4182 --- /dev/null +++ b/db.php @@ -0,0 +1,30 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); +} catch (PDOException $e) { + throw new RuntimeException( + 'Connexion à la base impossible. Vérifiez config.php, le nom de la base et que MySQL est démarré. Détail : ' . $e->getMessage(), + 0, + $e + ); +} + +return $pdo; diff --git a/index.php b/index.php new file mode 100644 index 0000000..7ae1e5b --- /dev/null +++ b/index.php @@ -0,0 +1,17 @@ +isConfigured() ? $access->homeUrl() : 'role.php'), true, 302); + exit; +} catch (Throwable) { + header('Location: role.php', true, 302); + exit; +} diff --git a/page_bootstrap.php b/page_bootstrap.php new file mode 100644 index 0000000..4f20d09 --- /dev/null +++ b/page_bootstrap.php @@ -0,0 +1,26 @@ +{$method}(); + } catch (Throwable $e) { + http_response_code(500); + $message = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); + echo 'Erreur PTA'; + echo ''; + echo '

    Impossible de démarrer PTA

    ' . $message . '

    '; + echo '

    Vérifiez config.php et le schéma de la base de données.

    '; + } +} diff --git a/pending.php b/pending.php new file mode 100644 index 0000000..15a7822 --- /dev/null +++ b/pending.php @@ -0,0 +1,6 @@ +reopen(); diff --git a/role.php b/role.php new file mode 100644 index 0000000..cbfb4ad --- /dev/null +++ b/role.php @@ -0,0 +1,17 @@ +index(); +} catch (Throwable $e) { + http_response_code(500); + echo 'Erreur PTA'; + echo ''; + echo '

    Impossible d’ouvrir la sélection du rôle

    ' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '

    '; +} diff --git a/save_planning.php b/save_planning.php new file mode 100644 index 0000000..36ab74c --- /dev/null +++ b/save_planning.php @@ -0,0 +1,12 @@ +save(); diff --git a/structures.php b/structures.php new file mode 100644 index 0000000..d61cb69 --- /dev/null +++ b/structures.php @@ -0,0 +1,6 @@ +validate();