Compare commits
8 Commits
57bd8da5c8
...
3cdbd43c13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdbd43c13 | ||
|
|
8d900bede8 | ||
|
|
30cb50031d | ||
|
|
935cc3ea1c | ||
|
|
b13b52b22b | ||
|
|
13ee16e25d | ||
|
|
b68ac15de5 | ||
|
|
66cc8c8ade |
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Le Dockerfile fait COPY . /var/www/html : tout ce qui est listé ici reste
|
||||||
|
# hors de l'image, donc hors de la racine web et hors des couches publiées.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Historique et documentation de version : inutiles dans l'image.
|
||||||
|
README*.md
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Journaux, sauvegardes et artefacts locaux
|
||||||
|
*.log
|
||||||
|
*.sql.gz
|
||||||
|
*.dump
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
@@ -4,12 +4,17 @@ APP_PORT=8080
|
|||||||
TZ=Europe/Paris
|
TZ=Europe/Paris
|
||||||
|
|
||||||
# MariaDB
|
# MariaDB
|
||||||
|
# Remplacer impérativement les deux mots de passe avant tout déploiement.
|
||||||
DB_NAME=pta
|
DB_NAME=pta
|
||||||
DB_USER=pta
|
DB_USER=pta
|
||||||
DB_PASSWORD=pta
|
DB_PASSWORD=a_remplacer
|
||||||
DB_ROOT_PASSWORD=pta
|
DB_ROOT_PASSWORD=a_remplacer_aussi
|
||||||
|
|
||||||
|
# Débogage : 1 renvoie le détail des exceptions au navigateur. Laisser à 0.
|
||||||
|
PTA_DEBUG=0
|
||||||
|
|
||||||
# phpMyAdmin
|
# phpMyAdmin
|
||||||
# 127.0.0.1 = uniquement accessible depuis le serveur lui-même.
|
# 127.0.0.1 = uniquement accessible depuis le serveur lui-même.
|
||||||
|
# phpMyAdmin demande désormais un identifiant MariaDB à chaque connexion.
|
||||||
PMA_BIND=127.0.0.1
|
PMA_BIND=127.0.0.1
|
||||||
PMA_PORT=8081
|
PMA_PORT=8081
|
||||||
|
|||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Configuration locale : contient les mots de passe MariaDB.
|
||||||
|
# Seul .env.example doit être versionné.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dumps et sauvegardes de base
|
||||||
|
*.sql.gz
|
||||||
|
*.dump
|
||||||
|
database/backup/
|
||||||
|
database/*_backup*.sql
|
||||||
|
|
||||||
|
# Journaux et fichiers temporaires
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Dépendances éventuelles
|
||||||
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# Artefacts d'éditeur et de système
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
218
CLAUDE.md
Normal file
218
CLAUDE.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
PTA (Planning de Travail Annualisé) — application PHP 8.3 multipage (MVC maison) pour la
|
||||||
|
planification annualisée des agents d'accueils de loisirs périscolaires/extrascolaires.
|
||||||
|
|
||||||
|
**Aucune dépendance** : pas de Composer, pas de npm, pas de framework, pas de bundler, pas de
|
||||||
|
suite de tests. Uniquement PHP + PDO/MySQL + JavaScript vanilla. Ne pas introduire de dépendance
|
||||||
|
sans raison forte : le PDF (`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont
|
||||||
|
volontairement réécrits à la main.
|
||||||
|
|
||||||
|
**Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages
|
||||||
|
utilisateur, commentaires. Conserver cette langue pour tout nouveau code.
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrage complet (nginx :8080, phpMyAdmin :8081, MariaDB, php-fpm)
|
||||||
|
cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# IMPORTANT : le code source est COPIÉ dans l'image au build (Dockerfile), il n'est pas monté.
|
||||||
|
# Toute modification PHP/JS/CSS exige un rebuild des DEUX services :
|
||||||
|
# (nginx sert lui-même les assets statiques depuis sa propre copie de /var/www/html)
|
||||||
|
docker compose up -d --build php nginx
|
||||||
|
|
||||||
|
docker compose logs -f php nginx db
|
||||||
|
docker compose ps
|
||||||
|
docker compose down # arrêt en conservant les données
|
||||||
|
docker compose down -v # DÉTRUIT le volume MariaDB
|
||||||
|
|
||||||
|
# Accès SQL direct
|
||||||
|
docker compose exec db mariadb -upta -p pta
|
||||||
|
|
||||||
|
# Vérification syntaxique (il n'existe ni linter ni tests automatisés)
|
||||||
|
find . -name '*.php' -not -path './.git/*' -exec php -l {} \;
|
||||||
|
```
|
||||||
|
|
||||||
|
Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué **qu'à la création initiale du volume**
|
||||||
|
MariaDB (`docker-entrypoint-initdb.d`) : il ne contient que le socle métier, et **aucune** des tables
|
||||||
|
ou vues ajoutées par les migrations.
|
||||||
|
|
||||||
|
Les fichiers `database/migration_*.sql` sont appliqués automatiquement au démarrage par le service
|
||||||
|
jetable `db-migrate` (`docker/db/apply-migrations.sh`), sur base neuve comme sur base existante.
|
||||||
|
Le service `php` attend sa sortie en succès : une migration en échec empêche l'application de
|
||||||
|
tourner sur une base incomplète, plutôt que de la laisser renvoyer une erreur 500 à l'accueil.
|
||||||
|
|
||||||
|
Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fichier déjà appliqué
|
||||||
|
et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester
|
||||||
|
idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`,
|
||||||
|
`INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une
|
||||||
|
migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`.
|
||||||
|
|
||||||
|
`PTA_DB_PORT`, `DB_PORT` et `PMA_PORT` du compose sont les ports joints **à l'intérieur** du réseau
|
||||||
|
Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`,
|
||||||
|
`PMA_PORT` de `.env`) : les modifier coupe l'accès à la base.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Deux types de points d'entrée, tous à la racine ou dans `api/`
|
||||||
|
|
||||||
|
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
|
||||||
|
|
||||||
|
**Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`,
|
||||||
|
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
require __DIR__ . '/page_bootstrap.php';
|
||||||
|
runPage('planning'); // -> PageController::planning()
|
||||||
|
```
|
||||||
|
|
||||||
|
`page_bootstrap.php` attrape les `Throwable` et affiche une page d'erreur HTML.
|
||||||
|
|
||||||
|
**Endpoints JSON/PDF** — `api/*.php` plus `save_planning.php`, `validate_planning.php`,
|
||||||
|
`reopen_planning.php` (ces trois-là sont restés à la racine pour raisons historiques) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->show();
|
||||||
|
```
|
||||||
|
|
||||||
|
`JsonResponse::bootstrap()` convertit warnings et exceptions en réponses JSON 500 — indispensable,
|
||||||
|
car le JS refuse toute réponse non-JSON. **Les endpoints PDF ne l'appellent pas** (ils émettent des
|
||||||
|
octets binaires via `PdfResponse::inline()`).
|
||||||
|
|
||||||
|
Pour ajouter un endpoint : créer le fichier mince dans `api/`, ajouter la méthode au contrôleur.
|
||||||
|
Pour ajouter une page : fichier racine + méthode dans `PageController` + vue `app/Views/pages/` +
|
||||||
|
partiel `app/Views/partials/` + module JS + **inscription de la clé dans le tableau `$navigation` de
|
||||||
|
`app/Views/layouts/header.php`, dans un groupe de `$navigationGroups`, et dans `acces_role_page` par
|
||||||
|
une migration SQL** (les pages autorisées sont lues en base, pas écrites en PHP — voir plus bas).
|
||||||
|
Une page absente des groupes reste affichée dans un groupe « Autres » : le filet évite qu'un oubli
|
||||||
|
la rende inatteignable. Chaque page passe aussi un `pageSubtitle` décrivant l'action de l'écran.
|
||||||
|
|
||||||
|
Seule exception assumée : `faq` (guide d'utilisation) est **volontairement absente de `$navigation`**
|
||||||
|
et rendue par le bloc `.view-tab-help` ancré à droite de la barre, sous condition `$showHelpTab`.
|
||||||
|
L'omettre de `$navigation` est ce qui l'empêche d'être aussi repêchée par le filet « Autres » et
|
||||||
|
d'apparaître deux fois. Elle reste soumise à `acces_role_page`
|
||||||
|
(`database/migration_v7_9d_faq.sql`, les trois rôles) : sans migration, ni bouton ni page.
|
||||||
|
|
||||||
|
### Chaîne d'un appel
|
||||||
|
|
||||||
|
```
|
||||||
|
api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send()
|
||||||
|
page.php → PageController → Models → Views/pages/x.php
|
||||||
|
├── layouts/header.php (nav filtrée par rôle)
|
||||||
|
├── partials/x.php (markup)
|
||||||
|
└── layouts/footer.php (injecte window.PTA_CONFIG + scripts)
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables
|
||||||
|
d'environnement `PTA_DB_*`) et appelle `SchemaModel::assertCurrent()` **à chaque requête**. Ajouter
|
||||||
|
une table ou une colonne obligatoire impose de mettre à jour les listes de `SchemaModel`, sinon
|
||||||
|
l'application entière refuse de démarrer.
|
||||||
|
|
||||||
|
### Rôles et périmètres (`app/Core/Access.php`)
|
||||||
|
|
||||||
|
Trois rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
|
||||||
|
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout, seul habilité à valider).
|
||||||
|
|
||||||
|
`role.php` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement
|
||||||
|
son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la
|
||||||
|
production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la
|
||||||
|
mécanique de droits.
|
||||||
|
|
||||||
|
**`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
|
||||||
|
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
|
||||||
|
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
|
||||||
|
la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait
|
||||||
|
échouer l'application si la migration manque — c'est volontairement fail-closed.
|
||||||
|
|
||||||
|
`dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit
|
||||||
|
d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas
|
||||||
|
jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.php` — ce repli est
|
||||||
|
indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite.
|
||||||
|
|
||||||
|
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
|
||||||
|
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
|
||||||
|
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
|
||||||
|
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
|
||||||
|
garde ni sans `Csrf::assertPayload($payload)`.**
|
||||||
|
|
||||||
|
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
|
||||||
|
`csrf_token: window.PTA_CONFIG.csrfToken`.
|
||||||
|
|
||||||
|
### Contraintes de sécurité à respecter
|
||||||
|
|
||||||
|
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
|
||||||
|
Tout `<script>` inline ajouté à une vue doit porter `nonce="<?= $cspNonce ?>"`, et aucun
|
||||||
|
`onclick=` / `style=` inline ne fonctionnera.
|
||||||
|
- **`PTA.utils.escapeHtml` échappe aussi les guillemets** et est donc valable en contexte texte
|
||||||
|
comme en contexte attribut. Ne pas la réimplémenter localement : c'est une variante incomplète
|
||||||
|
qui avait introduit un XSS stocké.
|
||||||
|
- **Ordre des blocs nginx** : nginx retient la première regex correspondante. Tout nouveau refus
|
||||||
|
doit être placé **avant** `location ~ \.php$`, sinon il est sans effet.
|
||||||
|
- `PTA_DEBUG` reste à `0`. Les détails d'exception passent par `Security::reportThrowable()`, qui
|
||||||
|
journalise côté serveur et ne renvoie rien au client.
|
||||||
|
- `session_start()` ne doit jamais être appelé directement : passer par `App\Core\Session::start()`.
|
||||||
|
|
||||||
|
### JavaScript
|
||||||
|
|
||||||
|
Pas de bundler ni de module ES. Chaque fichier est une IIFE qui lit le global `window.PTA` publié
|
||||||
|
par `core.js` (`config`, `elements`, `state`, `shared`, `utils`, `modules`) et s'enregistre :
|
||||||
|
|
||||||
|
```js
|
||||||
|
PTA.modules.agentOverview = { init, load };
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageController` décide quels scripts et quels modules charger (tableaux `scripts` / `modules`),
|
||||||
|
`footer.php` les injecte, puis `page-bootstrap.js` appelle `init()` sur chaque module déclaré et
|
||||||
|
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se
|
||||||
|
rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
|
||||||
|
|
||||||
|
Les assets sont versionnés à la main : `assets/style.css?v=7.9` et `assets/js/*.js?v=7.9` dans
|
||||||
|
`layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre
|
||||||
|
balise `<head>`. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon
|
||||||
|
les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
|
||||||
|
|
||||||
|
### Modèle métier
|
||||||
|
|
||||||
|
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de
|
||||||
|
contrat : 1 607 h de référence proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`).
|
||||||
|
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
|
||||||
|
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
|
||||||
|
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
|
||||||
|
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est
|
||||||
|
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
|
||||||
|
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
|
||||||
|
elles sont importées depuis l'API publique data.education.gouv.fr via
|
||||||
|
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
|
||||||
|
|
||||||
|
### Piège : logique de validation dupliquée
|
||||||
|
|
||||||
|
`database/pta_prod_schema_mariadb.sql` définit des vues (`v_suivi_quota_annuel`,
|
||||||
|
`v_plannings_en_attente_validation`, …) et des procédures (`sp_controler_planning`,
|
||||||
|
`sp_valider_planning`) — **aucune n'est appelée par le code PHP**. Les contrôles réellement
|
||||||
|
appliqués vivent dans `PlanningController::validate()`, `AnnualOverviewController::validateYear()`
|
||||||
|
et `PtaModel`. Modifier une règle métier impose de traiter le PHP ; le SQL correspondant est du
|
||||||
|
patrimoine à ne pas prendre pour la source de vérité.
|
||||||
|
|
||||||
|
Les contrôles renvoyés au client suivent partout la forme `{niveau: ERREUR|ALERTE|INFO, code,
|
||||||
|
message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation explicite
|
||||||
|
(409 + rappel avec `force_quota: true`).
|
||||||
|
|
||||||
|
## Points d'attention
|
||||||
|
|
||||||
|
- nginx interdit l'accès direct à `/app/`, `/database/`, `/docker/`, aux fichiers d'amorçage et aux
|
||||||
|
extensions de configuration (`docker/nginx/default.conf`) — ces règles doivent suivre toute
|
||||||
|
réorganisation de l'arborescence.
|
||||||
|
- phpMyAdmin n'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. Ne jamais redéfinir
|
||||||
|
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
|
||||||
|
- Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout
|
||||||
|
déploiement réel.
|
||||||
|
- Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier —
|
||||||
|
utiles pour comprendre une décision, mais seul le code fait foi.
|
||||||
@@ -64,6 +64,12 @@ Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis
|
|||||||
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`.
|
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.8 — Sécurité et droits en base
|
||||||
|
|
||||||
|
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
|
||||||
|
vues SQL sont décrits dans `README_V7_8.md`. La migration
|
||||||
|
`database/migration_v7_8_securite_acces.sql` est obligatoire.
|
||||||
|
|
||||||
## V7.4 — Rôles et droits d’accès
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ Application :
|
|||||||
http://IP_DU_SERVEUR:8080
|
http://IP_DU_SERVEUR:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut.
|
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
|
||||||
|
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
|
||||||
|
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
|
||||||
|
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
|
||||||
|
atteint le port.
|
||||||
|
|
||||||
Sur le serveur lui-même :
|
Sur le serveur lui-même :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
134
README_V7_8.md
Normal file
134
README_V7_8.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# PTA V7.8 — Correctifs de sécurité et descente des droits en base
|
||||||
|
|
||||||
|
Cette version ne change aucune règle métier. Elle corrige les failles relevées
|
||||||
|
lors de l'audit et déplace l'intégralité des règles d'accès dans la base.
|
||||||
|
|
||||||
|
## Failles corrigées
|
||||||
|
|
||||||
|
### 1. phpMyAdmin ouvert sans mot de passe (critique)
|
||||||
|
|
||||||
|
`docker-compose.yml` fournissait `PMA_USER` et `PMA_PASSWORD` au conteneur
|
||||||
|
phpMyAdmin. Ces deux variables font basculer phpMyAdmin en `auth_type=config` :
|
||||||
|
toute personne atteignant le port arrivait sur une session **déjà
|
||||||
|
authentifiée**, avec accès complet en lecture et en écriture à la base. Le
|
||||||
|
`.env` publiait de surcroît ce port sur `0.0.0.0`, donc sur tout le réseau.
|
||||||
|
|
||||||
|
Les deux variables ont été retirées et le port est revenu sur `127.0.0.1`.
|
||||||
|
phpMyAdmin réclame désormais un identifiant MariaDB à chaque connexion. Pour y
|
||||||
|
accéder depuis un poste distant : `ssh -L 8081:127.0.0.1:8081 utilisateur@serveur`.
|
||||||
|
|
||||||
|
### 2. XSS stocké via les attributs HTML
|
||||||
|
|
||||||
|
`PTA.utils.escapeHtml` s'appuyait sur `textContent` / `innerHTML`, qui
|
||||||
|
n'échappent ni `"` ni `'`. La fonction était pourtant utilisée dans une
|
||||||
|
vingtaine de contextes d'attribut (`value="…"`, `title="…"`, `aria-label="…"`).
|
||||||
|
Un libellé contenant un guillemet permettait de sortir de l'attribut et
|
||||||
|
d'injecter du script.
|
||||||
|
|
||||||
|
Les vecteurs réalistes étaient les libellés de vacances scolaires — importés
|
||||||
|
depuis une API externe — ainsi que les noms de lieux, les noms d'agents, les
|
||||||
|
libellés de diplôme et les commentaires PTA.
|
||||||
|
|
||||||
|
`escapeHtml` échappe désormais les cinq caractères et reste valable en contexte
|
||||||
|
texte comme en contexte attribut. L'implémentation dupliquée `escapeAttr` de
|
||||||
|
`annual-overview.js` a été supprimée au profit de la fonction commune.
|
||||||
|
|
||||||
|
### 3. Règles de refus nginx sans effet
|
||||||
|
|
||||||
|
Les blocs interdisant `config.php`, `db.php` et `page_bootstrap.php` étaient
|
||||||
|
placés **après** `location ~ \.php$`. nginx retenant la première expression
|
||||||
|
régulière correspondante, ces refus n'ont jamais été appliqués : les trois
|
||||||
|
fichiers d'amorçage étaient exécutables depuis le navigateur.
|
||||||
|
|
||||||
|
L'ordre des blocs a été corrigé et la couverture élargie (`/docker/`, fichiers
|
||||||
|
`.env`, `.sql`, `.ini`, `.log`, `.md`).
|
||||||
|
|
||||||
|
### 4. Divulgation d'informations techniques
|
||||||
|
|
||||||
|
`PTA_DEBUG` valait `1` par défaut : le message des exceptions — requêtes SQL,
|
||||||
|
noms de tables, chemins serveur — était renvoyé au navigateur. Les pages
|
||||||
|
d'erreur de `page_bootstrap.php` et de `role.php` affichaient de la même façon
|
||||||
|
`getMessage()`, et `db.php` y ajoutait l'hôte, l'utilisateur et le nom de la
|
||||||
|
base issus du message PDO.
|
||||||
|
|
||||||
|
Le mode debug est désormais désactivé par défaut. Tout détail technique est
|
||||||
|
journalisé côté serveur via `App\Core\Security::reportThrowable()` et n'atteint
|
||||||
|
le client que si `PTA_DEBUG` est explicitement activé.
|
||||||
|
|
||||||
|
### 5. Absence d'en-têtes de sécurité
|
||||||
|
|
||||||
|
Les réponses HTML portent maintenant une `Content-Security-Policy` **à nonce** :
|
||||||
|
`script-src 'self' 'nonce-…'`, sans `unsafe-inline`. PTA ne contient aucun
|
||||||
|
gestionnaire d'événement inline ni style inline, la politique peut donc rester
|
||||||
|
stricte. S'y ajoutent `X-Content-Type-Options`, `X-Frame-Options`,
|
||||||
|
`Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` et, en
|
||||||
|
HTTPS, `Strict-Transport-Security`.
|
||||||
|
|
||||||
|
Tout nouveau `<script>` inline doit porter `nonce="<?= $cspNonce ?>"`, sinon le
|
||||||
|
navigateur refusera de l'exécuter.
|
||||||
|
|
||||||
|
### 6. Session et CSRF
|
||||||
|
|
||||||
|
`session_start()` était appelé à trois endroits sans paramètres de cookie.
|
||||||
|
`App\Core\Session::start()` centralise le démarrage et impose `HttpOnly`,
|
||||||
|
`SameSite=Lax`, `use_strict_mode` et `secure` en HTTPS — y compris hors du
|
||||||
|
conteneur Docker, où le `php.ini` fourni ne s'applique pas.
|
||||||
|
|
||||||
|
Le changement de rôle (`role.php?action=reset`), qui modifiait l'état de la
|
||||||
|
session via un simple GET, exige désormais le jeton CSRF. Le jeton est
|
||||||
|
régénéré à chaque changement de rôle.
|
||||||
|
|
||||||
|
### 7. Secrets versionnables
|
||||||
|
|
||||||
|
Le dépôt n'avait ni `.gitignore` ni `.dockerignore`. Le `.env` contenant les
|
||||||
|
mots de passe MariaDB était prêt à être commité, et `COPY . /var/www/html`
|
||||||
|
copiait `.env` et `.git` dans l'image publiée. Les deux fichiers ont été créés.
|
||||||
|
|
||||||
|
## Descente des droits en base
|
||||||
|
|
||||||
|
`app/Core/Access.php` ne contient plus aucune règle : la matrice des droits et
|
||||||
|
les périmètres de visibilité sont lus dans des vues SQL.
|
||||||
|
|
||||||
|
| Vue | Rôle |
|
||||||
|
| ------------------------------ | ----------------------------------------------------------- |
|
||||||
|
| `v_acces_role_page` | pages autorisées par rôle |
|
||||||
|
| `v_acces_role_permission` | permissions fonctionnelles par rôle |
|
||||||
|
| `v_acces_identite_agent` | identités d'agents actifs sélectionnables |
|
||||||
|
| `v_acces_identite_structure` | lieux d'affectation actifs sélectionnables |
|
||||||
|
| `v_acces_agent_par_structure` | quels agents un lieu peut consulter (rattachement ou planif.) |
|
||||||
|
| `v_acces_perimetre_creneau` | lieu et planning d'un créneau, avant modification |
|
||||||
|
| `v_acces_perimetre_planning` | lieu et agent d'un planning, avant validation |
|
||||||
|
|
||||||
|
Les deux premières s'appuient sur les tables `acces_role_page` et
|
||||||
|
`acces_role_permission` : **modifier un droit se fait désormais en SQL, sans
|
||||||
|
redéploiement**. Une permission absente de la base vaut « refusé ».
|
||||||
|
|
||||||
|
`SchemaModel::assertCurrent()` vérifie la présence des sept vues au démarrage.
|
||||||
|
Si la migration n'a pas été jouée, l'application refuse de démarrer plutôt que
|
||||||
|
de tourner sans contrôle de périmètre.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Sauvegarder la base.
|
||||||
|
2. Exécuter une seule fois :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SOURCE database/migration_v7_8_securite_acces.sql;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Reconstruire les images, phpMyAdmin compris :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build php nginx
|
||||||
|
docker compose up -d --force-recreate phpmyadmin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reste à traiter
|
||||||
|
|
||||||
|
Le sélecteur de `role.php` n'est toujours **pas une authentification** :
|
||||||
|
n'importe quel visiteur peut choisir « Service Enfance » et obtenir le contrôle
|
||||||
|
complet de l'application. Tant que ce point n'est pas traité, aucune des
|
||||||
|
corrections ci-dessus ne protège une instance exposée sur Internet.
|
||||||
|
|
||||||
|
Le mot de passe MariaDB doit également être changé : le `.env` reprend encore la
|
||||||
|
valeur d'exemple.
|
||||||
118
README_V7_9.md
Normal file
118
README_V7_9.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# PTA V7.9 — Parcours et lisibilité du Service Enfance
|
||||||
|
|
||||||
|
## Le problème traité
|
||||||
|
|
||||||
|
Le rôle **Service Enfance** ouvre onze écrans. Jusqu'ici :
|
||||||
|
|
||||||
|
- il atterrissait sur **Saisie**, alors que son travail commence par le contrôle et non par la saisie ;
|
||||||
|
- les onze onglets étaient alignés à plat, sans hiérarchie : rien ne distinguait une tâche
|
||||||
|
quotidienne (« À valider ») d'un référentiel modifié deux fois par an (« Administration ») ;
|
||||||
|
- chaque bandeau de page affichait le **même** sous-titre générique, il fallait donc ouvrir un écran
|
||||||
|
pour savoir à quoi il servait ;
|
||||||
|
- les chiffres qui déclenchent une action (brouillons en attente, manques de couverture, quotas)
|
||||||
|
vivaient sur trois écrans distincts : personne ne voyait l'état général sans faire le tour ;
|
||||||
|
- la pastille « À valider » de la barre de navigation n'était alimentée que **sur la page
|
||||||
|
« À valider » elle-même**, donc invisible là où elle aurait servi.
|
||||||
|
|
||||||
|
## Ce qui change
|
||||||
|
|
||||||
|
### 1. Un tableau de bord comme page d'accueil
|
||||||
|
|
||||||
|
Nouvelle page `dashboard.php`, **réservée au Service Enfance**, qui devient sa page d'atterrissage.
|
||||||
|
|
||||||
|
- **Quatre tuiles d'action**, vertes quand il n'y a rien à faire, rouges dès qu'un compteur remonte :
|
||||||
|
plannings à valider, manques de couverture de la semaine, quotas dépassés, lieux sans horaires de
|
||||||
|
couverture. Chaque tuile est un lien vers l'écran de traitement.
|
||||||
|
- **« À valider en priorité »** : les cinq brouillons dont la semaine est la plus ancienne, avec un
|
||||||
|
bouton **Contrôler** qui ouvre directement l'écran de saisie sur le bon agent, le bon lieu, la
|
||||||
|
bonne semaine, curseur sur la validation.
|
||||||
|
- **« Couverture à renforcer »** : les cinq plages les plus déficitaires, triées par nombre d'agents
|
||||||
|
manquants, avec un lien vers l'écran de couverture déjà filtré.
|
||||||
|
- **« Suivi des quotas annuels »** : consommation globale, reste à affecter, agents sans aucune heure,
|
||||||
|
et le détail par agent avec une jauge et un lien vers sa vue annuelle.
|
||||||
|
- **« Actions fréquentes »** : six raccourcis vers les écrans les plus utilisés.
|
||||||
|
|
||||||
|
Un sélecteur de semaine en haut de page rejoue l'ensemble des indicateurs de couverture, et indique
|
||||||
|
si la semaine est scolaire, de vacances, ou à cheval sur les deux.
|
||||||
|
|
||||||
|
### 2. Navigation regroupée par intention
|
||||||
|
|
||||||
|
Les onglets sont désormais rangés en quatre groupes : **Piloter** (tableau de bord, à valider,
|
||||||
|
couverture), **Planifier** (saisie, équipes), **Consulter** (par agent, par lieu, vue annuelle, PTA
|
||||||
|
détaillé), **Paramétrer** (vacances, administration).
|
||||||
|
|
||||||
|
Les rôles qui n'ont que quelques pages — Agent (2), Responsable de structure (4) — gardent la barre
|
||||||
|
simple d'avant, sans titres de groupe. Une page autorisée en base mais absente des groupes reste
|
||||||
|
affichée dans un groupe « Autres » plutôt que de disparaître.
|
||||||
|
|
||||||
|
### 3. Chaque page dit ce qu'on y fait
|
||||||
|
|
||||||
|
Le sous-titre du bandeau est propre à chaque écran et décrit l'action, pas le produit.
|
||||||
|
|
||||||
|
### 4. La pastille « À valider » suit partout
|
||||||
|
|
||||||
|
Elle est alimentée depuis n'importe quel écran par un compteur léger
|
||||||
|
(`api/dashboard_summary.php?scope=badges`). L'échec de cet appel est silencieux : une commodité ne
|
||||||
|
doit jamais empêcher une page de fonctionner.
|
||||||
|
|
||||||
|
## Ce qui ne change pas
|
||||||
|
|
||||||
|
Aucune règle métier, aucun calcul, aucun droit n'est modifié.
|
||||||
|
|
||||||
|
Le tableau de bord ne fait que **relire** ce que les autres écrans calculent déjà : la file de
|
||||||
|
validation vient de `PlanningModel::pendingValidation()`, la couverture de
|
||||||
|
`CoverageModel::reportForStructure()`, et le quota reprend exactement la logique de `QuotaModel`
|
||||||
|
(année civile, motifs décomptés uniquement, quota de l'année la plus proche à défaut de l'année
|
||||||
|
exacte) — un agent ne peut donc pas afficher deux valeurs différentes selon l'écran consulté.
|
||||||
|
|
||||||
|
L'endpoint `api/dashboard_summary.php` est gardé par `requireService()` : 403 pour les autres rôles,
|
||||||
|
401 sans session.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Aucun geste manuel : `database/migration_v7_9_tableau_de_bord.sql` est appliqué au démarrage par le
|
||||||
|
service `db-migrate`, qui joue tous les `database/migration_*.sql` dans l'ordre. Un simple
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
suffit, sur base neuve comme sur base existante. La migration insère une seule ligne dans
|
||||||
|
`acces_role_page` et est idempotente (`ON DUPLICATE KEY UPDATE`).
|
||||||
|
|
||||||
|
Si la migration n'était pas jouée, l'onglet n'apparaîtrait pas, le Service Enfance atterrirait comme
|
||||||
|
avant sur l'écran de saisie et `dashboard.php` renverrait vers l'accueil : l'application resterait
|
||||||
|
entièrement fonctionnelle, simplement sans tableau de bord.
|
||||||
|
|
||||||
|
Retour arrière :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard';
|
||||||
|
DELETE FROM schema_migration WHERE fichier = 'migration_v7_9_tableau_de_bord.sql';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers
|
||||||
|
|
||||||
|
| Ajouté | Rôle |
|
||||||
|
| --- | --- |
|
||||||
|
| `dashboard.php` | point d'entrée de la page |
|
||||||
|
| `api/dashboard_summary.php` | endpoint JSON |
|
||||||
|
| `app/Controllers/DashboardController.php` | agrégation et gardes d'accès |
|
||||||
|
| `app/Models/DashboardModel.php` | suivi des quotas et compteurs |
|
||||||
|
| `app/Views/pages/dashboard.php`, `app/Views/partials/dashboard.php` | vue |
|
||||||
|
| `assets/js/dashboard.js` | module `PTA.modules.dashboard` |
|
||||||
|
| `database/migration_v7_9_tableau_de_bord.sql` | droit d'accès à la page |
|
||||||
|
|
||||||
|
| Modifié | Raison |
|
||||||
|
| --- | --- |
|
||||||
|
| `docker-compose.yml` | service `db-migrate`, port interne de la base rétabli à 3306, défaut de mot de passe aligné entre `db` et `php` |
|
||||||
|
| `docker/db/apply-migrations.sh` | application automatique et idempotente des migrations |
|
||||||
|
| `app/Core/Access.php` | `homeUrl()` vise le tableau de bord, avec repli si la migration manque |
|
||||||
|
| `app/Controllers/PageController.php` | méthode `dashboard()` et sous-titre propre à chaque page |
|
||||||
|
| `app/Views/layouts/header.php` | navigation regroupée, sous-titre contextuel |
|
||||||
|
| `app/Views/layouts/footer.php`, `app/Views/pages/role.php` | versions d'assets `?v=7.9` |
|
||||||
|
| `assets/js/page-bootstrap.js` | pastille « À valider » sur toutes les pages |
|
||||||
|
| `assets/style.css` | styles de la navigation groupée et du tableau de bord |
|
||||||
|
|
||||||
|
Les jauges de quota utilisent l'élément natif `<progress>` : la CSP interdit l'attribut `style`, une
|
||||||
|
largeur ne peut donc pas être calculée en JavaScript sur une simple `div`.
|
||||||
12
api/dashboard_summary.php
Normal file
12
api/dashboard_summary.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||||
|
|
||||||
|
use App\Controllers\DashboardController;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
|
||||||
|
JsonResponse::bootstrap();
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new DashboardController($pdo))->summary();
|
||||||
@@ -9,6 +9,7 @@ use App\Core\Csrf;
|
|||||||
use App\Core\JsonResponse;
|
use App\Core\JsonResponse;
|
||||||
use App\Core\PdfResponse;
|
use App\Core\PdfResponse;
|
||||||
use App\Core\Request;
|
use App\Core\Request;
|
||||||
|
use App\Core\Security;
|
||||||
use App\Models\AgentModel;
|
use App\Models\AgentModel;
|
||||||
use App\Models\QuotaModel;
|
use App\Models\QuotaModel;
|
||||||
use App\Models\StructureModel;
|
use App\Models\StructureModel;
|
||||||
@@ -360,7 +361,9 @@ final class AgentController
|
|||||||
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
|
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
|
||||||
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
|
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
|
||||||
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
|
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
|
||||||
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
// Le droit à l'enveloppe de formation est acquis par défaut : un
|
||||||
|
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
|
||||||
|
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||||
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
|
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
|
||||||
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
|
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
|
||||||
|
|
||||||
@@ -404,13 +407,13 @@ final class AgentController
|
|||||||
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
|
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
|
||||||
'id_poste' => (int) $postId,
|
'id_poste' => (int) $postId,
|
||||||
'type_contrat' => $contractType,
|
'type_contrat' => $contractType,
|
||||||
'formation_repartition_annuelle' => $trainingPlanned,
|
'droit_enveloppe_formation' => $trainingRight,
|
||||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
JsonResponse::send(500, [
|
JsonResponse::send(500, [
|
||||||
'error' => 'Impossible de créer l’agent.',
|
'error' => 'Impossible de créer l’agent.',
|
||||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +438,7 @@ final class AgentController
|
|||||||
'id_poste' => (int) $postId,
|
'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'] ?? ''),
|
'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,
|
'type_contrat' => $contractType,
|
||||||
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0,
|
'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
|
||||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -460,7 +463,9 @@ final class AgentController
|
|||||||
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
|
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
|
||||||
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
|
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
|
||||||
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
|
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
|
||||||
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
// Le droit à l'enveloppe de formation est acquis par défaut : un
|
||||||
|
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
|
||||||
|
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||||
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
|
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
|
||||||
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
|
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
|
||||||
$rawStructure = $payload['structure_id'] ?? null;
|
$rawStructure = $payload['structure_id'] ?? null;
|
||||||
@@ -501,7 +506,7 @@ final class AgentController
|
|||||||
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
|
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
|
||||||
'id_poste' => (int) $postId,
|
'id_poste' => (int) $postId,
|
||||||
'type_contrat' => $contractType,
|
'type_contrat' => $contractType,
|
||||||
'formation_repartition_annuelle' => $trainingPlanned,
|
'droit_enveloppe_formation' => $trainingRight,
|
||||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||||
'structure_id' => $structureId !== null ? (int) $structureId : null,
|
'structure_id' => $structureId !== null ? (int) $structureId : null,
|
||||||
]);
|
]);
|
||||||
|
|||||||
302
app/Controllers/DashboardController.php
Normal file
302
app/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Access;
|
||||||
|
use App\Core\JsonResponse;
|
||||||
|
use App\Models\CoverageModel;
|
||||||
|
use App\Models\DashboardModel;
|
||||||
|
use App\Models\PlanningModel;
|
||||||
|
use App\Models\StructureModel;
|
||||||
|
use App\Models\VacationModel;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tableau de bord du Service Enfance.
|
||||||
|
*
|
||||||
|
* Le tableau de bord n'ouvre aucun droit et n'applique aucune règle nouvelle :
|
||||||
|
* il rassemble sur un seul écran les compteurs déjà calculés par les écrans
|
||||||
|
* « À valider », « Couverture » et « Vue annuelle », et les transforme en
|
||||||
|
* liens directs vers l'action correspondante.
|
||||||
|
*/
|
||||||
|
final class DashboardController
|
||||||
|
{
|
||||||
|
private const APERCU_MAX = 5;
|
||||||
|
private const QUOTA_APERCU_MAX = 8;
|
||||||
|
|
||||||
|
private Access $access;
|
||||||
|
private DashboardModel $dashboardModel;
|
||||||
|
private PlanningModel $planningModel;
|
||||||
|
private CoverageModel $coverageModel;
|
||||||
|
private StructureModel $structureModel;
|
||||||
|
private VacationModel $vacationModel;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->access = new Access($pdo);
|
||||||
|
$this->dashboardModel = new DashboardModel($pdo);
|
||||||
|
$this->planningModel = new PlanningModel($pdo);
|
||||||
|
$this->coverageModel = new CoverageModel($pdo);
|
||||||
|
$this->structureModel = new StructureModel($pdo);
|
||||||
|
$this->vacationModel = new VacationModel($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(): void
|
||||||
|
{
|
||||||
|
$this->access->requireService();
|
||||||
|
|
||||||
|
// Portée « badges » : uniquement le compteur de la pastille de
|
||||||
|
// navigation, appelé depuis toutes les pages. Il doit rester léger.
|
||||||
|
if (trim((string) ($_GET['scope'] ?? '')) === 'badges') {
|
||||||
|
JsonResponse::send(200, [
|
||||||
|
'validation' => ['count' => $this->dashboardModel->pendingCount()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$week = $this->resolveWeek($_GET['week'] ?? null);
|
||||||
|
// L'année du quota suit l'année ISO de la semaine affichée : une
|
||||||
|
// semaine à cheval sur deux années civiles reste rattachée à l'année
|
||||||
|
// qui contient son jeudi, comme partout ailleurs dans PTA.
|
||||||
|
$year = $this->resolveYear($_GET['year'] ?? null, (int) $week['iso_year']);
|
||||||
|
|
||||||
|
JsonResponse::send(200, [
|
||||||
|
'semaine' => $this->weekPayload($week),
|
||||||
|
'validation' => $this->validationPayload(),
|
||||||
|
'couverture' => $this->coveragePayload((int) $week['iso_year'], (int) $week['iso_week']),
|
||||||
|
'quota' => $this->quotaPayload($year),
|
||||||
|
'perimetre' => $this->dashboardModel->scope(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File de validation : le volume total, le volume décompté et les
|
||||||
|
* brouillons les plus anciens, qui sont ceux à traiter en premier.
|
||||||
|
*/
|
||||||
|
private function validationPayload(): array
|
||||||
|
{
|
||||||
|
$plannings = $this->planningModel->pendingValidation();
|
||||||
|
$totalMinutes = 0;
|
||||||
|
$countedMinutes = 0;
|
||||||
|
$structures = [];
|
||||||
|
$agents = [];
|
||||||
|
|
||||||
|
foreach ($plannings as $planning) {
|
||||||
|
$totalMinutes += (int) $planning['total_minutes'];
|
||||||
|
$countedMinutes += (int) $planning['minutes_decomptees'];
|
||||||
|
$structures[(int) $planning['id_structure']] = true;
|
||||||
|
$agents[(int) $planning['id_agent']] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingValidation() trie déjà par semaine croissante : les premiers
|
||||||
|
// éléments sont les plus anciens, donc les plus urgents.
|
||||||
|
$apercu = array_slice($plannings, 0, self::APERCU_MAX);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'count' => count($plannings),
|
||||||
|
'total_minutes' => $totalMinutes,
|
||||||
|
'minutes_decomptees' => $countedMinutes,
|
||||||
|
'structure_count' => count($structures),
|
||||||
|
'agent_count' => count($agents),
|
||||||
|
'semaine_la_plus_ancienne' => $plannings[0]['date_debut'] ?? null,
|
||||||
|
'apercu' => array_map(static function (array $planning): array {
|
||||||
|
return [
|
||||||
|
'id_planning' => (int) $planning['id_planning'],
|
||||||
|
'id_agent' => (int) $planning['id_agent'],
|
||||||
|
'id_structure' => (int) $planning['id_structure'],
|
||||||
|
'agent_nom' => (string) $planning['agent_nom'],
|
||||||
|
'agent_prenom' => (string) $planning['agent_prenom'],
|
||||||
|
'structure_nom' => (string) $planning['structure_nom'],
|
||||||
|
'annee' => (int) $planning['annee'],
|
||||||
|
'numero_semaine' => (int) $planning['numero_semaine'],
|
||||||
|
'date_debut' => (string) $planning['date_debut'],
|
||||||
|
'date_fin_ouvrable' => (string) ($planning['date_fin_ouvrable'] ?? $planning['date_fin']),
|
||||||
|
'nombre_creneaux' => (int) $planning['nombre_creneaux'],
|
||||||
|
'total_minutes' => (int) $planning['total_minutes'],
|
||||||
|
'minutes_decomptees' => (int) $planning['minutes_decomptees'],
|
||||||
|
];
|
||||||
|
}, $apercu),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couverture de la semaine affichée, tous lieux actifs confondus.
|
||||||
|
* Reprend exactement le calcul de l'écran « Couverture ».
|
||||||
|
*/
|
||||||
|
private function coveragePayload(int $year, int $weekNumber): array
|
||||||
|
{
|
||||||
|
$gaps = [];
|
||||||
|
$unconfigured = [];
|
||||||
|
$affectedStructures = [];
|
||||||
|
$gapMinutes = 0;
|
||||||
|
$validationWarningCount = 0;
|
||||||
|
|
||||||
|
foreach ($this->structureModel->allActive() as $structure) {
|
||||||
|
$structureId = (int) $structure['id_structure'];
|
||||||
|
$report = $this->coverageModel->reportForStructure($structureId, $year, $weekNumber);
|
||||||
|
|
||||||
|
if (!$report['configured']) {
|
||||||
|
$unconfigured[] = [
|
||||||
|
'id_structure' => $structureId,
|
||||||
|
'nom' => (string) $structure['nom'],
|
||||||
|
'code' => (string) $structure['code'],
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validationWarningCount += (int) $report['summary']['validation_warning_count'];
|
||||||
|
|
||||||
|
foreach ($report['gaps'] as $gap) {
|
||||||
|
$gaps[] = [
|
||||||
|
'id_structure' => $structureId,
|
||||||
|
'structure_nom' => (string) $structure['nom'],
|
||||||
|
'date' => (string) $gap['date'],
|
||||||
|
'jour_libelle' => (string) $gap['jour_libelle'],
|
||||||
|
'heure_debut' => (string) $gap['heure_debut'],
|
||||||
|
'heure_fin' => (string) $gap['heure_fin'],
|
||||||
|
'minimum_agents' => (int) $gap['minimum_agents'],
|
||||||
|
'agents_planifies' => (int) $gap['agents_planifies'],
|
||||||
|
'agents_manquants' => (int) $gap['agents_manquants'],
|
||||||
|
];
|
||||||
|
$affectedStructures[$structureId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gapMinutes += (int) $report['summary']['gap_minutes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les manques les plus importants d'abord : c'est là que le service
|
||||||
|
// doit agir en priorité.
|
||||||
|
usort($gaps, static function (array $a, array $b): int {
|
||||||
|
return [$b['agents_manquants'], $a['date'], $a['heure_debut']]
|
||||||
|
<=> [$a['agents_manquants'], $b['date'], $b['heure_debut']];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'gap_count' => count($gaps),
|
||||||
|
'gap_minutes' => $gapMinutes,
|
||||||
|
'structure_count' => count($affectedStructures),
|
||||||
|
'validation_warning_count' => $validationWarningCount,
|
||||||
|
'unconfigured_count' => count($unconfigured),
|
||||||
|
'unconfigured' => array_slice($unconfigured, 0, self::APERCU_MAX),
|
||||||
|
'apercu' => array_slice($gaps, 0, self::APERCU_MAX),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suivi des quotas : combien d'agents dépassent leur quota annuel et combien
|
||||||
|
* n'ont encore aucune heure planifiée. Les cumuls tous agents confondus
|
||||||
|
* (consommation globale, reste à affecter) ne sont volontairement pas exposés :
|
||||||
|
* une somme d'agents aux quotités différentes ne décrit aucune situation réelle.
|
||||||
|
*/
|
||||||
|
private function quotaPayload(int $year): array
|
||||||
|
{
|
||||||
|
$tracking = $this->dashboardModel->quotaTracking($year);
|
||||||
|
$overCount = 0;
|
||||||
|
$emptyCount = 0;
|
||||||
|
|
||||||
|
foreach ($tracking as $agent) {
|
||||||
|
if ($agent['minutes_restantes'] < 0) {
|
||||||
|
$overCount++;
|
||||||
|
}
|
||||||
|
if ($agent['minutes_affectees'] === 0) {
|
||||||
|
$emptyCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tri décroissant sur le taux : les dépassements remontent en tête,
|
||||||
|
// les agents non planifiés restent visibles en bas de liste.
|
||||||
|
usort($tracking, static function (array $a, array $b): int {
|
||||||
|
return $b['taux_consommation'] <=> $a['taux_consommation'];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'annee' => $year,
|
||||||
|
'agent_count' => count($tracking),
|
||||||
|
'depassement_count' => $overCount,
|
||||||
|
'sans_heure_count' => $emptyCount,
|
||||||
|
'apercu' => array_slice($tracking, 0, self::QUOTA_APERCU_MAX),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte de la semaine affichée : dates, et caractère scolaire ou
|
||||||
|
* extrascolaire déduit des périodes de vacances enregistrées.
|
||||||
|
*/
|
||||||
|
private function weekPayload(array $week): array
|
||||||
|
{
|
||||||
|
/** @var DateTimeImmutable $start */
|
||||||
|
$start = $week['start'];
|
||||||
|
$end = $start->modify('+4 days');
|
||||||
|
$days = $this->vacationModel->typesForIsoWeek((int) $week['iso_year'], (int) $week['iso_week']);
|
||||||
|
|
||||||
|
$vacationLabels = [];
|
||||||
|
$vacationDays = 0;
|
||||||
|
foreach ($days as $day) {
|
||||||
|
if ($day['type_affectation'] === 'EXTRASCOLAIRE') {
|
||||||
|
$vacationDays++;
|
||||||
|
if ($day['vacances'] !== null) {
|
||||||
|
$vacationLabels[$day['vacances']['libelle']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($vacationDays === 0) {
|
||||||
|
$contexte = 'PERISCOLAIRE';
|
||||||
|
} elseif ($vacationDays === count($days)) {
|
||||||
|
$contexte = 'EXTRASCOLAIRE';
|
||||||
|
} else {
|
||||||
|
$contexte = 'MIXTE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valeur' => $week['value'],
|
||||||
|
'annee' => (int) $week['iso_year'],
|
||||||
|
'numero' => (int) $week['iso_week'],
|
||||||
|
'date_debut' => $start->format('Y-m-d'),
|
||||||
|
'date_fin' => $end->format('Y-m-d'),
|
||||||
|
'contexte' => $contexte,
|
||||||
|
'jours_vacances' => $vacationDays,
|
||||||
|
'vacances_libelles' => array_keys($vacationLabels),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
|
||||||
|
*/
|
||||||
|
private function resolveWeek(mixed $value): array
|
||||||
|
{
|
||||||
|
$raw = trim((string) $value);
|
||||||
|
if (preg_match('/^(\d{4})-W(\d{2})$/', $raw, $matches) === 1) {
|
||||||
|
$isoYear = (int) $matches[1];
|
||||||
|
$isoWeek = (int) $matches[2];
|
||||||
|
if ($isoWeek >= 1 && $isoWeek <= 53) {
|
||||||
|
$start = (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))
|
||||||
|
->setISODate($isoYear, $isoWeek, 1)
|
||||||
|
->setTime(0, 0);
|
||||||
|
return [
|
||||||
|
'value' => $raw,
|
||||||
|
'iso_year' => $isoYear,
|
||||||
|
'iso_week' => $isoWeek,
|
||||||
|
'start' => $start,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
|
||||||
|
return [
|
||||||
|
'value' => $now->format('o-\WW'),
|
||||||
|
'iso_year' => (int) $now->format('o'),
|
||||||
|
'iso_week' => (int) $now->format('W'),
|
||||||
|
'start' => $now->setISODate((int) $now->format('o'), (int) $now->format('W'), 1)->setTime(0, 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveYear(mixed $value, int $fallback): int
|
||||||
|
{
|
||||||
|
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
|
||||||
|
return $year === false ? $fallback : (int) $year;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
|||||||
use App\Core\Access;
|
use App\Core\Access;
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\Csrf;
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Security;
|
||||||
use App\Models\AgentModel;
|
use App\Models\AgentModel;
|
||||||
use App\Models\MotifModel;
|
use App\Models\MotifModel;
|
||||||
use App\Models\StructureModel;
|
use App\Models\StructureModel;
|
||||||
@@ -23,6 +24,20 @@ final class PageController extends Controller
|
|||||||
$this->access = new Access($pdo);
|
$this->access = new Access($pdo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dashboard(): void
|
||||||
|
{
|
||||||
|
$this->access->requirePage('dashboard');
|
||||||
|
$this->renderPage('dashboard', 'Tableau de bord', 'dashboard', [
|
||||||
|
'pageSubtitle' => 'Ce qui attend le Service Enfance : plannings à valider, couverture des lieux et suivi des quotas annuels.',
|
||||||
|
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
||||||
|
'scripts' => ['dashboard.js'],
|
||||||
|
'modules' => ['dashboard'],
|
||||||
|
'pageConfig' => [
|
||||||
|
'week' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function planning(): void
|
public function planning(): void
|
||||||
{
|
{
|
||||||
$this->access->requirePage('planning');
|
$this->access->requirePage('planning');
|
||||||
@@ -35,6 +50,7 @@ final class PageController extends Controller
|
|||||||
$modules[] = 'weekTemplates';
|
$modules[] = 'weekTemplates';
|
||||||
}
|
}
|
||||||
$this->renderPage('planning', 'Saisie du planning', 'planning', [
|
$this->renderPage('planning', 'Saisie du planning', 'planning', [
|
||||||
|
'pageSubtitle' => 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
|
||||||
'structures' => $this->loadStructures(),
|
'structures' => $this->loadStructures(),
|
||||||
'motifs' => $this->loadMotifs(),
|
'motifs' => $this->loadMotifs(),
|
||||||
'currentWeek' => $this->currentWeek(),
|
'currentWeek' => $this->currentWeek(),
|
||||||
@@ -55,6 +71,7 @@ final class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->access->requirePage('pending');
|
$this->access->requirePage('pending');
|
||||||
$this->renderPage('pending', 'Plannings à valider', 'pending', [
|
$this->renderPage('pending', 'Plannings à valider', 'pending', [
|
||||||
|
'pageSubtitle' => 'La file des brouillons prêts à être contrôlés. Ouvrir un planning charge l’écran de saisie sur la bonne semaine.',
|
||||||
'scripts' => ['pending-validation.js'],
|
'scripts' => ['pending-validation.js'],
|
||||||
'modules' => ['pendingValidation'],
|
'modules' => ['pendingValidation'],
|
||||||
]);
|
]);
|
||||||
@@ -65,6 +82,7 @@ final class PageController extends Controller
|
|||||||
$this->access->requirePage('coverage');
|
$this->access->requirePage('coverage');
|
||||||
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
|
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
|
||||||
$this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [
|
$this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [
|
||||||
|
'pageSubtitle' => 'Les plages où le nombre d’agents planifiés passe sous le minimum attendu du lieu.',
|
||||||
'structures' => $this->loadStructures(),
|
'structures' => $this->loadStructures(),
|
||||||
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
||||||
'scripts' => ['coverage.js'],
|
'scripts' => ['coverage.js'],
|
||||||
@@ -81,6 +99,7 @@ final class PageController extends Controller
|
|||||||
$this->access->requirePage('structures');
|
$this->access->requirePage('structures');
|
||||||
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
|
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
|
||||||
$this->renderPage('structures', 'Planning par lieu', 'structures', [
|
$this->renderPage('structures', 'Planning par lieu', 'structures', [
|
||||||
|
'pageSubtitle' => 'Le planning d’un lieu sur une semaine : les agents rattachés et ceux qui y sont réellement planifiés. Lecture seule.',
|
||||||
'structures' => $this->loadStructures(),
|
'structures' => $this->loadStructures(),
|
||||||
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
||||||
'scripts' => ['structure-overview.js'],
|
'scripts' => ['structure-overview.js'],
|
||||||
@@ -97,6 +116,9 @@ final class PageController extends Controller
|
|||||||
$this->access->requirePage('agents');
|
$this->access->requirePage('agents');
|
||||||
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
|
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
|
||||||
$this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [
|
$this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [
|
||||||
|
'pageSubtitle' => $forcedAgent
|
||||||
|
? 'Vos horaires mois par mois, tous lieux d’affectation confondus.'
|
||||||
|
: 'Le planning d’un agent mois par mois, tous lieux d’affectation confondus, avec le suivi de son quota.',
|
||||||
'agents' => $this->loadAgents(),
|
'agents' => $this->loadAgents(),
|
||||||
'structures' => $this->loadStructuresForAgentEditor(),
|
'structures' => $this->loadStructuresForAgentEditor(),
|
||||||
'motifs' => $this->loadMotifs(),
|
'motifs' => $this->loadMotifs(),
|
||||||
@@ -114,6 +136,7 @@ final class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->access->requirePage('annual');
|
$this->access->requirePage('annual');
|
||||||
$this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [
|
$this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [
|
||||||
|
'pageSubtitle' => 'L’annualisation d’un agent de janvier à décembre, et la validation de toute l’année en une fois.',
|
||||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||||
'scripts' => ['annual-overview.js'],
|
'scripts' => ['annual-overview.js'],
|
||||||
'modules' => ['annualOverview'],
|
'modules' => ['annualOverview'],
|
||||||
@@ -128,6 +151,7 @@ final class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->access->requirePage('pta');
|
$this->access->requirePage('pta');
|
||||||
$this->renderPage('pta', 'PTA annuel par agent', 'pta', [
|
$this->renderPage('pta', 'PTA annuel par agent', 'pta', [
|
||||||
|
'pageSubtitle' => 'Le PTA détaillé d’un agent : souhaits de lieux, contraintes et répartition des heures.',
|
||||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||||
'scripts' => ['pta.js'],
|
'scripts' => ['pta.js'],
|
||||||
@@ -143,6 +167,7 @@ final class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->access->requirePage('teams');
|
$this->access->requirePage('teams');
|
||||||
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
|
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
|
||||||
|
'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.',
|
||||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||||
'scripts' => ['teams.js'],
|
'scripts' => ['teams.js'],
|
||||||
@@ -158,6 +183,7 @@ final class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->access->requirePage('vacations');
|
$this->access->requirePage('vacations');
|
||||||
$this->renderPage('vacations', 'Vacances scolaires', 'vacations', [
|
$this->renderPage('vacations', 'Vacances scolaires', 'vacations', [
|
||||||
|
'pageSubtitle' => 'Les périodes de vacances qui déterminent le contexte périscolaire ou extrascolaire de chaque journée.',
|
||||||
'scripts' => ['vacation-calendar.js'],
|
'scripts' => ['vacation-calendar.js'],
|
||||||
'modules' => ['vacationCalendar'],
|
'modules' => ['vacationCalendar'],
|
||||||
]);
|
]);
|
||||||
@@ -167,6 +193,7 @@ final class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->access->requirePage('administration');
|
$this->access->requirePage('administration');
|
||||||
$this->renderPage('administration', 'Administration', 'administration', [
|
$this->renderPage('administration', 'Administration', 'administration', [
|
||||||
|
'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.',
|
||||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||||
'posts' => (new AgentModel($this->pdo))->allPosts(),
|
'posts' => (new AgentModel($this->pdo))->allPosts(),
|
||||||
@@ -175,12 +202,32 @@ final class PageController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guide d'utilisation. Page purement statique : aucun script, aucun appel
|
||||||
|
* API, aucune donnée métier. Elle suit malgré tout la règle des droits en
|
||||||
|
* base — tant que la migration n'est pas jouée, la page est refusée et le
|
||||||
|
* bouton d'accès n'apparaît pas dans la barre de navigation.
|
||||||
|
*/
|
||||||
|
public function faq(): void
|
||||||
|
{
|
||||||
|
$this->access->requirePage('faq');
|
||||||
|
$this->renderPage('faq', 'Guide d’utilisation', 'faq', [
|
||||||
|
'pageSubtitle' => 'Le parcours complet, du paramétrage des lieux à la validation des plannings, et les réponses aux questions les plus fréquentes.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
|
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
|
||||||
{
|
{
|
||||||
|
Security::sendHtmlHeaders();
|
||||||
$profile = $this->access->profile();
|
$profile = $this->access->profile();
|
||||||
$this->render('pages/' . $view, array_merge([
|
$this->render('pages/' . $view, array_merge([
|
||||||
'pageTitle' => $title,
|
'pageTitle' => $title,
|
||||||
|
// Chaque page explique en une phrase ce qu'on y fait : le bandeau
|
||||||
|
// générique ne disait rien et obligeait à ouvrir l'écran pour
|
||||||
|
// comprendre à quoi il servait.
|
||||||
|
'pageSubtitle' => 'Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.',
|
||||||
'activePage' => $activePage,
|
'activePage' => $activePage,
|
||||||
|
'cspNonce' => Security::nonce(),
|
||||||
'csrfToken' => Csrf::ensureToken(),
|
'csrfToken' => Csrf::ensureToken(),
|
||||||
'scripts' => [],
|
'scripts' => [],
|
||||||
'modules' => [],
|
'modules' => [],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
|||||||
use App\Core\Access;
|
use App\Core\Access;
|
||||||
use App\Core\Csrf;
|
use App\Core\Csrf;
|
||||||
use App\Core\JsonResponse;
|
use App\Core\JsonResponse;
|
||||||
|
use App\Core\MotifCategories;
|
||||||
use App\Core\Request;
|
use App\Core\Request;
|
||||||
use App\Models\AgentModel;
|
use App\Models\AgentModel;
|
||||||
use App\Models\MotifModel;
|
use App\Models\MotifModel;
|
||||||
@@ -98,7 +99,8 @@ final class PlanningController
|
|||||||
if (!is_array($rawEntries)) {
|
if (!is_array($rawEntries)) {
|
||||||
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
|
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
|
||||||
}
|
}
|
||||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
$agent = $this->agentModel->findActive((int) $agentId);
|
||||||
|
if ($agent === null) {
|
||||||
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
|
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
|
||||||
}
|
}
|
||||||
if ($this->structureModel->findActive((int) $structureId) === null) {
|
if ($this->structureModel->findActive((int) $structureId) === null) {
|
||||||
@@ -107,7 +109,10 @@ final class PlanningController
|
|||||||
|
|
||||||
[$year, $weekNumber] = $week;
|
[$year, $weekNumber] = $week;
|
||||||
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
|
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
|
||||||
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds());
|
// Les motifs autorisés dépendent de l'agent : « Formation » n'est pas
|
||||||
|
// affectable à un agent sans droit à l'enveloppe de formation.
|
||||||
|
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
|
||||||
|
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
|
||||||
$this->assertNoInternalOverlap($entries);
|
$this->assertNoInternalOverlap($entries);
|
||||||
$this->assertNoOtherLocationOverlap(
|
$this->assertNoOtherLocationOverlap(
|
||||||
$entries,
|
$entries,
|
||||||
@@ -169,7 +174,12 @@ final class PlanningController
|
|||||||
if ($this->structureModel->findActive((int) $structureId) === null) {
|
if ($this->structureModel->findActive((int) $structureId) === null) {
|
||||||
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
|
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
|
||||||
}
|
}
|
||||||
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) {
|
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
|
||||||
|
if ($entryAgent === null) {
|
||||||
|
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
|
||||||
|
}
|
||||||
|
$this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent);
|
||||||
|
if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), true)) {
|
||||||
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
|
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +560,48 @@ final class PlanningController
|
|||||||
return [$year, $week];
|
return [$year, $week];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse les affectations portant un motif fermé à l'agent, avec un message
|
||||||
|
* qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais
|
||||||
|
* sans dire pourquoi. L'interface masque déjà ces motifs — reste les
|
||||||
|
* plannings saisis avant le retrait du droit, et les appels directs.
|
||||||
|
*
|
||||||
|
* @param array<int, mixed> $rawEntries
|
||||||
|
*/
|
||||||
|
private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void
|
||||||
|
{
|
||||||
|
if (AgentModel::hasTrainingRight($agent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$restricted = [];
|
||||||
|
foreach ($this->motifModel->allActive() as $motif) {
|
||||||
|
if (MotifCategories::requiresTrainingRight((string) $motif['code'])) {
|
||||||
|
$restricted[(int) $motif['id_motif']] = (string) $motif['libelle'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($restricted === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rawEntries as $entry) {
|
||||||
|
if (!is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||||
|
if (isset($restricted[$motifId])) {
|
||||||
|
JsonResponse::send(422, [
|
||||||
|
'error' => sprintf(
|
||||||
|
'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.',
|
||||||
|
$restricted[$motifId],
|
||||||
|
(string) ($agent['prenom'] ?? ''),
|
||||||
|
(string) ($agent['nom'] ?? '')
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
|
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
|
||||||
{
|
{
|
||||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
|||||||
use App\Core\Access;
|
use App\Core\Access;
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\Csrf;
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Security;
|
||||||
use App\Models\AgentModel;
|
use App\Models\AgentModel;
|
||||||
use App\Models\StructureModel;
|
use App\Models\StructureModel;
|
||||||
use PDO;
|
use PDO;
|
||||||
@@ -21,8 +22,13 @@ final class RoleController extends Controller
|
|||||||
{
|
{
|
||||||
$access = new Access($this->pdo);
|
$access = new Access($this->pdo);
|
||||||
|
|
||||||
|
// Le changement de rôle modifie l'état de la session : il est protégé
|
||||||
|
// par le jeton CSRF au même titre que les écritures de l'API.
|
||||||
if (($_GET['action'] ?? '') === 'reset') {
|
if (($_GET['action'] ?? '') === 'reset') {
|
||||||
$access->clear();
|
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||||
|
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||||
|
$access->clear();
|
||||||
|
}
|
||||||
header('Location: role.php', true, 302);
|
header('Location: role.php', true, 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -47,8 +53,10 @@ final class RoleController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Security::sendHtmlHeaders();
|
||||||
$this->render('pages/role', [
|
$this->render('pages/role', [
|
||||||
'pageTitle' => 'Choisir votre accès',
|
'pageTitle' => 'Choisir votre accès',
|
||||||
|
'cspNonce' => Security::nonce(),
|
||||||
'csrfToken' => Csrf::ensureToken(),
|
'csrfToken' => Csrf::ensureToken(),
|
||||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ namespace App\Core;
|
|||||||
|
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôle des rôles et des périmètres.
|
||||||
|
*
|
||||||
|
* Aucune règle de droit n'est écrite ici : les pages autorisées, les
|
||||||
|
* permissions fonctionnelles et les périmètres de visibilité sont lus dans les
|
||||||
|
* vues SQL créées par database/migration_v7_8_securite_acces.sql. Cette classe
|
||||||
|
* ne fait que poser la question à la base et appliquer la réponse.
|
||||||
|
*/
|
||||||
final class Access
|
final class Access
|
||||||
{
|
{
|
||||||
public const ROLE_AGENT = 'AGENT';
|
public const ROLE_AGENT = 'AGENT';
|
||||||
@@ -14,11 +22,15 @@ final class Access
|
|||||||
|
|
||||||
private const SESSION_KEY = 'pta_access';
|
private const SESSION_KEY = 'pta_access';
|
||||||
|
|
||||||
|
/** @var array<string, array<string, bool>> */
|
||||||
|
private array $permissionCache = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<int, string>> */
|
||||||
|
private array $pageCache = [];
|
||||||
|
|
||||||
public function __construct(private PDO $pdo)
|
public function __construct(private PDO $pdo)
|
||||||
{
|
{
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
Session::start();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
@@ -85,13 +97,16 @@ final class Access
|
|||||||
$selection['structure_id'] = $structureId;
|
$selection['structure_id'] = $structureId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION[self::SESSION_KEY] = $selection;
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
|
$_SESSION[self::SESSION_KEY] = $selection;
|
||||||
|
// Le jeton CSRF suit le cycle de vie de la session : un changement de
|
||||||
|
// rôle invalide le jeton précédent.
|
||||||
|
unset($_SESSION['csrf_token']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clear(): void
|
public function clear(): void
|
||||||
{
|
{
|
||||||
unset($_SESSION[self::SESSION_KEY]);
|
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,39 +143,61 @@ final class Access
|
|||||||
return $profile;
|
return $profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions fonctionnelles lues dans v_acces_role_permission.
|
||||||
|
* Une permission absente de la base vaut « refusé ».
|
||||||
|
*
|
||||||
|
* @return array<string, bool>
|
||||||
|
*/
|
||||||
public function permissions(): array
|
public function permissions(): array
|
||||||
{
|
{
|
||||||
$role = $this->role();
|
$role = $this->role();
|
||||||
$service = $role === self::ROLE_SERVICE;
|
if ($role === null) {
|
||||||
$responsable = $role === self::ROLE_RESPONSABLE;
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
if (!isset($this->permissionCache[$role])) {
|
||||||
'read_only' => $role === self::ROLE_AGENT,
|
$stmt = $this->pdo->prepare(
|
||||||
'can_edit_draft' => $service || $responsable,
|
'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
|
||||||
'can_edit_cross_structure' => $service,
|
);
|
||||||
'can_validate' => $service,
|
$stmt->execute(['role' => $role]);
|
||||||
'can_reopen' => $service || $responsable,
|
|
||||||
'can_use_templates' => $service,
|
$permissions = [];
|
||||||
'can_copy_full_week' => $service,
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
'can_view_pending' => $service,
|
$permissions[(string) $row['permission']] = (bool) $row['autorise'];
|
||||||
'can_manage_coverage' => $service,
|
}
|
||||||
'can_manage_agents' => $service,
|
$this->permissionCache[$role] = $permissions;
|
||||||
'can_manage_structures' => $service,
|
}
|
||||||
'can_manage_vacations' => $service,
|
|
||||||
'can_manage_teams' => $service,
|
return $this->permissionCache[$role];
|
||||||
'can_manage_pta' => $service,
|
|
||||||
'can_forecast_next_year' => $service,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasPermission(string $permission): bool
|
||||||
|
{
|
||||||
|
return $this->permissions()[$permission] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages autorisées lues dans v_acces_role_page.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
public function allowedPages(): array
|
public function allowedPages(): array
|
||||||
{
|
{
|
||||||
return match ($this->role()) {
|
$role = $this->role();
|
||||||
self::ROLE_AGENT => ['agents', 'structures'],
|
if ($role === null) {
|
||||||
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
|
return [];
|
||||||
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
|
}
|
||||||
default => [],
|
|
||||||
};
|
if (!isset($this->pageCache[$role])) {
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT page FROM v_acces_role_page WHERE role = :role ORDER BY ordre_affichage, page'
|
||||||
|
);
|
||||||
|
$stmt->execute(['role' => $role]);
|
||||||
|
$this->pageCache[$role] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pageCache[$role];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canAccessPage(string $page): bool
|
public function canAccessPage(string $page): bool
|
||||||
@@ -168,12 +205,21 @@ final class Access
|
|||||||
return in_array($page, $this->allowedPages(), true);
|
return in_array($page, $this->allowedPages(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page d'atterrissage du rôle.
|
||||||
|
*
|
||||||
|
* Le Service Enfance arrive sur son tableau de bord plutôt que sur l'écran
|
||||||
|
* de saisie : son travail commence par le contrôle, pas par la saisie. Le
|
||||||
|
* repli sur « planning » est indispensable, car la page « dashboard » n'est
|
||||||
|
* autorisée que si la migration V7.9 a été jouée — sans ce repli,
|
||||||
|
* requirePage() renverrait indéfiniment vers une page interdite.
|
||||||
|
*/
|
||||||
public function homeUrl(): string
|
public function homeUrl(): string
|
||||||
{
|
{
|
||||||
return match ($this->role()) {
|
return match ($this->role()) {
|
||||||
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
|
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
|
||||||
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
|
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
|
||||||
self::ROLE_SERVICE => 'planning.php',
|
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
|
||||||
default => 'role.php',
|
default => 'role.php',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -248,10 +294,7 @@ final class Access
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT p.id_structure
|
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
|
||||||
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]);
|
$stmt->execute(['id' => $entryId]);
|
||||||
$structureId = $stmt->fetchColumn();
|
$structureId = $stmt->fetchColumn();
|
||||||
@@ -267,7 +310,9 @@ final class Access
|
|||||||
if ($this->role() === self::ROLE_SERVICE) {
|
if ($this->role() === self::ROLE_SERVICE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1');
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT id_structure FROM v_acces_perimetre_planning WHERE id_planning = :id LIMIT 1'
|
||||||
|
);
|
||||||
$stmt->execute(['id' => $planningId]);
|
$stmt->execute(['id' => $planningId]);
|
||||||
$structureId = $stmt->fetchColumn();
|
$structureId = $stmt->fetchColumn();
|
||||||
if ($structureId === false) {
|
if ($structureId === false) {
|
||||||
@@ -293,14 +338,14 @@ final class Access
|
|||||||
|
|
||||||
private function activeAgentExists(int $agentId): bool
|
private function activeAgentExists(int $agentId): bool
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1');
|
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
|
||||||
$stmt->execute(['id' => $agentId]);
|
$stmt->execute(['id' => $agentId]);
|
||||||
return $stmt->fetchColumn() !== false;
|
return $stmt->fetchColumn() !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function activeStructureExists(int $structureId): bool
|
private function activeStructureExists(int $structureId): bool
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
|
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
|
||||||
$stmt->execute(['id' => $structureId]);
|
$stmt->execute(['id' => $structureId]);
|
||||||
return $stmt->fetchColumn() !== false;
|
return $stmt->fetchColumn() !== false;
|
||||||
}
|
}
|
||||||
@@ -312,11 +357,9 @@ final class Access
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
|
'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
|
||||||
s.nom AS structure_nom
|
FROM v_acces_identite_agent
|
||||||
FROM agent a
|
WHERE id_agent = :id LIMIT 1'
|
||||||
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]);
|
$stmt->execute(['id' => $agentId]);
|
||||||
return $stmt->fetch() ?: null;
|
return $stmt->fetch() ?: null;
|
||||||
@@ -330,8 +373,8 @@ final class Access
|
|||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT id_structure, code, nom
|
'SELECT id_structure, code, nom
|
||||||
FROM structure
|
FROM v_acces_identite_structure
|
||||||
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
|
WHERE id_structure = :id LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute(['id' => $structureId]);
|
$stmt->execute(['id' => $structureId]);
|
||||||
return $stmt->fetch() ?: null;
|
return $stmt->fetch() ?: null;
|
||||||
@@ -344,25 +387,11 @@ final class Access
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT 1
|
'SELECT 1 FROM v_acces_agent_par_structure
|
||||||
FROM agent a
|
WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
|
||||||
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'
|
LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([
|
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
|
||||||
'agent_id' => $agentId,
|
|
||||||
'structure_id_default' => $structureId,
|
|
||||||
'structure_id_planning' => $structureId,
|
|
||||||
]);
|
|
||||||
return $stmt->fetchColumn() !== false;
|
return $stmt->fetchColumn() !== false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ final class Csrf
|
|||||||
{
|
{
|
||||||
public static function ensureToken(): string
|
public static function ensureToken(): string
|
||||||
{
|
{
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
Session::start();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($_SESSION['csrf_token'])) {
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
|||||||
@@ -21,18 +21,14 @@ final class JsonResponse
|
|||||||
});
|
});
|
||||||
|
|
||||||
set_exception_handler(static function (\Throwable $exception): void {
|
set_exception_handler(static function (\Throwable $exception): void {
|
||||||
error_log(sprintf(
|
// Security::reportThrowable journalise systématiquement le détail
|
||||||
'[PTA API] %s: %s in %s:%d',
|
// côté serveur et ne le renvoie au client que si PTA_DEBUG est
|
||||||
get_class($exception),
|
// explicitement activé. Par défaut, rien ne fuit.
|
||||||
$exception->getMessage(),
|
|
||||||
$exception->getFile(),
|
|
||||||
$exception->getLine()
|
|
||||||
));
|
|
||||||
|
|
||||||
$payload = ['error' => 'Une erreur serveur est survenue.'];
|
$payload = ['error' => 'Une erreur serveur est survenue.'];
|
||||||
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
|
if (Security::debugEnabled()) {
|
||||||
$payload['details'] = $exception->getMessage();
|
$payload['details'] = $exception->getMessage();
|
||||||
}
|
}
|
||||||
|
Security::reportThrowable($exception, 'Erreur API.');
|
||||||
|
|
||||||
self::send(500, $payload);
|
self::send(500, $payload);
|
||||||
});
|
});
|
||||||
@@ -42,6 +38,8 @@ final class JsonResponse
|
|||||||
{
|
{
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
|
|||||||
95
app/Core/MotifCategories.php
Normal file
95
app/Core/MotifCategories.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classement métier des motifs de planning, partagé par les modèles et les PDF.
|
||||||
|
*
|
||||||
|
* Le référentiel `motif_planning` distingue le temps de travail effectif (ALP matin,
|
||||||
|
* restauration, ALP soir, mercredi périscolaire, extrascolaire, préparations, ménage de fond…)
|
||||||
|
* du temps décompté mais non travaillé (formation, absence) et du temps hors quota
|
||||||
|
* (congé, autre absence, JNT). Tous ces motifs de travail doivent être présentés
|
||||||
|
* comme des heures travaillées, et pas seulement le motif générique `TRAVAIL`.
|
||||||
|
*/
|
||||||
|
final class MotifCategories
|
||||||
|
{
|
||||||
|
public const TRAVAIL = 'TRAVAIL';
|
||||||
|
public const FORMATION = 'FORMATION';
|
||||||
|
public const CONGE = 'CONGE';
|
||||||
|
public const ABSENCE = 'ABSENCE';
|
||||||
|
public const NON_DECOMPTE = 'NON_DECOMPTE';
|
||||||
|
|
||||||
|
/** Motifs comptant comme du temps de travail effectif. */
|
||||||
|
public const WORK_CODES = [
|
||||||
|
'TRAVAIL',
|
||||||
|
'ALP_MATIN',
|
||||||
|
'RESTAURATION',
|
||||||
|
'ALP_SOIR',
|
||||||
|
'MERCREDI_PERISCOLAIRE',
|
||||||
|
'EXTRASCOLAIRE',
|
||||||
|
'PREPARATION_PERISCOLAIRE',
|
||||||
|
'PREPARATION_EXTRASCOLAIRE',
|
||||||
|
'PREPARATION_LUNDI',
|
||||||
|
'MENAGE_FOND',
|
||||||
|
'JOUR_FRACTIONNEMENT',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Motifs décomptés du quota sans être du temps de travail effectif. */
|
||||||
|
private const FORMATION_CODES = ['FORMATION'];
|
||||||
|
private const ABSENCE_CODES = ['ABSENCE', 'MALADIE'];
|
||||||
|
private const CONGE_CODES = ['CONGE'];
|
||||||
|
private const NON_DECOMPTE_CODES = ['AUTRE_ABSENCE', 'JNT'];
|
||||||
|
|
||||||
|
public static function normalizeCode(?string $code): string
|
||||||
|
{
|
||||||
|
return strtoupper(trim((string) $code));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Motif dont l'affectation dépend d'un droit porté par l'agent : la formation
|
||||||
|
* n'est ouverte qu'aux agents bénéficiant de l'enveloppe annuelle de 14 h
|
||||||
|
* (colonne `droit_enveloppe_formation`, voir AgentModel::hasTrainingRight()).
|
||||||
|
*/
|
||||||
|
public static function requiresTrainingRight(?string $code): bool
|
||||||
|
{
|
||||||
|
return self::normalizeCode($code) === self::FORMATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il
|
||||||
|
* décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté
|
||||||
|
* en base après coup, et il évite qu'un total disparaisse des documents.
|
||||||
|
*/
|
||||||
|
public static function isWork(?string $code, bool $countsInQuota = false): bool
|
||||||
|
{
|
||||||
|
return self::category($code, $countsInQuota) === self::TRAVAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return self::TRAVAIL|self::FORMATION|self::CONGE|self::ABSENCE|self::NON_DECOMPTE
|
||||||
|
*/
|
||||||
|
public static function category(?string $code, bool $countsInQuota = false): string
|
||||||
|
{
|
||||||
|
$normalized = self::normalizeCode($code);
|
||||||
|
|
||||||
|
if (in_array($normalized, self::WORK_CODES, true)) {
|
||||||
|
return self::TRAVAIL;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, self::FORMATION_CODES, true)) {
|
||||||
|
return self::FORMATION;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, self::ABSENCE_CODES, true)) {
|
||||||
|
return self::ABSENCE;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, self::CONGE_CODES, true)) {
|
||||||
|
return self::CONGE;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, self::NON_DECOMPTE_CODES, true)) {
|
||||||
|
return self::NON_DECOMPTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $countsInQuota ? self::TRAVAIL : self::NON_DECOMPTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Core/Security.php
Normal file
89
app/Core/Security.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En-têtes de sécurité des réponses HTML.
|
||||||
|
*
|
||||||
|
* La CSP est nominative : aucun script inline n'est autorisé sans le nonce
|
||||||
|
* généré à chaque requête. PTA ne contient aucun gestionnaire d'événement
|
||||||
|
* inline (onclick=, onchange=…) ni style inline, la politique peut donc rester
|
||||||
|
* stricte sans dégrader l'interface.
|
||||||
|
*/
|
||||||
|
final class Security
|
||||||
|
{
|
||||||
|
private static ?string $nonce = null;
|
||||||
|
|
||||||
|
public static function nonce(): string
|
||||||
|
{
|
||||||
|
if (self::$nonce === null) {
|
||||||
|
self::$nonce = base64_encode(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sendHtmlHeaders(): void
|
||||||
|
{
|
||||||
|
if (headers_sent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonce = self::nonce();
|
||||||
|
$policy = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"style-src 'self'",
|
||||||
|
"font-src 'self'",
|
||||||
|
// Aucune requête sortante depuis le navigateur : les appels à
|
||||||
|
// data.education.gouv.fr sont faits côté serveur.
|
||||||
|
"connect-src 'self'",
|
||||||
|
sprintf("script-src 'self' 'nonce-%s'", $nonce),
|
||||||
|
];
|
||||||
|
|
||||||
|
header('Content-Security-Policy: ' . implode('; ', $policy));
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
header('Referrer-Policy: no-referrer');
|
||||||
|
header('Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()');
|
||||||
|
header('Cross-Origin-Opener-Policy: same-origin');
|
||||||
|
|
||||||
|
if (Session::isHttps()) {
|
||||||
|
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai uniquement si le mode debug a été activé explicitement.
|
||||||
|
* Par défaut, aucun détail technique ne sort du serveur.
|
||||||
|
*/
|
||||||
|
public static function debugEnabled(): bool
|
||||||
|
{
|
||||||
|
return in_array(strtolower((string) (getenv('PTA_DEBUG') ?: '0')), ['1', 'true', 'on', 'yes'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Journalise l'exception côté serveur et renvoie le message à afficher.
|
||||||
|
* Le détail technique n'est rendu que si PTA_DEBUG est explicitement actif.
|
||||||
|
*/
|
||||||
|
public static function reportThrowable(\Throwable $exception, string $publicMessage): string
|
||||||
|
{
|
||||||
|
error_log(sprintf(
|
||||||
|
'[PTA] %s: %s in %s:%d',
|
||||||
|
get_class($exception),
|
||||||
|
$exception->getMessage(),
|
||||||
|
$exception->getFile(),
|
||||||
|
$exception->getLine()
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::debugEnabled()
|
||||||
|
? $publicMessage . ' Détail : ' . $exception->getMessage()
|
||||||
|
: $publicMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Core/Session.php
Normal file
59
app/Core/Session.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarrage unique et durci de la session PHP.
|
||||||
|
*
|
||||||
|
* Les mêmes garanties sont posées ici et dans docker/php/php.ini : le php.ini
|
||||||
|
* ne s'applique qu'au conteneur fourni, alors que ce code protège aussi les
|
||||||
|
* déploiements sur un serveur PHP existant dont la configuration est inconnue.
|
||||||
|
*/
|
||||||
|
final class Session
|
||||||
|
{
|
||||||
|
private static bool $started = false;
|
||||||
|
|
||||||
|
public static function start(): void
|
||||||
|
{
|
||||||
|
if (self::$started || session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
self::$started = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
ini_set('session.use_only_cookies', '1');
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => self::isHttps(),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
self::$started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si la requête courante arrive en HTTPS, directement ou via un proxy
|
||||||
|
* de confiance. Sans cette détection, le cookie de session serait marqué
|
||||||
|
* « secure » sur une installation HTTP et la session ne fonctionnerait plus.
|
||||||
|
*/
|
||||||
|
public static function isHttps(): bool
|
||||||
|
{
|
||||||
|
if (($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,34 @@ use Throwable;
|
|||||||
|
|
||||||
final class AgentModel extends BaseModel
|
final class AgentModel extends BaseModel
|
||||||
{
|
{
|
||||||
|
/** Enveloppe annuelle complémentaire de formation : 14 h. */
|
||||||
|
public const ENVELOPPE_FORMATION_MINUTES = 840;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un agent sans droit à l'enveloppe de formation ne peut pas se voir
|
||||||
|
* affecter le motif « Formation ». Le droit vaut par défaut lorsque
|
||||||
|
* l'information n'est pas projetée : ne jamais retirer un droit sur une
|
||||||
|
* donnée absente.
|
||||||
|
*/
|
||||||
|
public static function hasTrainingRight(array $agent): bool
|
||||||
|
{
|
||||||
|
return (int) ($agent['droit_enveloppe_formation'] ?? 1) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes de formation ouvertes à l'agent sur l'année : l'enveloppe de 14 h
|
||||||
|
* s'il y a droit, rien sinon.
|
||||||
|
*/
|
||||||
|
public static function trainingEnvelopeMinutes(array $agent): int
|
||||||
|
{
|
||||||
|
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
|
||||||
|
}
|
||||||
|
|
||||||
private const AGENT_SELECT = <<<'SQL'
|
private const AGENT_SELECT = <<<'SQL'
|
||||||
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
|
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
|
||||||
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
|
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
|
||||||
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
|
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
|
||||||
a.formation_repartition_annuelle, a.commentaire_pta,
|
a.droit_enveloppe_formation, a.commentaire_pta,
|
||||||
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
|
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
|
||||||
po.famille AS poste_famille, po.heures_mercredi_minutes,
|
po.famille AS poste_famille, po.heures_mercredi_minutes,
|
||||||
po.heures_extrascolaire_minutes, po.autorise_preparation,
|
po.heures_extrascolaire_minutes, po.autorise_preparation,
|
||||||
@@ -129,7 +152,9 @@ final class AgentModel extends BaseModel
|
|||||||
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
|
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
|
||||||
$quotaReference = QuotaModel::REFERENCE_MINUTES;
|
$quotaReference = QuotaModel::REFERENCE_MINUTES;
|
||||||
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
|
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
|
||||||
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840;
|
$trainingEnvelope = self::trainingEnvelopeMinutes([
|
||||||
|
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->pdo->beginTransaction();
|
$this->pdo->beginTransaction();
|
||||||
@@ -139,13 +164,13 @@ final class AgentModel extends BaseModel
|
|||||||
matricule, nom, prenom, email, telephone, adresse,
|
matricule, nom, prenom, email, telephone, adresse,
|
||||||
est_diplome, diplome_libelle, date_debut_contrat,
|
est_diplome, diplome_libelle, date_debut_contrat,
|
||||||
id_poste, type_contrat, date_fin_contrat,
|
id_poste, type_contrat, date_fin_contrat,
|
||||||
formation_repartition_annuelle, commentaire_pta,
|
droit_enveloppe_formation, commentaire_pta,
|
||||||
id_structure, actif
|
id_structure, actif
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:matricule, :nom, :prenom, :email, :telephone, :adresse,
|
:matricule, :nom, :prenom, :email, :telephone, :adresse,
|
||||||
:est_diplome, :diplome_libelle, :date_debut_contrat,
|
:est_diplome, :diplome_libelle, :date_debut_contrat,
|
||||||
:id_poste, :type_contrat, :date_fin_contrat,
|
:id_poste, :type_contrat, :date_fin_contrat,
|
||||||
:formation_repartition_annuelle, :commentaire_pta,
|
:droit_enveloppe_formation, :commentaire_pta,
|
||||||
:structure_id, TRUE
|
:structure_id, TRUE
|
||||||
)'
|
)'
|
||||||
);
|
);
|
||||||
@@ -162,7 +187,7 @@ final class AgentModel extends BaseModel
|
|||||||
'id_poste' => $data['id_poste'],
|
'id_poste' => $data['id_poste'],
|
||||||
'type_contrat' => $data['type_contrat'],
|
'type_contrat' => $data['type_contrat'],
|
||||||
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
|
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
|
||||||
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0,
|
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
|
||||||
'commentaire_pta' => $data['commentaire_pta'],
|
'commentaire_pta' => $data['commentaire_pta'],
|
||||||
'structure_id' => $data['structure_id'],
|
'structure_id' => $data['structure_id'],
|
||||||
]);
|
]);
|
||||||
@@ -260,7 +285,7 @@ final class AgentModel extends BaseModel
|
|||||||
id_poste = :id_poste,
|
id_poste = :id_poste,
|
||||||
type_contrat = :type_contrat,
|
type_contrat = :type_contrat,
|
||||||
date_fin_contrat = :date_fin_contrat,
|
date_fin_contrat = :date_fin_contrat,
|
||||||
formation_repartition_annuelle = :formation_repartition_annuelle,
|
droit_enveloppe_formation = :droit_enveloppe_formation,
|
||||||
commentaire_pta = :commentaire_pta,
|
commentaire_pta = :commentaire_pta,
|
||||||
id_structure = :structure_id
|
id_structure = :structure_id
|
||||||
WHERE id_agent = :agent_id'
|
WHERE id_agent = :agent_id'
|
||||||
@@ -275,7 +300,7 @@ final class AgentModel extends BaseModel
|
|||||||
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
|
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
|
||||||
$update->bindValue(':type_contrat', $data['type_contrat']);
|
$update->bindValue(':type_contrat', $data['type_contrat']);
|
||||||
$update->bindValue(':date_fin_contrat', $data['date_fin_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(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT);
|
||||||
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
|
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
|
||||||
if ($newStructureId === null) {
|
if ($newStructureId === null) {
|
||||||
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
|
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
|
||||||
|
|||||||
183
app/Models/DashboardModel.php
Normal file
183
app/Models/DashboardModel.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrégats de pilotage du tableau de bord.
|
||||||
|
*
|
||||||
|
* Ce modèle ne porte AUCUNE règle métier nouvelle : il ne fait que compter et
|
||||||
|
* additionner ce que les autres écrans affichent déjà en détail. Le calcul du
|
||||||
|
* quota reprend exactement la logique de QuotaModel (année civile, motifs
|
||||||
|
* décomptés uniquement, quota de l'année la plus proche à défaut de l'année
|
||||||
|
* exacte) afin qu'un agent n'affiche jamais deux valeurs différentes selon
|
||||||
|
* l'écran consulté.
|
||||||
|
*/
|
||||||
|
final class DashboardModel extends BaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Suivi du quota annuel de tous les agents actifs, en deux requêtes.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function quotaTracking(int $year): array
|
||||||
|
{
|
||||||
|
$profiles = $this->quotaProfiles($year);
|
||||||
|
$rows = $this->allocatedMinutes($year);
|
||||||
|
$tracking = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$agentId = (int) $row['id_agent'];
|
||||||
|
$profile = $profiles[$agentId] ?? [
|
||||||
|
'quotite_travail' => 100.0,
|
||||||
|
'quota_cible_minutes' => QuotaModel::REFERENCE_MINUTES,
|
||||||
|
'annee' => $year,
|
||||||
|
];
|
||||||
|
|
||||||
|
$target = (int) $profile['quota_cible_minutes'];
|
||||||
|
$validated = (int) $row['minutes_valides'];
|
||||||
|
$draft = (int) $row['minutes_brouillon'];
|
||||||
|
$allocated = $validated + $draft;
|
||||||
|
|
||||||
|
$tracking[] = [
|
||||||
|
'id_agent' => $agentId,
|
||||||
|
'matricule' => (string) ($row['matricule'] ?? ''),
|
||||||
|
'nom' => (string) $row['nom'],
|
||||||
|
'prenom' => (string) $row['prenom'],
|
||||||
|
'structure_nom' => $row['structure_nom'] !== null ? (string) $row['structure_nom'] : null,
|
||||||
|
'poste_libelle' => $row['poste_libelle'] !== null ? (string) $row['poste_libelle'] : null,
|
||||||
|
'quotite_travail' => (float) $profile['quotite_travail'],
|
||||||
|
'quota_cible_minutes' => $target,
|
||||||
|
'quota_source_annee' => (int) $profile['annee'],
|
||||||
|
'quota_estime' => !isset($profiles[$agentId]),
|
||||||
|
'minutes_valides' => $validated,
|
||||||
|
'minutes_brouillon' => $draft,
|
||||||
|
'minutes_affectees' => $allocated,
|
||||||
|
'minutes_restantes' => $target - $allocated,
|
||||||
|
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 1) : 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tracking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes décomptées de l'année civile, par agent actif.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function allocatedMinutes(int $year): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT a.id_agent, a.matricule, a.nom, a.prenom,
|
||||||
|
s.nom AS structure_nom,
|
||||||
|
po.libelle AS poste_libelle,
|
||||||
|
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' AND m.id_motif IS NOT NULL THEN
|
||||||
|
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
|
||||||
|
ELSE 0 END), 0) AS minutes_valides,
|
||||||
|
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' AND m.id_motif IS NOT NULL 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 agent a
|
||||||
|
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||||
|
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
|
||||||
|
LEFT JOIN planning p ON p.id_agent = a.id_agent
|
||||||
|
LEFT JOIN creneau_horaire c
|
||||||
|
ON c.id_planning = p.id_planning
|
||||||
|
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||||||
|
LEFT JOIN motif_planning m
|
||||||
|
ON m.id_motif = c.id_motif
|
||||||
|
AND m.compte_dans_quota = TRUE
|
||||||
|
WHERE a.actif = TRUE
|
||||||
|
GROUP BY a.id_agent, a.matricule, a.nom, a.prenom, s.nom, po.libelle
|
||||||
|
ORDER BY a.nom, a.prenom"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'date_debut' => sprintf('%04d-01-01', $year),
|
||||||
|
'date_fin' => sprintf('%04d-12-31', $year),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quota retenu pour chaque agent : année exacte si elle existe, sinon
|
||||||
|
* l'année passée la plus proche, sinon l'année future la plus proche.
|
||||||
|
* Même ordre de préférence que QuotaModel::quotaProfile().
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function quotaProfiles(int $year): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT id_agent, annee, quotite_travail, quota_cible_minutes
|
||||||
|
FROM (
|
||||||
|
SELECT q.id_agent, q.annee, q.quotite_travail, q.quota_cible_minutes,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY q.id_agent
|
||||||
|
ORDER BY CASE
|
||||||
|
WHEN q.annee = :year_exact THEN 0
|
||||||
|
WHEN q.annee < :year_past THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
CASE WHEN q.annee <= :year_latest THEN q.annee END DESC,
|
||||||
|
CASE WHEN q.annee > :year_future THEN q.annee END ASC
|
||||||
|
) AS rang
|
||||||
|
FROM quota_agent_annuel q
|
||||||
|
) AS classement
|
||||||
|
WHERE rang = 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'year_exact' => $year,
|
||||||
|
'year_past' => $year,
|
||||||
|
'year_latest' => $year,
|
||||||
|
'year_future' => $year,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profiles = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$profiles[(int) $row['id_agent']] = [
|
||||||
|
'annee' => (int) $row['annee'],
|
||||||
|
'quotite_travail' => (float) $row['quotite_travail'],
|
||||||
|
'quota_cible_minutes' => (int) $row['quota_cible_minutes'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre de brouillons contenant au moins une affectation.
|
||||||
|
* Requête volontairement légère : elle alimente la pastille de navigation
|
||||||
|
* rechargée sur chaque page.
|
||||||
|
*/
|
||||||
|
public function pendingCount(): int
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->query(
|
||||||
|
"SELECT COUNT(*)
|
||||||
|
FROM (
|
||||||
|
SELECT p.id_planning
|
||||||
|
FROM planning p
|
||||||
|
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||||
|
WHERE p.statut = 'BROUILLON'
|
||||||
|
GROUP BY p.id_planning
|
||||||
|
) AS brouillons_servis"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre d'agents actifs et de lieux actifs, pour situer les compteurs.
|
||||||
|
*
|
||||||
|
* @return array{agents: int, structures: int}
|
||||||
|
*/
|
||||||
|
public function scope(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'agents' => (int) $this->pdo->query('SELECT COUNT(*) FROM agent WHERE actif = TRUE')->fetchColumn(),
|
||||||
|
'structures' => (int) $this->pdo->query('SELECT COUNT(*) FROM structure WHERE actif = TRUE')->fetchColumn(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\MotifCategories;
|
||||||
|
|
||||||
final class MotifModel extends BaseModel
|
final class MotifModel extends BaseModel
|
||||||
{
|
{
|
||||||
public function allActive(): array
|
public function allActive(): array
|
||||||
@@ -23,4 +25,29 @@ final class MotifModel extends BaseModel
|
|||||||
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
|
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Motifs réellement affectables à un agent : « Formation » disparaît de la
|
||||||
|
* liste dès lors que l'agent n'a pas droit à l'enveloppe de formation.
|
||||||
|
*/
|
||||||
|
public function allActiveForAgent(array $agent): array
|
||||||
|
{
|
||||||
|
if (AgentModel::hasTrainingRight($agent)) {
|
||||||
|
return $this->allActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->allActive(),
|
||||||
|
static fn(array $motif): bool => !MotifCategories::requiresTrainingRight((string) $motif['code'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] */
|
||||||
|
public function activeIdsForAgent(array $agent): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn(array $motif): int => (int) $motif['id_motif'],
|
||||||
|
$this->allActiveForAgent($agent)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\MotifCategories;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DatePeriod;
|
use DatePeriod;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -12,12 +13,8 @@ use RuntimeException;
|
|||||||
|
|
||||||
final class PtaModel extends BaseModel
|
final class PtaModel extends BaseModel
|
||||||
{
|
{
|
||||||
private const WORK_CODES = [
|
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
|
||||||
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR',
|
private const WORK_CODES = MotifCategories::WORK_CODES;
|
||||||
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
|
|
||||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
|
|
||||||
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
|
|
||||||
];
|
|
||||||
|
|
||||||
private const PREPARATION_CODES = [
|
private const PREPARATION_CODES = [
|
||||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
||||||
@@ -148,7 +145,7 @@ final class PtaModel extends BaseModel
|
|||||||
return $pta;
|
return $pta;
|
||||||
}
|
}
|
||||||
|
|
||||||
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
$training = AgentModel::trainingEnvelopeMinutes($agent);
|
||||||
$insert = $this->pdo->prepare(
|
$insert = $this->pdo->prepare(
|
||||||
"INSERT INTO pta_annuel (
|
"INSERT INTO pta_annuel (
|
||||||
id_agent, date_debut, date_fin, quotite_travail,
|
id_agent, date_debut, date_fin, quotite_travail,
|
||||||
@@ -381,7 +378,13 @@ final class PtaModel extends BaseModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
$formationMinutes = $categories['FORMATION'];
|
$formationMinutes = $categories['FORMATION'];
|
||||||
$formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
$formationTarget = AgentModel::trainingEnvelopeMinutes($agent);
|
||||||
|
if (!AgentModel::hasTrainingRight($agent) && $formationMinutes > 0) {
|
||||||
|
$controls[] = $this->control('ALERTE', 'FORMATION_SANS_DROIT', sprintf(
|
||||||
|
'Formation : %s planifiées alors que l’agent n’a pas droit à l’enveloppe de formation. Ces plages sont à retirer.',
|
||||||
|
$this->formatMinutes($formationMinutes)
|
||||||
|
));
|
||||||
|
}
|
||||||
if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
|
if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
|
||||||
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
|
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
|
||||||
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
|
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ final class SchemaModel extends BaseModel
|
|||||||
$requiredColumns = [
|
$requiredColumns = [
|
||||||
'creneau_horaire' => ['id_motif'],
|
'creneau_horaire' => ['id_motif'],
|
||||||
'planning' => ['date_validation'],
|
'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'],
|
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
|
||||||
'structure' => ['type_affectation'],
|
'structure' => ['type_affectation'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -78,5 +78,43 @@ final class SchemaModel extends BaseModel
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertAccessViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les vues d'accès portent l'intégralité des règles de droits. Si elles
|
||||||
|
* manquent, l'application doit refuser de démarrer plutôt que de tourner
|
||||||
|
* sans contrôle de périmètre.
|
||||||
|
*/
|
||||||
|
private function assertAccessViews(): void
|
||||||
|
{
|
||||||
|
$requiredViews = [
|
||||||
|
'v_acces_role_page',
|
||||||
|
'v_acces_role_permission',
|
||||||
|
'v_acces_identite_agent',
|
||||||
|
'v_acces_identite_structure',
|
||||||
|
'v_acces_agent_par_structure',
|
||||||
|
'v_acces_perimetre_creneau',
|
||||||
|
'v_acces_perimetre_planning',
|
||||||
|
];
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT TABLE_NAME
|
||||||
|
FROM information_schema.VIEWS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME IN ($placeholders)"
|
||||||
|
);
|
||||||
|
$stmt->execute($requiredViews);
|
||||||
|
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
|
||||||
|
|
||||||
|
if ($missing) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Contrôle d’accès indisponible : vue(s) manquante(s) : '
|
||||||
|
. implode(', ', $missing)
|
||||||
|
. '. Exécutez database/migration_v7_8_securite_acces.sql.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\MotifCategories;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
|
|
||||||
@@ -66,11 +67,11 @@ final class AgentTimeSummaryPdfService
|
|||||||
$rowH = 43.0;
|
$rowH = 43.0;
|
||||||
|
|
||||||
$rows = [
|
$rows = [
|
||||||
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']],
|
[MotifCategories::TRAVAIL, 'Heures travaillées', $totals['TRAVAIL'], $totals['DETAIL_TRAVAIL']],
|
||||||
['MALADIE', 'Absence', $totals['MALADIE']],
|
[MotifCategories::ABSENCE, 'Absence', $totals['ABSENCE'], []],
|
||||||
['FORMATION', 'Formation', $totals['FORMATION']],
|
[MotifCategories::FORMATION, 'Formation', $totals['FORMATION'], []],
|
||||||
['CONGE', 'Congé', $totals['CONGE']],
|
[MotifCategories::CONGE, 'Congé', $totals['CONGE'], []],
|
||||||
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']],
|
[MotifCategories::NON_DECOMPTE, 'Absence (non décomptée)', $totals['NON_DECOMPTE'], []],
|
||||||
];
|
];
|
||||||
|
|
||||||
$pdf->fillRect($x, $y, $w, $headerH, 0.92);
|
$pdf->fillRect($x, $y, $w, $headerH, 0.92);
|
||||||
@@ -78,19 +79,75 @@ final class AgentTimeSummaryPdfService
|
|||||||
$pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
|
$pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
|
||||||
|
|
||||||
$cursorY = $y + $headerH;
|
$cursorY = $y + $headerH;
|
||||||
foreach ($rows as [$code, $label, $minutes]) {
|
foreach ($rows as [$code, $label, $minutes, $detail]) {
|
||||||
$palette = $this->palette($code);
|
$palette = $this->palette($code);
|
||||||
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
|
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
|
||||||
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
|
$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);
|
$detailLines = $detail === [] ? [] : $this->detailLines($pdf, $detail, $w - 174);
|
||||||
|
if ($detailLines === []) {
|
||||||
|
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
|
||||||
|
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true);
|
||||||
|
} else {
|
||||||
|
$pdf->text($x + 32, $cursorY + 19, $label, 9.4, true);
|
||||||
|
$pdf->text($x + $w - 130, $cursorY + 19, $this->minutesLabel($minutes), 10, true);
|
||||||
|
$detailY = $cursorY + 30;
|
||||||
|
foreach ($detailLines as $line) {
|
||||||
|
$pdf->text($x + 32, $detailY, $line, 7);
|
||||||
|
$detailY += 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$cursorY += $rowH;
|
$cursorY += $rowH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détaille les motifs regroupés dans les heures travaillées (ALP matin, ALP soir,
|
||||||
|
* restauration, préparations…), sur deux lignes au maximum.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $detail
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function detailLines(SimplePdf $pdf, array $detail, float $width): array
|
||||||
|
{
|
||||||
|
arsort($detail);
|
||||||
|
$parts = [];
|
||||||
|
foreach ($detail as $label => $minutes) {
|
||||||
|
if ($minutes > 0) {
|
||||||
|
$parts[] = $label . ' ' . $this->minutesLabel($minutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parts === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les motifs sont ajoutés un par un : la coupure tombe ainsi entre deux motifs
|
||||||
|
// et jamais au milieu d'un libellé, en gardant la place des points de suspension.
|
||||||
|
$text = '';
|
||||||
|
$truncated = false;
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$candidate = $text === '' ? 'Dont ' . $part : $text . ' · ' . $part;
|
||||||
|
if (count($pdf->wrap($candidate . ' …', $width, 7)) > 2) {
|
||||||
|
$truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$text = $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdf->wrap($truncated ? $text . ' …' : $text, $width, 7);
|
||||||
|
}
|
||||||
|
|
||||||
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
|
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
|
||||||
{
|
{
|
||||||
$counted = $totals['TRAVAIL'] + $totals['MALADIE'] + $totals['FORMATION'] + $totals['CONGE'] + $totals['OTHER_COUNTED'];
|
// Le décompte suit le drapeau compte_dans_quota du motif, comme QuotaModel :
|
||||||
|
// additionner les catégories affichées y ferait entrer les congés, hors quota.
|
||||||
|
$counted = (int) $totals['DECOMPTE'];
|
||||||
$target = (int) ($quota['quota_cible_minutes'] ?? 0);
|
$target = (int) ($quota['quota_cible_minutes'] ?? 0);
|
||||||
$remaining = $target - $counted;
|
$remaining = $target - $counted;
|
||||||
$validated = (int) ($quota['minutes_valides'] ?? 0);
|
$validated = (int) ($quota['minutes_valides'] ?? 0);
|
||||||
@@ -135,28 +192,38 @@ final class AgentTimeSummaryPdfService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regroupe les minutes par catégorie métier. Tous les motifs de travail effectif
|
||||||
|
* (ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire,
|
||||||
|
* préparations, ménage de fond…) alimentent la ligne « Heures travaillées », et pas
|
||||||
|
* seulement le motif générique TRAVAIL.
|
||||||
|
*/
|
||||||
private function normalizeTotals(array $rows): array
|
private function normalizeTotals(array $rows): array
|
||||||
{
|
{
|
||||||
$totals = [
|
$totals = [
|
||||||
'TRAVAIL' => 0,
|
MotifCategories::TRAVAIL => 0,
|
||||||
'MALADIE' => 0,
|
MotifCategories::ABSENCE => 0,
|
||||||
'FORMATION' => 0,
|
MotifCategories::FORMATION => 0,
|
||||||
'CONGE' => 0,
|
MotifCategories::CONGE => 0,
|
||||||
'AUTRE' => 0,
|
MotifCategories::NON_DECOMPTE => 0,
|
||||||
'OTHER_COUNTED' => 0,
|
'DECOMPTE' => 0,
|
||||||
|
'DETAIL_TRAVAIL' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE'));
|
$code = MotifCategories::normalizeCode($row['motif_code'] ?? null);
|
||||||
$minutes = (int) ($row['minutes_total'] ?? 0);
|
$minutes = (int) ($row['minutes_total'] ?? 0);
|
||||||
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') {
|
$counts = (int) ($row['compte_dans_quota'] ?? 0) === 1;
|
||||||
$totals[$code] += $minutes;
|
$category = MotifCategories::category($code, $counts);
|
||||||
continue;
|
|
||||||
|
$totals[$category] += $minutes;
|
||||||
|
if ($counts) {
|
||||||
|
$totals['DECOMPTE'] += $minutes;
|
||||||
}
|
}
|
||||||
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
|
|
||||||
$totals['OTHER_COUNTED'] += $minutes;
|
if ($category === MotifCategories::TRAVAIL && $minutes > 0) {
|
||||||
} else {
|
$label = trim((string) ($row['motif_libelle'] ?? '')) ?: $code;
|
||||||
$totals['AUTRE'] += $minutes;
|
$totals['DETAIL_TRAVAIL'][$label] = ($totals['DETAIL_TRAVAIL'][$label] ?? 0) + $minutes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,10 +234,10 @@ final class AgentTimeSummaryPdfService
|
|||||||
private function palette(string $code): array
|
private function palette(string $code): array
|
||||||
{
|
{
|
||||||
return match (strtoupper($code)) {
|
return match (strtoupper($code)) {
|
||||||
'TRAVAIL' => ['accent' => '#2563EB'],
|
MotifCategories::TRAVAIL => ['accent' => '#2563EB'],
|
||||||
'FORMATION' => ['accent' => '#7C3AED'],
|
MotifCategories::FORMATION => ['accent' => '#7C3AED'],
|
||||||
'CONGE' => ['accent' => '#059669'],
|
MotifCategories::CONGE => ['accent' => '#059669'],
|
||||||
'MALADIE' => ['accent' => '#DC2626'],
|
MotifCategories::ABSENCE, 'MALADIE' => ['accent' => '#DC2626'],
|
||||||
default => ['accent' => '#64748B'],
|
default => ['accent' => '#64748B'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\MotifCategories;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
final class PlanningPdfService
|
final class PlanningPdfService
|
||||||
@@ -170,7 +171,7 @@ final class PlanningPdfService
|
|||||||
$blockY = $y + 5;
|
$blockY = $y + 5;
|
||||||
foreach ($blocks as $block) {
|
foreach ($blocks as $block) {
|
||||||
$entry = $block['entry'];
|
$entry = $block['entry'];
|
||||||
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
|
$palette = $this->entryPalette($entry);
|
||||||
$blockHeight = (float) $block['height'];
|
$blockHeight = (float) $block['height'];
|
||||||
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
|
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
|
||||||
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
|
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
|
||||||
@@ -269,7 +270,7 @@ final class PlanningPdfService
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
|
$palette = $this->entryPalette($entry);
|
||||||
$pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']);
|
$pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']);
|
||||||
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
|
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
|
||||||
|
|
||||||
@@ -320,21 +321,35 @@ final class PlanningPdfService
|
|||||||
return $pdf->wrap($first, $width, 7.0);
|
return $pdf->wrap($first, $width, 7.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le motif générique TRAVAIL n'apporte aucune information sur une plage déjà colorée
|
||||||
|
* en « temps de travail » ; les motifs de travail spécialisés (ALP matin, ALP soir,
|
||||||
|
* restauration…) restent affichés, ils précisent la nature du temps travaillé.
|
||||||
|
*/
|
||||||
private function motifDisplayLabel(array $entry): string
|
private function motifDisplayLabel(array $entry): string
|
||||||
{
|
{
|
||||||
return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL'
|
return MotifCategories::normalizeCode($entry['motif_code'] ?? null) === 'TRAVAIL'
|
||||||
? ''
|
? ''
|
||||||
: (string) ($entry['motif_libelle'] ?? 'Affectation');
|
: (string) ($entry['motif_libelle'] ?? 'Affectation');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{accent:string,background:string,label:string} */
|
/** @return array{accent:string,background:string,label:string} */
|
||||||
private function motifPalette(string $code): array
|
private function entryPalette(array $entry): array
|
||||||
{
|
{
|
||||||
return match (strtoupper($code)) {
|
return $this->motifPalette(
|
||||||
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
|
(string) ($entry['motif_code'] ?? 'AUTRE'),
|
||||||
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
|
(int) ($entry['compte_dans_quota'] ?? 0) === 1
|
||||||
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
|
);
|
||||||
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
|
}
|
||||||
|
|
||||||
|
/** @return array{accent:string,background:string,label:string} */
|
||||||
|
private function motifPalette(string $code, bool $countsInQuota = false): array
|
||||||
|
{
|
||||||
|
return match (MotifCategories::category($code, $countsInQuota)) {
|
||||||
|
MotifCategories::TRAVAIL => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
|
||||||
|
MotifCategories::FORMATION => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
|
||||||
|
MotifCategories::CONGE => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
|
||||||
|
MotifCategories::ABSENCE => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
|
||||||
default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'],
|
default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -342,18 +357,20 @@ final class PlanningPdfService
|
|||||||
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
|
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
|
||||||
{
|
{
|
||||||
$items = [
|
$items = [
|
||||||
['TRAVAIL', 'Travail'],
|
['TRAVAIL', 'Travail (dont ALP, restauration, preparation)'],
|
||||||
['FORMATION', 'Formation'],
|
['FORMATION', 'Formation'],
|
||||||
['CONGE', 'Conge'],
|
['CONGE', 'Conge'],
|
||||||
['MALADIE', 'Absence'],
|
['ABSENCE', 'Absence'],
|
||||||
['AUTRE', 'Autre absence'],
|
['JNT', 'Autre absence'],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($items as [$code, $label]) {
|
foreach ($items as [$code, $label]) {
|
||||||
$palette = $this->motifPalette($code);
|
$palette = $this->motifPalette($code);
|
||||||
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
|
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
|
||||||
$pdf->text($x + 12, $y, $label, 6.5);
|
$pdf->text($x + 12, $y, $label, 6.5);
|
||||||
$x += 76;
|
// Largeur estimée du libellé (Helvetica ~0,52 em) plutôt qu'un pas fixe :
|
||||||
|
// la première entrée est trop longue pour une colonne de 76 points.
|
||||||
|
$x += 12 + (mb_strlen($label) * 6.5 * 0.52) + 18;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
/** @var array $pageConfig */
|
/** @var array $pageConfig */
|
||||||
/** @var array $accessProfile */
|
/** @var array $accessProfile */
|
||||||
/** @var array $permissions */
|
/** @var array $permissions */
|
||||||
|
/** @var string $cspNonce */
|
||||||
?>
|
?>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||||
window.PTA_CONFIG = {
|
window.PTA_CONFIG = {
|
||||||
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
||||||
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||||
@@ -27,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
|
|||||||
'params' => $pageConfig,
|
'params' => $pageConfig,
|
||||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/core.js?v=7.7"></script>
|
<script src="assets/js/core.js?v=7.14"></script>
|
||||||
<?php foreach ($scripts as $script): ?>
|
<?php foreach ($scripts as $script): ?>
|
||||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
|
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.14"></script>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
|
<script src="assets/js/page-bootstrap.js?v=7.14"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,21 +1,73 @@
|
|||||||
<?php
|
<?php
|
||||||
/** @var string $pageTitle */
|
/** @var string $pageTitle */
|
||||||
|
/** @var string $pageSubtitle */
|
||||||
/** @var string $activePage */
|
/** @var string $activePage */
|
||||||
/** @var array $allowedPages */
|
/** @var array $allowedPages */
|
||||||
/** @var array $accessProfile */
|
/** @var array $accessProfile */
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
|
$isAgent = ($accessProfile['role'] ?? '') === 'AGENT';
|
||||||
|
|
||||||
$navigation = [
|
$navigation = [
|
||||||
|
'dashboard' => ['dashboard.php', 'Tableau de bord'],
|
||||||
'planning' => ['planning.php', 'Saisie'],
|
'planning' => ['planning.php', 'Saisie'],
|
||||||
'pending' => ['pending.php', 'À valider'],
|
'pending' => ['pending.php', 'À valider'],
|
||||||
'coverage' => ['coverage.php', 'Couverture'],
|
'coverage' => ['coverage.php', 'Couverture'],
|
||||||
'structures' => ['structures.php', 'Par lieu'],
|
'structures' => ['structures.php', 'Par lieu'],
|
||||||
'agents' => ['agents.php', ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Par agent'],
|
'agents' => ['agents.php', $isAgent ? 'Mon planning' : 'Par agent'],
|
||||||
'annual' => ['annual.php', 'Vue annuelle'],
|
'annual' => ['annual.php', 'Vue annuelle'],
|
||||||
'pta' => ['pta.php', 'PTA détaillé'],
|
'pta' => ['pta.php', 'PTA détaillé'],
|
||||||
'teams' => ['teams.php', 'Équipes'],
|
'teams' => ['teams.php', 'Équipes'],
|
||||||
'vacations' => ['vacations.php', 'Vacances'],
|
'vacations' => ['vacations.php', 'Vacances'],
|
||||||
'administration' => ['administration.php', 'Administration'],
|
'administration' => ['administration.php', 'Administration'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les onze onglets du Service Enfance étaient alignés à plat, sans hiérarchie :
|
||||||
|
* rien ne distinguait une tâche quotidienne d'un référentiel modifié deux fois
|
||||||
|
* par an. Ils sont désormais regroupés par intention. Les rôles qui n'ont que
|
||||||
|
* quelques pages gardent une barre simple, sans titres de groupe.
|
||||||
|
*/
|
||||||
|
$navigationGroups = [
|
||||||
|
'Piloter' => ['dashboard', 'pending', 'coverage'],
|
||||||
|
'Planifier' => ['planning', 'teams'],
|
||||||
|
'Consulter' => ['agents', 'structures', 'annual', 'pta'],
|
||||||
|
'Paramétrer' => ['vacations', 'administration'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$visibleGroups = [];
|
||||||
|
$grouped = [];
|
||||||
|
foreach ($navigationGroups as $groupLabel => $keys) {
|
||||||
|
$visibleKeys = array_values(array_filter(
|
||||||
|
$keys,
|
||||||
|
static fn (string $key): bool => in_array($key, $allowedPages, true) && isset($navigation[$key])
|
||||||
|
));
|
||||||
|
if ($visibleKeys !== []) {
|
||||||
|
$visibleGroups[$groupLabel] = $visibleKeys;
|
||||||
|
$grouped = array_merge($grouped, $visibleKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filet de sécurité : une page autorisée en base mais oubliée dans les groupes
|
||||||
|
// ci-dessus reste accessible plutôt que de disparaître de la navigation.
|
||||||
|
$ungrouped = array_values(array_filter(
|
||||||
|
$allowedPages,
|
||||||
|
static fn (string $key): bool => isset($navigation[$key]) && !in_array($key, $grouped, true)
|
||||||
|
));
|
||||||
|
if ($ungrouped !== []) {
|
||||||
|
$visibleGroups['Autres'] = $ungrouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visiblePageCount = count($grouped) + count($ungrouped);
|
||||||
|
$showGroupLabels = $visiblePageCount > 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le guide n'appartient à aucun groupe d'intention : ce n'est pas une étape du
|
||||||
|
* travail mais un secours permanent. Il est volontairement absent de
|
||||||
|
* $navigation — il ne doit apparaître qu'une seule fois, ancré à droite de la
|
||||||
|
* barre, et jamais repêché par le filet « Autres ».
|
||||||
|
*/
|
||||||
|
$showHelpTab = in_array('faq', $allowedPages, true);
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
@@ -23,15 +75,15 @@ $navigation = [
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
||||||
<link rel="stylesheet" href="assets/style.css?v=7.7">
|
<link rel="stylesheet" href="assets/style.css?v=7.14">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div class="page-header-identity">
|
||||||
<p class="eyebrow">PTA</p>
|
<p class="eyebrow">PTA</p>
|
||||||
<h1><?= htmlspecialchars($pageTitle) ?></h1>
|
<h1><?= htmlspecialchars($pageTitle) ?></h1>
|
||||||
<p class="subtitle">Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.</p>
|
<p class="subtitle"><?= htmlspecialchars($pageSubtitle ?? '') ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<?php if ($activePage === 'planning'): ?>
|
<?php if ($activePage === 'planning'): ?>
|
||||||
@@ -40,11 +92,11 @@ $navigation = [
|
|||||||
<div class="access-profile-badge">
|
<div class="access-profile-badge">
|
||||||
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
|
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
|
||||||
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
|
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
|
||||||
<?php if (!empty($accessProfile['structure_name']) && ($accessProfile['role'] ?? '') === 'AGENT'): ?>
|
<?php if (!empty($accessProfile['structure_name']) && $isAgent): ?>
|
||||||
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
|
<a class="button button-ghost switch-role-link" href="role.php?action=reset&csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Changer de rôle</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -52,12 +104,29 @@ $navigation = [
|
|||||||
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
|
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<nav class="view-tabs" aria-label="Navigation principale">
|
<nav class="view-tabs<?= $showGroupLabels ? ' view-tabs-grouped' : '' ?>" aria-label="Navigation principale">
|
||||||
<?php foreach ($navigation as $key => [$href, $label]): ?>
|
<?php foreach ($visibleGroups as $groupLabel => $keys): ?>
|
||||||
<?php if (!in_array($key, $allowedPages, true)) continue; ?>
|
<div class="view-tab-group" role="group"<?= $showGroupLabels ? ' aria-label="' . htmlspecialchars($groupLabel, ENT_QUOTES) . '"' : '' ?>>
|
||||||
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
|
<?php if ($showGroupLabels): ?>
|
||||||
<?= htmlspecialchars($label) ?>
|
<span class="view-tab-group-label"><?= htmlspecialchars($groupLabel) ?></span>
|
||||||
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
|
<?php endif; ?>
|
||||||
</a>
|
<div class="view-tab-group-items">
|
||||||
|
<?php foreach ($keys as $key): ?>
|
||||||
|
<?php [$href, $label] = $navigation[$key]; ?>
|
||||||
|
<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; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
<?php if ($showHelpTab): ?>
|
||||||
|
<div class="view-tab-help">
|
||||||
|
<a class="view-tab view-tab-faq<?= $activePage === 'faq' ? ' is-active' : '' ?>" href="faq.php"<?= $activePage === 'faq' ? ' aria-current="page"' : '' ?>>
|
||||||
|
<span class="view-tab-faq-mark" aria-hidden="true">?</span>
|
||||||
|
FAQ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
4
app/Views/pages/dashboard.php
Normal file
4
app/Views/pages/dashboard.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/dashboard.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
4
app/Views/pages/faq.php
Normal file
4
app/Views/pages/faq.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require dirname(__DIR__) . '/layouts/header.php';
|
||||||
|
require dirname(__DIR__) . '/partials/faq.php';
|
||||||
|
require dirname(__DIR__) . '/layouts/footer.php';
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
/** @var array $structures */
|
/** @var array $structures */
|
||||||
/** @var string $error */
|
/** @var string $error */
|
||||||
/** @var array|null $currentProfile */
|
/** @var array|null $currentProfile */
|
||||||
|
/** @var string $cspNonce */
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Choisir votre accès — PTA</title>
|
<title>Choisir votre accès — PTA</title>
|
||||||
<link rel="stylesheet" href="assets/style.css?v=7.4">
|
<link rel="stylesheet" href="assets/style.css?v=7.14">
|
||||||
</head>
|
</head>
|
||||||
<body class="role-page-body">
|
<body class="role-page-body">
|
||||||
<main class="role-page-shell">
|
<main class="role-page-shell">
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
<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é.
|
<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>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||||
(() => {
|
(() => {
|
||||||
const agentContext = document.querySelector('#agent-context');
|
const agentContext = document.querySelector('#agent-context');
|
||||||
const responsableContext = document.querySelector('#responsable-context');
|
const responsableContext = document.querySelector('#responsable-context');
|
||||||
|
|||||||
@@ -123,8 +123,8 @@
|
|||||||
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
|
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
|
||||||
</label>
|
</label>
|
||||||
<label class="check-field admin-field-wide">
|
<label class="check-field admin-field-wide">
|
||||||
<input id="new-agent-training-planned" type="checkbox">
|
<input id="new-agent-training-right" type="checkbox" checked>
|
||||||
<span>Une formation est déjà prévue dans la répartition annuelle : ne pas ajouter l’enveloppe complémentaire de 14 h</span>
|
<span>Droit à l’enveloppe de formation de 14 h. Décoché, le motif « Formation » ne pourra pas être affecté à cet agent lors de la saisie des horaires.</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="field admin-field-wide">
|
<label class="field admin-field-wide">
|
||||||
<span>Observations utiles à la construction du PTA</span>
|
<span>Observations utiles à la construction du PTA</span>
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
|
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
|
||||||
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
|
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
|
||||||
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
|
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
|
||||||
data-formation-repartition-annuelle="<?= !empty($agent['formation_repartition_annuelle']) ? '1' : '0' ?>"
|
data-droit-enveloppe-formation="<?= !isset($agent['droit_enveloppe_formation']) || !empty($agent['droit_enveloppe_formation']) ? '1' : '0' ?>"
|
||||||
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
|
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
|
||||||
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
|
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
|
||||||
>
|
>
|
||||||
@@ -326,8 +326,8 @@
|
|||||||
<input id="edit-agent-profile-contract-end" type="date">
|
<input id="edit-agent-profile-contract-end" type="date">
|
||||||
</label>
|
</label>
|
||||||
<label class="check-field admin-field-wide">
|
<label class="check-field admin-field-wide">
|
||||||
<input id="edit-agent-profile-training-planned" type="checkbox">
|
<input id="edit-agent-profile-training-right" type="checkbox">
|
||||||
<span>Formation déjà intégrée dans la répartition annuelle</span>
|
<span>Droit à l’enveloppe de formation de 14 h</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="field admin-field-wide">
|
<label class="field admin-field-wide">
|
||||||
<span>Observations PTA</span>
|
<span>Observations PTA</span>
|
||||||
|
|||||||
154
app/Views/partials/dashboard.php
Normal file
154
app/Views/partials/dashboard.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
/** @var string $currentWeek */
|
||||||
|
?>
|
||||||
|
<section id="dashboard-view" class="app-view page-content dashboard-page">
|
||||||
|
|
||||||
|
<section class="card dashboard-context-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Semaine de travail</h2>
|
||||||
|
<p class="section-subtitle" id="dashboard-week-label">Chargement de la semaine en cours…</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-context-actions">
|
||||||
|
<label class="field dashboard-week-field">
|
||||||
|
<span>Semaine analysée</span>
|
||||||
|
<input id="dashboard-week" type="week" value="<?= htmlspecialchars($currentWeek) ?>">
|
||||||
|
</label>
|
||||||
|
<button id="dashboard-refresh" class="button button-secondary" type="button">Actualiser</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="dashboard-context-note" id="dashboard-week-context" hidden></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="dashboard-message" class="message" role="status" aria-live="polite"></div>
|
||||||
|
<div id="dashboard-loading" class="empty-state" hidden>Consolidation des indicateurs…</div>
|
||||||
|
|
||||||
|
<div class="dashboard-tiles" aria-live="polite">
|
||||||
|
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-pending" href="pending.php">
|
||||||
|
<span class="dashboard-tile-label">Plannings à valider</span>
|
||||||
|
<strong class="dashboard-tile-value" id="dashboard-pending-count">—</strong>
|
||||||
|
<small class="dashboard-tile-detail" id="dashboard-pending-detail">Brouillons contenant des affectations</small>
|
||||||
|
<span class="dashboard-tile-cta">Ouvrir la file de validation</span>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-coverage" href="coverage.php">
|
||||||
|
<span class="dashboard-tile-label">Manques de couverture</span>
|
||||||
|
<strong class="dashboard-tile-value" id="dashboard-coverage-count">—</strong>
|
||||||
|
<small class="dashboard-tile-detail" id="dashboard-coverage-detail">Sur la semaine analysée</small>
|
||||||
|
<span class="dashboard-tile-cta">Voir le détail des plages</span>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-quota" href="annual.php">
|
||||||
|
<span class="dashboard-tile-label">Quotas dépassés</span>
|
||||||
|
<strong class="dashboard-tile-value" id="dashboard-quota-count">—</strong>
|
||||||
|
<small class="dashboard-tile-detail" id="dashboard-quota-detail">Agents au-delà de leur quota annuel</small>
|
||||||
|
<span class="dashboard-tile-cta">Ouvrir la vue annuelle</span>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-setup" href="administration.php">
|
||||||
|
<span class="dashboard-tile-label">Lieux à paramétrer</span>
|
||||||
|
<strong class="dashboard-tile-value" id="dashboard-setup-count">—</strong>
|
||||||
|
<small class="dashboard-tile-detail" id="dashboard-setup-detail">Sans horaires de couverture attendus</small>
|
||||||
|
<span class="dashboard-tile-cta">Ouvrir l’administration</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-columns">
|
||||||
|
<section class="card dashboard-list-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>À valider en priorité</h2>
|
||||||
|
<p class="section-subtitle">Les brouillons dont la semaine est la plus ancienne.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-secondary" href="pending.php">Tout voir</a>
|
||||||
|
</div>
|
||||||
|
<div id="dashboard-pending-empty" class="empty-state" hidden>Aucun planning n’attend de validation.</div>
|
||||||
|
<ul class="dashboard-list" id="dashboard-pending-list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card dashboard-list-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Couverture à renforcer</h2>
|
||||||
|
<p class="section-subtitle">Les plages les plus déficitaires de la semaine analysée.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-secondary" id="dashboard-coverage-link" href="coverage.php">Tout voir</a>
|
||||||
|
</div>
|
||||||
|
<div id="dashboard-coverage-empty" class="empty-state" hidden>Aucun manque de couverture sur cette semaine.</div>
|
||||||
|
<ul class="dashboard-list" id="dashboard-coverage-list"></ul>
|
||||||
|
<div id="dashboard-coverage-unconfigured" class="dashboard-inline-note" hidden></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card dashboard-quota-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Suivi des quotas annuels</h2>
|
||||||
|
<p class="section-subtitle" id="dashboard-quota-subtitle">Heures affectées, validées et brouillon comprises, sur l’année civile.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-secondary" href="annual.php">Vue annuelle détaillée</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-quota-summary">
|
||||||
|
<article class="summary-card">
|
||||||
|
<span>Agents suivis</span>
|
||||||
|
<strong id="dashboard-quota-agents">—</strong>
|
||||||
|
<small>actifs</small>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<span>Sans aucune heure</span>
|
||||||
|
<strong id="dashboard-quota-empty">—</strong>
|
||||||
|
<small>agent(s) non planifié(s)</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dashboard-quota-empty-state" class="empty-state" hidden>Aucun agent actif à suivre.</div>
|
||||||
|
<div class="dashboard-quota-table-wrap">
|
||||||
|
<table class="dashboard-quota-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Agent</th>
|
||||||
|
<th>Lieu de rattachement</th>
|
||||||
|
<th>Quota annuel</th>
|
||||||
|
<th>Affecté</th>
|
||||||
|
<th>Consommation</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dashboard-quota-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card dashboard-shortcuts-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Actions fréquentes</h2>
|
||||||
|
<p class="section-subtitle">Les écrans les plus utilisés, accessibles en un clic.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-shortcuts">
|
||||||
|
<a class="dashboard-shortcut" href="planning.php">
|
||||||
|
<strong>Saisir un planning</strong>
|
||||||
|
<small>Un agent, une semaine, un lieu.</small>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-shortcut" href="structures.php">
|
||||||
|
<strong>Consulter un lieu</strong>
|
||||||
|
<small>Qui travaille où cette semaine.</small>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-shortcut" href="teams.php">
|
||||||
|
<strong>Composer une équipe</strong>
|
||||||
|
<small>Mercredis et vacances scolaires.</small>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-shortcut" href="vacations.php">
|
||||||
|
<strong>Mettre à jour les vacances</strong>
|
||||||
|
<small>Périodes scolaires de référence.</small>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-shortcut" href="pta.php">
|
||||||
|
<strong>Ouvrir un PTA détaillé</strong>
|
||||||
|
<small>Souhaits, contraintes et répartition.</small>
|
||||||
|
</a>
|
||||||
|
<a class="dashboard-shortcut" href="administration.php">
|
||||||
|
<strong>Gérer les référentiels</strong>
|
||||||
|
<small>Agents, lieux, postes, couverture.</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
412
app/Views/partials/faq.php
Normal file
412
app/Views/partials/faq.php
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<?php
|
||||||
|
/** @var array $accessProfile */
|
||||||
|
/** @var array $allowedPages */
|
||||||
|
|
||||||
|
$roleActuel = (string) ($accessProfile['role'] ?? '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le guide décrit l'application entière, quel que soit le rôle : un
|
||||||
|
* responsable de structure doit pouvoir comprendre ce que fait le Service
|
||||||
|
* Enfance après lui. Chaque section indique donc explicitement qui en a le
|
||||||
|
* droit, plutôt que de masquer les étapes inaccessibles.
|
||||||
|
*/
|
||||||
|
$sommaire = [
|
||||||
|
'faq-parcours' => 'Le parcours en un coup d’œil',
|
||||||
|
'faq-roles' => 'Qui fait quoi : les trois rôles',
|
||||||
|
'faq-lieux' => '1. Ajouter un lieu d’affectation',
|
||||||
|
'faq-agents' => '2. Ajouter un agent',
|
||||||
|
'faq-couverture' => '3. Définir les horaires d’ouverture',
|
||||||
|
'faq-vacances' => '4. Renseigner les vacances scolaires',
|
||||||
|
'faq-saisie' => '5. Saisir un planning pour un agent',
|
||||||
|
'faq-validation' => '6. Contrôler et valider un planning',
|
||||||
|
'faq-equipes' => '7. Composer les équipes',
|
||||||
|
'faq-suivi' => '8. Suivre l’année et éditer les PDF',
|
||||||
|
'faq-quota' => 'Comprendre le quota annuel et les motifs',
|
||||||
|
'faq-questions' => 'Questions fréquentes',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<section class="page-content faq-page">
|
||||||
|
<div class="faq-layout">
|
||||||
|
<nav class="card faq-summary" aria-label="Sommaire du guide">
|
||||||
|
<p class="faq-summary-title">Sommaire</p>
|
||||||
|
<ol class="faq-summary-list">
|
||||||
|
<?php foreach ($sommaire as $ancre => $libelle): ?>
|
||||||
|
<li><a href="#<?= htmlspecialchars($ancre) ?>"><?= htmlspecialchars($libelle) ?></a></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ol>
|
||||||
|
<p class="faq-summary-note">
|
||||||
|
Vous êtes connecté en tant que
|
||||||
|
<strong><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? 'rôle non sélectionné')) ?></strong>.
|
||||||
|
Les étapes réservées à un autre rôle restent décrites ici, mais leurs écrans ne vous sont pas proposés.
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="faq-sections">
|
||||||
|
<article class="card faq-section" id="faq-parcours">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Le parcours en un coup d’œil</h2>
|
||||||
|
<p class="section-subtitle">PTA sert à répartir, sur toute l’année civile, les heures dues par chaque agent des accueils de loisirs. Le paramétrage se fait une fois, la saisie se répète chaque semaine.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="faq-flow">
|
||||||
|
<li><span class="faq-flow-step">1</span><strong>Lieux d’affectation</strong><small>Les crèches, écoles et centres où travaillent les agents.</small></li>
|
||||||
|
<li><span class="faq-flow-step">2</span><strong>Agents</strong><small>Identité, poste, quotité de travail et lieu principal.</small></li>
|
||||||
|
<li><span class="faq-flow-step">3</span><strong>Horaires d’ouverture</strong><small>Les plages où une présence minimale est obligatoire.</small></li>
|
||||||
|
<li><span class="faq-flow-step">4</span><strong>Vacances scolaires</strong><small>Elles distinguent le périscolaire de l’extrascolaire.</small></li>
|
||||||
|
<li><span class="faq-flow-step">5</span><strong>Saisie des plannings</strong><small>Semaine par semaine, agent par agent, en brouillon.</small></li>
|
||||||
|
<li><span class="faq-flow-step">6</span><strong>Contrôle et validation</strong><small>Le Service Enfance valide et fige la semaine.</small></li>
|
||||||
|
<li><span class="faq-flow-step">7</span><strong>Équipes</strong><small>Mercredis et vacances, avec contrôle de l’encadrement.</small></li>
|
||||||
|
<li><span class="faq-flow-step">8</span><strong>Suivi annuel</strong><small>Quotas, couverture, PTA détaillé et PDF.</small></li>
|
||||||
|
</ol>
|
||||||
|
<p class="hint">Les étapes 1 à 4 ne se font qu’une fois, puis à la marge (nouvel agent, nouveau lieu, nouvelle année scolaire). Le travail courant, ce sont les étapes 5 et 6.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-roles">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Qui fait quoi : les trois rôles</h2>
|
||||||
|
<p class="section-subtitle">Votre rôle détermine les onglets visibles et les actions autorisées. Il se choisit à l’ouverture de l’application et se change à tout moment par le bouton « Changer de rôle », en haut à droite.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="planning-table faq-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rôle</th>
|
||||||
|
<th>Ce qu’il voit</th>
|
||||||
|
<th>Ce qu’il peut faire</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr<?= $roleActuel === 'AGENT' ? ' class="faq-row-current"' : '' ?>>
|
||||||
|
<td><strong>Agent</strong></td>
|
||||||
|
<td>Son propre planning et celui de son lieu de rattachement.</td>
|
||||||
|
<td>Lecture seule. Aucune modification, aucune validation.</td>
|
||||||
|
</tr>
|
||||||
|
<tr<?= $roleActuel === 'RESPONSABLE_STRUCTURE' ? ' class="faq-row-current"' : '' ?>>
|
||||||
|
<td><strong>Responsable de structure</strong></td>
|
||||||
|
<td>Son lieu : la saisie, les agents qui y sont planifiés, la couverture.</td>
|
||||||
|
<td>Saisir et corriger les horaires et motifs de son lieu, <strong>en brouillon uniquement</strong>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr<?= $roleActuel === 'SERVICE_ENFANCE' ? ' class="faq-row-current"' : '' ?>>
|
||||||
|
<td><strong>Service Enfance</strong></td>
|
||||||
|
<td>Tous les lieux, tous les agents, tous les écrans.</td>
|
||||||
|
<td>Paramétrer, saisir, composer les équipes, <strong>valider</strong> et rouvrir les plannings.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Un planning validé n’est plus modifiable par un responsable de structure : il faut demander au Service Enfance de le rouvrir, ce qui le repasse en brouillon.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-lieux">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>1. Ajouter un lieu d’affectation</h2>
|
||||||
|
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. Un lieu doit exister avant qu’un agent puisse y être rattaché ou planifié.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="faq-steps">
|
||||||
|
<li>Ouvrez <strong>Administration</strong>, encadré « Ajouter un lieu d’affectation ».</li>
|
||||||
|
<li>Saisissez le <strong>code</strong> : un identifiant court et stable, sans espace, par exemple <code>CRECHE_MATUSALEME</code>. Il ne s’affiche que dans les tableaux techniques.</li>
|
||||||
|
<li>Saisissez le <strong>nom</strong> tel qu’il doit apparaître partout ailleurs, par exemple « Crèche Matusalème ».</li>
|
||||||
|
<li>Choisissez le <strong>type d’affectation</strong> : <em>Périscolaire</em> (autour du temps scolaire) ou <em>Extrascolaire</em> (mercredis et vacances). Ce choix appartient au lieu, pas à l’agent.</li>
|
||||||
|
<li>L’<strong>adresse</strong> est facultative et sert uniquement d’information.</li>
|
||||||
|
<li>Cliquez sur <strong>Ajouter le lieu</strong>. Il devient immédiatement sélectionnable dans tous les écrans.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="message message-info faq-callout">
|
||||||
|
Le type d’affectation reste modifiable après coup dans le tableau « Paramètres des lieux d’affectation », juste en dessous du formulaire : choisissez la nouvelle valeur puis cliquez sur <strong>Enregistrer</strong> sur la ligne concernée.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-agents">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>2. Ajouter un agent</h2>
|
||||||
|
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. La fiche agent détermine son quota annuel et les motifs qu’on pourra lui affecter.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="faq-steps">
|
||||||
|
<li><strong>Matricule, nom, prénom</strong> : l’identité de l’agent. Le matricule doit être unique.</li>
|
||||||
|
<li><strong>Poste PTA</strong> : la fonction occupée. Elle sert notamment à repérer les responsables dans les équipes.</li>
|
||||||
|
<li><strong>Type de contrat</strong> : <em>Permanent</em> (soumis au PTA) ou <em>Temporaire</em> (hors périmètre PTA, planifiable mais sans annualisation).</li>
|
||||||
|
<li><strong>Lieu d’affectation principal</strong> : le rattachement par défaut. Ce n’est pas une exclusivité — l’agent pourra être planifié sur d’autres lieux la même semaine.</li>
|
||||||
|
<li><strong>Quotité de travail</strong>, en pourcentage. C’est elle qui proratise le quota annuel : 100 % = 1 607 h, 80 % = 1 285 h 36, etc.</li>
|
||||||
|
<li><strong>Dates de contrat</strong> : information RH. Attention, la date de début <em>ne décale pas</em> l’année de référence, qui reste l’année civile complète.</li>
|
||||||
|
<li><strong>Qualification et diplôme</strong> : le champ « Diplôme » ne s’active que si l’agent est déclaré diplômé. Cette information alimente le contrôle du taux de diplômés dans les équipes.</li>
|
||||||
|
<li><strong>Droit à l’enveloppe de formation de 14 h</strong> : coché par défaut. Décoché, le motif « Formation » ne sera pas proposé pour cet agent lors de la saisie.</li>
|
||||||
|
<li><strong>Observations utiles à la construction du PTA</strong> : disponibilités particulières, organisation connue… Ces notes se retrouvent sur l’écran « PTA détaillé ».</li>
|
||||||
|
<li>Cliquez sur <strong>Ajouter l’agent</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="hint">Pour corriger une fiche existante, utilisez le tableau « Informations des agents » en bas de la page : le bouton <strong>Modifier</strong> ouvre la fiche complète, lieu principal compris.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-couverture">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>3. Définir les horaires d’ouverture</h2>
|
||||||
|
<p class="section-subtitle">Onglet <strong>Administration</strong>, encadré « Horaires de couverture des lieux ». Ces plages décrivent quand le lieu doit être tenu, et par combien d’agents au minimum.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="faq-steps">
|
||||||
|
<li>Choisissez le <strong>lieu d’affectation</strong> puis cliquez sur <strong>Charger</strong>.</li>
|
||||||
|
<li>L’éditeur affiche une section par jour, du <strong>lundi au vendredi</strong>.</li>
|
||||||
|
<li>Sur un jour, cliquez sur <strong>+ Ajouter une plage</strong>, puis renseignez l’heure de début, l’heure de fin et le <strong>nombre minimum d’agents</strong> attendus sur cette plage.</li>
|
||||||
|
<li>Répétez pour chaque plage d’ouverture réelle : par exemple 07:30–08:30 avec 2 agents, puis 11:30–13:30 avec 4 agents, puis 16:30–18:30 avec 3 agents.</li>
|
||||||
|
<li>Cliquez sur <strong>Enregistrer les horaires de couverture</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="message message-info faq-callout">
|
||||||
|
Seules les affectations portant le motif <strong>« Heure de travail »</strong> comptent comme une présence sur ces plages. Une formation, un congé ou une absence ne couvrent pas le lieu — c’est le principe même du contrôle.
|
||||||
|
</div>
|
||||||
|
<p class="hint">Tant qu’un lieu n’a aucune plage configurée, il apparaît comme « non configuré » dans l’onglet <strong>Couverture</strong> : l’application ne peut alors rien contrôler pour lui, et le silence ne veut pas dire que tout va bien.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-vacances">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>4. Renseigner les vacances scolaires</h2>
|
||||||
|
<p class="section-subtitle">Onglet <strong>Vacances</strong> — réservé au Service Enfance. Ces périodes déterminent si une journée relève du périscolaire ou de l’extrascolaire.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="faq-steps">
|
||||||
|
<li>Indiquez l’<strong>année civile</strong> souhaitée et lancez la recherche : PTA interroge l’API publique du ministère de l’Éducation nationale et agrège les deux années scolaires concernées.</li>
|
||||||
|
<li>Contrôlez les <strong>dates proposées</strong>. Elles restent modifiables : corrigez ce qui doit l’être avant d’enregistrer.</li>
|
||||||
|
<li><strong>Cochez uniquement</strong> les périodes à conserver, puis enregistrez.</li>
|
||||||
|
<li>Si une période manque ou doit être adaptée aux besoins du service, utilisez le formulaire <strong>« Ajouter une période manuellement »</strong>.</li>
|
||||||
|
<li>Le tableau <strong>« Périodes enregistrées »</strong> est la référence réellement utilisée par l’application : c’est lui qui fait foi, pas la proposition de l’API.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="hint">Cette étape est à refaire à chaque nouvelle année civile. Sans période enregistrée, PTA considère toutes les journées comme périscolaires.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-saisie">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>5. Saisir un planning pour un agent</h2>
|
||||||
|
<p class="section-subtitle">Onglet <strong>Saisie</strong> — Service Enfance et responsables de structure. Un planning, c’est <strong>un agent × une semaine × un lieu</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="faq-steps">
|
||||||
|
<li><strong>Sélection du planning.</strong> Choisissez le lieu, puis l’agent, puis la semaine. Les listes se débloquent dans cet ordre. Un responsable de structure a son lieu déjà imposé.</li>
|
||||||
|
<li><strong>Lisez le bandeau de quota</strong> qui apparaît : quotité de l’agent, quota de la période, heures déjà affectées (validées et brouillons confondus) et reste à affecter.</li>
|
||||||
|
<li><strong>Saisissez les affectations.</strong> Le tableau propose une ligne par jour, du lundi au vendredi. Sur chaque jour, renseignez une ou plusieurs plages horaires, chacune avec son propre <strong>motif</strong>. Les heures se saisissent par pas de 15 minutes.</li>
|
||||||
|
<li>Cliquez sur <strong>Appliquer les horaires renseignés</strong> : les plages basculent dans le planning de la semaine.</li>
|
||||||
|
<li><strong>Vérifiez le planning global de l’agent</strong>, en dessous. Il montre <em>toutes</em> ses affectations de la semaine, y compris celles posées sur d’autres lieux. Le total des heures décomptées s’affiche à droite.</li>
|
||||||
|
<li>Pour corriger, cliquez sur un créneau pour le <strong>déplacer ou le dupliquer</strong>, ou faites-le glisser directement vers un autre jour.</li>
|
||||||
|
<li>Cliquez sur <strong>Enregistrer le brouillon</strong>. Le planning est conservé au statut <strong>BROUILLON</strong> et reste modifiable.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="message message-info faq-callout">
|
||||||
|
Un même agent peut être planifié sur plusieurs lieux la même semaine. En revanche, deux créneaux ne peuvent jamais se chevaucher : une plage déjà posée ailleurs apparaît dans le planning global et bloque toute saisie qui l’empiéterait.
|
||||||
|
</div>
|
||||||
|
<h3>Gagner du temps avec les semaines types</h3>
|
||||||
|
<p>Quand une semaine se répète à l’identique, le Service Enfance peut l’enregistrer puis la rejouer :</p>
|
||||||
|
<ul class="faq-list">
|
||||||
|
<li><strong>Enregistrer comme semaine type</strong> : mémorise la semaine affichée sous un nom.</li>
|
||||||
|
<li><strong>Importer une semaine type</strong> : la rejoue sur la semaine en cours de saisie.</li>
|
||||||
|
<li><strong>Appliquer sur une période</strong> : la rejoue sur plusieurs semaines d’un coup. Un aperçu annonce les conflits avant d’écrire quoi que ce soit — le mode « Compléter » refuse d’écraser, le mode « Remplacer » supprime les créneaux existants des jours concernés.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-validation">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>6. Contrôler et valider un planning</h2>
|
||||||
|
<p class="section-subtitle">Onglets <strong>À valider</strong> et <strong>Saisie</strong> — la validation est réservée au Service Enfance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="faq-steps">
|
||||||
|
<li>Ouvrez <strong>À valider</strong> : la file de tous les brouillons contenant des affectations.</li>
|
||||||
|
<li>Cliquez sur un planning : l’écran de <strong>Saisie</strong> s’ouvre sur le bon agent et la bonne semaine, positionné sur la zone de validation.</li>
|
||||||
|
<li>Contrôlez les horaires, puis cliquez sur <strong>Valider le planning</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
<p>La validation applique quatre contrôles bloquants et une alerte :</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="planning-table faq-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Niveau</th>
|
||||||
|
<th>Contrôle</th>
|
||||||
|
<th>Conséquence</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span class="faq-level faq-level-error">Erreur</span></td>
|
||||||
|
<td>Le planning ne contient aucune affectation.</td>
|
||||||
|
<td rowspan="4">La validation est <strong>refusée</strong>. Corrigez puis recommencez.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="faq-level faq-level-error">Erreur</span></td>
|
||||||
|
<td>Une affectation est en dehors de la semaine sélectionnée.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="faq-level faq-level-error">Erreur</span></td>
|
||||||
|
<td>Deux affectations du planning se chevauchent.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="faq-level faq-level-error">Erreur</span></td>
|
||||||
|
<td>Une affectation chevauche un créneau déjà validé sur un autre lieu.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="faq-level faq-level-warning">Alerte</span></td>
|
||||||
|
<td>Le quota annuel de l’agent est dépassé.</td>
|
||||||
|
<td>Une <strong>confirmation explicite</strong> est demandée. Le dépassement est possible, mais il doit être assumé.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Une fois validé, le planning est figé : le bouton <strong>Modifier le planning</strong> le rouvre et le repasse en brouillon, ce qui impose une nouvelle validation. Modifier un créneau validé depuis l’onglet « Par agent » produit exactement le même effet, automatiquement.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-equipes">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>7. Composer les équipes</h2>
|
||||||
|
<p class="section-subtitle">Onglet <strong>Équipes</strong> — réservé au Service Enfance. Une équipe couvre une période (mercredis, vacances) sur un lieu, et vérifie que l’encadrement est réglementaire.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>Une équipe est indépendante des plannings hebdomadaires : elle répond à la question « qui encadre, sur cette période, et est-ce suffisant ? ».</p>
|
||||||
|
<ol class="faq-steps">
|
||||||
|
<li><strong>Nommez l’équipe</strong> de façon parlante, par exemple « Vacances d’hiver — Équipe Matusalème ».</li>
|
||||||
|
<li>Choisissez son <strong>type</strong> : <em>Mercredi périscolaire</em>, <em>Extrascolaire</em> ou <em>Périscolaire semaine</em>. Un même agent peut appartenir à plusieurs types d’équipes dans l’année.</li>
|
||||||
|
<li>Choisissez le <strong>lieu</strong>, puis les <strong>dates de début et de fin</strong> de la période couverte.</li>
|
||||||
|
<li>Renseignez l’<strong>effectif d’enfants</strong> attendu, séparé en moins de 6 ans et 6 ans et plus.</li>
|
||||||
|
<li>Ajustez les <strong>ratios d’encadrement</strong> : par défaut 1 agent pour 8 enfants de moins de 6 ans, et 1 pour 12 au-delà. PTA en déduit le nombre d’agents requis.</li>
|
||||||
|
<li>Fixez le <strong>minimum d’agents</strong> et le <strong>pourcentage minimum de diplômés</strong> (50 % par défaut).</li>
|
||||||
|
<li>Créez l’équipe, puis <strong>ajoutez-y les agents</strong> depuis la liste « Équipes enregistrées ».</li>
|
||||||
|
</ol>
|
||||||
|
<p>À chaque modification, PTA recalcule ses contrôles :</p>
|
||||||
|
<ul class="faq-list">
|
||||||
|
<li><span class="faq-level faq-level-error">Erreur</span> <strong>Encadrement insuffisant</strong> — moins d’agents affectés que requis par les ratios et le minimum.</li>
|
||||||
|
<li><span class="faq-level faq-level-error">Erreur</span> <strong>Qualification insuffisante</strong> — le taux de diplômés est sous le seuil paramétré.</li>
|
||||||
|
<li><span class="faq-level faq-level-warning">Alerte</span> <strong>Responsable manquant</strong> — aucun agent de l’équipe n’occupe un poste de responsable.</li>
|
||||||
|
<li><span class="faq-level faq-level-warning">Alerte</span> <strong>Contrainte prioritaire</strong> — un agent a une préconisation médicale ou un temps partiel thérapeutique actif sur la période.</li>
|
||||||
|
<li><span class="faq-level faq-level-info">Info</span> <strong>Préférence respectée</strong> — un agent avait exprimé un souhait pour ce lieu dans son PTA détaillé.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">L’équipe passe ensuite de <strong>Brouillon</strong> à <strong>À contrôler</strong> puis <strong>Validée</strong>. Les contraintes prioritaires et les souhaits de lieu se saisissent dans l’onglet <strong>PTA détaillé</strong> de chaque agent.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-suivi">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>8. Suivre l’année et éditer les PDF</h2>
|
||||||
|
<p class="section-subtitle">Les écrans de consultation ne modifient rien : ils servent à contrôler, comparer et imprimer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="planning-table faq-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Onglet</th>
|
||||||
|
<th>À quoi il sert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Tableau de bord</strong></td>
|
||||||
|
<td>Le point d’entrée du Service Enfance : brouillons les plus anciens à valider, plages de couverture déficitaires et suivi des quotas annuels.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Couverture</strong></td>
|
||||||
|
<td>Les plages où le nombre d’agents en « Heure de travail » passe sous le minimum attendu du lieu, et les lieux encore non configurés.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Par lieu</strong></td>
|
||||||
|
<td>Le planning d’un lieu sur une semaine : agents rattachés et agents réellement planifiés. Lecture seule.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Par agent</strong></td>
|
||||||
|
<td>Le planning d’un agent mois par mois, tous lieux confondus, avec son quota. C’est ici qu’on corrige un créneau isolé ou qu’on duplique une semaine.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Vue annuelle</strong></td>
|
||||||
|
<td>L’annualisation d’un agent de janvier à décembre, et la validation de toute l’année en une seule fois.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>PTA détaillé</strong></td>
|
||||||
|
<td>La fiche annuelle complète : répartition des heures, congés et JNT, contrôles métier, contraintes prioritaires, préférences et interdictions de lieu.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<h3>Les documents imprimables</h3>
|
||||||
|
<ul class="faq-list">
|
||||||
|
<li><strong>PDF de l’agent (7 semaines)</strong>, depuis l’écran de saisie : le planning détaillé sur sept semaines à partir de la semaine affichée.</li>
|
||||||
|
<li><strong>PDF du lieu</strong>, depuis la vue par lieu : la semaine complète d’un lieu, tous agents confondus.</li>
|
||||||
|
<li><strong>Récapitulatif horaire et contrat</strong>, depuis la fiche agent : les heures travaillées et le suivi du quota annuel.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-quota">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Comprendre le quota annuel et les motifs</h2>
|
||||||
|
<p class="section-subtitle">C’est la mécanique centrale de l’application : tout le reste sert à la nourrir.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="faq-list">
|
||||||
|
<li>Le quota suit l’<strong>année civile</strong>, du 1<sup>er</sup> janvier au 31 décembre — jamais l’année scolaire, jamais la date de contrat.</li>
|
||||||
|
<li>La référence est de <strong>1 607 heures</strong> à 100 %, <strong>proratisée par la quotité</strong> de l’agent.</li>
|
||||||
|
<li>Les heures « déjà affectées » comptent <strong>les brouillons comme les plannings validés</strong> : le compteur ne ment pas en attendant la validation.</li>
|
||||||
|
<li>Chaque plage horaire porte un <strong>motif</strong>, et c’est le motif qui décide si les heures sont décomptées du quota.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Trois motifs ne sont <strong>pas</strong> décomptés du quota :</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="planning-table faq-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Motif</th>
|
||||||
|
<th>Décompté du quota ?</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Heure de travail, ALP matin, Restauration, ALP soir, Mercredi périscolaire, Temps extrascolaire</td><td><strong>Oui</strong></td></tr>
|
||||||
|
<tr><td>Préparation (périscolaire, extrascolaire, du lundi), Ménage de fond</td><td><strong>Oui</strong></td></tr>
|
||||||
|
<tr><td>Formation, Journée de fractionnement, Absence</td><td><strong>Oui</strong></td></tr>
|
||||||
|
<tr><td>Congé annuel</td><td>Non</td></tr>
|
||||||
|
<tr><td>Autre absence</td><td>Non</td></tr>
|
||||||
|
<tr><td>Journée non travaillée (JNT)</td><td>Non</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Le motif « Formation » n’est proposé que pour les agents disposant du droit à l’enveloppe de formation de 14 h, coché sur leur fiche.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card faq-section" id="faq-questions">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Questions fréquentes</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl class="faq-qa">
|
||||||
|
<dt>Je ne vois pas l’onglet dont on me parle.</dt>
|
||||||
|
<dd>Les onglets dépendent du rôle sélectionné. Vérifiez le badge en haut à droite et, si nécessaire, utilisez « Changer de rôle ». Si l’onglet reste absent, votre rôle n’y a pas droit.</dd>
|
||||||
|
|
||||||
|
<dt>Impossible de choisir un agent dans l’écran de saisie.</dt>
|
||||||
|
<dd>Il faut d’abord choisir un lieu d’affectation : la liste des agents en dépend. La semaine ne se débloque qu’une fois l’agent choisi.</dd>
|
||||||
|
|
||||||
|
<dt>L’application refuse ma saisie pour cause de chevauchement.</dt>
|
||||||
|
<dd>L’agent a déjà une affectation sur ce créneau, éventuellement <strong>sur un autre lieu</strong>. Regardez le « Planning global de l’agent » : il affiche toutes ses affectations de la semaine, tous lieux confondus.</dd>
|
||||||
|
|
||||||
|
<dt>Le quota est dépassé, que faire ?</dt>
|
||||||
|
<dd>C’est une alerte, pas une erreur. Vous pouvez confirmer la validation en connaissance de cause, ou retirer des heures. Le dépassement reste visible dans le suivi des quotas.</dd>
|
||||||
|
|
||||||
|
<dt>Comment corriger un planning déjà validé ?</dt>
|
||||||
|
<dd>Seul le Service Enfance peut le rouvrir : bouton <strong>Modifier le planning</strong> dans l’écran de saisie. Le planning repasse en brouillon et devra être validé à nouveau. Toute modification d’un créneau validé depuis « Par agent » déclenche la même bascule.</dd>
|
||||||
|
|
||||||
|
<dt>Un agent doit travailler sur deux lieux la même semaine.</dt>
|
||||||
|
<dd>C’est prévu. Le lieu principal de la fiche agent n’est qu’un rattachement par défaut : saisissez simplement un planning sur chacun des lieux, tant que les horaires ne se chevauchent pas.</dd>
|
||||||
|
|
||||||
|
<dt>L’onglet Couverture ne signale rien pour un de mes lieux.</dt>
|
||||||
|
<dd>Ce lieu n’a probablement aucune plage d’ouverture configurée. Sans plage attendue, l’application n’a rien à comparer. Renseignez-les dans <strong>Administration</strong> → « Horaires de couverture des lieux ».</dd>
|
||||||
|
|
||||||
|
<dt>Les vacances scolaires affichées sont fausses.</dt>
|
||||||
|
<dd>Les dates proposées par l’API restent modifiables avant enregistrement, et le formulaire manuel permet d’ajouter ou de corriger une période. Seul le tableau « Périodes enregistrées » est utilisé par l’application.</dd>
|
||||||
|
|
||||||
|
<dt>Mes modifications n’apparaissent pas après enregistrement.</dt>
|
||||||
|
<dd>Rechargez la page avec <kbd>Ctrl</kbd> + <kbd>F5</kbd>. Si le problème persiste, un message d’erreur rouge s’affiche normalement sous le formulaire concerné : signalez son contenu à l’administrateur de l’application.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -10,7 +10,7 @@ $defaultCalendarYear = $currentYear;
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>Vacances scolaires</h2>
|
<h2>Vacances scolaires</h2>
|
||||||
<p class="section-subtitle">Consultez toutes les vacances d’une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
|
<p class="section-subtitle">Consultez toutes les vacances d'une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ $defaultCalendarYear = $currentYear;
|
|||||||
<div class="selection-grid vacation-filters">
|
<div class="selection-grid vacation-filters">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Académie</span>
|
<span>Académie</span>
|
||||||
<input id="vacation-academy" type="text" maxlength="100" value="Orléans-Tours" placeholder="Ex. Orléans-Tours" list="vacation-academies" autocomplete="off">
|
<input id="vacation-academy" type="text" maxlength="100" value="Dijon" placeholder="Ex. Dijon" list="vacation-academies" autocomplete="off">
|
||||||
<datalist id="vacation-academies">
|
<datalist id="vacation-academies">
|
||||||
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
|
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
|
||||||
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
|
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
|
||||||
@@ -45,8 +45,8 @@ $defaultCalendarYear = $currentYear;
|
|||||||
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
|
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
|
||||||
<div class="section-heading compact-heading">
|
<div class="section-heading compact-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3>Dates proposées par l’API</h3>
|
<h3>Dates proposées par l'API</h3>
|
||||||
<p class="section-subtitle">Toutes les périodes de l’année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
|
<p class="section-subtitle">Toutes les périodes de l'année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="vacation-select-all">
|
<label class="vacation-select-all">
|
||||||
<input id="vacation-select-all" type="checkbox">
|
<input id="vacation-select-all" type="checkbox">
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Session;
|
||||||
use App\Models\SchemaModel;
|
use App\Models\SchemaModel;
|
||||||
|
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
Session::start();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo = require dirname(__DIR__) . '/db.php';
|
$pdo = require dirname(__DIR__) . '/db.php';
|
||||||
(new SchemaModel($pdo))->assertCurrent();
|
(new SchemaModel($pdo))->assertCurrent();
|
||||||
|
|||||||
@@ -211,7 +211,7 @@
|
|||||||
row.dataset.dateFinContrat = agent.date_fin_contrat || '';
|
row.dataset.dateFinContrat = agent.date_fin_contrat || '';
|
||||||
row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : '';
|
row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : '';
|
||||||
row.dataset.typeContrat = agent.type_contrat || 'PERMANENT';
|
row.dataset.typeContrat = agent.type_contrat || 'PERMANENT';
|
||||||
row.dataset.formationRepartitionAnnuelle = Number(agent.formation_repartition_annuelle) ? '1' : '0';
|
row.dataset.droitEnveloppeFormation = Number(agent.droit_enveloppe_formation ?? 1) ? '1' : '0';
|
||||||
row.dataset.commentairePta = agent.commentaire_pta || '';
|
row.dataset.commentairePta = agent.commentaire_pta || '';
|
||||||
row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : '';
|
row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : '';
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
document.querySelector('#edit-agent-profile-contract-end').value = row.dataset.dateFinContrat || '';
|
document.querySelector('#edit-agent-profile-contract-end').value = row.dataset.dateFinContrat || '';
|
||||||
document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || '';
|
document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || '';
|
||||||
document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT';
|
document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT';
|
||||||
document.querySelector('#edit-agent-profile-training-planned').checked = row.dataset.formationRepartitionAnnuelle === '1';
|
document.querySelector('#edit-agent-profile-training-right').checked = row.dataset.droitEnveloppeFormation !== '0';
|
||||||
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
|
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
|
||||||
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
|
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
|
||||||
toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma'));
|
toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma'));
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
date_fin_contrat: document.querySelector('#edit-agent-profile-contract-end').value,
|
date_fin_contrat: document.querySelector('#edit-agent-profile-contract-end').value,
|
||||||
id_poste: Number(document.querySelector('#edit-agent-profile-post').value),
|
id_poste: Number(document.querySelector('#edit-agent-profile-post').value),
|
||||||
type_contrat: document.querySelector('#edit-agent-profile-contract-type').value,
|
type_contrat: document.querySelector('#edit-agent-profile-contract-type').value,
|
||||||
formation_repartition_annuelle: document.querySelector('#edit-agent-profile-training-planned').checked,
|
droit_enveloppe_formation: document.querySelector('#edit-agent-profile-training-right').checked,
|
||||||
commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value,
|
commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value,
|
||||||
structure_id: structureValue ? Number(structureValue) : null,
|
structure_id: structureValue ? Number(structureValue) : null,
|
||||||
}),
|
}),
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
date_fin_contrat: document.querySelector('#new-agent-contract-end').value,
|
date_fin_contrat: document.querySelector('#new-agent-contract-end').value,
|
||||||
id_poste: Number(document.querySelector('#new-agent-post').value),
|
id_poste: Number(document.querySelector('#new-agent-post').value),
|
||||||
type_contrat: document.querySelector('#new-agent-contract-type').value,
|
type_contrat: document.querySelector('#new-agent-contract-type').value,
|
||||||
formation_repartition_annuelle: document.querySelector('#new-agent-training-planned').checked,
|
droit_enveloppe_formation: document.querySelector('#new-agent-training-right').checked,
|
||||||
commentaire_pta: document.querySelector('#new-agent-pta-comment').value,
|
commentaire_pta: document.querySelector('#new-agent-pta-comment').value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -419,7 +419,6 @@
|
|||||||
form.reset();
|
form.reset();
|
||||||
document.querySelector('#new-agent-rate').value = '100';
|
document.querySelector('#new-agent-rate').value = '100';
|
||||||
document.querySelector('#new-agent-contract-type').value = 'PERMANENT';
|
document.querySelector('#new-agent-contract-type').value = 'PERMANENT';
|
||||||
document.querySelector('#new-agent-training-planned').checked = false;
|
|
||||||
document.querySelector('#new-agent-qualified').value = '0';
|
document.querySelector('#new-agent-qualified').value = '0';
|
||||||
toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma'));
|
toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma'));
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,28 @@
|
|||||||
setMessage(message);
|
setMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le sélecteur de motif est rendu côté serveur avec tous les motifs actifs :
|
||||||
|
* on le reconstruit à l'ouverture pour retirer « Formation » aux agents sans
|
||||||
|
* droit à l'enveloppe de formation. Le motif déjà porté par le créneau reste
|
||||||
|
* proposé, sans quoi le modal changerait silencieusement de motif.
|
||||||
|
*/
|
||||||
|
function renderMotifOptions(selectedMotifId) {
|
||||||
|
const select = document.querySelector('#edit-agent-entry-motif');
|
||||||
|
const motifs = window.PTA_CONFIG?.motifs || [];
|
||||||
|
if (!select || motifs.length === 0) return;
|
||||||
|
|
||||||
|
const agent = shared.lastAgentOverviewData?.agent;
|
||||||
|
const hasTrainingRight = Number(agent?.droit_enveloppe_formation ?? 1) !== 0;
|
||||||
|
|
||||||
|
select.innerHTML = motifs
|
||||||
|
.filter((motif) => hasTrainingRight
|
||||||
|
|| String(motif.code).toUpperCase() !== 'FORMATION'
|
||||||
|
|| Number(motif.id) === Number(selectedMotifId))
|
||||||
|
.map((motif) => `<option value="${Number(motif.id)}">${escapeHtml(motif.label)}</option>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function openModal(entryId) {
|
function openModal(entryId) {
|
||||||
const entry = findEntry(entryId);
|
const entry = findEntry(entryId);
|
||||||
if (!entry || !canEditEntry(entry)) return;
|
if (!entry || !canEditEntry(entry)) return;
|
||||||
@@ -263,6 +285,7 @@
|
|||||||
document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau));
|
document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau));
|
||||||
document.querySelector('#edit-agent-entry-date').value = entry.date_jour;
|
document.querySelector('#edit-agent-entry-date').value = entry.date_jour;
|
||||||
document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure));
|
document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure));
|
||||||
|
renderMotifOptions(Number(entry.id_motif));
|
||||||
document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif));
|
document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif));
|
||||||
document.querySelector('#edit-agent-entry-start').value = entry.heure_debut;
|
document.querySelector('#edit-agent-entry-start').value = entry.heure_debut;
|
||||||
document.querySelector('#edit-agent-entry-end').value = entry.heure_fin;
|
document.querySelector('#edit-agent-entry-end').value = entry.heure_fin;
|
||||||
|
|||||||
@@ -5,12 +5,9 @@
|
|||||||
if (!PTA) return;
|
if (!PTA) return;
|
||||||
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
|
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
const escapeAttr = (value) => String(value ?? '')
|
// escapeHtml échappe désormais aussi les guillemets : une seule
|
||||||
.replaceAll('&', '&')
|
// implémentation partagée, valable en contexte texte comme attribut.
|
||||||
.replaceAll('\"', '"')
|
const escapeAttr = escapeHtml;
|
||||||
.replaceAll("'", ''')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>');
|
|
||||||
let current = null;
|
let current = null;
|
||||||
|
|
||||||
function show(text, type = 'info') {
|
function show(text, type = 'info') {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
entries: [],
|
entries: [],
|
||||||
otherStructureEntries: [],
|
otherStructureEntries: [],
|
||||||
currentStructure: null,
|
currentStructure: null,
|
||||||
|
currentAgent: null,
|
||||||
planningId: null,
|
planningId: null,
|
||||||
planningStatus: null,
|
planningStatus: null,
|
||||||
weekDays: [],
|
weekDays: [],
|
||||||
@@ -44,10 +45,21 @@
|
|||||||
|
|
||||||
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
|
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Échappe une valeur pour l'insertion dans du HTML, texte ET attribut.
|
||||||
|
*
|
||||||
|
* La sérialisation via textContent/innerHTML n'échappe ni " ni ' : elle est
|
||||||
|
* sûre entre deux balises, mais permet de sortir d'un attribut value="…" ou
|
||||||
|
* title="…". PTA insère des libellés de vacances, des noms de lieux et des
|
||||||
|
* commentaires dans des attributs : les guillemets doivent être échappés.
|
||||||
|
*/
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
const div = document.createElement('div');
|
return String(value ?? '')
|
||||||
div.textContent = String(value ?? '');
|
.replaceAll('&', '&')
|
||||||
return div.innerHTML;
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(text, type = 'info') {
|
function showMessage(text, type = 'info') {
|
||||||
|
|||||||
325
assets/js/dashboard.js
Normal file
325
assets/js/dashboard.js
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PTA = window.PTA;
|
||||||
|
if (!PTA) throw new Error('PTA core doit être chargé avant dashboard.js');
|
||||||
|
|
||||||
|
const { utils } = PTA;
|
||||||
|
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils;
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
week: document.querySelector('#dashboard-week'),
|
||||||
|
weekLabel: document.querySelector('#dashboard-week-label'),
|
||||||
|
weekContext: document.querySelector('#dashboard-week-context'),
|
||||||
|
refresh: document.querySelector('#dashboard-refresh'),
|
||||||
|
message: document.querySelector('#dashboard-message'),
|
||||||
|
loading: document.querySelector('#dashboard-loading'),
|
||||||
|
|
||||||
|
pendingCount: document.querySelector('#dashboard-pending-count'),
|
||||||
|
pendingDetail: document.querySelector('#dashboard-pending-detail'),
|
||||||
|
pendingTile: document.querySelector('#dashboard-tile-pending'),
|
||||||
|
pendingList: document.querySelector('#dashboard-pending-list'),
|
||||||
|
pendingEmpty: document.querySelector('#dashboard-pending-empty'),
|
||||||
|
|
||||||
|
coverageCount: document.querySelector('#dashboard-coverage-count'),
|
||||||
|
coverageDetail: document.querySelector('#dashboard-coverage-detail'),
|
||||||
|
coverageTile: document.querySelector('#dashboard-tile-coverage'),
|
||||||
|
coverageList: document.querySelector('#dashboard-coverage-list'),
|
||||||
|
coverageEmpty: document.querySelector('#dashboard-coverage-empty'),
|
||||||
|
coverageLink: document.querySelector('#dashboard-coverage-link'),
|
||||||
|
coverageUnconfigured: document.querySelector('#dashboard-coverage-unconfigured'),
|
||||||
|
|
||||||
|
quotaCount: document.querySelector('#dashboard-quota-count'),
|
||||||
|
quotaDetail: document.querySelector('#dashboard-quota-detail'),
|
||||||
|
quotaTile: document.querySelector('#dashboard-tile-quota'),
|
||||||
|
quotaSubtitle: document.querySelector('#dashboard-quota-subtitle'),
|
||||||
|
quotaAgents: document.querySelector('#dashboard-quota-agents'),
|
||||||
|
quotaEmptyAgents: document.querySelector('#dashboard-quota-empty'),
|
||||||
|
quotaBody: document.querySelector('#dashboard-quota-body'),
|
||||||
|
quotaEmptyState: document.querySelector('#dashboard-quota-empty-state'),
|
||||||
|
|
||||||
|
setupCount: document.querySelector('#dashboard-setup-count'),
|
||||||
|
setupDetail: document.querySelector('#dashboard-setup-detail'),
|
||||||
|
setupTile: document.querySelector('#dashboard-tile-setup'),
|
||||||
|
|
||||||
|
navBadge: document.querySelector('#pending-validation-tab-badge'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pendingItems = [];
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
function setMessage(text = '', type = 'info') {
|
||||||
|
if (!elements.message) return;
|
||||||
|
elements.message.className = text ? `message message-${type}` : 'message';
|
||||||
|
elements.message.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une tuile est neutre quand il n'y a rien à faire, alertante dès qu'un
|
||||||
|
* compteur remonte. La couleur ne dit rien de plus que le chiffre : elle
|
||||||
|
* évite juste d'avoir à lire les quatre tuiles pour savoir où agir.
|
||||||
|
*/
|
||||||
|
function setTileTone(tile, count) {
|
||||||
|
if (!tile) return;
|
||||||
|
tile.classList.toggle('is-alert', Number(count) > 0);
|
||||||
|
tile.classList.toggle('is-clear', Number(count) === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(count, singular, pluralForm) {
|
||||||
|
return Number(count) > 1 ? pluralForm : singular;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekParam() {
|
||||||
|
return elements.week?.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekValueOf(item) {
|
||||||
|
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNavBadge(count) {
|
||||||
|
if (!elements.navBadge) return;
|
||||||
|
elements.navBadge.textContent = String(count);
|
||||||
|
elements.navBadge.hidden = Number(count) === 0;
|
||||||
|
elements.navBadge.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${count} planning${Number(count) > 1 ? 's' : ''} en attente de validation`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeek(week) {
|
||||||
|
if (!week) return;
|
||||||
|
const contexteLabel = {
|
||||||
|
PERISCOLAIRE: 'Semaine scolaire — contexte périscolaire',
|
||||||
|
EXTRASCOLAIRE: 'Semaine de vacances — contexte extrascolaire',
|
||||||
|
MIXTE: 'Semaine à cheval entre période scolaire et vacances',
|
||||||
|
}[week.contexte] || '';
|
||||||
|
|
||||||
|
if (elements.weekLabel) {
|
||||||
|
elements.weekLabel.textContent =
|
||||||
|
`Semaine ${week.numero} · du ${formatDate(week.date_debut)} au ${formatDate(week.date_fin)}`;
|
||||||
|
}
|
||||||
|
if (elements.weekContext) {
|
||||||
|
const vacances = Array.isArray(week.vacances_libelles) ? week.vacances_libelles : [];
|
||||||
|
const suffix = vacances.length ? ` (${vacances.join(', ')})` : '';
|
||||||
|
elements.weekContext.textContent = contexteLabel ? `${contexteLabel}${suffix}` : '';
|
||||||
|
elements.weekContext.hidden = !contexteLabel;
|
||||||
|
}
|
||||||
|
if (elements.coverageLink) {
|
||||||
|
elements.coverageLink.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
|
||||||
|
}
|
||||||
|
if (elements.coverageTile) {
|
||||||
|
elements.coverageTile.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPending(validation) {
|
||||||
|
const count = Number(validation.count || 0);
|
||||||
|
pendingItems = Array.isArray(validation.apercu) ? validation.apercu : [];
|
||||||
|
|
||||||
|
setNavBadge(count);
|
||||||
|
setTileTone(elements.pendingTile, count);
|
||||||
|
if (elements.pendingCount) elements.pendingCount.textContent = String(count);
|
||||||
|
if (elements.pendingDetail) {
|
||||||
|
elements.pendingDetail.textContent = count === 0
|
||||||
|
? 'Aucun brouillon en attente'
|
||||||
|
: `${formatDuration(Number(validation.minutes_decomptees || 0))} décomptées · `
|
||||||
|
+ `${validation.agent_count} ${plural(validation.agent_count, 'agent', 'agents')} · `
|
||||||
|
+ `${validation.structure_count} ${plural(validation.structure_count, 'lieu', 'lieux')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.pendingEmpty) elements.pendingEmpty.hidden = count !== 0;
|
||||||
|
if (!elements.pendingList) return;
|
||||||
|
|
||||||
|
elements.pendingList.innerHTML = pendingItems.map((item) => `
|
||||||
|
<li class="dashboard-list-item">
|
||||||
|
<div class="dashboard-list-main">
|
||||||
|
<strong>${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)}</strong>
|
||||||
|
<small>${escapeHtml(item.structure_nom)} · semaine ${Number(item.numero_semaine)} du ${formatDate(item.date_debut)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-list-meta">
|
||||||
|
<span>${Number(item.nombre_creneaux)} ${plural(item.nombre_creneaux, 'créneau', 'créneaux')}</span>
|
||||||
|
<strong>${formatDuration(Number(item.total_minutes || 0))}</strong>
|
||||||
|
</div>
|
||||||
|
<button class="button button-success dashboard-list-action" type="button" data-planning-id="${Number(item.id_planning)}">
|
||||||
|
Contrôler
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCoverage(coverage, week, perimetre) {
|
||||||
|
const gapCount = Number(coverage.gap_count || 0);
|
||||||
|
const unconfigured = Number(coverage.unconfigured_count || 0);
|
||||||
|
// Sans aucun lieu enregistré, « tous les minimums sont atteints » serait
|
||||||
|
// vrai mais trompeur : il n'y a simplement rien à contrôler.
|
||||||
|
const sansLieu = Number(perimetre?.structures || 0) === 0;
|
||||||
|
|
||||||
|
setTileTone(elements.coverageTile, gapCount);
|
||||||
|
setTileTone(elements.setupTile, unconfigured);
|
||||||
|
|
||||||
|
if (elements.coverageCount) elements.coverageCount.textContent = String(gapCount);
|
||||||
|
if (elements.coverageDetail) {
|
||||||
|
elements.coverageDetail.textContent = gapCount === 0
|
||||||
|
? (sansLieu ? 'Aucun lieu d’affectation enregistré' : 'Tous les minimums sont atteints')
|
||||||
|
: `${formatDuration(Number(coverage.gap_minutes || 0))} non couvertes · `
|
||||||
|
+ `${coverage.structure_count} ${plural(coverage.structure_count, 'lieu concerné', 'lieux concernés')}`;
|
||||||
|
}
|
||||||
|
if (elements.setupCount) elements.setupCount.textContent = String(unconfigured);
|
||||||
|
if (elements.setupDetail) {
|
||||||
|
elements.setupDetail.textContent = unconfigured === 0
|
||||||
|
? (sansLieu ? 'Aucun lieu à paramétrer pour l’instant' : 'Tous les lieux ont des horaires attendus')
|
||||||
|
: 'Aucun minimum d’agents n’y est contrôlé';
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(coverage.apercu) ? coverage.apercu : [];
|
||||||
|
if (elements.coverageEmpty) elements.coverageEmpty.hidden = items.length !== 0;
|
||||||
|
if (elements.coverageList) {
|
||||||
|
const weekValue = week?.valeur || '';
|
||||||
|
elements.coverageList.innerHTML = items.map((gap) => {
|
||||||
|
const href = `coverage.php?week=${encodeURIComponent(weekValue)}&structure_id=${Number(gap.id_structure)}`;
|
||||||
|
return `
|
||||||
|
<li class="dashboard-list-item">
|
||||||
|
<div class="dashboard-list-main">
|
||||||
|
<strong>${escapeHtml(gap.structure_nom)}</strong>
|
||||||
|
<small>${escapeHtml(gap.jour_libelle)} ${formatDate(gap.date)} · ${escapeHtml(gap.heure_debut)} → ${escapeHtml(gap.heure_fin)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-list-meta">
|
||||||
|
<span>${Number(gap.agents_planifies)} / ${Number(gap.minimum_agents)} agents</span>
|
||||||
|
<strong class="dashboard-gap-count">−${Number(gap.agents_manquants)}</strong>
|
||||||
|
</div>
|
||||||
|
<a class="button button-secondary dashboard-list-action" href="${href}">Analyser</a>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.coverageUnconfigured) {
|
||||||
|
const list = Array.isArray(coverage.unconfigured) ? coverage.unconfigured : [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
elements.coverageUnconfigured.hidden = true;
|
||||||
|
elements.coverageUnconfigured.textContent = '';
|
||||||
|
} else {
|
||||||
|
const names = list.map((structure) => escapeHtml(structure.nom)).join(', ');
|
||||||
|
const reste = unconfigured - list.length;
|
||||||
|
elements.coverageUnconfigured.innerHTML =
|
||||||
|
`<strong>Non contrôlés :</strong> ${names}${reste > 0 ? ` et ${reste} autre${reste > 1 ? 's' : ''}` : ''}. `
|
||||||
|
+ 'Définissez leurs horaires de couverture dans l’administration.';
|
||||||
|
elements.coverageUnconfigured.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuota(quota) {
|
||||||
|
const overCount = Number(quota.depassement_count || 0);
|
||||||
|
setTileTone(elements.quotaTile, overCount);
|
||||||
|
|
||||||
|
if (elements.quotaCount) elements.quotaCount.textContent = String(overCount);
|
||||||
|
if (elements.quotaDetail) {
|
||||||
|
elements.quotaDetail.textContent = overCount === 0
|
||||||
|
? (Number(quota.agent_count || 0) === 0
|
||||||
|
? 'Aucun agent actif à suivre'
|
||||||
|
: `Aucun dépassement sur ${quota.annee}`)
|
||||||
|
: `Sur ${quota.agent_count} ${plural(quota.agent_count, 'agent suivi', 'agents suivis')} en ${quota.annee}`;
|
||||||
|
}
|
||||||
|
if (elements.quotaTile) {
|
||||||
|
elements.quotaTile.href = `annual.php?year=${Number(quota.annee)}`;
|
||||||
|
}
|
||||||
|
if (elements.quotaSubtitle) {
|
||||||
|
elements.quotaSubtitle.textContent =
|
||||||
|
`Année civile ${quota.annee} · heures validées et brouillon comprises, motifs décomptés uniquement.`;
|
||||||
|
}
|
||||||
|
if (elements.quotaAgents) elements.quotaAgents.textContent = String(quota.agent_count || 0);
|
||||||
|
if (elements.quotaEmptyAgents) elements.quotaEmptyAgents.textContent = String(quota.sans_heure_count || 0);
|
||||||
|
|
||||||
|
const items = Array.isArray(quota.apercu) ? quota.apercu : [];
|
||||||
|
if (elements.quotaEmptyState) elements.quotaEmptyState.hidden = items.length !== 0;
|
||||||
|
if (!elements.quotaBody) return;
|
||||||
|
|
||||||
|
elements.quotaBody.innerHTML = items.map((agent) => {
|
||||||
|
const over = Number(agent.minutes_restantes) < 0;
|
||||||
|
const rate = Number(agent.taux_consommation || 0);
|
||||||
|
const href = `annual.php?agent_id=${Number(agent.id_agent)}&year=${Number(quota.annee)}`;
|
||||||
|
return `
|
||||||
|
<tr class="${over ? 'is-over-quota' : ''}">
|
||||||
|
<td>
|
||||||
|
<strong>${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong>
|
||||||
|
<small>${escapeHtml(agent.poste_libelle || 'Poste non renseigné')}</small>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(agent.structure_nom || 'Non rattaché')}</td>
|
||||||
|
<td>
|
||||||
|
<strong>${formatDuration(Number(agent.quota_cible_minutes || 0))}</strong>
|
||||||
|
<small>quotité ${Number(agent.quotite_travail)} %</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>${formatDuration(Number(agent.minutes_affectees || 0))}</strong>
|
||||||
|
<small>${formatDuration(Number(agent.minutes_brouillon || 0))} en brouillon</small>
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-quota-gauge-cell">
|
||||||
|
<progress class="dashboard-quota-gauge${over ? ' is-over' : ''}" max="100" value="${Math.min(100, Math.max(0, rate))}" aria-hidden="true"></progress>
|
||||||
|
<span>${rate} %</span>
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-quota-action-cell">
|
||||||
|
<a class="button button-secondary" href="${href}">Ouvrir</a>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPendingPlanning(item) {
|
||||||
|
if (!item) return;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
structure_id: String(item.id_structure),
|
||||||
|
agent_id: String(item.id_agent),
|
||||||
|
week: weekValueOf(item),
|
||||||
|
focus: 'validation',
|
||||||
|
notice: `Planning de ${item.agent_prenom} ${item.agent_nom} ouvert depuis le tableau de bord.`,
|
||||||
|
});
|
||||||
|
window.location.href = `planning.php?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(options = {}) {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
if (!options.silent) setMessage('');
|
||||||
|
if (elements.loading) elements.loading.hidden = false;
|
||||||
|
if (elements.refresh) elements.refresh.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
const week = weekParam();
|
||||||
|
if (week) query.set('week', week);
|
||||||
|
|
||||||
|
const response = await fetch(`api/dashboard_summary.php?${query.toString()}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await readJsonResponse(response);
|
||||||
|
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le tableau de bord.'));
|
||||||
|
|
||||||
|
renderWeek(data.semaine);
|
||||||
|
renderPending(data.validation || {});
|
||||||
|
renderCoverage(data.couverture || {}, data.semaine, data.perimetre);
|
||||||
|
renderQuota(data.quota || {});
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
if (elements.loading) elements.loading.hidden = true;
|
||||||
|
if (elements.refresh) elements.refresh.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
elements.refresh?.addEventListener('click', () => load());
|
||||||
|
elements.week?.addEventListener('change', () => load());
|
||||||
|
elements.pendingList?.addEventListener('click', (event) => {
|
||||||
|
const trigger = event.target.closest('[data-planning-id]');
|
||||||
|
if (!trigger) return;
|
||||||
|
const id = Number(trigger.dataset.planningId);
|
||||||
|
openPendingPlanning(pendingItems.find((item) => Number(item.id_planning) === id));
|
||||||
|
});
|
||||||
|
|
||||||
|
load({ silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
PTA.modules.dashboard = { init, load, refresh: load };
|
||||||
|
})();
|
||||||
@@ -73,8 +73,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pastille « À valider » de la barre de navigation.
|
||||||
|
*
|
||||||
|
* Elle n'était alimentée que sur la page « À valider » elle-même, donc
|
||||||
|
* invisible là où elle sert : depuis n'importe quel écran, le service voit
|
||||||
|
* maintenant si des brouillons attendent. La requête est volontairement
|
||||||
|
* réduite à un compteur, et son échec est silencieux — une commodité ne
|
||||||
|
* doit jamais empêcher une page de fonctionner.
|
||||||
|
*/
|
||||||
|
async function refreshNavigationCounters() {
|
||||||
|
const badge = document.querySelector('#pending-validation-tab-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
// Les écrans qui chargent déjà ce compteur s'en chargent eux-mêmes.
|
||||||
|
if (PTA.modules.pendingValidation || PTA.modules.dashboard) return;
|
||||||
|
if (!PTA.config?.permissions?.can_view_pending) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api/dashboard_summary.php?scope=badges', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await PTA.utils.readJsonResponse(response);
|
||||||
|
const count = Number(data?.validation?.count || 0);
|
||||||
|
badge.textContent = String(count);
|
||||||
|
badge.hidden = count === 0;
|
||||||
|
badge.setAttribute('aria-label', `${count} planning${count > 1 ? 's' : ''} en attente de validation`);
|
||||||
|
} catch (error) {
|
||||||
|
badge.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initialisePlanning();
|
initialisePlanning();
|
||||||
initialiseStructureOverview();
|
initialiseStructureOverview();
|
||||||
initialiseAgentOverview();
|
initialiseAgentOverview();
|
||||||
initialiseCoverage();
|
initialiseCoverage();
|
||||||
|
refreshNavigationCounters();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -110,8 +110,37 @@
|
|||||||
updateAgentPdfButton();
|
updateAgentPdfButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Motif proposé par défaut sur une nouvelle plage : le temps de travail, très
|
||||||
|
* majoritaire à la saisie. On se rabat sur le premier motif décomptant le quota
|
||||||
|
* si le référentiel ne contient pas le code TRAVAIL.
|
||||||
|
*/
|
||||||
|
function defaultMotifId() {
|
||||||
|
const motifs = window.PTA_CONFIG?.motifs || [];
|
||||||
|
const motif = motifs.find((item) => String(item.code).toUpperCase() === 'TRAVAIL')
|
||||||
|
|| motifs.find((item) => item.quota)
|
||||||
|
|| motifs[0];
|
||||||
|
return motif ? Number(motif.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un agent sans droit à l'enveloppe de formation ne peut pas recevoir le motif
|
||||||
|
* « Formation ». Le droit vaut tant que l'agent n'est pas chargé : on ne retire
|
||||||
|
* jamais une possibilité sur une donnée absente.
|
||||||
|
*/
|
||||||
|
function agentHasTrainingRight() {
|
||||||
|
return Number(state.currentAgent?.droit_enveloppe_formation ?? 1) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMotifAllowedForAgent(motif) {
|
||||||
|
return agentHasTrainingRight() || String(motif.code).toUpperCase() !== 'FORMATION';
|
||||||
|
}
|
||||||
|
|
||||||
function motifOptionsHtml(selectedMotifId = null) {
|
function motifOptionsHtml(selectedMotifId = null) {
|
||||||
return (window.PTA_CONFIG?.motifs || [])
|
return (window.PTA_CONFIG?.motifs || [])
|
||||||
|
// Un motif interdit reste proposé s'il est déjà celui de la plage :
|
||||||
|
// le retirer changerait silencieusement le motif d'une saisie existante.
|
||||||
|
.filter((motif) => isMotifAllowedForAgent(motif) || Number(motif.id) === Number(selectedMotifId))
|
||||||
.map((motif) => {
|
.map((motif) => {
|
||||||
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
|
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
|
||||||
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
|
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
|
||||||
@@ -122,13 +151,16 @@
|
|||||||
|
|
||||||
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
|
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
|
||||||
const suffix = `${dayLabel} plage ${index + 1}`;
|
const suffix = `${dayLabel} plage ${index + 1}`;
|
||||||
|
const fallbackMotifId = defaultMotifId();
|
||||||
|
const motifId = selectedMotifId ?? fallbackMotifId;
|
||||||
|
const placeholder = fallbackMotifId === null ? '<option value="">Choisir un motif</option>' : '';
|
||||||
return `
|
return `
|
||||||
<div class="weekly-time-range">
|
<div class="weekly-time-range">
|
||||||
<label class="weekly-range-motif-field">
|
<label class="weekly-range-motif-field">
|
||||||
<span>Motif</span>
|
<span>Motif</span>
|
||||||
<select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}">
|
<select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}">
|
||||||
<option value="">Choisir un motif</option>
|
${placeholder}
|
||||||
${motifOptionsHtml(selectedMotifId)}
|
${motifOptionsHtml(motifId)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -649,6 +681,7 @@
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.otherStructureEntries = [];
|
state.otherStructureEntries = [];
|
||||||
state.currentStructure = null;
|
state.currentStructure = null;
|
||||||
|
state.currentAgent = null;
|
||||||
state.planningId = null;
|
state.planningId = null;
|
||||||
state.quota = null;
|
state.quota = null;
|
||||||
state.savedCurrentCountedMinutes = 0;
|
state.savedCurrentCountedMinutes = 0;
|
||||||
@@ -710,6 +743,7 @@
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.otherStructureEntries = [];
|
state.otherStructureEntries = [];
|
||||||
state.currentStructure = null;
|
state.currentStructure = null;
|
||||||
|
state.currentAgent = null;
|
||||||
state.planningId = null;
|
state.planningId = null;
|
||||||
state.savedCurrentCountedMinutes = 0;
|
state.savedCurrentCountedMinutes = 0;
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -728,6 +762,7 @@
|
|||||||
|
|
||||||
state.quota = data.quota || null;
|
state.quota = data.quota || null;
|
||||||
state.currentStructure = data.current_structure || null;
|
state.currentStructure = data.current_structure || null;
|
||||||
|
state.currentAgent = data.agent || null;
|
||||||
const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day]));
|
const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day]));
|
||||||
state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) }));
|
state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) }));
|
||||||
state.otherStructureEntries = (data.other_entries || []).map((entry) => ({
|
state.otherStructureEntries = (data.other_entries || []).map((entry) => ({
|
||||||
@@ -819,6 +854,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isMotifAllowedForAgent(motif)) {
|
||||||
|
errors.push(`${day?.label || row.dataset.date} ${label} : le motif « ${motif.label} » n’est pas ouvert à cet agent, qui n’a pas droit à l’enveloppe de formation.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!start || !end) {
|
if (!start || !end) {
|
||||||
errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`);
|
errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
|
|
||||||
templateSelect.disabled = false;
|
templateSelect.disabled = false;
|
||||||
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
||||||
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
|
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
|
||||||
)).join('');
|
)).join('');
|
||||||
renderSelectedTemplateDetails();
|
renderSelectedTemplateDetails();
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
}
|
}
|
||||||
periodTemplateSelect.disabled = false;
|
periodTemplateSelect.disabled = false;
|
||||||
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
||||||
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
|
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
|
||||||
)).join('');
|
)).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
463
assets/style.css
463
assets/style.css
@@ -2553,3 +2553,466 @@ textarea:disabled {
|
|||||||
@media print {
|
@media print {
|
||||||
.annual-validation-card { display: none !important; }
|
.annual-validation-card { display: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PTA V7.9 - Navigation regroupée et tableau de bord
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* --- Barre de navigation par intention --------------------- */
|
||||||
|
.view-tabs-grouped {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.45rem 0.35rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tabs-grouped .view-tab-group + .view-tab-group {
|
||||||
|
border-left: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab-group-label {
|
||||||
|
padding-left: 0.35rem;
|
||||||
|
color: #7b8794;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab-group-items {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tabs-grouped .view-tab {
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sans titres de groupe, la barre retrouve son alignement d'origine. */
|
||||||
|
.view-tabs:not(.view-tabs-grouped) .view-tab-group,
|
||||||
|
.view-tabs:not(.view-tabs-grouped) .view-tab-group-items {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-identity { min-width: 0; }
|
||||||
|
.page-header-identity .subtitle { max-width: 68ch; }
|
||||||
|
|
||||||
|
/* --- Tuiles d'action --------------------------------------- */
|
||||||
|
.dashboard-context-card .section-heading { align-items: flex-end; }
|
||||||
|
.dashboard-context-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.dashboard-week-field { min-width: 190px; }
|
||||||
|
.dashboard-context-note {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 10px 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 5px solid #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e3a5f;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tile {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid #e4e8eb;
|
||||||
|
border-left: 6px solid #cbd5e1;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 6px 24px rgba(23, 32, 42, 0.05);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.dashboard-tile:hover,
|
||||||
|
.dashboard-tile:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 28px rgba(23, 32, 42, 0.11);
|
||||||
|
}
|
||||||
|
.dashboard-tile-label {
|
||||||
|
color: #5f6b7a;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.dashboard-tile-value { font-size: 2.1rem; line-height: 1.1; }
|
||||||
|
.dashboard-tile-detail { color: #66737d; font-size: 0.84rem; }
|
||||||
|
.dashboard-tile-cta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #1f4c73;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.dashboard-tile-cta::after { content: ' →'; }
|
||||||
|
|
||||||
|
.dashboard-tile.is-alert { border-left-color: #c72f3f; }
|
||||||
|
.dashboard-tile.is-alert .dashboard-tile-value { color: #a3212f; }
|
||||||
|
.dashboard-tile.is-clear { border-left-color: #2f855a; }
|
||||||
|
.dashboard-tile.is-clear .dashboard-tile-value { color: #256d3b; }
|
||||||
|
|
||||||
|
/* --- Listes « à traiter » ---------------------------------- */
|
||||||
|
.dashboard-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.dashboard-columns > .card { margin-bottom: 0; }
|
||||||
|
.dashboard-list-card { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.dashboard-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.dashboard-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid #e6ebee;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #f9fbfc;
|
||||||
|
}
|
||||||
|
.dashboard-list-main { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.dashboard-list-main strong { display: block; }
|
||||||
|
.dashboard-list-main small { color: #66737d; font-size: 0.82rem; }
|
||||||
|
.dashboard-list-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dashboard-list-meta span { color: #66737d; font-size: 0.78rem; }
|
||||||
|
.dashboard-list-action {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dashboard-gap-count { color: #a3212f; }
|
||||||
|
|
||||||
|
.dashboard-inline-note {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 5px solid #d97706;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #7d5c00;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Suivi des quotas -------------------------------------- */
|
||||||
|
.dashboard-quota-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.dashboard-quota-table-wrap { overflow-x: auto; }
|
||||||
|
.dashboard-quota-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.dashboard-quota-table th,
|
||||||
|
.dashboard-quota-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eceff1;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.dashboard-quota-table th {
|
||||||
|
color: #5f6b7a;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.dashboard-quota-table td small { display: block; color: #66737d; font-size: 0.78rem; }
|
||||||
|
.dashboard-quota-table tr.is-over-quota { background: #fef4f4; }
|
||||||
|
.dashboard-quota-action-cell { text-align: right; }
|
||||||
|
.dashboard-quota-action-cell .button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 13px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quota-gauge-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Jauge native : la CSP interdit l'attribut style, la largeur ne peut donc pas
|
||||||
|
être calculée en JavaScript sur une simple div. */
|
||||||
|
.dashboard-quota-gauge {
|
||||||
|
width: 110px;
|
||||||
|
height: 9px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e4e9ec;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.dashboard-quota-gauge::-webkit-progress-bar { background: #e4e9ec; border-radius: 999px; }
|
||||||
|
.dashboard-quota-gauge::-webkit-progress-value { background: #2f6f9f; border-radius: 999px; }
|
||||||
|
.dashboard-quota-gauge::-moz-progress-bar { background: #2f6f9f; border-radius: 999px; }
|
||||||
|
.dashboard-quota-gauge.is-over::-webkit-progress-value { background: #c72f3f; }
|
||||||
|
.dashboard-quota-gauge.is-over::-moz-progress-bar { background: #c72f3f; }
|
||||||
|
|
||||||
|
/* --- Raccourcis -------------------------------------------- */
|
||||||
|
.dashboard-shortcuts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.dashboard-shortcut {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #e4e8eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f9fbfc;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dashboard-shortcut:hover,
|
||||||
|
.dashboard-shortcut:focus-visible { border-color: #b9c6cf; background: #fff; }
|
||||||
|
.dashboard-shortcut small { color: #66737d; font-size: 0.82rem; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.view-tabs-grouped { flex-direction: column; align-items: flex-start; gap: 0.6rem; }
|
||||||
|
.view-tabs-grouped .view-tab-group + .view-tab-group { border-left: 0; }
|
||||||
|
.view-tab-group { width: 100%; padding: 0; }
|
||||||
|
.view-tab-group-items { flex-wrap: wrap; }
|
||||||
|
.dashboard-context-card .section-heading { align-items: stretch; }
|
||||||
|
.dashboard-context-actions { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.dashboard-list-item { flex-wrap: wrap; }
|
||||||
|
.dashboard-list-meta { text-align: left; }
|
||||||
|
.dashboard-list-action { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PTA V7.10 - Guide d'utilisation (FAQ)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* --- Bouton d'accès ancré à droite de la navigation --------- */
|
||||||
|
/* La barre défile horizontalement quand les onglets débordent ;
|
||||||
|
`position: sticky` garde le guide visible quoi qu'il arrive. */
|
||||||
|
.view-tab-help {
|
||||||
|
display: flex;
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 0.55rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: -14px 0 12px -10px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tabs-grouped .view-tab-help { border-left: 1px solid #e2e8f0; }
|
||||||
|
|
||||||
|
.view-tab-faq {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: #263238;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab-faq-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #263238;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab-faq:hover,
|
||||||
|
.view-tab-faq:focus-visible { background: #fff; }
|
||||||
|
|
||||||
|
/* --- Mise en page du guide --------------------------------- */
|
||||||
|
.faq-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-summary {
|
||||||
|
position: sticky;
|
||||||
|
top: 18px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-summary-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #7b8794;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-summary-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: #66737d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-summary-list li { margin-bottom: 7px; }
|
||||||
|
.faq-summary-list a { color: #263238; text-decoration: none; }
|
||||||
|
.faq-summary-list a:hover,
|
||||||
|
.faq-summary-list a:focus-visible { text-decoration: underline; }
|
||||||
|
|
||||||
|
.faq-summary-note {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #eceff1;
|
||||||
|
color: #66737d;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-section { scroll-margin-top: 18px; }
|
||||||
|
.faq-section h3 { margin: 22px 0 8px; font-size: 1rem; }
|
||||||
|
.faq-section p { line-height: 1.55; }
|
||||||
|
.faq-section code {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #eef1f4;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
.faq-section kbd {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid #cfd8dc;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Le parcours en huit étapes ---------------------------- */
|
||||||
|
.faq-flow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-flow li {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border: 1px solid #e4e8eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f9fbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-flow-step {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #263238;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-flow li small { color: #66737d; font-size: 0.82rem; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* --- Étapes numérotées et listes --------------------------- */
|
||||||
|
.faq-steps { margin: 0; padding-left: 1.3rem; }
|
||||||
|
.faq-steps li { margin-bottom: 9px; line-height: 1.55; }
|
||||||
|
.faq-list { margin: 0 0 4px; padding-left: 1.3rem; }
|
||||||
|
.faq-list li { margin-bottom: 7px; line-height: 1.55; }
|
||||||
|
|
||||||
|
.faq-callout { margin: 16px 0 0; }
|
||||||
|
|
||||||
|
.faq-table th,
|
||||||
|
.faq-table td { vertical-align: top; line-height: 1.5; }
|
||||||
|
.faq-table tr.faq-row-current { background: #f2f7f4; }
|
||||||
|
|
||||||
|
.faq-level {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.faq-level-error { background: #fdecec; color: #a3212f; }
|
||||||
|
.faq-level-warning { background: #fffbeb; color: #7d5c00; }
|
||||||
|
.faq-level-info { background: #eef2f7; color: #37474f; }
|
||||||
|
|
||||||
|
/* --- Questions / réponses ---------------------------------- */
|
||||||
|
.faq-qa { margin: 0; }
|
||||||
|
.faq-qa dt {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.faq-qa dt:first-child { margin-top: 0; }
|
||||||
|
.faq-qa dd {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
padding-left: 13px;
|
||||||
|
border-left: 3px solid #e4e8eb;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.faq-layout { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.faq-summary { position: static; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.view-tab-help {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.view-tabs-grouped .view-tab-help { border-left: 0; }
|
||||||
|
}
|
||||||
|
|||||||
7
dashboard.php
Normal file
7
dashboard.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/page_bootstrap.php';
|
||||||
|
|
||||||
|
runPage('dashboard');
|
||||||
201
database/migration_v7_8_securite_acces.sql
Normal file
201
database/migration_v7_8_securite_acces.sql
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PTA V7.8 — Sécurisation : les règles d'accès descendent en base
|
||||||
|
-- ============================================================================
|
||||||
|
-- À exécuter UNE SEULE FOIS sur une base PTA V7.7 existante :
|
||||||
|
-- SOURCE database/migration_v7_8_securite_acces.sql;
|
||||||
|
--
|
||||||
|
-- Objectif : plus aucune règle de périmètre ni de droit n'est reconstruite en
|
||||||
|
-- PHP. Le contrôleur pose une question à la base, la base répond oui ou non.
|
||||||
|
-- Toute évolution des droits se fait désormais en SQL, sans redéploiement.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 1. MATRICE DES DROITS : rôle -> pages et rôle -> permissions
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS acces_role_page (
|
||||||
|
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
|
||||||
|
page VARCHAR(50) NOT NULL,
|
||||||
|
ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100,
|
||||||
|
|
||||||
|
PRIMARY KEY (role, page)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acces_role_permission (
|
||||||
|
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
|
||||||
|
permission VARCHAR(50) NOT NULL,
|
||||||
|
autorise BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
PRIMARY KEY (role, permission)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Pages autorisées — reprise à l'identique des droits V7.7.
|
||||||
|
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
|
||||||
|
('AGENT', 'agents', 10),
|
||||||
|
('AGENT', 'structures', 20),
|
||||||
|
|
||||||
|
('RESPONSABLE_STRUCTURE', 'planning', 10),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'structures', 20),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'agents', 30),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'coverage', 40),
|
||||||
|
|
||||||
|
('SERVICE_ENFANCE', 'planning', 10),
|
||||||
|
('SERVICE_ENFANCE', 'pending', 20),
|
||||||
|
('SERVICE_ENFANCE', 'coverage', 30),
|
||||||
|
('SERVICE_ENFANCE', 'structures', 40),
|
||||||
|
('SERVICE_ENFANCE', 'agents', 50),
|
||||||
|
('SERVICE_ENFANCE', 'annual', 60),
|
||||||
|
('SERVICE_ENFANCE', 'pta', 70),
|
||||||
|
('SERVICE_ENFANCE', 'teams', 80),
|
||||||
|
('SERVICE_ENFANCE', 'vacations', 90),
|
||||||
|
('SERVICE_ENFANCE', 'administration', 100)
|
||||||
|
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
|
||||||
|
|
||||||
|
-- Permissions fonctionnelles — reprise à l'identique des droits V7.7.
|
||||||
|
INSERT INTO acces_role_permission (role, permission, autorise) VALUES
|
||||||
|
('AGENT', 'read_only', TRUE),
|
||||||
|
('AGENT', 'can_edit_draft', FALSE),
|
||||||
|
('AGENT', 'can_edit_cross_structure', FALSE),
|
||||||
|
('AGENT', 'can_validate', FALSE),
|
||||||
|
('AGENT', 'can_reopen', FALSE),
|
||||||
|
('AGENT', 'can_use_templates', FALSE),
|
||||||
|
('AGENT', 'can_copy_full_week', FALSE),
|
||||||
|
('AGENT', 'can_view_pending', FALSE),
|
||||||
|
('AGENT', 'can_manage_coverage', FALSE),
|
||||||
|
('AGENT', 'can_manage_agents', FALSE),
|
||||||
|
('AGENT', 'can_manage_structures', FALSE),
|
||||||
|
('AGENT', 'can_manage_vacations', FALSE),
|
||||||
|
('AGENT', 'can_manage_teams', FALSE),
|
||||||
|
('AGENT', 'can_manage_pta', FALSE),
|
||||||
|
('AGENT', 'can_forecast_next_year', FALSE),
|
||||||
|
|
||||||
|
('RESPONSABLE_STRUCTURE', 'read_only', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_edit_draft', TRUE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_edit_cross_structure', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_validate', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_reopen', TRUE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_use_templates', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_copy_full_week', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_view_pending', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_coverage', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_agents', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_structures', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_vacations', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_teams', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_pta', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_forecast_next_year', FALSE),
|
||||||
|
|
||||||
|
('SERVICE_ENFANCE', 'read_only', FALSE),
|
||||||
|
('SERVICE_ENFANCE', 'can_edit_draft', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_edit_cross_structure', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_validate', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_reopen', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_use_templates', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_copy_full_week', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_view_pending', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_coverage', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_agents', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_structures', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_vacations', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_teams', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_pta', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_forecast_next_year', TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE autorise = VALUES(autorise);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 2. VUES DE DROITS
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_acces_role_page AS
|
||||||
|
SELECT role, page, ordre_affichage
|
||||||
|
FROM acces_role_page;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_acces_role_permission AS
|
||||||
|
SELECT role, permission, autorise
|
||||||
|
FROM acces_role_permission;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 3. VUES D'IDENTITÉ
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Identité complète d'un agent actif : sert à valider la sélection de rôle et
|
||||||
|
-- à construire le bandeau de profil. Un agent inactif n'apparaît jamais ici,
|
||||||
|
-- il ne peut donc pas être choisi comme identité.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_identite_agent AS
|
||||||
|
SELECT
|
||||||
|
a.id_agent,
|
||||||
|
a.matricule,
|
||||||
|
a.nom,
|
||||||
|
a.prenom,
|
||||||
|
a.id_structure,
|
||||||
|
s.nom AS structure_nom,
|
||||||
|
s.code AS structure_code
|
||||||
|
FROM agent a
|
||||||
|
LEFT JOIN structure s
|
||||||
|
ON s.id_structure = a.id_structure
|
||||||
|
AND s.actif = TRUE
|
||||||
|
WHERE a.actif = TRUE;
|
||||||
|
|
||||||
|
-- Lieux d'affectation sélectionnables par un responsable de structure.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_identite_structure AS
|
||||||
|
SELECT
|
||||||
|
s.id_structure,
|
||||||
|
s.code,
|
||||||
|
s.nom,
|
||||||
|
s.type_affectation
|
||||||
|
FROM structure s
|
||||||
|
WHERE s.actif = TRUE;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 4. VUES DE PÉRIMÈTRE
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Quels agents un lieu d'affectation a-t-il le droit de consulter ?
|
||||||
|
-- Deux origines : le rattachement principal, ou une planification effective
|
||||||
|
-- sur ce lieu. C'est la règle exacte que le PHP reconstruisait à la main.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_agent_par_structure AS
|
||||||
|
SELECT DISTINCT
|
||||||
|
a.id_agent,
|
||||||
|
a.id_structure AS id_structure_autorisee,
|
||||||
|
'RATTACHEMENT' AS origine
|
||||||
|
FROM agent a
|
||||||
|
WHERE a.actif = TRUE
|
||||||
|
AND a.id_structure IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT
|
||||||
|
a.id_agent,
|
||||||
|
p.id_structure AS id_structure_autorisee,
|
||||||
|
'PLANIFICATION' AS origine
|
||||||
|
FROM agent a
|
||||||
|
INNER JOIN planning p ON p.id_agent = a.id_agent
|
||||||
|
WHERE a.actif = TRUE;
|
||||||
|
|
||||||
|
-- Périmètre d'un créneau : à quel lieu et à quel planning appartient-il ?
|
||||||
|
-- Utilisé avant toute modification ou suppression d'affectation.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_perimetre_creneau AS
|
||||||
|
SELECT
|
||||||
|
c.id_creneau,
|
||||||
|
c.id_planning,
|
||||||
|
p.id_structure,
|
||||||
|
p.id_agent,
|
||||||
|
p.statut AS statut_planning,
|
||||||
|
s.nom AS structure_nom
|
||||||
|
FROM creneau_horaire c
|
||||||
|
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||||
|
INNER JOIN structure s ON s.id_structure = p.id_structure;
|
||||||
|
|
||||||
|
-- Périmètre d'un planning : utilisé avant validation ou réouverture.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_perimetre_planning AS
|
||||||
|
SELECT
|
||||||
|
p.id_planning,
|
||||||
|
p.id_structure,
|
||||||
|
p.id_agent,
|
||||||
|
p.statut AS statut_planning,
|
||||||
|
s.nom AS structure_nom,
|
||||||
|
a.nom AS agent_nom,
|
||||||
|
a.prenom AS agent_prenom
|
||||||
|
FROM planning p
|
||||||
|
INNER JOIN structure s ON s.id_structure = p.id_structure
|
||||||
|
INNER JOIN agent a ON a.id_agent = p.id_agent;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Vérification recommandée après import :
|
||||||
|
-- SELECT role, COUNT(*) FROM acces_role_page GROUP BY role;
|
||||||
|
-- SELECT * FROM v_acces_agent_par_structure LIMIT 10;
|
||||||
|
-- ============================================================================
|
||||||
26
database/migration_v7_9_tableau_de_bord.sql
Normal file
26
database/migration_v7_9_tableau_de_bord.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PTA V7.9 — Tableau de bord du Service Enfance
|
||||||
|
-- ============================================================================
|
||||||
|
-- À exécuter UNE SEULE FOIS sur une base PTA V7.8 existante :
|
||||||
|
-- SOURCE database/migration_v7_9_tableau_de_bord.sql;
|
||||||
|
--
|
||||||
|
-- La page « dashboard » suit la règle posée en V7.8 : une page absente de
|
||||||
|
-- acces_role_page est refusée. Tant que cette migration n'est pas jouée,
|
||||||
|
-- l'onglet n'apparaît pas et le Service Enfance atterrit comme avant sur
|
||||||
|
-- l'écran de saisie — l'application reste fonctionnelle, sans tableau de bord.
|
||||||
|
--
|
||||||
|
-- Aucune table, aucune vue, aucune donnée métier n'est modifiée : le tableau de
|
||||||
|
-- bord ne fait que relire ce que les autres écrans calculent déjà.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
|
||||||
|
('SERVICE_ENFANCE', 'dashboard', 5)
|
||||||
|
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Vérification après import :
|
||||||
|
-- SELECT * FROM v_acces_role_page WHERE role = 'SERVICE_ENFANCE' ORDER BY ordre_affichage;
|
||||||
|
--
|
||||||
|
-- Retour arrière :
|
||||||
|
-- DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard';
|
||||||
|
-- ============================================================================
|
||||||
23
database/migration_v7_9b_droit_enveloppe_formation.sql
Normal file
23
database/migration_v7_9b_droit_enveloppe_formation.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PTA — Droit à l'enveloppe annuelle de formation (14 h)
|
||||||
|
-- ============================================================================
|
||||||
|
-- Tous les agents ne bénéficient pas de l'enveloppe complémentaire de 14 h de
|
||||||
|
-- formation. Cette colonne porte ce droit, coché par défaut à la création d'un
|
||||||
|
-- agent. Un agent sans ce droit ne peut plus se voir affecter le motif
|
||||||
|
-- « Formation » lors de la saisie des horaires, et son enveloppe de formation
|
||||||
|
-- au PTA annuel vaut 0.
|
||||||
|
--
|
||||||
|
-- La colonne ne porte volontairement pas de clause AFTER : `migration_v7_9c`
|
||||||
|
-- retire `formation_repartition_annuelle`, qui aurait servi de repère, et le
|
||||||
|
-- socle ne la crée plus du tout sur une base neuve.
|
||||||
|
--
|
||||||
|
-- Nommage : les migrations sont jouées dans l'ordre alphabétique. Le suffixe
|
||||||
|
-- « 9b » place ce fichier après migration_v7_9_tableau_de_bord.sql, ce qu'un
|
||||||
|
-- « v7_10 » ou « v7_11 » n'aurait pas fait ('1' trie avant '8' et '9').
|
||||||
|
--
|
||||||
|
-- Idempotente : rejouable sans effet de bord.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE agent
|
||||||
|
ADD COLUMN IF NOT EXISTS droit_enveloppe_formation BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
COMMENT 'Droit à l''enveloppe annuelle de 14 h de formation';
|
||||||
17
database/migration_v7_9c_retrait_formation_repartition.sql
Normal file
17
database/migration_v7_9c_retrait_formation_repartition.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PTA — Retrait de `agent.formation_repartition_annuelle`
|
||||||
|
-- ============================================================================
|
||||||
|
-- Cette colonne portait une seconde case à cocher sur la fiche agent : « une
|
||||||
|
-- formation est déjà prévue dans la répartition annuelle, ne pas ajouter
|
||||||
|
-- l'enveloppe complémentaire de 14 h ». Elle faisait doublon avec le droit à
|
||||||
|
-- l'enveloppe de formation (`droit_enveloppe_formation`, migration v7_9b), seul
|
||||||
|
-- conservé : l'enveloppe vaut 14 h si l'agent y a droit, 0 sinon.
|
||||||
|
--
|
||||||
|
-- Le calcul de `pta_annuel.enveloppe_formation_minutes` ne dépend donc plus que
|
||||||
|
-- de `droit_enveloppe_formation` (AgentModel::trainingEnvelopeMinutes()).
|
||||||
|
--
|
||||||
|
-- Idempotente : rejouable sans effet de bord.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE agent
|
||||||
|
DROP COLUMN IF EXISTS formation_repartition_annuelle;
|
||||||
30
database/migration_v7_9d_faq.sql
Normal file
30
database/migration_v7_9d_faq.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PTA — Guide d'utilisation (page « faq »)
|
||||||
|
-- ============================================================================
|
||||||
|
-- Appliquée automatiquement au démarrage par le service db-migrate.
|
||||||
|
-- Pour un import manuel sur une base existante :
|
||||||
|
-- SOURCE database/migration_v7_9d_faq.sql;
|
||||||
|
--
|
||||||
|
-- Le guide suit la règle posée en V7.8 : une page absente de acces_role_page
|
||||||
|
-- est refusée. Il est ouvert aux trois rôles — c'est un secours, pas un droit
|
||||||
|
-- métier — et son bouton d'accès n'apparaît dans la barre de navigation que si
|
||||||
|
-- cette migration a été jouée.
|
||||||
|
--
|
||||||
|
-- La page est purement statique : aucune table, aucune vue, aucune donnée
|
||||||
|
-- métier n'est touchée. L'ordre d'affichage est indicatif, le bouton étant
|
||||||
|
-- ancré à droite de la barre plutôt que rangé dans un groupe d'onglets.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
|
||||||
|
('AGENT', 'faq', 200),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'faq', 200),
|
||||||
|
('SERVICE_ENFANCE', 'faq', 200)
|
||||||
|
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Vérification après import :
|
||||||
|
-- SELECT role, page FROM v_acces_role_page WHERE page = 'faq';
|
||||||
|
--
|
||||||
|
-- Retour arrière :
|
||||||
|
-- DELETE FROM acces_role_page WHERE page = 'faq';
|
||||||
|
-- ============================================================================
|
||||||
@@ -105,7 +105,6 @@ CREATE TABLE agent (
|
|||||||
id_poste INT UNSIGNED NULL,
|
id_poste INT UNSIGNED NULL,
|
||||||
type_contrat ENUM('PERMANENT', 'TEMPORAIRE') NOT NULL DEFAULT 'PERMANENT',
|
type_contrat ENUM('PERMANENT', 'TEMPORAIRE') NOT NULL DEFAULT 'PERMANENT',
|
||||||
date_fin_contrat DATE NULL,
|
date_fin_contrat DATE NULL,
|
||||||
formation_repartition_annuelle BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
commentaire_pta VARCHAR(1000) NULL,
|
commentaire_pta VARCHAR(1000) NULL,
|
||||||
id_structure INT UNSIGNED NULL COMMENT 'Lieu principal de rattachement',
|
id_structure INT UNSIGNED NULL COMMENT 'Lieu principal de rattachement',
|
||||||
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|||||||
6
db.php
6
db.php
@@ -20,8 +20,12 @@ try {
|
|||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
]);
|
]);
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
|
// Le message PDO contient l'hôte, l'utilisateur et le nom de la base :
|
||||||
|
// il est journalisé côté serveur, jamais propagé vers le navigateur.
|
||||||
|
error_log(sprintf('[PTA] Connexion base impossible : %s', $e->getMessage()));
|
||||||
|
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
'Connexion à la base impossible. Vérifiez config.php, le nom de la base et que MySQL est démarré. Détail : ' . $e->getMessage(),
|
'Connexion à la base impossible. Vérifiez la configuration de la base de données et que le serveur MySQL est démarré.',
|
||||||
0,
|
0,
|
||||||
$e
|
$e
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,36 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Pile de test de la branche V7.9.
|
||||||
|
# ============================================================================
|
||||||
|
# Noms de conteneurs, réseau, volume et ports publiés sont suffixés « test » :
|
||||||
|
# cette pile tourne EN PARALLÈLE de la pile principale (pta-db, pta-php, …)
|
||||||
|
# sans lui prendre ni ses conteneurs, ni ses données, ni ses ports.
|
||||||
|
#
|
||||||
|
# `docker compose up -d --build` suffit : le service db-migrate applique
|
||||||
|
# lui-même les migrations SQL, dont celle du tableau de bord V7.9.
|
||||||
|
#
|
||||||
|
# Ce répertoire n'a volontairement pas de fichier .env (il est ignoré par git) :
|
||||||
|
# les valeurs par défaut ci-dessous s'appliquent donc telles quelles, et les
|
||||||
|
# ports publiés (9080 / 9081 / 9306) ne peuvent pas entrer en conflit avec la
|
||||||
|
# pile principale (8080 / 8081). Si vous y copiez le .env de la pile principale,
|
||||||
|
# pensez à y changer APP_PORT et PMA_PORT.
|
||||||
|
#
|
||||||
|
# ATTENTION — deux familles de ports à ne pas confondre :
|
||||||
|
#
|
||||||
|
# · ports PUBLIÉS sur la machine hôte, dans les blocs `ports:` :
|
||||||
|
# 9080 application 9081 phpMyAdmin 9306 MariaDB
|
||||||
|
# Ceux-là se changent librement, ce sont eux qu'on tape dans un navigateur
|
||||||
|
# ou dans un client SQL.
|
||||||
|
#
|
||||||
|
# · ports JOINTS À L'INTÉRIEUR du réseau Docker, dans les blocs
|
||||||
|
# `environment:` (PTA_DB_PORT, DB_PORT, PMA_PORT) : MariaDB y écoute
|
||||||
|
# TOUJOURS sur 3306, quel que soit le port publié. Y mettre autre chose
|
||||||
|
# coupe l'accès à la base et l'application renvoie « Connexion à la base
|
||||||
|
# impossible ».
|
||||||
|
# ============================================================================
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
container_name: pta-db
|
container_name: pta-db-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MARIADB_DATABASE: ${DB_NAME:-pta}
|
MARIADB_DATABASE: ${DB_NAME:-pta}
|
||||||
@@ -13,9 +42,14 @@ services:
|
|||||||
- --character-set-server=utf8mb4
|
- --character-set-server=utf8mb4
|
||||||
- --collation-server=utf8mb4_unicode_ci
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
volumes:
|
volumes:
|
||||||
- mariadb_data:/var/lib/mysql
|
- mariadb_data_test:/var/lib/mysql
|
||||||
# Ce fichier n'est exécuté QUE lors de l'initialisation d'un volume vide.
|
# Ce fichier n'est exécuté QUE lors de l'initialisation d'un volume vide.
|
||||||
- ./database/pta_prod_schema_mariadb.sql:/docker-entrypoint-initdb.d/01-pta-schema.sql:ro
|
- ./database/pta_prod_schema_mariadb.sql:/docker-entrypoint-initdb.d/01-pta-schema.sql:ro
|
||||||
|
ports:
|
||||||
|
# Base joignable depuis la machine hôte pour un client SQL externe, sur la
|
||||||
|
# boucle locale uniquement. À l'intérieur du réseau Docker, les services
|
||||||
|
# continuent de joindre MariaDB sur son port natif 3306.
|
||||||
|
- "${DB_BIND:-127.0.0.1}:${DB_PORT_HOST:-9306}:3306"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -p\"$${MARIADB_ROOT_PASSWORD}\" --silent"]
|
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -p\"$${MARIADB_ROOT_PASSWORD}\" --silent"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -23,65 +57,108 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
networks:
|
networks:
|
||||||
- pta
|
- pta-test
|
||||||
|
|
||||||
|
# Service jetable : applique les migrations SQL puis s'arrête. Le service php
|
||||||
|
# ne démarre qu'après sa sortie en succès, si bien qu'une migration en échec
|
||||||
|
# empêche l'application de tourner sur une base incomplète.
|
||||||
|
db-migrate:
|
||||||
|
image: mariadb:11.4
|
||||||
|
container_name: pta-db-migrate-test
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
# Port joint À L'INTÉRIEUR du réseau Docker : MariaDB y écoute sur 3306.
|
||||||
|
# Le port publié sur l'hôte (9306) n'a pas cours ici.
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: ${DB_NAME:-pta}
|
||||||
|
# Les migrations créent tables et vues : elles passent par le compte
|
||||||
|
# d'administration, jamais par le compte applicatif.
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: ${DB_ROOT_PASSWORD:-pta}
|
||||||
|
TZ: ${TZ:-Europe/Paris}
|
||||||
|
volumes:
|
||||||
|
- ./database:/migrations:ro
|
||||||
|
- ./docker/db/apply-migrations.sh:/usr/local/bin/apply-migrations.sh:ro
|
||||||
|
entrypoint: ["/bin/sh", "/usr/local/bin/apply-migrations.sh"]
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- pta-test
|
||||||
|
|
||||||
php:
|
php:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: php
|
target: php
|
||||||
container_name: pta-php
|
container_name: pta-php-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PTA_DB_HOST: db
|
PTA_DB_HOST: db
|
||||||
|
# Port joint À L'INTÉRIEUR du réseau Docker, où MariaDB écoute sur 3306.
|
||||||
|
# Ce n'est pas le port publié sur l'hôte : pour joindre la base depuis la
|
||||||
|
# machine, utiliser 127.0.0.1:9306, publié par le service db ci-dessus.
|
||||||
PTA_DB_PORT: 3306
|
PTA_DB_PORT: 3306
|
||||||
PTA_DB_NAME: ${DB_NAME:-pta}
|
PTA_DB_NAME: ${DB_NAME:-pta}
|
||||||
PTA_DB_USER: ${DB_USER:-pta}
|
PTA_DB_USER: ${DB_USER:-pta}
|
||||||
PTA_DB_PASS: ${DB_PASSWORD:-change_me}
|
# Le défaut doit rester identique à MARIADB_PASSWORD du service db :
|
||||||
|
# deux défauts différents rendent la pile inutilisable sans fichier .env.
|
||||||
|
PTA_DB_PASS: ${DB_PASSWORD:-pta}
|
||||||
|
# Aucun détail technique n'est renvoyé au navigateur tant que ce drapeau
|
||||||
|
# n'est pas explicitement passé à 1. Ne jamais l'activer en production.
|
||||||
|
PTA_DEBUG: ${PTA_DEBUG:-0}
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
db-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- pta
|
- pta-test
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: nginx
|
target: nginx
|
||||||
container_name: pta-nginx
|
container_name: pta-nginx-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-8080}:80"
|
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-9080}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- php
|
- php
|
||||||
networks:
|
networks:
|
||||||
- pta
|
- pta-test
|
||||||
|
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
image: phpmyadmin:5.2.2
|
image: phpmyadmin:5.2.2
|
||||||
container_name: pta-phpmyadmin
|
container_name: pta-phpmyadmin-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PMA_HOST: db
|
PMA_HOST: db
|
||||||
|
# Port interne au réseau Docker, jamais un port publié.
|
||||||
PMA_PORT: 3306
|
PMA_PORT: 3306
|
||||||
PMA_USER: ${DB_USER:-pta}
|
# PMA_USER / PMA_PASSWORD sont volontairement absents : leur présence fait
|
||||||
PMA_PASSWORD: ${DB_PASSWORD:-change_me}
|
# basculer phpMyAdmin en auth_type=config, c'est-à-dire une session déjà
|
||||||
|
# authentifiée pour quiconque atteint le port. Sans eux, phpMyAdmin exige
|
||||||
|
# un identifiant et un mot de passe MariaDB à chaque connexion.
|
||||||
UPLOAD_LIMIT: 64M
|
UPLOAD_LIMIT: 64M
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
ports:
|
ports:
|
||||||
# Par défaut phpMyAdmin n'est accessible que depuis la machine hôte.
|
# phpMyAdmin n'écoute que sur la boucle locale : il donne un accès complet
|
||||||
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-8081}:80"
|
# à la base. Pour y accéder à distance, passer par un tunnel SSH
|
||||||
|
# (voir README_DOCKER.md), plutôt que d'ouvrir ce port sur le réseau.
|
||||||
|
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-9081}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- pta
|
- pta-test
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pta:
|
pta-test:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadb_data:
|
mariadb_data_test:
|
||||||
|
|||||||
153
docker/db/apply-migrations.sh
Executable file
153
docker/db/apply-migrations.sh
Executable file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ============================================================================
|
||||||
|
# PTA — application automatique des migrations SQL au démarrage de la stack
|
||||||
|
# ============================================================================
|
||||||
|
# Exécuté par le service jetable `db-migrate` du docker-compose, une fois que
|
||||||
|
# MariaDB est déclaré sain. Le service `php` attend la fin de ce script en
|
||||||
|
# succès : si une migration échoue, l'application ne démarre pas du tout.
|
||||||
|
#
|
||||||
|
# Le schéma initial (database/pta_prod_schema_mariadb.sql) reste joué par
|
||||||
|
# docker-entrypoint-initdb.d à la création du volume. Ce script ne traite que
|
||||||
|
# les fichiers `migration_*.sql`, dans l'ordre alphabétique, sur une base déjà
|
||||||
|
# existante comme sur une base fraîchement créée.
|
||||||
|
#
|
||||||
|
# Un registre `schema_migration` mémorise chaque fichier appliqué avec son
|
||||||
|
# empreinte. Un fichier déjà appliqué et inchangé est ignoré ; un fichier
|
||||||
|
# modifié est rejoué — les migrations PTA sont écrites idempotentes
|
||||||
|
# (CREATE TABLE IF NOT EXISTS, INSERT ... ON DUPLICATE KEY UPDATE,
|
||||||
|
# CREATE OR REPLACE VIEW), les rejouer est sans effet de bord.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DB_HOST="${DB_HOST:-db}"
|
||||||
|
DB_PORT="${DB_PORT:-3306}"
|
||||||
|
DB_NAME="${DB_NAME:-pta}"
|
||||||
|
DB_USER="${DB_USER:-root}"
|
||||||
|
MIGRATIONS_DIR="${MIGRATIONS_DIR:-/migrations}"
|
||||||
|
|
||||||
|
# Le mot de passe passe par MYSQL_PWD et jamais par la ligne de commande :
|
||||||
|
# un `ps` dans le conteneur ne doit pas le révéler.
|
||||||
|
MYSQL_PWD="${DB_PASSWORD:-}"
|
||||||
|
export MYSQL_PWD
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[pta-migrate] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
sql() {
|
||||||
|
# --ssl-verify-server-cert=0 : le client MariaDB 11.4 désactive de toute
|
||||||
|
# façon cette vérification quand le mot de passe ne vient pas de la ligne
|
||||||
|
# de commande, en émettant un avertissement trompeur parlant de « login
|
||||||
|
# sans mot de passe ». Le poser explicitement supprime ce bruit dans les
|
||||||
|
# journaux de déploiement sans rien changer au comportement réel : la
|
||||||
|
# liaison reste interne au réseau bridge du compose.
|
||||||
|
mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" \
|
||||||
|
--database="$DB_NAME" --default-character-set=utf8mb4 \
|
||||||
|
--ssl-verify-server-cert=0 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Attente de la base
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Le compose garantit déjà `service_healthy`, mais le healthcheck interroge la
|
||||||
|
# boucle locale du conteneur : cette boucle couvre le court instant où le port
|
||||||
|
# n'accepte pas encore de connexion depuis le réseau.
|
||||||
|
attente=0
|
||||||
|
until sql -N -B -e 'SELECT 1' >/dev/null 2>&1; do
|
||||||
|
attente=$((attente + 1))
|
||||||
|
if [ "$attente" -ge 30 ]; then
|
||||||
|
log "ERREUR : base $DB_NAME injoignable sur $DB_HOST:$DB_PORT après 60 s."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "Base pas encore joignable, nouvelle tentative dans 2 s ($attente/30)…"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Base $DB_NAME joignable sur $DB_HOST:$DB_PORT."
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Registre des migrations appliquées
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
sql <<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migration (
|
||||||
|
fichier VARCHAR(190) NOT NULL,
|
||||||
|
empreinte CHAR(32) NOT NULL,
|
||||||
|
applique_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (fichier)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Application des migrations
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
applique=0
|
||||||
|
ignore=0
|
||||||
|
|
||||||
|
for fichier in "$MIGRATIONS_DIR"/migration_*.sql; do
|
||||||
|
# Le glob non résolu se présente tel quel quand aucun fichier ne correspond.
|
||||||
|
[ -f "$fichier" ] || continue
|
||||||
|
|
||||||
|
nom=$(basename "$fichier")
|
||||||
|
empreinte=$(md5sum < "$fichier" | cut -d' ' -f1)
|
||||||
|
connue=$(sql -N -B -e \
|
||||||
|
"SELECT empreinte FROM schema_migration WHERE fichier = '$nom'")
|
||||||
|
|
||||||
|
if [ "$connue" = "$empreinte" ]; then
|
||||||
|
log "$nom : déjà appliquée, ignorée."
|
||||||
|
ignore=$((ignore + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$connue" ]; then
|
||||||
|
log "$nom : contenu modifié depuis la dernière application, rejeu."
|
||||||
|
else
|
||||||
|
log "$nom : application…"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sql < "$fichier"; then
|
||||||
|
log "ERREUR : échec de $nom. L'application ne sera pas démarrée."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sql -e "INSERT INTO schema_migration (fichier, empreinte)
|
||||||
|
VALUES ('$nom', '$empreinte')
|
||||||
|
ON DUPLICATE KEY UPDATE empreinte = VALUES(empreinte),
|
||||||
|
applique_le = CURRENT_TIMESTAMP"
|
||||||
|
|
||||||
|
log "$nom : appliquée."
|
||||||
|
applique=$((applique + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Bilan : $applique migration(s) appliquée(s), $ignore ignorée(s)."
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Contrôle final : les vues de droits doivent exister
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Même contrôle que SchemaModel::assertAccessViews() côté PHP. Le faire ici
|
||||||
|
# évite de laisser démarrer une pile dont la page d'accueil renverrait une
|
||||||
|
# erreur 500 incompréhensible.
|
||||||
|
manquantes=$(sql -N -B -e "
|
||||||
|
SELECT GROUP_CONCAT(attendue.nom ORDER BY attendue.nom SEPARATOR ', ')
|
||||||
|
FROM (
|
||||||
|
SELECT 'v_acces_role_page' AS nom
|
||||||
|
UNION ALL SELECT 'v_acces_role_permission'
|
||||||
|
UNION ALL SELECT 'v_acces_identite_agent'
|
||||||
|
UNION ALL SELECT 'v_acces_identite_structure'
|
||||||
|
UNION ALL SELECT 'v_acces_agent_par_structure'
|
||||||
|
UNION ALL SELECT 'v_acces_perimetre_creneau'
|
||||||
|
UNION ALL SELECT 'v_acces_perimetre_planning'
|
||||||
|
) AS attendue
|
||||||
|
LEFT JOIN information_schema.VIEWS v
|
||||||
|
ON v.TABLE_SCHEMA = DATABASE()
|
||||||
|
AND v.TABLE_NAME = attendue.nom
|
||||||
|
WHERE v.TABLE_NAME IS NULL")
|
||||||
|
|
||||||
|
if [ -n "$manquantes" ] && [ "$manquantes" != "NULL" ]; then
|
||||||
|
log "ERREUR : vue(s) de contrôle d'accès manquante(s) : $manquantes"
|
||||||
|
log "La migration v7.8 n'a pas produit le résultat attendu."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Les sept vues de contrôle d'accès sont présentes. Base à jour."
|
||||||
@@ -8,8 +8,67 @@ server {
|
|||||||
charset utf-8;
|
charset utf-8;
|
||||||
client_max_body_size 64M;
|
client_max_body_size 64M;
|
||||||
|
|
||||||
location / {
|
# Ne pas annoncer la version du serveur.
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
server_tokens off;
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# ORDRE IMPORTANT
|
||||||
|
# nginx évalue les expressions régulières dans l'ordre du fichier et retient
|
||||||
|
# la PREMIÈRE qui correspond. Tous les blocs de refus doivent donc précéder
|
||||||
|
# le bloc générique « location ~ \.php$ », sinon ils ne s'appliquent jamais
|
||||||
|
# et les fichiers d'amorçage deviennent exécutables depuis le navigateur.
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Fichiers inclus par l'application, jamais des points d'entrée.
|
||||||
|
location ~* ^/(config|db|page_bootstrap)\.php$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Classes, modèles, schémas SQL et documentation de version.
|
||||||
|
location ^~ /app/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /database/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /docker/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* ^/README[^/]*$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configuration, dumps et journaux ne doivent jamais être téléchargeables.
|
||||||
|
location ~* \.(?:env|sql|sql\.gz|ini|log|bak|dump|yml|yaml|md)$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fichiers et dossiers cachés (.env, .git, .htaccess…).
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# Contenus servis
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
# Les réponses PHP portent leurs propres en-têtes de sécurité, CSP à
|
||||||
|
# nonce comprise (App\Core\Security) ; ici on couvre le statique.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ \.php$ {
|
location ~ \.php$ {
|
||||||
@@ -23,33 +82,13 @@ server {
|
|||||||
fastcgi_param DOCUMENT_ROOT $document_root;
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
fastcgi_param HTTP_PROXY "";
|
fastcgi_param HTTP_PROXY "";
|
||||||
|
|
||||||
|
# Les erreurs PHP restent gérées par l'application (JSON ou page HTML) :
|
||||||
|
# nginx ne les remplace pas par ses propres pages.
|
||||||
|
fastcgi_intercept_errors off;
|
||||||
fastcgi_read_timeout 120s;
|
fastcgi_read_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Les classes, modèles et fichiers SQL ne doivent jamais être servis directement.
|
location / {
|
||||||
location ^~ /app/ {
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /database/ {
|
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* ^/(config\.php|db\.php|page_bootstrap\.php|README[^/]*)$ {
|
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ /\. {
|
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
|
|
||||||
expires 7d;
|
|
||||||
add_header Cache-Control "public, max-age=604800";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
faq.php
Normal file
7
faq.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/page_bootstrap.php';
|
||||||
|
|
||||||
|
runPage('faq');
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/app/autoload.php';
|
require_once __DIR__ . '/app/autoload.php';
|
||||||
|
|
||||||
use App\Controllers\PageController;
|
use App\Controllers\PageController;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
function runPage(string $method): void
|
function runPage(string $method): void
|
||||||
{
|
{
|
||||||
@@ -17,10 +18,14 @@ function runPage(string $method): void
|
|||||||
$controller->{$method}();
|
$controller->{$method}();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
$message = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
|
Security::sendHtmlHeaders();
|
||||||
|
$message = htmlspecialchars(
|
||||||
|
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de cette page.'),
|
||||||
|
ENT_QUOTES,
|
||||||
|
'UTF-8'
|
||||||
|
);
|
||||||
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
||||||
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
|
echo '<body><h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
|
||||||
echo '<h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
|
echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
|
||||||
echo '<p>Vérifiez <code>config.php</code> et le schéma de la base de données.</p></body></html>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
role.php
11
role.php
@@ -5,13 +5,20 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/app/autoload.php';
|
require_once __DIR__ . '/app/autoload.php';
|
||||||
|
|
||||||
use App\Controllers\RoleController;
|
use App\Controllers\RoleController;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = require __DIR__ . '/app/bootstrap.php';
|
$pdo = require __DIR__ . '/app/bootstrap.php';
|
||||||
(new RoleController($pdo))->index();
|
(new RoleController($pdo))->index();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
Security::sendHtmlHeaders();
|
||||||
|
$message = htmlspecialchars(
|
||||||
|
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de la sélection du rôle.'),
|
||||||
|
ENT_QUOTES,
|
||||||
|
'UTF-8'
|
||||||
|
);
|
||||||
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
||||||
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
|
echo '<body><h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . $message . '</p>';
|
||||||
echo '<h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</p></body></html>';
|
echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user