V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree ux-service-enfance : - tableau de bord Service Enfance (dashboard.php) et navigation regroupée ; - guide d'utilisation (faq.php) accessible depuis toute la navigation ; - éditeur de semaine type mis en avant sur l'écran de saisie ; - droit à l'enveloppe de formation par agent, en remplacement de la case « formation déjà prévue dans la répartition annuelle » ; - correctif de « [object Object] » sur les cartes de quota du PTA ; - motif de travail par défaut à la saisie, heures travaillées complètes dans les PDF. Le docker-compose.yml de production est conservé : celui du worktree décrit une pile de test (conteneurs et volume suffixés « test », ports 9080/9081), qui aurait fait redémarrer l'application sur un volume MariaDB vide. Les migrations database/migration_v7_9*.sql sont obligatoires ; le service db-migrate les applique au déploiement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ use App\Core\Csrf;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\PdfResponse;
|
||||
use App\Core\Request;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\QuotaModel;
|
||||
use App\Models\StructureModel;
|
||||
@@ -360,7 +361,9 @@ final class AgentController
|
||||
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
|
||||
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
|
||||
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
|
||||
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
// 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'] ?? ''));
|
||||
$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,
|
||||
'id_poste' => (int) $postId,
|
||||
'type_contrat' => $contractType,
|
||||
'formation_repartition_annuelle' => $trainingPlanned,
|
||||
'droit_enveloppe_formation' => $trainingRight,
|
||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible de créer l’agent.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||||
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -435,7 +438,7 @@ final class AgentController
|
||||
'id_poste' => (int) $postId,
|
||||
'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''),
|
||||
'type_contrat' => $contractType,
|
||||
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0,
|
||||
'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
|
||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||
]),
|
||||
]);
|
||||
@@ -460,7 +463,9 @@ final class AgentController
|
||||
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
|
||||
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
|
||||
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
|
||||
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
// 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'] ?? ''));
|
||||
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
|
||||
$rawStructure = $payload['structure_id'] ?? null;
|
||||
@@ -501,7 +506,7 @@ final class AgentController
|
||||
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
|
||||
'id_poste' => (int) $postId,
|
||||
'type_contrat' => $contractType,
|
||||
'formation_repartition_annuelle' => $trainingPlanned,
|
||||
'droit_enveloppe_formation' => $trainingRight,
|
||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : 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\Controller;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\MotifModel;
|
||||
use App\Models\StructureModel;
|
||||
@@ -23,6 +24,20 @@ final class PageController extends Controller
|
||||
$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
|
||||
{
|
||||
$this->access->requirePage('planning');
|
||||
@@ -32,9 +47,14 @@ final class PageController extends Controller
|
||||
$modules = ['planning'];
|
||||
if (!empty($permissions['can_use_templates'])) {
|
||||
$scripts[] = 'week-templates.js';
|
||||
$scripts[] = 'week-template-editor.js';
|
||||
$modules[] = 'weekTemplates';
|
||||
$modules[] = 'weekTemplateEditor';
|
||||
}
|
||||
$this->renderPage('planning', 'Saisie du planning', 'planning', [
|
||||
'pageSubtitle' => !empty($permissions['can_use_templates'])
|
||||
? 'Composer une semaine type et la dupliquer sur une période, ou saisir une semaine en particulier.'
|
||||
: 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
|
||||
'structures' => $this->loadStructures(),
|
||||
'motifs' => $this->loadMotifs(),
|
||||
'currentWeek' => $this->currentWeek(),
|
||||
@@ -55,6 +75,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$this->access->requirePage('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'],
|
||||
'modules' => ['pendingValidation'],
|
||||
]);
|
||||
@@ -65,6 +86,7 @@ final class PageController extends Controller
|
||||
$this->access->requirePage('coverage');
|
||||
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
|
||||
$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(),
|
||||
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
||||
'scripts' => ['coverage.js'],
|
||||
@@ -81,6 +103,7 @@ final class PageController extends Controller
|
||||
$this->access->requirePage('structures');
|
||||
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
|
||||
$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(),
|
||||
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
|
||||
'scripts' => ['structure-overview.js'],
|
||||
@@ -97,6 +120,9 @@ final class PageController extends Controller
|
||||
$this->access->requirePage('agents');
|
||||
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
|
||||
$this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [
|
||||
'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(),
|
||||
'structures' => $this->loadStructuresForAgentEditor(),
|
||||
'motifs' => $this->loadMotifs(),
|
||||
@@ -114,6 +140,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$this->access->requirePage('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(),
|
||||
'scripts' => ['annual-overview.js'],
|
||||
'modules' => ['annualOverview'],
|
||||
@@ -128,6 +155,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$this->access->requirePage('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(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['pta.js'],
|
||||
@@ -143,6 +171,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$this->access->requirePage('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(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['teams.js'],
|
||||
@@ -158,6 +187,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$this->access->requirePage('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'],
|
||||
'modules' => ['vacationCalendar'],
|
||||
]);
|
||||
@@ -167,6 +197,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$this->access->requirePage('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(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'posts' => (new AgentModel($this->pdo))->allPosts(),
|
||||
@@ -175,12 +206,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
|
||||
{
|
||||
Security::sendHtmlHeaders();
|
||||
$profile = $this->access->profile();
|
||||
$this->render('pages/' . $view, array_merge([
|
||||
'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,
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'scripts' => [],
|
||||
'modules' => [],
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\MotifCategories;
|
||||
use App\Core\Request;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\MotifModel;
|
||||
@@ -98,7 +99,8 @@ final class PlanningController
|
||||
if (!is_array($rawEntries)) {
|
||||
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.']);
|
||||
}
|
||||
if ($this->structureModel->findActive((int) $structureId) === null) {
|
||||
@@ -107,7 +109,10 @@ final class PlanningController
|
||||
|
||||
[$year, $weekNumber] = $week;
|
||||
$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->assertNoOtherLocationOverlap(
|
||||
$entries,
|
||||
@@ -169,7 +174,12 @@ final class PlanningController
|
||||
if ($this->structureModel->findActive((int) $structureId) === null) {
|
||||
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
|
||||
}
|
||||
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) {
|
||||
$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.']);
|
||||
}
|
||||
|
||||
@@ -550,6 +560,48 @@ final class PlanningController
|
||||
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
|
||||
{
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
||||
use App\Core\Access;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\StructureModel;
|
||||
use PDO;
|
||||
@@ -21,8 +22,13 @@ final class RoleController extends Controller
|
||||
{
|
||||
$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') {
|
||||
$access->clear();
|
||||
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||
$access->clear();
|
||||
}
|
||||
header('Location: role.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
@@ -47,8 +53,10 @@ final class RoleController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
Security::sendHtmlHeaders();
|
||||
$this->render('pages/role', [
|
||||
'pageTitle' => 'Choisir votre accès',
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
|
||||
@@ -8,8 +8,11 @@ use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\MotifModel;
|
||||
use App\Models\QuotaModel;
|
||||
use App\Models\StructureModel;
|
||||
use App\Models\WeekTemplateModel;
|
||||
use DateTimeImmutable;
|
||||
use DomainException;
|
||||
@@ -55,6 +58,120 @@ final class WeekTemplateController
|
||||
JsonResponse::send(200, ['templates' => $templates]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contenu détaillé d'une semaine type, pour l'ouvrir dans l'éditeur.
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
$templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT);
|
||||
|
||||
if (!$agentId || !$templateId) {
|
||||
JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']);
|
||||
}
|
||||
|
||||
$template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId);
|
||||
if ($template === null) {
|
||||
JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']);
|
||||
}
|
||||
|
||||
$entries = array_map(static function (array $entry): array {
|
||||
return [
|
||||
'jour_semaine' => (int) $entry['jour_semaine'],
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
// L'éditeur manipule des heures au format HH:MM.
|
||||
'heure_debut' => substr((string) $entry['heure_debut'], 0, 5),
|
||||
'heure_fin' => substr((string) $entry['heure_fin'], 0, 5),
|
||||
'structure_nom' => (string) $entry['structure_nom'],
|
||||
'structure_active' => (int) $entry['structure_actif'] === 1,
|
||||
'motif_libelle' => (string) $entry['motif_libelle'],
|
||||
'motif_actif' => (int) $entry['motif_actif'] === 1,
|
||||
'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1,
|
||||
];
|
||||
}, $this->templateModel->entries((int) $templateId));
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
'updated_at' => (string) $template['date_modification'],
|
||||
],
|
||||
'entries' => $entries,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une semaine type composée dans l'éditeur.
|
||||
*/
|
||||
public function storeEntries(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$name = (string) ($payload['name'] ?? '');
|
||||
$entries = $payload['entries'] ?? null;
|
||||
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$agentId || !is_array($entries)) {
|
||||
JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']);
|
||||
}
|
||||
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
|
||||
// Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un
|
||||
// agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable.
|
||||
$allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent);
|
||||
$allowedStructureIds = array_map(
|
||||
static fn (array $structure): int => (int) $structure['id_structure'],
|
||||
(new StructureModel($this->pdo))->allActive()
|
||||
);
|
||||
|
||||
try {
|
||||
$template = $this->templateModel->saveFromEntries(
|
||||
(int) $agentId,
|
||||
$name,
|
||||
$entries,
|
||||
$allowedMotifIds,
|
||||
$allowedStructureIds,
|
||||
$replace
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
|
||||
JsonResponse::send($status, [
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible d’enregistrer la semaine type.',
|
||||
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
'entry_count' => (int) $template['nombre_creneaux'],
|
||||
],
|
||||
'message' => sprintf(
|
||||
'La semaine type « %s » a été enregistrée avec %d plage%s.',
|
||||
$template['nom'],
|
||||
$template['nombre_creneaux'],
|
||||
$template['nombre_creneaux'] > 1 ? 's' : ''
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
|
||||
@@ -6,6 +6,14 @@ namespace App\Core;
|
||||
|
||||
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
|
||||
{
|
||||
public const ROLE_AGENT = 'AGENT';
|
||||
@@ -14,11 +22,15 @@ final class 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)
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
Session::start();
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
@@ -85,13 +97,16 @@ final class Access
|
||||
$selection['structure_id'] = $structureId;
|
||||
}
|
||||
|
||||
$_SESSION[self::SESSION_KEY] = $selection;
|
||||
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
|
||||
{
|
||||
unset($_SESSION[self::SESSION_KEY]);
|
||||
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
@@ -128,39 +143,61 @@ final class Access
|
||||
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
|
||||
{
|
||||
$role = $this->role();
|
||||
$service = $role === self::ROLE_SERVICE;
|
||||
$responsable = $role === self::ROLE_RESPONSABLE;
|
||||
if ($role === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'read_only' => $role === self::ROLE_AGENT,
|
||||
'can_edit_draft' => $service || $responsable,
|
||||
'can_edit_cross_structure' => $service,
|
||||
'can_validate' => $service,
|
||||
'can_reopen' => $service || $responsable,
|
||||
'can_use_templates' => $service,
|
||||
'can_copy_full_week' => $service,
|
||||
'can_view_pending' => $service,
|
||||
'can_manage_coverage' => $service,
|
||||
'can_manage_agents' => $service,
|
||||
'can_manage_structures' => $service,
|
||||
'can_manage_vacations' => $service,
|
||||
'can_manage_teams' => $service,
|
||||
'can_manage_pta' => $service,
|
||||
'can_forecast_next_year' => $service,
|
||||
];
|
||||
if (!isset($this->permissionCache[$role])) {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
|
||||
);
|
||||
$stmt->execute(['role' => $role]);
|
||||
|
||||
$permissions = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$permissions[(string) $row['permission']] = (bool) $row['autorise'];
|
||||
}
|
||||
$this->permissionCache[$role] = $permissions;
|
||||
}
|
||||
|
||||
return $this->permissionCache[$role];
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return match ($this->role()) {
|
||||
self::ROLE_AGENT => ['agents', 'structures'],
|
||||
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
|
||||
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
|
||||
default => [],
|
||||
};
|
||||
$role = $this->role();
|
||||
if ($role === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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
|
||||
@@ -168,12 +205,21 @@ final class Access
|
||||
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
|
||||
{
|
||||
return match ($this->role()) {
|
||||
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
|
||||
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
|
||||
self::ROLE_SERVICE => 'planning.php',
|
||||
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
|
||||
default => 'role.php',
|
||||
};
|
||||
}
|
||||
@@ -248,10 +294,7 @@ final class Access
|
||||
return;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT p.id_structure
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
WHERE c.id_creneau = :id LIMIT 1'
|
||||
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $entryId]);
|
||||
$structureId = $stmt->fetchColumn();
|
||||
@@ -267,7 +310,9 @@ final class Access
|
||||
if ($this->role() === self::ROLE_SERVICE) {
|
||||
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]);
|
||||
$structureId = $stmt->fetchColumn();
|
||||
if ($structureId === false) {
|
||||
@@ -293,14 +338,14 @@ final class Access
|
||||
|
||||
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]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
private function activeStructureExists(int $structureId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
@@ -312,11 +357,9 @@ final class Access
|
||||
return null;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
|
||||
s.nom AS structure_nom
|
||||
FROM agent a
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
|
||||
'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
|
||||
FROM v_acces_identite_agent
|
||||
WHERE id_agent = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
@@ -330,8 +373,8 @@ final class Access
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_structure, code, nom
|
||||
FROM structure
|
||||
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
|
||||
FROM v_acces_identite_structure
|
||||
WHERE id_structure = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
@@ -344,25 +387,11 @@ final class Access
|
||||
return false;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT 1
|
||||
FROM agent a
|
||||
WHERE a.id_agent = :agent_id
|
||||
AND a.actif = TRUE
|
||||
AND (
|
||||
a.id_structure = :structure_id_default
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM planning p
|
||||
WHERE p.id_agent = a.id_agent
|
||||
AND p.id_structure = :structure_id_planning
|
||||
)
|
||||
)
|
||||
'SELECT 1 FROM v_acces_agent_par_structure
|
||||
WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'structure_id_default' => $structureId,
|
||||
'structure_id_planning' => $structureId,
|
||||
]);
|
||||
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ final class Csrf
|
||||
{
|
||||
public static function ensureToken(): string
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
Session::start();
|
||||
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -21,18 +21,14 @@ final class JsonResponse
|
||||
});
|
||||
|
||||
set_exception_handler(static function (\Throwable $exception): void {
|
||||
error_log(sprintf(
|
||||
'[PTA API] %s: %s in %s:%d',
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
$exception->getFile(),
|
||||
$exception->getLine()
|
||||
));
|
||||
|
||||
// Security::reportThrowable journalise systématiquement le détail
|
||||
// côté serveur et ne le renvoie au client que si PTA_DEBUG est
|
||||
// explicitement activé. Par défaut, rien ne fuit.
|
||||
$payload = ['error' => 'Une erreur serveur est survenue.'];
|
||||
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
|
||||
if (Security::debugEnabled()) {
|
||||
$payload['details'] = $exception->getMessage();
|
||||
}
|
||||
Security::reportThrowable($exception, 'Erreur API.');
|
||||
|
||||
self::send(500, $payload);
|
||||
});
|
||||
@@ -42,6 +38,8 @@ final class JsonResponse
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
/** 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'
|
||||
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
|
||||
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
|
||||
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
|
||||
a.formation_repartition_annuelle, a.commentaire_pta,
|
||||
a.droit_enveloppe_formation, a.commentaire_pta,
|
||||
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
|
||||
po.famille AS poste_famille, po.heures_mercredi_minutes,
|
||||
po.heures_extrascolaire_minutes, po.autorise_preparation,
|
||||
@@ -129,7 +152,9 @@ final class AgentModel extends BaseModel
|
||||
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
|
||||
$quotaReference = QuotaModel::REFERENCE_MINUTES;
|
||||
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
|
||||
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840;
|
||||
$trainingEnvelope = self::trainingEnvelopeMinutes([
|
||||
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
@@ -139,13 +164,13 @@ final class AgentModel extends BaseModel
|
||||
matricule, nom, prenom, email, telephone, adresse,
|
||||
est_diplome, diplome_libelle, date_debut_contrat,
|
||||
id_poste, type_contrat, date_fin_contrat,
|
||||
formation_repartition_annuelle, commentaire_pta,
|
||||
droit_enveloppe_formation, commentaire_pta,
|
||||
id_structure, actif
|
||||
) VALUES (
|
||||
:matricule, :nom, :prenom, :email, :telephone, :adresse,
|
||||
:est_diplome, :diplome_libelle, :date_debut_contrat,
|
||||
:id_poste, :type_contrat, :date_fin_contrat,
|
||||
:formation_repartition_annuelle, :commentaire_pta,
|
||||
:droit_enveloppe_formation, :commentaire_pta,
|
||||
:structure_id, TRUE
|
||||
)'
|
||||
);
|
||||
@@ -162,7 +187,7 @@ final class AgentModel extends BaseModel
|
||||
'id_poste' => $data['id_poste'],
|
||||
'type_contrat' => $data['type_contrat'],
|
||||
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
|
||||
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0,
|
||||
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
|
||||
'commentaire_pta' => $data['commentaire_pta'],
|
||||
'structure_id' => $data['structure_id'],
|
||||
]);
|
||||
@@ -260,7 +285,7 @@ final class AgentModel extends BaseModel
|
||||
id_poste = :id_poste,
|
||||
type_contrat = :type_contrat,
|
||||
date_fin_contrat = :date_fin_contrat,
|
||||
formation_repartition_annuelle = :formation_repartition_annuelle,
|
||||
droit_enveloppe_formation = :droit_enveloppe_formation,
|
||||
commentaire_pta = :commentaire_pta,
|
||||
id_structure = :structure_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(':type_contrat', $data['type_contrat']);
|
||||
$update->bindValue(':date_fin_contrat', $data['date_fin_contrat']);
|
||||
$update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT);
|
||||
$update->bindValue(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT);
|
||||
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
|
||||
if ($newStructureId === 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;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
|
||||
final class MotifModel extends BaseModel
|
||||
{
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
@@ -12,12 +13,8 @@ use RuntimeException;
|
||||
|
||||
final class PtaModel extends BaseModel
|
||||
{
|
||||
private const WORK_CODES = [
|
||||
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR',
|
||||
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
|
||||
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
|
||||
];
|
||||
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
|
||||
private const WORK_CODES = MotifCategories::WORK_CODES;
|
||||
|
||||
private const PREPARATION_CODES = [
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
||||
@@ -148,7 +145,7 @@ final class PtaModel extends BaseModel
|
||||
return $pta;
|
||||
}
|
||||
|
||||
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
||||
$training = AgentModel::trainingEnvelopeMinutes($agent);
|
||||
$insert = $this->pdo->prepare(
|
||||
"INSERT INTO pta_annuel (
|
||||
id_agent, date_debut, date_fin, quotite_travail,
|
||||
@@ -381,7 +378,13 @@ final class PtaModel extends BaseModel
|
||||
}
|
||||
|
||||
$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) {
|
||||
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
|
||||
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
|
||||
|
||||
@@ -16,6 +16,16 @@ use InvalidArgumentException;
|
||||
*/
|
||||
final class QuotaModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Les 1 607 h sont un volume NET : la formule légale a déjà retranché les
|
||||
* week-ends, les 25 jours de congés payés et les 8 jours fériés moyens
|
||||
* (365 - 104 - 25 - 8 = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus
|
||||
* les 7 h de la journée de solidarité).
|
||||
*
|
||||
* Conséquence : ni un jour de congé payé, ni un jour férié ne s'impute au
|
||||
* quota. Les imputer reviendrait à les décompter une seconde fois. Seul le
|
||||
* travail effectif alimente le compteur, via `compte_dans_quota`.
|
||||
*/
|
||||
public const REFERENCE_MINUTES = 1607 * 60;
|
||||
|
||||
public function createForAgent(int $agentId, int $year, float $rate): void
|
||||
|
||||
@@ -52,7 +52,7 @@ final class SchemaModel extends BaseModel
|
||||
$requiredColumns = [
|
||||
'creneau_horaire' => ['id_motif'],
|
||||
'planning' => ['date_validation'],
|
||||
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'commentaire_pta'],
|
||||
'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'],
|
||||
];
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +62,175 @@ final class WeekTemplateModel extends BaseModel
|
||||
|
||||
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || strlen($name) > 240) {
|
||||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||||
}
|
||||
// La colonne `nom` fait 120 caractères : la borne annoncée à
|
||||
// l'utilisateur et celle réellement appliquée doivent coïncider.
|
||||
$name = $this->normalizeName($name);
|
||||
|
||||
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
|
||||
if (!$sourceEntries) {
|
||||
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
|
||||
}
|
||||
|
||||
return $this->persistTemplate($agentId, $name, $sourceEntries, $replaceExisting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une semaine type composée directement dans l'éditeur, sans
|
||||
* partir d'une semaine réellement planifiée.
|
||||
*
|
||||
* Les plages arrivent du navigateur : elles sont intégralement revalidées
|
||||
* ici, y compris les droits de motif, car l'écran n'est qu'une commodité —
|
||||
* le serveur reste seul juge de ce qui entre en base.
|
||||
*
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds motifs affectables à cet agent
|
||||
* @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur
|
||||
*/
|
||||
public function saveFromEntries(
|
||||
int $agentId,
|
||||
string $name,
|
||||
array $rawEntries,
|
||||
array $allowedMotifIds,
|
||||
array $allowedStructureIds,
|
||||
bool $replaceExisting = false
|
||||
): array {
|
||||
$name = $this->normalizeName($name);
|
||||
$entries = $this->normalizeEditorEntries($rawEntries, $allowedMotifIds, $allowedStructureIds);
|
||||
|
||||
if ($entries === []) {
|
||||
throw new DomainException('Une semaine type doit contenir au moins une plage horaire.');
|
||||
}
|
||||
|
||||
return $this->persistTemplate($agentId, $name, $entries, $replaceExisting);
|
||||
}
|
||||
|
||||
private function normalizeName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || mb_strlen($name) > 120) {
|
||||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds
|
||||
* @param int[] $allowedStructureIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array
|
||||
{
|
||||
$jours = [1 => 'lundi', 2 => 'mardi', 3 => 'mercredi', 4 => 'jeudi', 5 => 'vendredi'];
|
||||
$entries = [];
|
||||
|
||||
foreach ($rawEntries as $rawEntry) {
|
||||
if (!is_array($rawEntry)) {
|
||||
throw new DomainException('Une plage de la semaine type est illisible.');
|
||||
}
|
||||
|
||||
$weekday = filter_var($rawEntry['jour_semaine'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1, 'max_range' => 5],
|
||||
]);
|
||||
if ($weekday === false) {
|
||||
throw new DomainException('Une semaine type ne peut contenir que des jours du lundi au vendredi.');
|
||||
}
|
||||
|
||||
$start = $this->normalizeQuarterHour($rawEntry['heure_debut'] ?? null, $jours[$weekday]);
|
||||
$end = $this->normalizeQuarterHour($rawEntry['heure_fin'] ?? null, $jours[$weekday]);
|
||||
if ($end <= $start) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, la fin (%s) doit être postérieure au début (%s).',
|
||||
$jours[$weekday],
|
||||
substr($end, 0, 5),
|
||||
substr($start, 0, 5)
|
||||
));
|
||||
}
|
||||
|
||||
$structureId = filter_var($rawEntry['id_structure'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
if ($structureId === false || !in_array((int) $structureId, $allowedStructureIds, true)) {
|
||||
throw new DomainException(sprintf('Le lieu d’affectation choisi le %s n’est pas disponible.', $jours[$weekday]));
|
||||
}
|
||||
|
||||
$motifId = filter_var($rawEntry['id_motif'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
if ($motifId === false || !in_array((int) $motifId, $allowedMotifIds, true)) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le motif choisi le %s n’est pas affectable à cet agent.',
|
||||
$jours[$weekday]
|
||||
));
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'jour_semaine' => (int) $weekday,
|
||||
'id_structure' => (int) $structureId,
|
||||
'id_motif' => (int) $motifId,
|
||||
'heure_debut' => $start,
|
||||
'heure_fin' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertEditorEntriesNoOverlap($entries, $jours);
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
return [$a['jour_semaine'], $a['heure_debut']] <=> [$b['jour_semaine'], $b['heure_debut']];
|
||||
});
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeQuarterHour(mixed $value, string $dayLabel): string
|
||||
{
|
||||
$time = trim((string) $value);
|
||||
if (preg_match('/^([01]\d|2[0-3]):(00|15|30|45)(:00)?$/', $time) !== 1) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, les horaires doivent être renseignés par tranches de 15 minutes.',
|
||||
$dayLabel
|
||||
));
|
||||
}
|
||||
return substr($time, 0, 5) . ':00';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux plages du même jour ne peuvent pas se chevaucher, même sur deux
|
||||
* lieux différents : un agent n'est jamais à deux endroits à la fois.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @param array<int, string> $jours
|
||||
*/
|
||||
private function assertEditorEntriesNoOverlap(array $entries, array $jours): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$first = $entries[$i];
|
||||
$second = $entries[$j];
|
||||
if ($first['jour_semaine'] === $second['jour_semaine']
|
||||
&& $first['heure_debut'] < $second['heure_fin']
|
||||
&& $first['heure_fin'] > $second['heure_debut']) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, deux plages se chevauchent : %s-%s et %s-%s.',
|
||||
$jours[$first['jour_semaine']] ?? 'jour',
|
||||
substr($first['heure_debut'], 0, 5),
|
||||
substr($first['heure_fin'], 0, 5),
|
||||
substr($second['heure_debut'], 0, 5),
|
||||
substr($second['heure_fin'], 0, 5)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écriture du modèle et de ses plages, quelle qu'en soit l'origine :
|
||||
* une semaine réellement planifiée ou l'éditeur de semaine type.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
@@ -106,7 +265,7 @@ final class WeekTemplateModel extends BaseModel
|
||||
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
|
||||
);
|
||||
|
||||
foreach ($sourceEntries as $entry) {
|
||||
foreach ($rows as $entry) {
|
||||
$insertEntry->execute([
|
||||
'template_id' => $templateId,
|
||||
'weekday' => (int) $entry['jour_semaine'],
|
||||
@@ -121,7 +280,7 @@ final class WeekTemplateModel extends BaseModel
|
||||
return [
|
||||
'id_modele' => $templateId,
|
||||
'nom' => $name,
|
||||
'nombre_creneaux' => count($sourceEntries),
|
||||
'nombre_creneaux' => count($rows),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
@@ -66,11 +67,11 @@ final class AgentTimeSummaryPdfService
|
||||
$rowH = 43.0;
|
||||
|
||||
$rows = [
|
||||
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']],
|
||||
['MALADIE', 'Absence', $totals['MALADIE']],
|
||||
['FORMATION', 'Formation', $totals['FORMATION']],
|
||||
['CONGE', 'Congé', $totals['CONGE']],
|
||||
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']],
|
||||
[MotifCategories::TRAVAIL, 'Heures travaillées', $totals['TRAVAIL'], $totals['DETAIL_TRAVAIL']],
|
||||
[MotifCategories::ABSENCE, 'Absence', $totals['ABSENCE'], []],
|
||||
[MotifCategories::FORMATION, 'Formation', $totals['FORMATION'], []],
|
||||
[MotifCategories::CONGE, 'Congé', $totals['CONGE'], []],
|
||||
[MotifCategories::NON_DECOMPTE, 'Absence (non décomptée)', $totals['NON_DECOMPTE'], []],
|
||||
];
|
||||
|
||||
$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);
|
||||
|
||||
$cursorY = $y + $headerH;
|
||||
foreach ($rows as [$code, $label, $minutes]) {
|
||||
foreach ($rows as [$code, $label, $minutes, $detail]) {
|
||||
$palette = $this->palette($code);
|
||||
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
|
||||
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
|
||||
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
|
||||
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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);
|
||||
$remaining = $target - $counted;
|
||||
$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
|
||||
{
|
||||
$totals = [
|
||||
'TRAVAIL' => 0,
|
||||
'MALADIE' => 0,
|
||||
'FORMATION' => 0,
|
||||
'CONGE' => 0,
|
||||
'AUTRE' => 0,
|
||||
'OTHER_COUNTED' => 0,
|
||||
MotifCategories::TRAVAIL => 0,
|
||||
MotifCategories::ABSENCE => 0,
|
||||
MotifCategories::FORMATION => 0,
|
||||
MotifCategories::CONGE => 0,
|
||||
MotifCategories::NON_DECOMPTE => 0,
|
||||
'DECOMPTE' => 0,
|
||||
'DETAIL_TRAVAIL' => [],
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE'));
|
||||
$code = MotifCategories::normalizeCode($row['motif_code'] ?? null);
|
||||
$minutes = (int) ($row['minutes_total'] ?? 0);
|
||||
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') {
|
||||
$totals[$code] += $minutes;
|
||||
continue;
|
||||
$counts = (int) ($row['compte_dans_quota'] ?? 0) === 1;
|
||||
$category = MotifCategories::category($code, $counts);
|
||||
|
||||
$totals[$category] += $minutes;
|
||||
if ($counts) {
|
||||
$totals['DECOMPTE'] += $minutes;
|
||||
}
|
||||
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
|
||||
$totals['OTHER_COUNTED'] += $minutes;
|
||||
} else {
|
||||
$totals['AUTRE'] += $minutes;
|
||||
|
||||
if ($category === MotifCategories::TRAVAIL && $minutes > 0) {
|
||||
$label = trim((string) ($row['motif_libelle'] ?? '')) ?: $code;
|
||||
$totals['DETAIL_TRAVAIL'][$label] = ($totals['DETAIL_TRAVAIL'][$label] ?? 0) + $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,10 +234,10 @@ final class AgentTimeSummaryPdfService
|
||||
private function palette(string $code): array
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'TRAVAIL' => ['accent' => '#2563EB'],
|
||||
'FORMATION' => ['accent' => '#7C3AED'],
|
||||
'CONGE' => ['accent' => '#059669'],
|
||||
'MALADIE' => ['accent' => '#DC2626'],
|
||||
MotifCategories::TRAVAIL => ['accent' => '#2563EB'],
|
||||
MotifCategories::FORMATION => ['accent' => '#7C3AED'],
|
||||
MotifCategories::CONGE => ['accent' => '#059669'],
|
||||
MotifCategories::ABSENCE, 'MALADIE' => ['accent' => '#DC2626'],
|
||||
default => ['accent' => '#64748B'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class PlanningPdfService
|
||||
@@ -170,7 +171,7 @@ final class PlanningPdfService
|
||||
$blockY = $y + 5;
|
||||
foreach ($blocks as $block) {
|
||||
$entry = $block['entry'];
|
||||
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
|
||||
$palette = $this->entryPalette($entry);
|
||||
$blockHeight = (float) $block['height'];
|
||||
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
|
||||
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
|
||||
@@ -269,7 +270,7 @@ final class PlanningPdfService
|
||||
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, 3.0, $cardHeight, $palette['accent']);
|
||||
|
||||
@@ -320,21 +321,35 @@ final class PlanningPdfService
|
||||
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
|
||||
{
|
||||
return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL'
|
||||
return MotifCategories::normalizeCode($entry['motif_code'] ?? null) === 'TRAVAIL'
|
||||
? ''
|
||||
: (string) ($entry['motif_libelle'] ?? 'Affectation');
|
||||
}
|
||||
|
||||
/** @return array{accent:string,background:string,label:string} */
|
||||
private function motifPalette(string $code): array
|
||||
private function entryPalette(array $entry): array
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
|
||||
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
|
||||
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
|
||||
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
|
||||
return $this->motifPalette(
|
||||
(string) ($entry['motif_code'] ?? 'AUTRE'),
|
||||
(int) ($entry['compte_dans_quota'] ?? 0) === 1
|
||||
);
|
||||
}
|
||||
|
||||
/** @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'],
|
||||
};
|
||||
}
|
||||
@@ -342,18 +357,20 @@ final class PlanningPdfService
|
||||
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
|
||||
{
|
||||
$items = [
|
||||
['TRAVAIL', 'Travail'],
|
||||
['TRAVAIL', 'Travail (dont ALP, restauration, preparation)'],
|
||||
['FORMATION', 'Formation'],
|
||||
['CONGE', 'Conge'],
|
||||
['MALADIE', 'Absence'],
|
||||
['AUTRE', 'Autre absence'],
|
||||
['ABSENCE', 'Absence'],
|
||||
['JNT', 'Autre absence'],
|
||||
];
|
||||
|
||||
foreach ($items as [$code, $label]) {
|
||||
$palette = $this->motifPalette($code);
|
||||
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
|
||||
$pdf->text($x + 12, $y, $label, 6.5);
|
||||
$x += 76;
|
||||
// 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 $accessProfile */
|
||||
/** @var array $permissions */
|
||||
/** @var string $cspNonce */
|
||||
?>
|
||||
</main>
|
||||
<script>
|
||||
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||
window.PTA_CONFIG = {
|
||||
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
||||
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||
@@ -27,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
|
||||
'params' => $pageConfig,
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
||||
</script>
|
||||
<script src="assets/js/core.js?v=7.7"></script>
|
||||
<script src="assets/js/core.js?v=7.16"></script>
|
||||
<?php foreach ($scripts as $script): ?>
|
||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
|
||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.16"></script>
|
||||
<?php endforeach; ?>
|
||||
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
|
||||
<script src="assets/js/page-bootstrap.js?v=7.16"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,21 +1,73 @@
|
||||
<?php
|
||||
/** @var string $pageTitle */
|
||||
/** @var string $pageSubtitle */
|
||||
/** @var string $activePage */
|
||||
/** @var array $allowedPages */
|
||||
/** @var array $accessProfile */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$isAgent = ($accessProfile['role'] ?? '') === 'AGENT';
|
||||
|
||||
$navigation = [
|
||||
'dashboard' => ['dashboard.php', 'Tableau de bord'],
|
||||
'planning' => ['planning.php', 'Saisie'],
|
||||
'pending' => ['pending.php', 'À valider'],
|
||||
'coverage' => ['coverage.php', 'Couverture'],
|
||||
'structures' => ['structures.php', 'Par lieu'],
|
||||
'agents' => ['agents.php', ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Par agent'],
|
||||
'agents' => ['agents.php', $isAgent ? 'Mon planning' : 'Par agent'],
|
||||
'annual' => ['annual.php', 'Vue annuelle'],
|
||||
'pta' => ['pta.php', 'PTA détaillé'],
|
||||
'teams' => ['teams.php', 'Équipes'],
|
||||
'vacations' => ['vacations.php', 'Vacances'],
|
||||
'administration' => ['administration.php', 'Administration'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 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>
|
||||
<html lang="fr">
|
||||
@@ -23,15 +75,15 @@ $navigation = [
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.7">
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.16">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="page-header-identity">
|
||||
<p class="eyebrow">PTA</p>
|
||||
<h1><?= htmlspecialchars($pageTitle) ?></h1>
|
||||
<p class="subtitle">Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.</p>
|
||||
<p class="subtitle"><?= htmlspecialchars($pageSubtitle ?? '') ?></p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<?php if ($activePage === 'planning'): ?>
|
||||
@@ -40,11 +92,11 @@ $navigation = [
|
||||
<div class="access-profile-badge">
|
||||
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
|
||||
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
|
||||
<?php if (!empty($accessProfile['structure_name']) && ($accessProfile['role'] ?? '') === 'AGENT'): ?>
|
||||
<?php if (!empty($accessProfile['structure_name']) && $isAgent): ?>
|
||||
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
|
||||
<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>
|
||||
</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>
|
||||
<?php endif; ?>
|
||||
|
||||
<nav class="view-tabs" aria-label="Navigation principale">
|
||||
<?php foreach ($navigation as $key => [$href, $label]): ?>
|
||||
<?php if (!in_array($key, $allowedPages, true)) continue; ?>
|
||||
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
|
||||
<?= htmlspecialchars($label) ?>
|
||||
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
|
||||
</a>
|
||||
<nav class="view-tabs<?= $showGroupLabels ? ' view-tabs-grouped' : '' ?>" aria-label="Navigation principale">
|
||||
<?php foreach ($visibleGroups as $groupLabel => $keys): ?>
|
||||
<div class="view-tab-group" role="group"<?= $showGroupLabels ? ' aria-label="' . htmlspecialchars($groupLabel, ENT_QUOTES) . '"' : '' ?>>
|
||||
<?php if ($showGroupLabels): ?>
|
||||
<span class="view-tab-group-label"><?= htmlspecialchars($groupLabel) ?></span>
|
||||
<?php endif; ?>
|
||||
<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 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>
|
||||
|
||||
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 string $error */
|
||||
/** @var array|null $currentProfile */
|
||||
/** @var string $cspNonce */
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
@@ -11,7 +12,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Choisir votre accès — PTA</title>
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.4">
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.16">
|
||||
</head>
|
||||
<body class="role-page-body">
|
||||
<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é.
|
||||
</aside>
|
||||
</main>
|
||||
<script>
|
||||
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||
(() => {
|
||||
const agentContext = document.querySelector('#agent-context');
|
||||
const responsableContext = document.querySelector('#responsable-context');
|
||||
|
||||
@@ -123,8 +123,8 @@
|
||||
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="new-agent-training-planned" type="checkbox">
|
||||
<span>Une formation est déjà prévue dans la répartition annuelle : ne pas ajouter l’enveloppe complémentaire de 14 h</span>
|
||||
<input id="new-agent-training-right" type="checkbox" checked>
|
||||
<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 class="field admin-field-wide">
|
||||
<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-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
|
||||
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-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
|
||||
>
|
||||
@@ -326,8 +326,8 @@
|
||||
<input id="edit-agent-profile-contract-end" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="edit-agent-profile-training-planned" type="checkbox">
|
||||
<span>Formation déjà intégrée dans la répartition annuelle</span>
|
||||
<input id="edit-agent-profile-training-right" type="checkbox">
|
||||
<span>Droit à l’enveloppe de formation de 14 h</span>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<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>
|
||||
@@ -1,3 +1,10 @@
|
||||
<?php
|
||||
// Les rôles sans droit aux semaines types n'ont qu'un seul mode de saisie :
|
||||
// le sélecteur de méthode disparaît et la numérotation des étapes se resserre.
|
||||
$usesTemplates = !empty($permissions['can_use_templates']);
|
||||
$etapeSaisie = $usesTemplates ? 3 : 2;
|
||||
$etapePlanning = $usesTemplates ? 4 : 3;
|
||||
?>
|
||||
<section id="editor-view" class="app-view page-content">
|
||||
<?php if (($accessProfile['role'] ?? '') === 'RESPONSABLE_STRUCTURE'): ?>
|
||||
<div class="message message-info role-page-notice">
|
||||
@@ -33,6 +40,83 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($usesTemplates): ?>
|
||||
<section class="card editor-mode-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>2. Choisir la méthode de saisie</h2>
|
||||
<p class="section-subtitle">Une semaine type se compose une fois puis se duplique sur toute une période. La saisie semaine par semaine reste disponible pour les corrections.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-mode-choices" role="tablist" aria-label="Méthode de saisie">
|
||||
<button id="editor-mode-template" class="editor-mode-choice is-active" type="button"
|
||||
role="tab" aria-selected="true" aria-controls="template-mode-panel">
|
||||
<span class="editor-mode-choice-badge">Le plus rapide</span>
|
||||
<strong>Semaine type</strong>
|
||||
<small>Composer une semaine de référence, puis la dupliquer sur une période entière.</small>
|
||||
</button>
|
||||
<button id="editor-mode-week" class="editor-mode-choice" type="button"
|
||||
role="tab" aria-selected="false" aria-controls="week-mode-panel">
|
||||
<strong>Semaine précise</strong>
|
||||
<small>Saisir ou corriger une seule semaine, jour par jour.</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="template-mode-panel" class="editor-mode-panel" role="tabpanel" aria-labelledby="editor-mode-template">
|
||||
<section class="card template-editor-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Éditeur de semaine type</h2>
|
||||
<p class="section-subtitle">Les plages saisies ici ne sont pas encore planifiées : elles décrivent une semaine de référence, réutilisable autant de fois que nécessaire.</p>
|
||||
</div>
|
||||
<div class="totals template-editor-total">
|
||||
<span>Total de la semaine type</span>
|
||||
<strong id="template-editor-total">0 h 00</strong>
|
||||
<small id="template-editor-total-detail">dont 0 h 00 décomptées</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="template-editor-toolbar">
|
||||
<label class="field">
|
||||
<span>Semaine type de l’agent</span>
|
||||
<select id="template-editor-select" disabled>
|
||||
<option value="">Nouvelle semaine type</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom de la semaine type <strong>*</strong></span>
|
||||
<input id="template-editor-name" type="text" maxlength="120" placeholder="Ex. Semaine scolaire classique" disabled>
|
||||
</label>
|
||||
<div class="template-editor-toolbar-actions">
|
||||
<button id="template-editor-delete" class="button button-danger" type="button" disabled>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="template-editor-empty" class="empty-state">Choisissez un lieu d’affectation et un agent pour composer sa semaine type.</div>
|
||||
<div id="template-editor-grid" class="template-editor-grid" hidden></div>
|
||||
|
||||
<div id="template-editor-message" class="message" role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="template-editor-actions">
|
||||
<button id="template-editor-save" class="button button-primary" type="button" disabled>
|
||||
Enregistrer la semaine type
|
||||
</button>
|
||||
<button id="template-editor-apply-period" class="button button-success" type="button" disabled>
|
||||
Dupliquer sur une période…
|
||||
</button>
|
||||
<button id="template-editor-apply-week" class="button button-secondary" type="button" disabled>
|
||||
Appliquer à la semaine sélectionnée…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Enregistrer une semaine type ne modifie aucun planning. Rien n’est planifié tant que vous ne l’avez pas dupliquée sur une période ou appliquée à une semaine.</p>
|
||||
</section>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="week-mode-panel" class="editor-mode-panel"<?= $usesTemplates ? ' hidden' : '' ?>>
|
||||
|
||||
<section class="quota-strip" id="editor-quota" hidden>
|
||||
<article class="quota-card">
|
||||
<span>Quotité</span>
|
||||
@@ -57,7 +141,7 @@
|
||||
<section class="card" id="entry-card" aria-disabled="true">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>2. Saisir ou modifier les affectations</h2>
|
||||
<h2><?= $etapeSaisie ?>. Saisir ou modifier les affectations</h2>
|
||||
<p class="section-subtitle">Chaque plage horaire possède son propre motif et sa propre règle de décompte du quota annuel.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +176,7 @@
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>3. Planning global de l’agent</h2>
|
||||
<h2><?= $etapePlanning ?>. Planning global de l’agent</h2>
|
||||
<p id="week-label" class="section-subtitle">Toutes les affectations de l’agent sur la semaine, quel que soit le lieu d’affectation. Cliquez sur un créneau pour le déplacer ou le dupliquer, ou glissez-le directement vers un autre jour.</p>
|
||||
</div>
|
||||
<div class="planning-summary-actions">
|
||||
@@ -133,6 +217,8 @@
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /#week-mode-panel -->
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ $defaultCalendarYear = $currentYear;
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -21,7 +21,7 @@ $defaultCalendarYear = $currentYear;
|
||||
<div class="selection-grid vacation-filters">
|
||||
<label class="field">
|
||||
<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">
|
||||
<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">
|
||||
@@ -45,8 +45,8 @@ $defaultCalendarYear = $currentYear;
|
||||
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
|
||||
<div class="section-heading compact-heading">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<label class="vacation-select-all">
|
||||
<input id="vacation-select-all" type="checkbox">
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Session;
|
||||
use App\Models\SchemaModel;
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
Session::start();
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/db.php';
|
||||
(new SchemaModel($pdo))->assertCurrent();
|
||||
|
||||
Reference in New Issue
Block a user