pour prod
This commit is contained in:
69
README.md
Normal file
69
README.md
Normal file
@@ -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`.
|
||||||
37
README_V6.md
Normal file
37
README_V6.md
Normal file
@@ -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.
|
||||||
61
README_V7.md
Normal file
61
README_V7.md
Normal file
@@ -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.
|
||||||
10
README_V7_1.md
Normal file
10
README_V7_1.md
Normal file
@@ -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.
|
||||||
31
README_V7_2.md
Normal file
31
README_V7_2.md
Normal file
@@ -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.
|
||||||
15
README_V7_3.md
Normal file
15
README_V7_3.md
Normal file
@@ -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.
|
||||||
48
README_V7_4.md
Normal file
48
README_V7_4.md
Normal file
@@ -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`.
|
||||||
18
README_V7_5.md
Normal file
18
README_V7_5.md
Normal file
@@ -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.
|
||||||
21
README_V7_6.md
Normal file
21
README_V7_6.md
Normal file
@@ -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.
|
||||||
19
README_V7_7.md
Normal file
19
README_V7_7.md
Normal file
@@ -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.
|
||||||
6
administration.php
Normal file
6
administration.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/page_bootstrap.php';
|
||||||
|
runPage('administration');
|
||||||
6
agents.php
Normal file
6
agents.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/page_bootstrap.php';
|
||||||
|
runPage('agents');
|
||||||
6
annual.php
Normal file
6
annual.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/page_bootstrap.php';
|
||||||
|
runPage('annual');
|
||||||
12
api/agent_assignments.php
Normal file
12
api/agent_assignments.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->assignments();
|
||||||
10
api/agent_hours_contract_pdf.php
Normal file
10
api/agent_hours_contract_pdf.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->hoursContractPdf();
|
||||||
12
api/agent_month_overview.php
Normal file
12
api/agent_month_overview.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->monthOverview();
|
||||||
12
api/agent_overview.php
Normal file
12
api/agent_overview.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->overview();
|
||||||
10
api/agent_planning_pdf.php
Normal file
10
api/agent_planning_pdf.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->pdf();
|
||||||
10
api/agent_time_summary_pdf.php
Normal file
10
api/agent_time_summary_pdf.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->timeSummaryPdf();
|
||||||
12
api/agents.php
Normal file
12
api/agents.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->listForPlanning();
|
||||||
12
api/annual_overview.php
Normal file
12
api/annual_overview.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AnnualOverviewController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AnnualOverviewController($pdo))->overview();
|
||||||
12
api/annual_validate.php
Normal file
12
api/annual_validate.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AnnualOverviewController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AnnualOverviewController($pdo))->validateYear();
|
||||||
12
api/apply_week_template.php
Normal file
12
api/apply_week_template.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\WeekTemplateController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new WeekTemplateController($pdo))->apply();
|
||||||
12
api/apply_week_template_period.php
Normal file
12
api/apply_week_template_period.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\WeekTemplateController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new WeekTemplateController($pdo))->applyPeriod();
|
||||||
12
api/copy_agent_week.php
Normal file
12
api/copy_agent_week.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PlanningController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->copyAgentWeek();
|
||||||
12
api/coverage_alerts.php
Normal file
12
api/coverage_alerts.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\CoverageController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new CoverageController($pdo))->alerts();
|
||||||
12
api/create_agent.php
Normal file
12
api/create_agent.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->create();
|
||||||
12
api/create_structure.php
Normal file
12
api/create_structure.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\StructureController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new StructureController($pdo))->create();
|
||||||
12
api/delete_agent_entry.php
Normal file
12
api/delete_agent_entry.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PlanningController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->deleteAgentEntry();
|
||||||
12
api/delete_vacation_period.php
Normal file
12
api/delete_vacation_period.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\VacationController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new VacationController($pdo))->delete();
|
||||||
12
api/delete_week_template.php
Normal file
12
api/delete_week_template.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\WeekTemplateController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new WeekTemplateController($pdo))->destroy();
|
||||||
12
api/move_or_duplicate_entry.php
Normal file
12
api/move_or_duplicate_entry.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PlanningController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->moveOrDuplicateEntry();
|
||||||
12
api/pending_plannings.php
Normal file
12
api/pending_plannings.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PlanningController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->pendingValidation();
|
||||||
12
api/planning.php
Normal file
12
api/planning.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PlanningController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->show();
|
||||||
12
api/preview_school_holidays.php
Normal file
12
api/preview_school_holidays.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\VacationController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new VacationController($pdo))->preview();
|
||||||
12
api/preview_week_template_period.php
Normal file
12
api/preview_week_template_period.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\WeekTemplateController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new WeekTemplateController($pdo))->previewPeriod();
|
||||||
12
api/pta_constraint.php
Normal file
12
api/pta_constraint.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PtaController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PtaController($pdo))->constraint();
|
||||||
12
api/pta_summary.php
Normal file
12
api/pta_summary.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PtaController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PtaController($pdo))->summary();
|
||||||
12
api/pta_wish.php
Normal file
12
api/pta_wish.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PtaController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PtaController($pdo))->wish();
|
||||||
12
api/save_structure_coverage_rules.php
Normal file
12
api/save_structure_coverage_rules.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\CoverageController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new CoverageController($pdo))->saveRules();
|
||||||
12
api/save_vacation_periods.php
Normal file
12
api/save_vacation_periods.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\VacationController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new VacationController($pdo))->save();
|
||||||
12
api/save_week_template.php
Normal file
12
api/save_week_template.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\WeekTemplateController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new WeekTemplateController($pdo))->store();
|
||||||
12
api/structure_coverage_rules.php
Normal file
12
api/structure_coverage_rules.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\CoverageController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new CoverageController($pdo))->rules();
|
||||||
12
api/structure_overview.php
Normal file
12
api/structure_overview.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\StructureController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new StructureController($pdo))->overview();
|
||||||
10
api/structure_planning_pdf.php
Normal file
10
api/structure_planning_pdf.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\StructureController;
|
||||||
|
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new StructureController($pdo))->pdf();
|
||||||
12
api/team_members.php
Normal file
12
api/team_members.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\TeamController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new TeamController($pdo))->member();
|
||||||
12
api/team_status.php
Normal file
12
api/team_status.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\TeamController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new TeamController($pdo))->status();
|
||||||
12
api/teams.php
Normal file
12
api/teams.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\TeamController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new TeamController($pdo))->index();
|
||||||
12
api/update_agent.php
Normal file
12
api/update_agent.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\AgentController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new AgentController($pdo))->updateProfile();
|
||||||
12
api/update_agent_entry.php
Normal file
12
api/update_agent_entry.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\PlanningController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->updateAgentEntry();
|
||||||
12
api/update_structure.php
Normal file
12
api/update_structure.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\StructureController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new StructureController($pdo))->updateType();
|
||||||
12
api/vacation_periods.php
Normal file
12
api/vacation_periods.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\VacationController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new VacationController($pdo))->index();
|
||||||
12
api/week_templates.php
Normal file
12
api/week_templates.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\WeekTemplateController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new WeekTemplateController($pdo))->index();
|
||||||
529
app/Controllers/AgentController.php
Normal file
529
app/Controllers/AgentController.php
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\PdfResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\AgentModel;
|
||||||
|
use App\Models\QuotaModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use App\Services\AgentHoursContractPdfService;
|
||||||
|
use App\Services\AgentTimeSummaryPdfService;
|
||||||
|
use App\Services\PlanningPdfService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class AgentController
|
||||||
|
{
|
||||||
|
private AgentModel $agentModel;
|
||||||
|
private StructureModel $structureModel;
|
||||||
|
private QuotaModel $quotaModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
210
app/Controllers/AnnualOverviewController.php
Normal file
210
app/Controllers/AnnualOverviewController.php
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\AnnualOverviewModel;
|
||||||
|
use App\Models\PlanningModel;
|
||||||
|
use App\Models\QuotaModel;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class AnnualOverviewController
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function overview(): void
|
||||||
|
{
|
||||||
|
(new Access($this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
233
app/Controllers/CoverageController.php
Normal file
233
app/Controllers/CoverageController.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\CoverageModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class CoverageController
|
||||||
|
{
|
||||||
|
private CoverageModel $coverageModel;
|
||||||
|
private StructureModel $structureModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
271
app/Controllers/PageController.php
Normal file
271
app/Controllers/PageController.php
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Controller;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Models\AgentModel;
|
||||||
|
use App\Models\MotifModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class PageController extends Controller
|
||||||
|
{
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
644
app/Controllers/PlanningController.php
Normal file
644
app/Controllers/PlanningController.php
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\AgentModel;
|
||||||
|
use App\Models\MotifModel;
|
||||||
|
use App\Models\PlanningModel;
|
||||||
|
use App\Models\QuotaModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use App\Models\VacationModel;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DomainException;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class PlanningController
|
||||||
|
{
|
||||||
|
private PlanningModel $planningModel;
|
||||||
|
private AgentModel $agentModel;
|
||||||
|
private StructureModel $structureModel;
|
||||||
|
private MotifModel $motifModel;
|
||||||
|
private QuotaModel $quotaModel;
|
||||||
|
private VacationModel $vacationModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/Controllers/PtaController.php
Normal file
174
app/Controllers/PtaController.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\AgentModel;
|
||||||
|
use App\Models\PtaModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class PtaController
|
||||||
|
{
|
||||||
|
private PtaModel $ptaModel;
|
||||||
|
private AgentModel $agentModel;
|
||||||
|
private StructureModel $structureModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Controllers/RoleController.php
Normal file
59
app/Controllers/RoleController.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Controller;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Models\AgentModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class RoleController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$access = new Access($this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
app/Controllers/StructureController.php
Normal file
225
app/Controllers/StructureController.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\PdfResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\CoverageModel;
|
||||||
|
use App\Models\QuotaModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use App\Services\PlanningPdfService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class StructureController
|
||||||
|
{
|
||||||
|
private StructureModel $structureModel;
|
||||||
|
private QuotaModel $quotaModel;
|
||||||
|
private CoverageModel $coverageModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/Controllers/TeamController.php
Normal file
144
app/Controllers/TeamController.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\AgentModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use App\Models\TeamModel;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class TeamController
|
||||||
|
{
|
||||||
|
private TeamModel $teamModel;
|
||||||
|
private AgentModel $agentModel;
|
||||||
|
private StructureModel $structureModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/Controllers/VacationController.php
Normal file
170
app/Controllers/VacationController.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\VacationModel;
|
||||||
|
use App\Services\SchoolHolidayApiService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class VacationController
|
||||||
|
{
|
||||||
|
private VacationModel $vacationModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
335
app/Controllers/WeekTemplateController.php
Normal file
335
app/Controllers/WeekTemplateController.php
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Models\AgentModel;
|
||||||
|
use App\Models\QuotaModel;
|
||||||
|
use App\Models\WeekTemplateModel;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DomainException;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class WeekTemplateController
|
||||||
|
{
|
||||||
|
private WeekTemplateModel $templateModel;
|
||||||
|
private AgentModel $agentModel;
|
||||||
|
private QuotaModel $quotaModel;
|
||||||
|
private Access $access;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
368
app/Core/Access.php
Normal file
368
app/Core/Access.php
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class Access
|
||||||
|
{
|
||||||
|
public const ROLE_AGENT = 'AGENT';
|
||||||
|
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
|
||||||
|
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
|
||||||
|
|
||||||
|
private const SESSION_KEY = 'pta_access';
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
$role = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Core/Controller.php
Normal file
19
app/Core/Controller.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
protected function render(string $view, array $data = []): void
|
||||||
|
{
|
||||||
|
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
|
||||||
|
if (!is_file($viewFile)) {
|
||||||
|
throw new \RuntimeException('Vue introuvable : ' . $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
require $viewFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Core/Csrf.php
Normal file
31
app/Core/Csrf.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Csrf
|
||||||
|
{
|
||||||
|
public static function ensureToken(): string
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertPayload(array $payload): void
|
||||||
|
{
|
||||||
|
$expected = self::ensureToken();
|
||||||
|
$provided = (string) ($payload['csrf_token'] ?? '');
|
||||||
|
|
||||||
|
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||||
|
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Core/JsonResponse.php
Normal file
51
app/Core/JsonResponse.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class JsonResponse
|
||||||
|
{
|
||||||
|
public static function bootstrap(): void
|
||||||
|
{
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('html_errors', '0');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||||
|
if (!(error_reporting() & $severity)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||||
|
});
|
||||||
|
|
||||||
|
set_exception_handler(static function (\Throwable $exception): void {
|
||||||
|
error_log(sprintf(
|
||||||
|
'[PTA API] %s: %s in %s:%d',
|
||||||
|
get_class($exception),
|
||||||
|
$exception->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Core/PdfResponse.php
Normal file
27
app/Core/PdfResponse.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class PdfResponse
|
||||||
|
{
|
||||||
|
public static function inline(string $content, string $filename): void
|
||||||
|
{
|
||||||
|
$safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf';
|
||||||
|
if (!str_ends_with(strtolower($safeFilename), '.pdf')) {
|
||||||
|
$safeFilename .= '.pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: application/pdf');
|
||||||
|
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
|
||||||
|
header('Content-Length: ' . strlen($content));
|
||||||
|
header('Cache-Control: private, max-age=0, must-revalidate');
|
||||||
|
header('Pragma: public');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $content;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Core/Request.php
Normal file
25
app/Core/Request.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Request
|
||||||
|
{
|
||||||
|
public static function json(): array
|
||||||
|
{
|
||||||
|
$payload = json_decode((string) file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
JsonResponse::send(400, ['error' => '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.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
440
app/Models/AgentModel.php
Normal file
440
app/Models/AgentModel.php
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class AgentModel extends BaseModel
|
||||||
|
{
|
||||||
|
private const AGENT_SELECT = <<<'SQL'
|
||||||
|
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
|
||||||
|
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
|
||||||
|
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
|
||||||
|
a.formation_repartition_annuelle, a.commentaire_pta,
|
||||||
|
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
|
||||||
|
po.famille AS poste_famille, po.heures_mercredi_minutes,
|
||||||
|
po.heures_extrascolaire_minutes, po.autorise_preparation,
|
||||||
|
po.peut_assurer_animation, po.preparation_lundi_si_100,
|
||||||
|
a.id_structure,
|
||||||
|
s.nom AS structure_nom,
|
||||||
|
s.nom AS structure_principale_nom,
|
||||||
|
s.type_affectation AS structure_type_affectation,
|
||||||
|
s.type_affectation AS structure_principale_type_affectation
|
||||||
|
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
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
public function allActive(): array
|
||||||
|
{
|
||||||
|
return $this->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
342
app/Models/AnnualOverviewModel.php
Normal file
342
app/Models/AnnualOverviewModel.php
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateInterval;
|
||||||
|
use DatePeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class AnnualOverviewModel extends BaseModel
|
||||||
|
{
|
||||||
|
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
|
||||||
|
|
||||||
|
public function overview(int $agentId, int $year): array
|
||||||
|
{
|
||||||
|
$agentModel = new AgentModel($this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Models/BaseModel.php
Normal file
14
app/Models/BaseModel.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
abstract class BaseModel
|
||||||
|
{
|
||||||
|
public function __construct(protected PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
225
app/Models/CoverageModel.php
Normal file
225
app/Models/CoverageModel.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class CoverageModel extends BaseModel
|
||||||
|
{
|
||||||
|
private const DAY_LABELS = [
|
||||||
|
1 => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/MotifModel.php
Normal file
26
app/Models/MotifModel.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
final class MotifModel extends BaseModel
|
||||||
|
{
|
||||||
|
public function allActive(): array
|
||||||
|
{
|
||||||
|
return $this->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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
986
app/Models/PlanningModel.php
Normal file
986
app/Models/PlanningModel.php
Normal file
@@ -0,0 +1,986 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class PlanningModel extends BaseModel
|
||||||
|
{
|
||||||
|
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
642
app/Models/PtaModel.php
Normal file
642
app/Models/PtaModel.php
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateInterval;
|
||||||
|
use DatePeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PtaModel extends BaseModel
|
||||||
|
{
|
||||||
|
private const WORK_CODES = [
|
||||||
|
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR',
|
||||||
|
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
|
||||||
|
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
|
||||||
|
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const PREPARATION_CODES = [
|
||||||
|
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function summary(int $agentId, string $contextDate): array
|
||||||
|
{
|
||||||
|
$agentModel = new AgentModel($this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
209
app/Models/QuotaModel.php
Normal file
209
app/Models/QuotaModel.php
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suivi du quota PTA sur l'année CIVILE.
|
||||||
|
*
|
||||||
|
* Le PTA est planifié du 1er janvier au 31 décembre de l'année concernée.
|
||||||
|
* La date de début du contrat reste une information RH utile, mais elle ne sert
|
||||||
|
* plus d'ancre pour décaler la période annuelle du quota.
|
||||||
|
*/
|
||||||
|
final class QuotaModel extends BaseModel
|
||||||
|
{
|
||||||
|
public const REFERENCE_MINUTES = 1607 * 60;
|
||||||
|
|
||||||
|
public function createForAgent(int $agentId, int $year, float $rate): void
|
||||||
|
{
|
||||||
|
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
|
||||||
|
$stmt = $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
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/Models/SchemaModel.php
Normal file
82
app/Models/SchemaModel.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
final class SchemaModel extends BaseModel
|
||||||
|
{
|
||||||
|
public function assertCurrent(): void
|
||||||
|
{
|
||||||
|
$requiredTables = [
|
||||||
|
'agent',
|
||||||
|
'structure',
|
||||||
|
'agent_structure',
|
||||||
|
'motif_planning',
|
||||||
|
'semaine',
|
||||||
|
'planning',
|
||||||
|
'creneau_horaire',
|
||||||
|
'quota_agent_annuel',
|
||||||
|
'modele_semaine_agent',
|
||||||
|
'modele_semaine_creneau',
|
||||||
|
'structure_couverture_horaire',
|
||||||
|
'periode_vacance',
|
||||||
|
'poste_agent',
|
||||||
|
'pta_annuel',
|
||||||
|
'agent_structure_souhait',
|
||||||
|
'agent_contrainte',
|
||||||
|
'equipe_pta',
|
||||||
|
'equipe_pta_membre',
|
||||||
|
'structure_creneau_reference',
|
||||||
|
];
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
|
||||||
|
$stmt = $this->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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/Models/StructureModel.php
Normal file
114
app/Models/StructureModel.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
final class StructureModel extends BaseModel
|
||||||
|
{
|
||||||
|
public function allActive(): array
|
||||||
|
{
|
||||||
|
return $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
236
app/Models/TeamModel.php
Normal file
236
app/Models/TeamModel.php
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class TeamModel extends BaseModel
|
||||||
|
{
|
||||||
|
public function all(?string $startDate = null, ?string $endDate = null): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_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,
|
||||||
|
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,
|
||||||
|
SUM(CASE WHEN em.fonction_equipe = 'RESPONSABLE' THEN 1 ELSE 0 END) AS responsables
|
||||||
|
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";
|
||||||
|
$params = [];
|
||||||
|
$conditions = [];
|
||||||
|
if ($startDate !== null) {
|
||||||
|
$conditions[] = 'e.date_fin >= :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;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/Models/VacationModel.php
Normal file
131
app/Models/VacationModel.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class VacationModel extends BaseModel
|
||||||
|
{
|
||||||
|
public function all(?string $schoolYear = null): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire,
|
||||||
|
academie, zone, source, date_creation
|
||||||
|
FROM periode_vacance
|
||||||
|
WHERE actif = TRUE";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($schoolYear !== null && $schoolYear !== '') {
|
||||||
|
$sql .= ' AND annee_scolaire = :annee_scolaire';
|
||||||
|
$params['annee_scolaire'] = $schoolYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY date_debut, libelle';
|
||||||
|
$stmt = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
861
app/Models/WeekTemplateModel.php
Normal file
861
app/Models/WeekTemplateModel.php
Normal file
@@ -0,0 +1,861 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DomainException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class WeekTemplateModel extends BaseModel
|
||||||
|
{
|
||||||
|
public function listForAgent(int $agentId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->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']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
208
app/Services/AgentHoursContractPdfService.php
Normal file
208
app/Services/AgentHoursContractPdfService.php
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le document annuel de validation du quota horaire d'un agent.
|
||||||
|
* Le PDF est autonome et ne nécessite aucune dépendance Composer.
|
||||||
|
*/
|
||||||
|
final class AgentHoursContractPdfService
|
||||||
|
{
|
||||||
|
private const PAGE_WIDTH = 595.28;
|
||||||
|
private const PAGE_HEIGHT = 841.89;
|
||||||
|
private const MARGIN = 42.0;
|
||||||
|
|
||||||
|
public function annualContract(array $agent, array $quota): string
|
||||||
|
{
|
||||||
|
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||||
|
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
|
||||||
|
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
|
||||||
|
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
app/Services/AgentTimeSummaryPdfService.php
Normal file
184
app/Services/AgentTimeSummaryPdfService.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif.
|
||||||
|
*/
|
||||||
|
final class AgentTimeSummaryPdfService
|
||||||
|
{
|
||||||
|
private const PAGE_WIDTH = 595.28;
|
||||||
|
private const PAGE_HEIGHT = 841.89;
|
||||||
|
private const MARGIN = 42.0;
|
||||||
|
|
||||||
|
public function annualSummary(array $agent, array $quota, array $rows): string
|
||||||
|
{
|
||||||
|
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||||
|
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
|
||||||
|
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
|
||||||
|
$totals = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
426
app/Services/PlanningPdfService.php
Normal file
426
app/Services/PlanningPdfService.php
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final class PlanningPdfService
|
||||||
|
{
|
||||||
|
private const MARGIN = 28.0;
|
||||||
|
private const WEEK_DAYS = [1 => '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<string, array<int, 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<string, array<int, 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<int, array{date:string,label:string,display:string}> */
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
484
app/Services/SchoolHolidayApiService.php
Normal file
484
app/Services/SchoolHolidayApiService.php
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class SchoolHolidayApiService
|
||||||
|
{
|
||||||
|
private const DATASET = 'fr-en-calendrier-scolaire';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* data.gouv.fr publie actuellement v2.0 comme URL de base officielle.
|
||||||
|
* v2.1 et l'ancienne API 1.0 restent des solutions de repli afin que
|
||||||
|
* la page continue à fonctionner lors d'une évolution du portail.
|
||||||
|
*/
|
||||||
|
private const EXPLORE_ENDPOINTS = [
|
||||||
|
'https://data.education.gouv.fr/api/explore/v2.0/catalog/datasets/' . self::DATASET . '/records',
|
||||||
|
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/' . self::DATASET . '/records',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
|
||||||
|
|
||||||
|
private const ACADEMY_ZONES = [
|
||||||
|
'aix-marseille' => '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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
app/Services/SimplePdf.php
Normal file
291
app/Services/SimplePdf.php
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Petit générateur PDF autonome, volontairement limité aux besoins des plannings.
|
||||||
|
* Il utilise les polices standard PDF Helvetica / Helvetica-Bold et ne requiert
|
||||||
|
* aucune dépendance Composer.
|
||||||
|
*/
|
||||||
|
final class SimplePdf
|
||||||
|
{
|
||||||
|
public const A4_LANDSCAPE_WIDTH = 841.89;
|
||||||
|
public const A4_LANDSCAPE_HEIGHT = 595.28;
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Views/layouts/footer.php
Normal file
36
app/Views/layouts/footer.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
/** @var array $motifs */
|
||||||
|
/** @var array $scripts */
|
||||||
|
/** @var array $modules */
|
||||||
|
/** @var array $pageConfig */
|
||||||
|
/** @var array $accessProfile */
|
||||||
|
/** @var array $permissions */
|
||||||
|
?>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
window.PTA_CONFIG = {
|
||||||
|
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
||||||
|
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
permissions: <?= json_encode($permissions, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
motifs: <?= json_encode(array_map(static function (array $motif): array {
|
||||||
|
return [
|
||||||
|
'id' => (int) $motif['id_motif'],
|
||||||
|
'code' => (string) $motif['code'],
|
||||||
|
'label' => (string) $motif['libelle'],
|
||||||
|
'quota' => (int) $motif['compte_dans_quota'] === 1,
|
||||||
|
];
|
||||||
|
}, $motifs), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>
|
||||||
|
};
|
||||||
|
window.PTA_PAGE_CONFIG = <?= json_encode([
|
||||||
|
'modules' => $modules,
|
||||||
|
'params' => $pageConfig,
|
||||||
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
|
</script>
|
||||||
|
<script src="assets/js/core.js?v=7.7"></script>
|
||||||
|
<?php foreach ($scripts as $script): ?>
|
||||||
|
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
app/Views/layouts/header.php
Normal file
63
app/Views/layouts/header.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
/** @var string $pageTitle */
|
||||||
|
/** @var string $activePage */
|
||||||
|
/** @var array $allowedPages */
|
||||||
|
/** @var array $accessProfile */
|
||||||
|
|
||||||
|
$navigation = [
|
||||||
|
'planning' => ['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'],
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
||||||
|
<link rel="stylesheet" href="assets/style.css?v=7.7">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">PTA</p>
|
||||||
|
<h1><?= htmlspecialchars($pageTitle) ?></h1>
|
||||||
|
<p class="subtitle">Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<?php if ($activePage === 'planning'): ?>
|
||||||
|
<div id="planning-status" class="status-badge status-neutral">Nouveau planning</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="access-profile-badge">
|
||||||
|
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
|
||||||
|
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
|
||||||
|
<?php if (!empty($accessProfile['structure_name']) && ($accessProfile['role'] ?? '') === 'AGENT'): ?>
|
||||||
|
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if (!empty($_GET['access_denied'])): ?>
|
||||||
|
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<nav class="view-tabs" aria-label="Navigation principale">
|
||||||
|
<?php foreach ($navigation as $key => [$href, $label]): ?>
|
||||||
|
<?php if (!in_array($key, $allowedPages, true)) continue; ?>
|
||||||
|
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
|
||||||
|
<?= htmlspecialchars($label) ?>
|
||||||
|
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</nav>
|
||||||
4
app/Views/pages/administration.php
Normal file
4
app/Views/pages/administration.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/administration.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/agents.php
Normal file
4
app/Views/pages/agents.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/agent_overview.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/annual.php
Normal file
4
app/Views/pages/annual.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/annual_overview.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/coverage.php
Normal file
4
app/Views/pages/coverage.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/coverage_alerts.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/pending.php
Normal file
4
app/Views/pages/pending.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/pending_validation.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/planning.php
Normal file
4
app/Views/pages/planning.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/planning_editor.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/pta.php
Normal file
4
app/Views/pages/pta.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/pta.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
120
app/Views/pages/role.php
Normal file
120
app/Views/pages/role.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
/** @var array $agents */
|
||||||
|
/** @var array $structures */
|
||||||
|
/** @var string $error */
|
||||||
|
/** @var array|null $currentProfile */
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Choisir votre accès — PTA</title>
|
||||||
|
<link rel="stylesheet" href="assets/style.css?v=7.4">
|
||||||
|
</head>
|
||||||
|
<body class="role-page-body">
|
||||||
|
<main class="role-page-shell">
|
||||||
|
<section class="role-intro">
|
||||||
|
<p class="eyebrow">PTA</p>
|
||||||
|
<h1>Quel est votre rôle ?</h1>
|
||||||
|
<p>Votre choix détermine les pages visibles et les actions autorisées pendant cette session.</p>
|
||||||
|
<?php if ($currentProfile): ?>
|
||||||
|
<div class="role-current-profile">
|
||||||
|
Profil actuel : <strong><?= htmlspecialchars((string) $currentProfile['role_label']) ?></strong>
|
||||||
|
— <?= htmlspecialchars((string) $currentProfile['label']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($error !== ''): ?>
|
||||||
|
<div class="message message-error"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" class="role-selection-form" id="role-selection-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||||
|
|
||||||
|
<div class="role-choice-grid">
|
||||||
|
<label class="role-choice-card">
|
||||||
|
<input type="radio" name="role" value="AGENT" required>
|
||||||
|
<span class="role-choice-content">
|
||||||
|
<strong>Agent</strong>
|
||||||
|
<small>Lecture de mon planning et du planning de mon lieu de rattachement.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="role-choice-card">
|
||||||
|
<input type="radio" name="role" value="RESPONSABLE_STRUCTURE" required>
|
||||||
|
<span class="role-choice-content">
|
||||||
|
<strong>Responsable de structure</strong>
|
||||||
|
<small>Saisie et modification des horaires et motifs de mon lieu, uniquement en brouillon.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="role-choice-card">
|
||||||
|
<input type="radio" name="role" value="SERVICE_ENFANCE" required>
|
||||||
|
<span class="role-choice-content">
|
||||||
|
<strong>Service Enfance</strong>
|
||||||
|
<small>Contrôle complet, prévisionnel N+1, administration et validation des plannings.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="agent-context" class="role-context-card" hidden>
|
||||||
|
<label class="field">
|
||||||
|
<span>Je suis l’agent</span>
|
||||||
|
<select name="agent_id" id="role-agent-select">
|
||||||
|
<option value="">Choisir mon identité</option>
|
||||||
|
<?php foreach ($agents as $agent): ?>
|
||||||
|
<option value="<?= (int) $agent['id_agent'] ?>">
|
||||||
|
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' (' . $agent['matricule'] . ')') ?>
|
||||||
|
<?= !empty($agent['structure_nom']) ? ' — ' . htmlspecialchars((string) $agent['structure_nom']) : '' ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="responsable-context" class="role-context-card" hidden>
|
||||||
|
<label class="field">
|
||||||
|
<span>Je suis responsable du lieu</span>
|
||||||
|
<select name="structure_id" id="role-structure-select">
|
||||||
|
<option value="">Choisir le lieu d’affectation</option>
|
||||||
|
<?php foreach ($structures as $structure): ?>
|
||||||
|
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||||
|
<?= htmlspecialchars((string) $structure['nom']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="role-form-actions">
|
||||||
|
<button type="submit" class="button button-primary">Accéder à PTA</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="role-security-note">
|
||||||
|
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const agentContext = document.querySelector('#agent-context');
|
||||||
|
const responsableContext = document.querySelector('#responsable-context');
|
||||||
|
const agentSelect = document.querySelector('#role-agent-select');
|
||||||
|
const structureSelect = document.querySelector('#role-structure-select');
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
const role = document.querySelector('input[name="role"]:checked')?.value || '';
|
||||||
|
agentContext.hidden = role !== 'AGENT';
|
||||||
|
responsableContext.hidden = role !== 'RESPONSABLE_STRUCTURE';
|
||||||
|
agentSelect.required = role === 'AGENT';
|
||||||
|
structureSelect.required = role === 'RESPONSABLE_STRUCTURE';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="role"]').forEach((input) => input.addEventListener('change', refresh));
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
app/Views/pages/structures.php
Normal file
4
app/Views/pages/structures.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/structure_overview.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/teams.php
Normal file
4
app/Views/pages/teams.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/teams.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user