pour prod

This commit is contained in:
Loic Masi
2026-08-07 16:13:43 +02:00
commit 5fbf76868f
157 changed files with 24085 additions and 0 deletions

View File

@@ -0,0 +1,529 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\PdfResponse;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Services\AgentHoursContractPdfService;
use App\Services\AgentTimeSummaryPdfService;
use App\Services\PlanningPdfService;
use DateTimeImmutable;
use PDO;
use Throwable;
final class AgentController
{
private AgentModel $agentModel;
private StructureModel $structureModel;
private QuotaModel $quotaModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->agentModel = new AgentModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->quotaModel = new QuotaModel($pdo);
$this->access = new Access($pdo);
}
public function listForPlanning(): void
{
$this->access->requireDraftEditor();
$selectedLocation = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT) ?: 0;
if ($selectedLocation) {
$this->access->requireStructureEdit((int) $selectedLocation);
}
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
}
public function overview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
if (!$agentId || $week === null) {
JsonResponse::send(400, ['error' => 'Agent ou semaine invalide.']);
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
[$year, $weekNumber] = $week;
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d');
JsonResponse::send(200, [
'agent' => $agent,
'week' => ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd],
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStart),
'entries' => $this->agentModel->entriesForWeek((int) $agentId, $year, $weekNumber),
]);
}
public function monthOverview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$monthValue = trim((string) ($_GET['month'] ?? ''));
if (!$agentId || !preg_match('/^(\d{4})-(\d{2})$/', $monthValue, $matches)) {
JsonResponse::send(400, ['error' => 'Agent ou mois invalide.']);
}
$this->access->requireAgentView((int) $agentId);
$year = (int) $matches[1];
$monthNumber = (int) $matches[2];
if ($monthNumber < 1 || $monthNumber > 12) {
JsonResponse::send(400, ['error' => 'Mois invalide.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
$firstDay = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $monthNumber));
$lastDay = $firstDay->modify('last day of this month');
$gridStart = $firstDay->modify('-' . ((int) $firstDay->format('N') - 1) . ' days');
$lastWeekMonday = $lastDay->modify('-' . ((int) $lastDay->format('N') - 1) . ' days');
$gridEnd = $lastWeekMonday->modify('+4 days');
$dayLabels = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi'];
$monthLabels = [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril', 5 => 'Mai', 6 => 'Juin',
7 => 'Juillet', 8 => 'Août', 9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
];
$weeks = [];
for ($monday = $gridStart; $monday <= $lastWeekMonday; $monday = $monday->modify('+7 days')) {
$days = [];
for ($weekday = 1; $weekday <= 5; $weekday++) {
$date = $monday->modify('+' . ($weekday - 1) . ' days');
$days[] = [
'date' => $date->format('Y-m-d'),
'label' => $dayLabels[$weekday],
'display' => $date->format('d/m'),
'in_month' => (int) $date->format('n') === $monthNumber,
];
}
$weeks[] = [
'value' => sprintf('%04d-W%02d', (int) $monday->format('o'), (int) $monday->format('W')),
'number' => (int) $monday->format('W'),
'date_debut' => $monday->format('Y-m-d'),
'date_fin' => $monday->modify('+4 days')->format('Y-m-d'),
'days' => $days,
];
}
JsonResponse::send(200, [
'agent' => $agent,
'month' => [
'value' => $monthValue,
'year' => $year,
'month' => $monthNumber,
'label' => $monthLabels[$monthNumber] . ' ' . $year,
'date_debut' => $firstDay->format('Y-m-d'),
'date_fin' => $lastDay->format('Y-m-d'),
'grid_start' => $gridStart->format('Y-m-d'),
'grid_end' => $gridEnd->format('Y-m-d'),
],
'weeks' => $weeks,
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $firstDay->format('Y-m-d')),
'entries' => $this->agentModel->entriesForDateRange(
(int) $agentId,
$gridStart->format('Y-m-d'),
$gridEnd->format('Y-m-d')
),
]);
}
public function timeSummaryPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$dateValue = trim((string) ($_GET['date'] ?? ''));
$year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT);
if ($dateValue === '' && $year) {
$dateValue = sprintf('%04d-01-01', (int) $year);
}
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $dateValue);
if (!$agentId || !$date || $date->format('Y-m-d') !== $dateValue) {
http_response_code(400);
echo 'Agent ou date de référence invalide.';
return;
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
http_response_code(404);
echo 'Agent introuvable.';
return;
}
$quota = $this->quotaModel->forDateForApi((int) $agentId, $dateValue);
$summary = $this->agentModel->timeSummaryForRange(
(int) $agentId,
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$pdf = (new AgentTimeSummaryPdfService())->annualSummary($agent, $quota, $summary);
PdfResponse::inline(
$pdf,
sprintf(
'bilan_heures_%s_%s_%s_%s.pdf',
$agent['nom'],
$agent['prenom'],
str_replace('-', '', (string) $quota['date_debut_periode']),
str_replace('-', '', (string) $quota['date_fin_periode'])
)
);
}
public function pdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
$weekCount = filter_input(INPUT_GET, 'weeks', FILTER_VALIDATE_INT) ?: 7;
$weekCount = in_array((int) $weekCount, [6, 7], true) ? (int) $weekCount : 7;
if (!$agentId || $week === null) {
http_response_code(400);
echo 'Agent ou semaine invalide.';
return;
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
http_response_code(404);
echo 'Agent introuvable.';
return;
}
[$year, $weekNumber] = $week;
$firstMonday = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
$weeks = [];
for ($index = 0; $index < $weekCount; $index++) {
$monday = $firstMonday->modify('+' . ($index * 7) . ' days');
$isoYear = (int) $monday->format('o');
$isoWeek = (int) $monday->format('W');
$weeks[] = [
'value' => sprintf('%04d-W%02d', $isoYear, $isoWeek),
'date_debut' => $monday->format('Y-m-d'),
'date_fin' => $monday->modify('+6 days')->format('Y-m-d'),
];
}
$lastFriday = $firstMonday->modify('+' . (($weekCount - 1) * 7 + 4) . ' days');
$entries = $this->agentModel->entriesForDateRange(
(int) $agentId,
$firstMonday->format('Y-m-d'),
$lastFriday->format('Y-m-d')
);
$pdf = (new PlanningPdfService())->agentWeeks(
$agent,
$entries,
$weeks,
$this->quotaModel->forDateForApi((int) $agentId, $firstMonday->format('Y-m-d'))
);
PdfResponse::inline(
$pdf,
sprintf(
'planning_%s_%s_%s_%d_semaines.pdf',
$agent['nom'],
$agent['prenom'],
str_replace('-W', '_S', $weekValue),
$weekCount
)
);
}
public function hoursContractPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$dateValue = trim((string) ($_GET['date'] ?? ''));
$year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT);
if ($dateValue === '' && $year) {
$dateValue = sprintf('%04d-01-01', (int) $year);
}
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $dateValue);
if (!$agentId || !$date || $date->format('Y-m-d') !== $dateValue) {
http_response_code(400);
echo 'Agent ou date de référence invalide.';
return;
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
http_response_code(404);
echo 'Agent introuvable.';
return;
}
$quota = $this->quotaModel->forDateForApi((int) $agentId, $dateValue);
$pdf = (new AgentHoursContractPdfService())->annualContract($agent, $quota);
PdfResponse::inline(
$pdf,
sprintf(
'contrat_horaire_%s_%s_%s.pdf',
$agent['nom'],
$agent['prenom'],
str_replace('-', '', (string) $quota['date_debut_periode'])
)
);
}
public function assignments(): void
{
$this->access->requireService();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
JsonResponse::send(200, ['agents' => $this->agentModel->allActive()]);
}
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$rawStructure = $payload['structure_id'] ?? null;
$structureId = ($rawStructure === null || $rawStructure === '' || $rawStructure === 0)
? null
: filter_var($rawStructure, FILTER_VALIDATE_INT);
if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide ou inactif.']);
}
try {
$this->agentModel->updateAssignment((int) $agentId, $structureId !== null ? (int) $structureId : null);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de modifier les paramètres de lagent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'message' => $structureId === null
? 'Les paramètres de lagent ont été mis à jour sans lieu principal.'
: 'Le lieu principal de lagent a été mis à jour.',
]);
}
public function create(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$matricule = trim((string) ($payload['matricule'] ?? ''));
$nom = trim((string) ($payload['nom'] ?? ''));
$prenom = trim((string) ($payload['prenom'] ?? ''));
$email = trim((string) ($payload['email'] ?? ''));
$telephone = trim((string) ($payload['telephone'] ?? ''));
$adresse = trim((string) ($payload['adresse'] ?? ''));
$estDiplome = filter_var($payload['est_diplome'] ?? false, FILTER_VALIDATE_BOOLEAN);
$diplomeLibelle = trim((string) ($payload['diplome_libelle'] ?? ''));
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$rate = filter_var($payload['quotite_travail'] ?? null, FILTER_VALIDATE_FLOAT);
$contractStart = trim((string) ($payload['date_debut_contrat'] ?? ''));
$contractStartDate = DateTimeImmutable::createFromFormat('!Y-m-d', $contractStart);
$contractEnd = trim((string) ($payload['date_fin_contrat'] ?? ''));
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
if ($matricule === '' || $nom === '' || $prenom === '' || !$structureId || !$postId
|| !in_array((int) $postId, $validPostIds, true)
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
JsonResponse::send(422, ['error' => 'Le matricule, le nom, le prénom, le poste, le type de contrat, le lieu principal et des dates de contrat cohérentes sont obligatoires.']);
}
if ($rate === false || $rate <= 0 || $rate > 100) {
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
}
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
JsonResponse::send(422, ['error' => 'Ladresse e-mail saisie nest pas valide.']);
}
if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255
|| strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Le lieu daffectation principal sélectionné est invalide ou inactif.']);
}
if ($this->agentModel->matriculeExists($matricule)) {
JsonResponse::send(409, ['error' => 'Un agent utilise déjà ce matricule.']);
}
try {
$created = $this->agentModel->createWithDefaults([
'matricule' => $matricule,
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null,
'est_diplome' => $estDiplome,
'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null,
'structure_id' => (int) $structureId,
'quotite_travail' => (float) $rate,
'date_debut_contrat' => $contractStart,
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de créer lagent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$structure = $this->structureModel->findActive((int) $structureId);
JsonResponse::send(201, [
'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']),
'agent' => array_merge($created, [
'matricule' => $matricule,
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null,
'est_diplome' => $estDiplome ? 1 : 0,
'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null,
'id_structure' => (int) $structureId,
'structure_nom' => (string) $structure['nom'],
'structure_type_affectation' => (string) $structure['type_affectation'],
'quotite_travail' => (float) $rate,
'date_debut_contrat' => $contractStart,
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''),
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]),
]);
}
public function updateProfile(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$email = trim((string) ($payload['email'] ?? ''));
$telephone = trim((string) ($payload['telephone'] ?? ''));
$adresse = trim((string) ($payload['adresse'] ?? ''));
$estDiplome = filter_var($payload['est_diplome'] ?? false, FILTER_VALIDATE_BOOLEAN);
$diplomeLibelle = trim((string) ($payload['diplome_libelle'] ?? ''));
$contractStart = trim((string) ($payload['date_debut_contrat'] ?? ''));
$contractStartDate = DateTimeImmutable::createFromFormat('!Y-m-d', $contractStart);
$contractEnd = trim((string) ($payload['date_fin_contrat'] ?? ''));
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
$rawStructure = $payload['structure_id'] ?? null;
$structureId = ($rawStructure === null || $rawStructure === '' || $rawStructure === 0)
? null
: filter_var($rawStructure, FILTER_VALIDATE_INT);
if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
if (!$postId || !in_array((int) $postId, $validPostIds, true)
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']);
}
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
JsonResponse::send(422, ['error' => 'Ladresse e-mail saisie nest pas valide.']);
}
if (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
}
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation principal invalide ou inactif.']);
}
try {
$this->agentModel->updateProfile((int) $agentId, [
'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null,
'est_diplome' => $estDiplome,
'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null,
'date_debut_contrat' => $contractStart,
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
'structure_id' => $structureId !== null ? (int) $structureId : null,
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de modifier les informations de lagent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'message' => 'Les informations de lagent ont été mises à jour.',
'agent' => $this->agentModel->findActive((int) $agentId),
]);
}
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$week = (int) $matches[2];
return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null;
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\AnnualOverviewModel;
use App\Models\PlanningModel;
use App\Models\QuotaModel;
use PDO;
use Throwable;
final class AnnualOverviewController
{
public function __construct(private PDO $pdo)
{
}
public function overview(): void
{
(new Access($this->pdo))->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT);
$year ??= (int) date('Y');
if (!$agentId || $year < 2000 || $year > 2100) {
JsonResponse::send(422, ['error' => 'Agent ou année invalide.']);
}
try {
JsonResponse::send(200, (new AnnualOverviewModel($this->pdo))->overview((int) $agentId, (int) $year));
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de générer la vue annuelle.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
}
/**
* Contrôle puis validation en une seule opération de tous les plannings
* en brouillon de l'agent pour l'année civile affichée.
*
* Aucune validation partielle : si une erreur est trouvée, aucun planning
* n'est validé. Les semaines concernées sont retournées au front afin de
* pouvoir les ouvrir dans un nouvel onglet et les corriger.
*/
public function validateYear(): void
{
(new Access($this->pdo))->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$year = filter_var($payload['year'] ?? null, FILTER_VALIDATE_INT);
$forceQuota = filter_var($payload['force_quota'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || !$year || $year < 2000 || $year > 2100) {
JsonResponse::send(422, ['error' => 'Agent ou année invalide.']);
}
try {
$planningModel = new PlanningModel($this->pdo);
$quotaModel = new QuotaModel($this->pdo);
$plannings = $planningModel->pendingValidationForAgentYear((int) $agentId, (int) $year);
if ($plannings === []) {
JsonResponse::send(200, [
'success' => true,
'status' => 'nothing_to_validate',
'message' => 'Aucun planning en brouillon avec des créneaux nest à valider pour cette année.',
'validated_count' => 0,
'plannings' => [],
'quota' => $quotaModel->annualForApi((int) $agentId, (int) $year),
]);
}
$results = [];
$hasErrors = false;
foreach ($plannings as $planning) {
$controls = $this->controlsForPlanning($planningModel, $planning);
$errors = array_values(array_filter(
$controls,
static fn(array $row): bool => $row['niveau'] === 'ERREUR'
));
$hasErrors = $hasErrors || $errors !== [];
$results[] = [
'id_planning' => (int) $planning['id_planning'],
'id_structure' => (int) $planning['id_structure'],
'structure_nom' => (string) $planning['structure_nom'],
'annee' => (int) $planning['annee'],
'numero_semaine' => (int) $planning['numero_semaine'],
'date_debut' => (string) $planning['date_debut'],
'date_fin' => (string) $planning['date_fin'],
'nombre_creneaux' => (int) $planning['nombre_creneaux'],
'total_minutes' => (int) $planning['total_minutes'],
'controls' => $controls,
'has_errors' => $errors !== [],
];
}
$quota = $quotaModel->annualForApi((int) $agentId, (int) $year);
$quotaWarning = (int) $quota['minutes_restantes'] < 0;
if ($hasErrors) {
JsonResponse::send(422, [
'success' => false,
'status' => 'errors',
'error' => 'Certaines semaines contiennent des erreurs. Aucune semaine na été validée.',
'plannings' => $results,
'quota' => $quota,
]);
}
if ($quotaWarning && !$forceQuota) {
JsonResponse::send(409, [
'success' => false,
'status' => 'warning',
'message' => sprintf(
'Le quota annuel est dépassé de %s. Confirmez pour valider malgré cet avertissement.',
$quotaModel->formatMinutes(abs((int) $quota['minutes_restantes']))['libelle']
),
'plannings' => $results,
'quota' => $quota,
]);
}
$ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings);
$validatedCount = $planningModel->markValidatedMany($ids);
JsonResponse::send(200, [
'success' => true,
'status' => 'validated',
'validated_count' => $validatedCount,
'message' => sprintf(
'%d planning(s) ont été validés pour lannée %d.',
$validatedCount,
(int) $year
),
'plannings' => $results,
'quota' => $quotaModel->annualForApi((int) $agentId, (int) $year),
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de contrôler ou valider lannée complète.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
}
private function controlsForPlanning(PlanningModel $model, array $planning): array
{
$planningId = (int) $planning['id_planning'];
$controls = [];
if ($model->countEntries($planningId) === 0) {
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
}
foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CRENEAU_HORS_SEMAINE',
sprintf('Laffectation #%d du %s est en dehors de la semaine sélectionnée.', $row['id_creneau'], $row['date_jour'])
);
}
foreach ($model->internalOverlaps($planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_INTERNE',
sprintf('Les affectations #%d et #%d se chevauchent le %s.', $row['c1'], $row['c2'], $row['date_jour'])
);
}
foreach ($model->externalOverlaps($planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_AUTRE_STRUCTURE',
sprintf(
'Laffectation #%d chevauche laffectation #%d sur le lieu « %s » le %s (%s).',
$row['c1'],
$row['c2'],
$row['structure_nom'],
$row['date_jour'],
strtolower((string) $row['autre_statut'])
)
);
}
if ($controls === []) {
$this->addControl($controls, 'INFO', 'OK', 'Aucune anomalie bloquante détectée pour cette semaine.');
}
return $controls;
}
private function addControl(array &$controls, string $level, string $code, string $message): void
{
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\CoverageModel;
use App\Models\StructureModel;
use PDO;
use Throwable;
final class CoverageController
{
private CoverageModel $coverageModel;
private StructureModel $structureModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->coverageModel = new CoverageModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->access = new Access($pdo);
}
public function alerts(): void
{
$this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
if ($this->access->role() === Access::ROLE_RESPONSABLE) {
$structureId = $this->access->structureId();
}
if ($week === null) {
JsonResponse::send(400, ['error' => 'Semaine invalide.']);
}
[$year, $weekNumber] = $week;
$structures = $this->structureModel->allActive();
if ($structureId) {
$structures = array_values(array_filter(
$structures,
static fn (array $structure): bool => (int) $structure['id_structure'] === (int) $structureId
));
}
$alerts = [];
$validationWarnings = [];
$unconfigured = [];
$affectedStructures = [];
$gapMinutes = 0;
foreach ($structures as $structure) {
$id = (int) $structure['id_structure'];
$report = $this->coverageModel->reportForStructure($id, $year, $weekNumber);
if (!$report['configured']) {
$unconfigured[] = [
'id_structure' => $id,
'nom' => $structure['nom'],
'code' => $structure['code'],
];
continue;
}
foreach ($report['gaps'] as $gap) {
$gap['id_structure'] = $id;
$gap['structure_nom'] = $structure['nom'];
$gap['structure_code'] = $structure['code'];
$gap['structure_type_affectation'] = $structure['type_affectation'];
$alerts[] = $gap;
$affectedStructures[$id] = true;
$gapMinutes += $this->durationMinutes($gap['heure_debut'], $gap['heure_fin']);
}
foreach ($report['validation_warnings'] as $warning) {
$warning['id_structure'] = $id;
$warning['structure_nom'] = $structure['nom'];
$validationWarnings[] = $warning;
}
}
JsonResponse::send(200, [
'week' => $weekValue,
'alerts' => $alerts,
'validation_warnings' => $validationWarnings,
'unconfigured_structures' => $unconfigured,
'summary' => [
'gap_count' => count($alerts),
'affected_structure_count' => count($affectedStructures),
'gap_minutes' => $gapMinutes,
'validation_warning_count' => count($validationWarnings),
'unconfigured_structure_count' => count($unconfigured),
],
]);
}
public function rules(): void
{
$this->access->requireService();
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
if (!$structureId) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide.']);
}
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
JsonResponse::send(404, ['error' => 'Lieu daffectation introuvable.']);
}
JsonResponse::send(200, [
'structure' => $structure,
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
]);
}
public function saveRules(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$rawRules = $payload['rules'] ?? null;
if (!$structureId || !is_array($rawRules)) {
JsonResponse::send(422, ['error' => 'Paramètres de couverture invalides.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(404, ['error' => 'Lieu daffectation introuvable.']);
}
$rules = [];
foreach ($rawRules as $index => $rawRule) {
if (!is_array($rawRule)) {
JsonResponse::send(422, ['error' => 'Une règle de couverture est invalide.']);
}
$day = filter_var($rawRule['jour_semaine'] ?? null, FILTER_VALIDATE_INT);
$start = trim((string) ($rawRule['heure_debut'] ?? ''));
$end = trim((string) ($rawRule['heure_fin'] ?? ''));
$minimum = filter_var($rawRule['minimum_agents'] ?? 1, FILTER_VALIDATE_INT);
if (!$day || $day < 1 || $day > 5 || !$this->validQuarterHour($start) || !$this->validQuarterHour($end)) {
JsonResponse::send(422, ['error' => sprintf('La règle de couverture n°%d contient un jour ou un horaire invalide.', $index + 1)]);
}
if ($this->timeToMinutes($end) <= $this->timeToMinutes($start)) {
JsonResponse::send(422, ['error' => sprintf('La fin doit être postérieure au début pour la règle n°%d.', $index + 1)]);
}
if (!$minimum || $minimum < 1 || $minimum > 50) {
JsonResponse::send(422, ['error' => 'Le nombre minimum dagents doit être compris entre 1 et 50.']);
}
$rules[] = [
'jour_semaine' => (int) $day,
'heure_debut' => $start,
'heure_fin' => $end,
'minimum_agents' => (int) $minimum,
];
}
$this->assertNoOverlap($rules);
try {
$this->coverageModel->replaceRules((int) $structureId, $rules);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible denregistrer les horaires de couverture.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'message' => 'Les horaires de couverture du lieu ont été enregistrés.',
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
]);
}
private function assertNoOverlap(array $rules): void
{
$byDay = [];
foreach ($rules as $rule) {
$byDay[(int) $rule['jour_semaine']][] = $rule;
}
foreach ($byDay as $day => $dayRules) {
usort($dayRules, fn (array $a, array $b): int => $this->timeToMinutes($a['heure_debut']) <=> $this->timeToMinutes($b['heure_debut']));
$previousEnd = null;
foreach ($dayRules as $rule) {
$start = $this->timeToMinutes($rule['heure_debut']);
$end = $this->timeToMinutes($rule['heure_fin']);
if ($previousEnd !== null && $start < $previousEnd) {
JsonResponse::send(422, ['error' => sprintf('Deux plages de couverture se chevauchent pour le jour %d.', $day)]);
}
$previousEnd = $end;
}
}
}
private function validQuarterHour(string $time): bool
{
if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) {
return false;
}
return true;
}
private function timeToMinutes(string $time): int
{
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function durationMinutes(string $start, string $end): int
{
return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start));
}
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$week = (int) $matches[2];
return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null;
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\StructureModel;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
final class PageController extends Controller
{
private Access $access;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
}
public function planning(): void
{
$this->access->requirePage('planning');
$forcedStructure = $this->access->editableStructureId();
$permissions = $this->access->permissions();
$scripts = ['planning-editor.js'];
$modules = ['planning'];
if (!empty($permissions['can_use_templates'])) {
$scripts[] = 'week-templates.js';
$modules[] = 'weekTemplates';
}
$this->renderPage('planning', 'Saisie du planning', 'planning', [
'structures' => $this->loadStructures(),
'motifs' => $this->loadMotifs(),
'currentWeek' => $this->currentWeek(),
'scripts' => $scripts,
'modules' => $modules,
'pageConfig' => [
'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null),
'agentId' => $this->positiveInt($_GET['agent_id'] ?? null),
'week' => $this->weekValue($_GET['week'] ?? null),
'focus' => (string) ($_GET['focus'] ?? ''),
'notice' => trim((string) ($_GET['notice'] ?? '')),
'structureLocked' => $forcedStructure !== null,
],
]);
}
public function pending(): void
{
$this->access->requirePage('pending');
$this->renderPage('pending', 'Plannings à valider', 'pending', [
'scripts' => ['pending-validation.js'],
'modules' => ['pendingValidation'],
]);
}
public function coverage(): void
{
$this->access->requirePage('coverage');
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
$this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [
'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['coverage.js'],
'modules' => ['coverage'],
'pageConfig' => [
'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null),
'structureLocked' => $forcedStructure !== null,
],
]);
}
public function structures(): void
{
$this->access->requirePage('structures');
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
$this->renderPage('structures', 'Planning par lieu', 'structures', [
'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['structure-overview.js'],
'modules' => ['structureOverview'],
'pageConfig' => [
'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null),
'structureLocked' => $forcedStructure !== null,
],
]);
}
public function agents(): void
{
$this->access->requirePage('agents');
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
$this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [
'agents' => $this->loadAgents(),
'structures' => $this->loadStructuresForAgentEditor(),
'motifs' => $this->loadMotifs(),
'scripts' => ['agent-overview.js'],
'modules' => ['agentOverview'],
'pageConfig' => [
'agentId' => $forcedAgent ?: $this->positiveInt($_GET['agent_id'] ?? null),
'month' => $this->monthValue($_GET['month'] ?? null),
'agentLocked' => $forcedAgent !== null,
],
]);
}
public function annual(): void
{
$this->access->requirePage('annual');
$this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [
'agents' => (new AgentModel($this->pdo))->allActive(),
'scripts' => ['annual-overview.js'],
'modules' => ['annualOverview'],
'pageConfig' => [
'agentId' => $this->positiveInt($_GET['agent_id'] ?? null),
'year' => $this->yearValue($_GET['year'] ?? null),
],
]);
}
public function pta(): void
{
$this->access->requirePage('pta');
$this->renderPage('pta', 'PTA annuel par agent', 'pta', [
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['pta.js'],
'modules' => ['pta'],
'pageConfig' => [
'agentId' => $this->positiveInt($_GET['agent_id'] ?? null),
'date' => $this->dateValue($_GET['date'] ?? null),
],
]);
}
public function teams(): void
{
$this->access->requirePage('teams');
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['teams.js'],
'modules' => ['teams'],
'pageConfig' => [
'dateDebut' => $this->dateValue($_GET['date_debut'] ?? null),
'dateFin' => $this->dateValue($_GET['date_fin'] ?? null),
],
]);
}
public function vacations(): void
{
$this->access->requirePage('vacations');
$this->renderPage('vacations', 'Vacances scolaires', 'vacations', [
'scripts' => ['vacation-calendar.js'],
'modules' => ['vacationCalendar'],
]);
}
public function administration(): void
{
$this->access->requirePage('administration');
$this->renderPage('administration', 'Administration', 'administration', [
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => (new AgentModel($this->pdo))->allPosts(),
'scripts' => ['administration.js', 'coverage.js'],
'modules' => ['administration', 'coverage'],
]);
}
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
{
$profile = $this->access->profile();
$this->render('pages/' . $view, array_merge([
'pageTitle' => $title,
'activePage' => $activePage,
'csrfToken' => Csrf::ensureToken(),
'scripts' => [],
'modules' => [],
'motifs' => [],
'pageConfig' => [],
'accessProfile' => $profile,
'permissions' => $profile['permissions'],
'allowedPages' => $this->access->allowedPages(),
], $data));
}
private function loadStructures(): array
{
$model = new StructureModel($this->pdo);
if ($this->access->role() === Access::ROLE_SERVICE) {
return $model->allActive();
}
$structureId = $this->access->structureId();
if ($structureId === null) {
return [];
}
$structure = $model->findActive($structureId);
return $structure ? [$structure] : [];
}
private function loadStructuresForAgentEditor(): array
{
if ($this->access->role() === Access::ROLE_SERVICE) {
return (new StructureModel($this->pdo))->allActive();
}
return $this->loadStructures();
}
private function loadAgents(): array
{
$model = new AgentModel($this->pdo);
if ($this->access->role() === Access::ROLE_SERVICE) {
return $model->allActive();
}
if ($this->access->role() === Access::ROLE_AGENT) {
$agent = $this->access->agentId() ? $model->findActive((int) $this->access->agentId()) : null;
return $agent ? [$agent] : [];
}
$structureId = $this->access->structureId();
return $structureId ? $model->allVisibleForStructure($structureId) : [];
}
private function loadMotifs(): array
{
return (new MotifModel($this->pdo))->allActive();
}
private function currentWeek(): string
{
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
}
private function positiveInt(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $number === false ? null : (int) $number;
}
private function weekValue(mixed $value): ?string
{
$value = trim((string) $value);
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
}
private function dateValue(mixed $value): ?string
{
$value = trim((string) $value);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date && $date->format('Y-m-d') === $value ? $value : null;
}
private function yearValue(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $number === false ? null : (int) $number;
}
private function monthValue(mixed $value): ?string
{
$value = trim((string) $value);
return preg_match('/^\\d{4}-(0[1-9]|1[0-2])$/', $value) === 1 ? $value : null;
}
}

View File

@@ -0,0 +1,644 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\PlanningModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Models\VacationModel;
use DateTimeImmutable;
use DomainException;
use PDO;
use Throwable;
final class PlanningController
{
private PlanningModel $planningModel;
private AgentModel $agentModel;
private StructureModel $structureModel;
private MotifModel $motifModel;
private QuotaModel $quotaModel;
private VacationModel $vacationModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->planningModel = new PlanningModel($pdo);
$this->agentModel = new AgentModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->motifModel = new MotifModel($pdo);
$this->quotaModel = new QuotaModel($pdo);
$this->vacationModel = new VacationModel($pdo);
$this->access = new Access($pdo);
}
public function show(): void
{
$this->access->requireDraftEditor();
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
if (!$structureId || !$agentId || $week === null) {
JsonResponse::send(400, ['error' => 'Paramètres invalides.']);
}
$this->access->requireStructureEdit((int) $structureId);
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
JsonResponse::send(404, ['error' => 'Lieu daffectation introuvable.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
[$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$planning = $this->planningModel->findByContext((int) $structureId, (int) $agentId, $year, $weekNumber);
JsonResponse::send(200, [
'planning' => $planning,
'entries' => $planning ? $this->planningModel->entries((int) $planning['id_planning']) : [],
'other_entries' => $this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId),
'current_structure' => $structure,
'agent' => $agent,
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate),
'calendar_days' => $this->vacationModel->typesForIsoWeek($year, $weekNumber),
]);
}
public function save(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$weekValue = trim((string) ($payload['week'] ?? ''));
$rawEntries = $payload['entries'] ?? [];
$week = $this->parseWeek($weekValue);
if (!$structureId || !$agentId || $week === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation, agent ou semaine invalide.']);
}
$this->access->requireStructureEdit((int) $structureId);
if (!is_array($rawEntries)) {
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide ou inactif.']);
}
[$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds());
$this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap(
$entries,
$this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId)
);
try {
$planningId = $this->planningModel->saveDraft(
(int) $structureId,
(int) $agentId,
$year,
$weekNumber,
$entries
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Erreur lors de lenregistrement du planning.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'success' => true,
'planning_id' => $planningId,
'empty' => count($entries) === 0,
'message' => count($entries) === 0
? 'Toutes les affectations ont été supprimées. Le planning vide a été enregistré.'
: 'Brouillon enregistré avec succès.',
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate),
]);
}
public function updateAgentEntry(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$motifId = filter_var($payload['motif_id'] ?? null, FILTER_VALIDATE_INT);
$date = trim((string) ($payload['date'] ?? ''));
$start = trim((string) ($payload['start'] ?? ''));
$end = trim((string) ($payload['end'] ?? ''));
if (!$entryId || !$structureId || !$motifId) {
JsonResponse::send(422, ['error' => 'Créneau, lieu ou motif invalide.']);
}
$this->access->requireEntryEdit((int) $entryId);
$this->access->requireStructureEdit((int) $structureId);
$entry = $this->planningModel->findEntryDetailed((int) $entryId);
if ($entry === null) {
JsonResponse::send(404, ['error' => 'Créneau introuvable.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide ou inactif.']);
}
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) {
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
}
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts dheure.']);
}
try {
$result = $this->planningModel->updateEntryFromAgentView(
(int) $entryId,
(int) $structureId,
(int) $motifId,
$date,
$start . ':00',
$end . ':00'
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de modifier le créneau.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$reopened = count($result['reopened_planning_ids']) > 0;
JsonResponse::send(200, [
'success' => true,
'message' => $reopened
? 'Créneau modifié. Le ou les plannings validés concernés ont été repassés en brouillon et devront être validés à nouveau.'
: 'Créneau modifié avec succès.',
'requires_revalidation' => $reopened,
'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $date),
]);
}
public function deleteAgentEntry(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT);
if (!$entryId) {
JsonResponse::send(422, ['error' => 'Créneau invalide.']);
}
$this->access->requireEntryEdit((int) $entryId);
try {
$result = $this->planningModel->deleteEntryFromAgentView((int) $entryId);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de supprimer le créneau.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$message = $result['remaining_entries'] === 0
? 'Le dernier créneau a été supprimé. Le planning est maintenant vide.'
: 'Créneau supprimé avec succès.';
if ($result['reopened']) {
$message .= ' Le planning validé concerné a été repassé en brouillon.';
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'planning_empty' => $result['remaining_entries'] === 0,
'requires_revalidation' => $result['reopened'],
'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], (string) $result['date']),
]);
}
public function moveOrDuplicateEntry(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT);
$targetDate = trim((string) ($payload['target_date'] ?? ''));
$action = strtolower(trim((string) ($payload['action'] ?? 'move')));
if (!$entryId || !in_array($action, ['move', 'duplicate'], true)) {
JsonResponse::send(422, ['error' => 'Créneau ou action invalide.']);
}
$this->access->requireEntryEdit((int) $entryId);
$entry = $this->planningModel->findEntryDetailed((int) $entryId);
if ($entry === null) {
JsonResponse::send(404, ['error' => 'Créneau introuvable.']);
}
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $targetDate);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $targetDate;
if (!$validDate || (int) $dateObj->format('N') > 5) {
JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']);
}
try {
$result = $this->planningModel->moveOrDuplicateEntry(
(int) $entryId,
$targetDate,
$action === 'duplicate'
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => $action === 'duplicate'
? 'Impossible de dupliquer le créneau.'
: 'Impossible de déplacer le créneau.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$reopened = count($result['reopened_planning_ids']) > 0;
$message = match ($result['action']) {
'duplicate' => 'Créneau dupliqué avec succès.',
'move' => 'Créneau déplacé avec succès.',
default => 'Le créneau se trouve déjà sur ce jour.',
};
if ($reopened) {
$message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.';
}
JsonResponse::send(200, [
'success' => true,
'action' => $result['action'],
'message' => $message,
'requires_revalidation' => $reopened,
'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $targetDate),
]);
}
public function copyAgentWeek(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$sourceWeekValue = trim((string) ($payload['source_week'] ?? ''));
$targetWeekValue = trim((string) ($payload['target_week'] ?? ''));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
$sourceWeek = $this->parseWeek($sourceWeekValue);
$targetWeek = $this->parseWeek($targetWeekValue);
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
$result = $this->planningModel->copyAgentWeek(
(int) $agentId,
$sourceWeek[0],
$sourceWeek[1],
$targetWeek[0],
$targetWeek[1],
$mode
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de dupliquer la semaine.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$message = sprintf(
'%d créneau(x) ont été copiés vers %s.',
(int) $result['copied_entries'],
$targetWeekValue
);
if ($result['reopened_planning_ids']) {
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'requires_revalidation' => count($result['reopened_planning_ids']) > 0,
'quota' => $this->quotaModel->forDateForApi(
(int) $agentId,
(new DateTimeImmutable())->setISODate($targetWeek[0], $targetWeek[1], 1)->format('Y-m-d')
),
]);
}
public function pendingValidation(): void
{
$this->access->requireService();
$plannings = $this->planningModel->pendingValidation();
$totalMinutes = array_sum(array_map(
static fn(array $planning): int => (int) $planning['total_minutes'],
$plannings
));
$countedMinutes = array_sum(array_map(
static fn(array $planning): int => (int) $planning['minutes_decomptees'],
$plannings
));
JsonResponse::send(200, [
'plannings' => $plannings,
'summary' => [
'count' => count($plannings),
'total_minutes' => $totalMinutes,
'counted_minutes' => $countedMinutes,
],
]);
}
public function validate(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT);
$forceQuota = filter_var($payload['force_quota'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$planningId) {
JsonResponse::send(422, ['error' => 'Planning invalide.']);
}
$planning = $this->planningModel->findDetailed((int) $planningId);
if ($planning === null) {
JsonResponse::send(404, ['error' => 'Planning introuvable.']);
}
$controls = [];
if ($this->planningModel->countEntries((int) $planningId) === 0) {
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
}
foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CRENEAU_HORS_SEMAINE',
sprintf('Laffectation #%d du %s est en dehors de la semaine sélectionnée.', $row['id_creneau'], $row['date_jour'])
);
}
foreach ($this->planningModel->internalOverlaps((int) $planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_INTERNE',
sprintf('Les affectations #%d et #%d se chevauchent le %s.', $row['c1'], $row['c2'], $row['date_jour'])
);
}
foreach ($this->planningModel->validatedExternalOverlaps((int) $planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_AUTRE_STRUCTURE',
sprintf(
'Laffectation #%d chevauche laffectation #%d déjà validée sur le lieu « %s » le %s.',
$row['c1'],
$row['c2'],
$row['structure_nom'],
$row['date_jour']
)
);
}
$quota = $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']);
if ((int) $quota['minutes_restantes'] < 0) {
$this->addControl(
$controls,
'ALERTE',
'QUOTA_DEPASSE',
sprintf(
'Le quota annuel est dépassé de %s. Quota cible : %s ; affecté : %s.',
$this->quotaModel->formatMinutes(abs((int) $quota['minutes_restantes']))['libelle'],
$quota['quota_cible']['libelle'],
$quota['affectees']['libelle']
)
);
}
$errors = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ERREUR'));
$alerts = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ALERTE'));
if ($errors) {
JsonResponse::send(422, [
'success' => false,
'status' => 'errors',
'controls' => $controls,
'quota' => $quota,
'error' => 'Le planning contient des erreurs bloquantes.',
]);
}
if ($alerts && !$forceQuota) {
JsonResponse::send(409, [
'success' => false,
'status' => 'warning',
'controls' => $controls,
'quota' => $quota,
'message' => 'Le planning dépasse le quota annuel. Une confirmation est nécessaire.',
]);
}
$this->planningModel->markValidated((int) $planningId);
$this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.');
JsonResponse::send(200, [
'success' => true,
'status' => 'validated',
'controls' => $controls,
'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']),
'message' => 'Planning validé avec succès.',
]);
}
public function reopen(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT);
if (!$planningId) {
JsonResponse::send(422, ['error' => 'Planning invalide.']);
}
$this->access->requirePlanningEdit((int) $planningId);
$planning = $this->planningModel->findDetailed((int) $planningId);
if ($planning === null) {
JsonResponse::send(404, ['error' => 'Planning introuvable.']);
}
if ($planning['statut'] !== 'VALIDE') {
JsonResponse::send(409, ['error' => 'Ce planning est déjà modifiable.']);
}
if (!$this->planningModel->reopen((int) $planningId)) {
JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']);
}
JsonResponse::send(200, [
'success' => true,
'status' => 'BROUILLON',
'message' => 'Le planning est repassé en brouillon. Vous pouvez maintenant corriger les affectations puis le valider à nouveau.',
'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']),
]);
}
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$year = (int) $matches[1];
$week = (int) $matches[2];
if ($week < 1 || $week > 53) {
return null;
}
return [$year, $week];
}
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+6 days');
$normalized = [];
foreach ($entries as $index => $entry) {
$date = (string) ($entry['date'] ?? '');
$motifId = filter_var($entry['motif_id'] ?? null, FILTER_VALIDATE_INT);
$start = (string) ($entry['start'] ?? '');
$end = (string) ($entry['end'] ?? '');
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
if (!$validDate || !$motifId || !in_array((int) $motifId, $validMotifs, true)
|| !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'Affectation invalide à la ligne ' . ($index + 1) . '.']);
}
if ($dateObj < $weekStart || $dateObj > $weekEnd) {
JsonResponse::send(422, ['error' => 'Une affectation se trouve en dehors de la semaine sélectionnée.']);
}
$normalized[] = [
'date' => $date,
'motif_id' => (int) $motifId,
'start' => $start . ':00',
'end' => $end . ':00',
];
}
return $normalized;
}
private function assertNoInternalOverlap(array $entries): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$first = $entries[$i];
$second = $entries[$j];
if ($first['date'] === $second['date'] && $first['start'] < $second['end'] && $first['end'] > $second['start']) {
JsonResponse::send(409, [
'error' => sprintf(
'Deux affectations saisies se chevauchent le %s (%s-%s et %s-%s).',
$first['date'],
substr($first['start'], 0, 5),
substr($first['end'], 0, 5),
substr($second['start'], 0, 5),
substr($second['end'], 0, 5)
),
'code' => 'CHEVAUCHEMENT_SAISIE',
]);
}
}
}
}
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{
foreach ($entries as $entry) {
foreach ($existingEntries as $existing) {
$existingStart = $existing['heure_debut'] . ':00';
$existingEnd = $existing['heure_fin'] . ':00';
if ($entry['date'] === $existing['date_jour'] && $entry['start'] < $existingEnd && $entry['end'] > $existingStart) {
JsonResponse::send(409, [
'error' => sprintf(
'Double affectation impossible : lagent est déjà planifié sur le lieu « %s » le %s de %s à %s (%s).',
$existing['structure_nom'],
$existing['date_jour'],
$existing['heure_debut'],
$existing['heure_fin'],
strtolower((string) $existing['statut'])
),
'code' => 'CHEVAUCHEMENT_AUTRE_STRUCTURE',
'conflict' => [
'structure' => $existing['structure_nom'],
'date' => $existing['date_jour'],
'start' => $existing['heure_debut'],
'end' => $existing['heure_fin'],
'status' => $existing['statut'],
],
]);
}
}
}
}
private function addControl(array &$controls, string $level, string $code, string $message): void
{
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\PtaModel;
use App\Models\StructureModel;
use DateTimeImmutable;
use PDO;
use Throwable;
final class PtaController
{
private PtaModel $ptaModel;
private AgentModel $agentModel;
private StructureModel $structureModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->ptaModel = new PtaModel($pdo);
$this->agentModel = new AgentModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->access = new Access($pdo);
}
public function summary(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$date = trim((string) ($_GET['date'] ?? date('Y-m-d')));
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if (!$agentId || !$parsed || $parsed->format('Y-m-d') !== $date) {
JsonResponse::send(422, ['error' => 'Agent ou date de référence invalide.']);
}
try {
JsonResponse::send(200, $this->ptaModel->summary((int) $agentId, $date));
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de calculer le PTA de lagent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
}
public function constraint(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$action = strtoupper(trim((string) ($payload['action'] ?? 'SAVE')));
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
if (!$agentId || $this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($action === 'DELETE') {
$constraintId = filter_var($payload['constraint_id'] ?? null, FILTER_VALIDATE_INT);
if (!$constraintId) {
JsonResponse::send(422, ['error' => 'Contrainte invalide.']);
}
$deleted = $this->ptaModel->deleteConstraint((int) $constraintId, (int) $agentId);
JsonResponse::send($deleted ? 200 : 404, [
'message' => $deleted ? 'La contrainte a été supprimée.' : 'Contrainte introuvable.',
]);
}
$type = strtoupper(trim((string) ($payload['type_contrainte'] ?? '')));
$start = trim((string) ($payload['date_debut'] ?? ''));
$end = trim((string) ($payload['date_fin'] ?? ''));
$comment = trim((string) ($payload['commentaire'] ?? ''));
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $start);
$endDate = $end !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $end) : null;
if (!in_array($type, ['MEDICALE', 'TEMPS_PARTIEL_THERAPEUTIQUE'], true)
|| !$startDate || $startDate->format('Y-m-d') !== $start
|| ($end !== '' && (!$endDate || $endDate->format('Y-m-d') !== $end || $endDate < $startDate))
|| $comment === '') {
JsonResponse::send(422, ['error' => 'Type, dates ou commentaire de contrainte invalides.']);
}
$rate = $payload['quotite_temporaire'] ?? null;
$rate = ($rate === null || $rate === '') ? null : filter_var($rate, FILTER_VALIDATE_FLOAT);
if ($rate !== null && ($rate === false || $rate <= 0 || $rate > 100)) {
JsonResponse::send(422, ['error' => 'La quotité temporaire doit être comprise entre 1 et 100 %.']);
}
$maxDayHours = $payload['maximum_heures_jour'] ?? null;
$maxWeekHours = $payload['maximum_heures_semaine'] ?? null;
$maxDayMinutes = ($maxDayHours === null || $maxDayHours === '') ? null : (int) round((float) $maxDayHours * 60);
$maxWeekMinutes = ($maxWeekHours === null || $maxWeekHours === '') ? null : (int) round((float) $maxWeekHours * 60);
try {
$id = $this->ptaModel->saveConstraint([
'id_agent' => (int) $agentId,
'type_contrainte' => $type,
'date_debut' => $start,
'date_fin' => $end !== '' ? $end : null,
'quotite_temporaire' => $rate,
'maximum_minutes_jour' => $maxDayMinutes,
'maximum_minutes_semaine' => $maxWeekMinutes,
'interdit_matin' => !empty($payload['interdit_matin']) ? 1 : 0,
'interdit_midi' => !empty($payload['interdit_midi']) ? 1 : 0,
'interdit_soir' => !empty($payload['interdit_soir']) ? 1 : 0,
'commentaire' => $comment,
]);
JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]);
} catch (Throwable $e) {
JsonResponse::send(500, ['error' => 'Impossible denregistrer la contrainte.', 'details' => $e->getMessage()]);
}
}
public function wish(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$action = strtoupper(trim((string) ($payload['action'] ?? 'SAVE')));
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
if (!$agentId || $this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($action === 'DELETE') {
$wishId = filter_var($payload['wish_id'] ?? null, FILTER_VALIDATE_INT);
if (!$wishId) {
JsonResponse::send(422, ['error' => 'Préférence invalide.']);
}
$deleted = $this->ptaModel->deleteWish((int) $wishId, (int) $agentId);
JsonResponse::send($deleted ? 200 : 404, [
'message' => $deleted ? 'La préférence ou interdiction a été supprimée.' : 'Élément introuvable.',
]);
}
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$type = strtoupper(trim((string) ($payload['type_souhait'] ?? '')));
$priority = filter_var($payload['priorite'] ?? 1, FILTER_VALIDATE_INT);
$distance = $payload['distance_km'] ?? null;
$distance = ($distance === null || $distance === '') ? null : filter_var($distance, FILTER_VALIDATE_FLOAT);
$comment = trim((string) ($payload['commentaire'] ?? ''));
if (!$structureId || $this->structureModel->findActive((int) $structureId) === null
|| !in_array($type, ['PREFERENCE', 'INTERDICTION'], true)
|| !$priority || $priority < 1 || $priority > 5
|| ($distance !== null && ($distance === false || $distance < 0))) {
JsonResponse::send(422, ['error' => 'Lieu, type, priorité ou distance invalide.']);
}
try {
$id = $this->ptaModel->saveWish([
'id_agent' => (int) $agentId,
'id_structure' => (int) $structureId,
'type_souhait' => $type,
'priorite' => (int) $priority,
'distance_km' => $distance,
'commentaire' => $comment !== '' ? $comment : null,
]);
JsonResponse::send(201, ['message' => 'La préférence de lieu a été enregistrée.', 'id_souhait' => $id]);
} catch (Throwable $e) {
JsonResponse::send(500, ['error' => 'Impossible denregistrer la préférence.', 'details' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Models\AgentModel;
use App\Models\StructureModel;
use PDO;
final class RoleController extends Controller
{
public function __construct(private PDO $pdo)
{
}
public function index(): void
{
$access = new Access($this->pdo);
if (($_GET['action'] ?? '') === 'reset') {
$access->clear();
header('Location: role.php', true, 302);
exit;
}
$error = '';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$expected = Csrf::ensureToken();
$provided = (string) ($_POST['csrf_token'] ?? '');
if ($provided === '' || !hash_equals($expected, $provided)) {
$error = 'La session a expiré. Rechargez la page et recommencez.';
} else {
try {
$role = strtoupper(trim((string) ($_POST['role'] ?? '')));
$agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null);
header('Location: ' . $access->homeUrl(), true, 302);
exit;
} catch (\DomainException $e) {
$error = $e->getMessage();
}
}
}
$this->render('pages/role', [
'pageTitle' => 'Choisir votre accès',
'csrfToken' => Csrf::ensureToken(),
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'error' => $error,
'currentProfile' => $access->isConfigured() ? $access->profile() : null,
]);
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\PdfResponse;
use App\Core\Request;
use App\Models\CoverageModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Services\PlanningPdfService;
use DateTimeImmutable;
use PDO;
use Throwable;
final class StructureController
{
private StructureModel $structureModel;
private QuotaModel $quotaModel;
private CoverageModel $coverageModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->structureModel = new StructureModel($pdo);
$this->quotaModel = new QuotaModel($pdo);
$this->coverageModel = new CoverageModel($pdo);
$this->access = new Access($pdo);
}
public function overview(): void
{
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
if (!$structureId || $week === null) {
JsonResponse::send(400, ['error' => 'Lieu daffectation ou semaine invalide.']);
}
$this->access->requireStructureView((int) $structureId);
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
JsonResponse::send(404, ['error' => 'Lieu daffectation introuvable.']);
}
[$year, $weekNumber] = $week;
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d');
$entries = $this->structureModel->entriesForOverview((int) $structureId, $year, $weekNumber);
$entriesByAgent = [];
foreach ($entries as $entry) {
$entriesByAgent[(int) $entry['id_agent']][] = $entry;
}
$agents = [];
foreach ($this->structureModel->agentsForOverview((int) $structureId, $year, $weekNumber) as $agent) {
$agentId = (int) $agent['id_agent'];
$agents[] = [
'id_agent' => $agentId,
'matricule' => $agent['matricule'],
'nom' => $agent['nom'],
'prenom' => $agent['prenom'],
'lieu_principal' => (int) ($agent['id_structure_principale'] ?? 0) === (int) $structureId,
'quota' => $this->quotaModel->forDateForApi($agentId, $weekStart),
'entries' => $entriesByAgent[$agentId] ?? [],
];
}
JsonResponse::send(200, [
'structure' => $structure,
'week' => ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd],
'agents' => $agents,
'coverage' => $this->coverageModel->reportForStructure((int) $structureId, $year, $weekNumber),
]);
}
public function pdf(): void
{
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
if (!$structureId || $week === null) {
http_response_code(400);
echo 'Lieu daffectation ou semaine invalide.';
return;
}
$this->access->requireStructureView((int) $structureId);
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
http_response_code(404);
echo 'Lieu daffectation introuvable.';
return;
}
[$year, $weekNumber] = $week;
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d');
$weekData = ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd];
$entries = $this->structureModel->entriesForOverview((int) $structureId, $year, $weekNumber);
$entriesByAgent = [];
foreach ($entries as $entry) {
$entriesByAgent[(int) $entry['id_agent']][] = $entry;
}
$agents = [];
foreach ($this->structureModel->agentsForOverview((int) $structureId, $year, $weekNumber) as $agent) {
$agentId = (int) $agent['id_agent'];
$agents[] = [
'id_agent' => $agentId,
'matricule' => $agent['matricule'],
'nom' => $agent['nom'],
'prenom' => $agent['prenom'],
'lieu_principal' => (int) ($agent['id_structure_principale'] ?? 0) === (int) $structureId,
'quota' => $this->quotaModel->forDateForApi($agentId, $weekStart),
'entries' => $entriesByAgent[$agentId] ?? [],
];
}
$pdf = (new PlanningPdfService())->structureWeek($structure, $agents, $weekData);
PdfResponse::inline(
$pdf,
sprintf('planning_lieu_%s_%s.pdf', $structure['code'] ?: $structure['id_structure'], str_replace('-W', '_S', $weekValue))
);
}
public function create(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$rawCode = trim((string) ($payload['code'] ?? ''));
$code = strtoupper((string) preg_replace('/\s+/', '_', $rawCode));
$name = trim((string) ($payload['nom'] ?? ''));
$address = trim((string) ($payload['adresse'] ?? ''));
$typeAffectation = strtoupper(trim((string) ($payload['type_affectation'] ?? '')));
if ($code === '' || $name === '') {
JsonResponse::send(422, ['error' => 'Le code et le nom du lieu daffectation sont obligatoires.']);
}
if (!in_array($typeAffectation, ['PERISCOLAIRE', 'EXTRASCOLAIRE'], true)) {
JsonResponse::send(422, ['error' => 'Le type du lieu doit être Périscolaire ou Extrascolaire.']);
}
if (!preg_match('/^[A-Z0-9_-]{2,50}$/', $code)) {
JsonResponse::send(422, ['error' => 'Le code doit contenir uniquement des lettres, chiffres, tirets ou underscores.']);
}
if (strlen($name) > 150 || strlen($address) > 255) {
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
}
if ($this->structureModel->codeExists($code)) {
JsonResponse::send(409, ['error' => 'Un lieu daffectation utilise déjà ce code.']);
}
try {
$structure = $this->structureModel->create($code, $name, $address !== '' ? $address : null, $typeAffectation);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de créer le lieu daffectation.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(201, [
'message' => sprintf('Le lieu daffectation « %s » a été ajouté.', $name),
'structure' => $structure,
]);
}
public function updateType(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$typeAffectation = strtoupper(trim((string) ($payload['type_affectation'] ?? '')));
if (!$structureId) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide.']);
}
if (!in_array($typeAffectation, ['PERISCOLAIRE', 'EXTRASCOLAIRE'], true)) {
JsonResponse::send(422, ['error' => 'Le type du lieu doit être Périscolaire ou Extrascolaire.']);
}
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
JsonResponse::send(404, ['error' => 'Lieu daffectation introuvable.']);
}
try {
$this->structureModel->updateType((int) $structureId, $typeAffectation);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de modifier le type du lieu daffectation.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'message' => sprintf('Le type du lieu « %s » a été mis à jour.', $structure['nom']),
'structure' => [
'id_structure' => (int) $structureId,
'type_affectation' => $typeAffectation,
],
]);
}
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$week = (int) $matches[2];
return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null;
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\StructureModel;
use App\Models\TeamModel;
use DateTimeImmutable;
use PDO;
use Throwable;
final class TeamController
{
private TeamModel $teamModel;
private AgentModel $agentModel;
private StructureModel $structureModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->teamModel = new TeamModel($pdo);
$this->agentModel = new AgentModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->access = new Access($pdo);
}
public function index(): void
{
$this->access->requireService();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
$start = trim((string) ($_GET['date_debut'] ?? '')) ?: null;
$end = trim((string) ($_GET['date_fin'] ?? '')) ?: null;
JsonResponse::send(200, ['teams' => $this->teamModel->all($start, $end)]);
}
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$type = strtoupper(trim((string) ($payload['type_equipe'] ?? '')));
$label = trim((string) ($payload['libelle'] ?? ''));
$start = trim((string) ($payload['date_debut'] ?? ''));
$end = trim((string) ($payload['date_fin'] ?? ''));
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $start);
$endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $end);
if (!$structureId || $this->structureModel->findActive((int) $structureId) === null
|| !in_array($type, ['MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE', 'PERISCOLAIRE_SEMAINE'], true)
|| $label === '' || !$startDate || !$endDate
|| $startDate->format('Y-m-d') !== $start || $endDate->format('Y-m-d') !== $end
|| $endDate < $startDate) {
JsonResponse::send(422, ['error' => 'Les informations de léquipe sont incomplètes ou invalides.']);
}
$under6 = max(0, (int) ($payload['nombre_enfants_moins_6'] ?? 0));
$over6 = max(0, (int) ($payload['nombre_enfants_6_plus'] ?? 0));
$ratioUnder6 = max(1, (int) ($payload['ratio_moins_6'] ?? 8));
$ratioOver6 = max(1, (int) ($payload['ratio_6_plus'] ?? 12));
$minAgents = max(1, (int) ($payload['minimum_agents'] ?? 1));
$qualified = (float) ($payload['pourcentage_diplomes_min'] ?? 50);
if ($qualified < 0 || $qualified > 100) {
JsonResponse::send(422, ['error' => 'Le pourcentage minimal de diplômés doit être compris entre 0 et 100 %.']);
}
try {
$id = $this->teamModel->create([
'id_structure' => (int) $structureId,
'type_equipe' => $type,
'libelle' => $label,
'date_debut' => $start,
'date_fin' => $end,
'nombre_enfants_moins_6' => $under6,
'nombre_enfants_6_plus' => $over6,
'ratio_moins_6' => $ratioUnder6,
'ratio_6_plus' => $ratioOver6,
'minimum_agents' => $minAgents,
'pourcentage_diplomes_min' => $qualified,
'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null,
]);
JsonResponse::send(201, ['message' => 'Léquipe a été créée.', 'id_equipe' => $id]);
} catch (Throwable $e) {
JsonResponse::send(500, ['error' => 'Impossible de créer léquipe.', 'details' => $e->getMessage()]);
}
}
public function member(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$action = strtoupper(trim((string) ($payload['action'] ?? 'ADD')));
$teamId = filter_var($payload['team_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
if (!$teamId || !$agentId || $this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(422, ['error' => 'Équipe ou agent invalide.']);
}
if ($action === 'REMOVE') {
$deleted = $this->teamModel->removeMember((int) $teamId, (int) $agentId);
JsonResponse::send($deleted ? 200 : 404, ['message' => $deleted ? 'Lagent a été retiré de léquipe.' : 'Affectation introuvable.']);
}
$function = strtoupper(trim((string) ($payload['fonction_equipe'] ?? 'ANIMATION')));
if (!in_array($function, ['RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN'], true)) {
JsonResponse::send(422, ['error' => 'Fonction dans léquipe invalide.']);
}
try {
$this->teamModel->addMember(
(int) $teamId,
(int) $agentId,
$function,
trim((string) ($payload['commentaire'] ?? '')) ?: null
);
JsonResponse::send(200, ['message' => 'Lagent a été ajouté à léquipe.']);
} catch (Throwable $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
}
}
public function status(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$teamId = filter_var($payload['team_id'] ?? null, FILTER_VALIDATE_INT);
$status = strtoupper(trim((string) ($payload['statut'] ?? '')));
if (!$teamId || !in_array($status, ['BROUILLON', 'A_CONTROLER', 'VALIDEE'], true)) {
JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']);
}
$this->teamModel->setStatus((int) $teamId, $status);
JsonResponse::send(200, ['message' => 'Le statut de léquipe a été mis à jour.']);
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\VacationModel;
use App\Services\SchoolHolidayApiService;
use DateTimeImmutable;
use PDO;
use Throwable;
final class VacationController
{
private VacationModel $vacationModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->vacationModel = new VacationModel($pdo);
$this->access = new Access($pdo);
}
public function index(): void
{
$this->access->requireService();
$schoolYear = trim((string) ($_GET['school_year'] ?? ''));
if ($schoolYear !== '' && !$this->isSchoolYear($schoolYear)) {
JsonResponse::send(422, ['error' => 'Année scolaire invalide. Format attendu : 2026-2027.']);
}
JsonResponse::send(200, [
'periods' => $this->vacationModel->all($schoolYear !== '' ? $schoolYear : null),
]);
}
public function preview(): void
{
$this->access->requireService();
$academy = trim((string) ($_GET['academy'] ?? ''));
$calendarYearRaw = trim((string) ($_GET['year'] ?? ''));
if ($academy === '' || (function_exists('mb_strlen') ? mb_strlen($academy, 'UTF-8') : strlen($academy)) > 100) {
JsonResponse::send(422, ['error' => 'Renseignez une académie valide.']);
}
if (!preg_match('/^\d{4}$/', $calendarYearRaw)) {
JsonResponse::send(422, ['error' => 'Année civile invalide. Format attendu : 2026.']);
}
$calendarYear = (int) $calendarYearRaw;
if ($calendarYear < 2000 || $calendarYear > 2100) {
JsonResponse::send(422, ['error' => 'Année civile invalide.']);
}
try {
$result = (new SchoolHolidayApiService())->previewCalendarYearDetailed($academy, $calendarYear);
$periods = $result['periods'];
$warnings = $result['warnings'];
} catch (Throwable $e) {
JsonResponse::send(502, [
'error' => 'Impossible de charger les propositions depuis lAPI officielle.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'academy' => $academy,
'year' => $calendarYear,
'periods' => $periods,
'warnings' => $warnings ?? [],
'message' => $periods === []
? 'Aucune période na été trouvée pour lannée ' . $calendarYear . '.'
: sprintf('%d période%s proposée%s pour toute lannée %d. Aucune donnée na été enregistrée en base.', count($periods), count($periods) > 1 ? 's' : '', count($periods) > 1 ? 's' : '', $calendarYear),
]);
}
public function save(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$rawPeriods = $payload['periods'] ?? [];
if (!is_array($rawPeriods) || $rawPeriods === []) {
JsonResponse::send(422, ['error' => 'Sélectionnez au moins une période à enregistrer.']);
}
if (count($rawPeriods) > 50) {
JsonResponse::send(422, ['error' => 'Trop de périodes ont été envoyées en une seule fois.']);
}
$periods = [];
foreach ($rawPeriods as $index => $raw) {
if (!is_array($raw)) {
JsonResponse::send(422, ['error' => 'Période invalide à la ligne ' . ($index + 1) . '.']);
}
$label = trim((string) ($raw['libelle'] ?? ''));
$start = trim((string) ($raw['date_debut'] ?? ''));
$end = trim((string) ($raw['date_fin'] ?? ''));
$schoolYear = trim((string) ($raw['annee_scolaire'] ?? ''));
$academy = trim((string) ($raw['academie'] ?? ''));
$zone = trim((string) ($raw['zone'] ?? ''));
$source = strtoupper(trim((string) ($raw['source'] ?? 'MANUEL')));
if ($label === '' || (function_exists('mb_strlen') ? mb_strlen($label, 'UTF-8') : strlen($label)) > 150 || !$this->isDate($start) || !$this->isDate($end) || $end < $start) {
JsonResponse::send(422, ['error' => 'Dates ou libellé invalides à la ligne ' . ($index + 1) . '.']);
}
if (!$this->isSchoolYear($schoolYear)) {
JsonResponse::send(422, ['error' => 'Année scolaire invalide à la ligne ' . ($index + 1) . '.']);
}
if (!in_array($source, ['MANUEL', 'DATA_GOUV'], true)) {
$source = 'MANUEL';
}
$periods[] = [
'libelle' => $label,
'date_debut' => $start,
'date_fin' => $end,
'annee_scolaire' => $schoolYear,
'academie' => function_exists('mb_substr') ? mb_substr($academy, 0, 100, 'UTF-8') : substr($academy, 0, 100),
'zone' => function_exists('mb_substr') ? mb_substr($zone, 0, 50, 'UTF-8') : substr($zone, 0, 50),
'source' => $source,
];
}
$count = $this->vacationModel->saveMany($periods);
JsonResponse::send(200, [
'success' => true,
'count' => $count,
'message' => sprintf('%d période%s de vacances enregistrée%s après validation manuelle.', $count, $count > 1 ? 's' : '', $count > 1 ? 's' : ''),
]);
}
public function delete(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$id = filter_var($payload['period_id'] ?? null, FILTER_VALIDATE_INT);
if (!$id) {
JsonResponse::send(422, ['error' => 'Période invalide.']);
}
if (!$this->vacationModel->delete((int) $id)) {
JsonResponse::send(404, ['error' => 'Période introuvable.']);
}
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
}
private function isDate(string $value): bool
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value;
}
private function isSchoolYear(string $value): bool
{
if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {
return false;
}
return (int) $matches[2] === (int) $matches[1] + 1;
}
}

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\QuotaModel;
use App\Models\WeekTemplateModel;
use DateTimeImmutable;
use DomainException;
use PDO;
use Throwable;
final class WeekTemplateController
{
private WeekTemplateModel $templateModel;
private AgentModel $agentModel;
private QuotaModel $quotaModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->templateModel = new WeekTemplateModel($pdo);
$this->agentModel = new AgentModel($pdo);
$this->quotaModel = new QuotaModel($pdo);
$this->access = new Access($pdo);
}
public function index(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
$templates = array_map(static function (array $template): array {
return [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'entry_count' => (int) $template['nombre_creneaux'],
'location_count' => (int) $template['nombre_lieux'],
'updated_at' => (string) $template['date_modification'],
];
}, $this->templateModel->listForAgent((int) $agentId));
JsonResponse::send(200, ['templates' => $templates]);
}
public function store(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$week = $this->parseWeek(trim((string) ($payload['week'] ?? '')));
$name = trim((string) ($payload['name'] ?? ''));
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || $week === null) {
JsonResponse::send(422, ['error' => 'Agent ou semaine invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
[$year, $weekNumber] = $week;
$template = $this->templateModel->saveFromWeek((int) $agentId, $year, $weekNumber, $name, $replace);
} catch (DomainException $e) {
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
JsonResponse::send($status, [
'error' => $e->getMessage(),
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible denregistrer la semaine type.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'success' => true,
'template' => $template,
'message' => sprintf('La semaine type « %s » a été enregistrée avec %d créneau%s.', $template['nom'], $template['nombre_creneaux'], $template['nombre_creneaux'] > 1 ? 'x' : ''),
]);
}
public function apply(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$week = $this->parseWeek(trim((string) ($payload['week'] ?? '')));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode dimport invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
[$year, $weekNumber] = $week;
$result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible dimporter la semaine type.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$reopened = count($result['reopened_planning_ids']) > 0;
$message = sprintf(
'La semaine type « %s » a été appliquée : %d créneau%s importé%s.',
$result['template']['nom'],
$result['inserted'],
$result['inserted'] > 1 ? 'x' : '',
$result['inserted'] > 1 ? 's' : ''
);
if ($reopened) {
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'requires_revalidation' => $reopened,
'quota' => $this->quotaModel->forDateForApi(
(int) $agentId,
(new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d')
),
]);
}
public function previewPeriod(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$startDate = trim((string) ($payload['start_date'] ?? ''));
$endDate = trim((string) ($payload['end_date'] ?? ''));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
$periodFilter = strtolower(trim((string) ($payload['period_filter'] ?? 'all')));
if (
!$templateId
|| !$agentId
|| $startDate === ''
|| $endDate === ''
|| !in_array($mode, ['merge', 'replace'], true)
|| !in_array($periodFilter, ['all', 'school', 'extra'], true)
) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode dapplication invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
$preview = $this->templateModel->previewPeriod(
(int) $templateId,
(int) $agentId,
$startDate,
$endDate,
$mode,
$periodFilter
);
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de prévisualiser lapplication de la semaine type.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
// Les créneaux détaillés sont utiles au modèle mais pas nécessaires au navigateur.
unset($preview['target_entries']);
JsonResponse::send(200, [
'success' => true,
'preview' => $preview,
]);
}
public function applyPeriod(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$startDate = trim((string) ($payload['start_date'] ?? ''));
$endDate = trim((string) ($payload['end_date'] ?? ''));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
$periodFilter = strtolower(trim((string) ($payload['period_filter'] ?? 'all')));
if (
!$templateId
|| !$agentId
|| $startDate === ''
|| $endDate === ''
|| !in_array($mode, ['merge', 'replace'], true)
|| !in_array($periodFilter, ['all', 'school', 'extra'], true)
) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode dapplication invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
$result = $this->templateModel->applyToPeriod(
(int) $templateId,
(int) $agentId,
$startDate,
$endDate,
$mode,
$periodFilter
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible dappliquer la semaine type sur la période.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$message = sprintf(
'La semaine type « %s » a été appliquée du %s au %s : %d créneau%s créé%s sur %d jour%s et %d semaine%s.',
$result['template']['name'],
$result['start_date'],
$result['end_date'],
$result['inserted'],
$result['inserted'] > 1 ? 'x' : '',
$result['inserted'] > 1 ? 's' : '',
$result['days_touched'],
$result['days_touched'] > 1 ? 's' : '',
$result['weeks_touched'],
$result['weeks_touched'] > 1 ? 's' : ''
);
$scopeLabel = match ($result['period_filter']) {
'school' => 'uniquement sur les périodes scolaires',
'extra' => 'uniquement sur les périodes extrascolaires',
default => 'sur toutes les semaines de la période',
};
$message .= ' Périmètre : ' . $scopeLabel . '.';
if ($result['skipped_count'] > 0) {
$message .= sprintf(
' %d créneau%s ignoré%s pour respecter les périodes scolaires/vacances.',
$result['skipped_count'],
$result['skipped_count'] > 1 ? 'x' : '',
$result['skipped_count'] > 1 ? 's' : ''
);
}
if ($result['deleted'] > 0) {
$message .= sprintf(' %d ancien%s créneau%s remplacé%s.', $result['deleted'], $result['deleted'] > 1 ? 's' : '', $result['deleted'] > 1 ? 'x' : '', $result['deleted'] > 1 ? 's' : '');
}
if ($result['reopened_planning_ids'] !== []) {
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
}
$quotas = [];
foreach (array_unique([(string) $result['start_date'], (string) $result['end_date']]) as $contextDate) {
$quota = $this->quotaModel->forDateForApi((int) $agentId, $contextDate);
$quotas[(string) $quota['date_debut_periode']] = $quota;
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'result' => $result,
'requires_revalidation' => $result['reopened_planning_ids'] !== [],
'quotas' => $quotas,
]);
}
public function destroy(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
if (!$templateId || !$agentId) {
JsonResponse::send(422, ['error' => 'Semaine type ou agent invalide.']);
}
if (!$this->templateModel->delete((int) $templateId, (int) $agentId)) {
JsonResponse::send(404, ['error' => 'Semaine type introuvable.']);
}
JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']);
}
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$year = (int) $matches[1];
$week = (int) $matches[2];
return $week >= 1 && $week <= 53 ? [$year, $week] : null;
}
}

368
app/Core/Access.php Normal file
View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
final class Access
{
public const ROLE_AGENT = 'AGENT';
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
private const SESSION_KEY = 'pta_access';
public function __construct(private PDO $pdo)
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
public function isConfigured(): bool
{
$role = $this->role();
if ($role === self::ROLE_AGENT) {
return $this->agentId() !== null && $this->agentRecord() !== null;
}
if ($role === self::ROLE_RESPONSABLE) {
return $this->structureId() !== null && $this->structureRecord() !== null;
}
return $role === self::ROLE_SERVICE;
}
public function role(): ?string
{
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
? $role
: null;
}
public function agentId(): ?int
{
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
return $value === false ? null : (int) $value;
}
public function structureId(): ?int
{
if ($this->role() === self::ROLE_AGENT) {
$agent = $this->agentRecord();
return isset($agent['id_structure']) && $agent['id_structure'] !== null
? (int) $agent['id_structure']
: null;
}
$value = filter_var($_SESSION[self::SESSION_KEY]['structure_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
return $value === false ? null : (int) $value;
}
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
{
$role = strtoupper(trim($role));
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
throw new \DomainException('Rôle invalide.');
}
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
if ($role === self::ROLE_AGENT) {
if (!$agentId || !$this->activeAgentExists($agentId)) {
throw new \DomainException('Sélectionnez un agent actif.');
}
$selection['agent_id'] = $agentId;
} elseif ($role === self::ROLE_RESPONSABLE) {
if (!$structureId || !$this->activeStructureExists($structureId)) {
throw new \DomainException('Sélectionnez un lieu daffectation actif.');
}
$selection['structure_id'] = $structureId;
}
$_SESSION[self::SESSION_KEY] = $selection;
session_regenerate_id(true);
}
public function clear(): void
{
unset($_SESSION[self::SESSION_KEY]);
session_regenerate_id(true);
}
public function profile(): array
{
$role = $this->role();
$permissions = $this->permissions();
$profile = [
'role' => $role,
'role_label' => $this->roleLabel(),
'label' => $this->roleLabel(),
'agent_id' => $this->agentId(),
'structure_id' => $this->structureId(),
'permissions' => $permissions,
];
if ($role === self::ROLE_AGENT) {
$agent = $this->agentRecord();
if ($agent !== null) {
$profile['label'] = trim((string) $agent['prenom'] . ' ' . (string) $agent['nom']);
$profile['matricule'] = $agent['matricule'];
$profile['structure_name'] = $agent['structure_nom'];
$profile['structure_id'] = $agent['id_structure'] !== null ? (int) $agent['id_structure'] : null;
}
} elseif ($role === self::ROLE_RESPONSABLE) {
$structure = $this->structureRecord();
if ($structure !== null) {
$profile['label'] = (string) $structure['nom'];
$profile['structure_name'] = (string) $structure['nom'];
$profile['structure_code'] = (string) $structure['code'];
}
}
return $profile;
}
public function permissions(): array
{
$role = $this->role();
$service = $role === self::ROLE_SERVICE;
$responsable = $role === self::ROLE_RESPONSABLE;
return [
'read_only' => $role === self::ROLE_AGENT,
'can_edit_draft' => $service || $responsable,
'can_edit_cross_structure' => $service,
'can_validate' => $service,
'can_reopen' => $service || $responsable,
'can_use_templates' => $service,
'can_copy_full_week' => $service,
'can_view_pending' => $service,
'can_manage_coverage' => $service,
'can_manage_agents' => $service,
'can_manage_structures' => $service,
'can_manage_vacations' => $service,
'can_manage_teams' => $service,
'can_manage_pta' => $service,
'can_forecast_next_year' => $service,
];
}
public function allowedPages(): array
{
return match ($this->role()) {
self::ROLE_AGENT => ['agents', 'structures'],
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
default => [],
};
}
public function canAccessPage(string $page): bool
{
return in_array($page, $this->allowedPages(), true);
}
public function homeUrl(): string
{
return match ($this->role()) {
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => 'planning.php',
default => 'role.php',
};
}
public function requirePage(string $page): void
{
if (!$this->isConfigured()) {
header('Location: role.php', true, 302);
exit;
}
if (!$this->canAccessPage($page)) {
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
exit;
}
}
public function requireRoles(array $roles): void
{
if (!$this->isConfigured()) {
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
}
if (!in_array($this->role(), $roles, true)) {
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
}
}
public function requireService(): void
{
$this->requireRoles([self::ROLE_SERVICE]);
}
public function requireDraftEditor(): void
{
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
}
public function requireStructureView(int $structureId): void
{
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
}
}
public function requireStructureEdit(int $structureId): void
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
}
}
public function requireAgentView(int $agentId): void
{
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
if ($this->role() === self::ROLE_SERVICE) {
return;
}
if ($this->role() === self::ROLE_AGENT && $this->agentId() === $agentId) {
return;
}
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
return;
}
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
}
public function requireEntryEdit(int $entryId): void
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_SERVICE) {
return;
}
$stmt = $this->pdo->prepare(
'SELECT p.id_structure
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
WHERE c.id_creneau = :id LIMIT 1'
);
$stmt->execute(['id' => $entryId]);
$structureId = $stmt->fetchColumn();
if ($structureId === false) {
JsonResponse::send(404, ['error' => 'Créneau introuvable.']);
}
$this->requireStructureEdit((int) $structureId);
}
public function requirePlanningEdit(int $planningId): void
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_SERVICE) {
return;
}
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1');
$stmt->execute(['id' => $planningId]);
$structureId = $stmt->fetchColumn();
if ($structureId === false) {
JsonResponse::send(404, ['error' => 'Planning introuvable.']);
}
$this->requireStructureEdit((int) $structureId);
}
public function editableStructureId(): ?int
{
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
}
private function roleLabel(): string
{
return match ($this->role()) {
self::ROLE_AGENT => 'Agent',
self::ROLE_RESPONSABLE => 'Responsable de structure',
self::ROLE_SERVICE => 'Service Enfance',
default => 'Rôle non sélectionné',
};
}
private function activeAgentExists(int $agentId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1');
$stmt->execute(['id' => $agentId]);
return $stmt->fetchColumn() !== false;
}
private function activeStructureExists(int $structureId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
$stmt->execute(['id' => $structureId]);
return $stmt->fetchColumn() !== false;
}
private function agentRecord(): ?array
{
$agentId = $this->agentId();
if ($agentId === null) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
s.nom AS structure_nom
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
}
private function structureRecord(): ?array
{
$structureId = $this->structureId();
if ($structureId === null) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom
FROM structure
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
return $stmt->fetch() ?: null;
}
private function responsibleCanViewAgent(int $agentId): bool
{
$structureId = $this->structureId();
if ($structureId === null) {
return false;
}
$stmt = $this->pdo->prepare(
'SELECT 1
FROM agent a
WHERE a.id_agent = :agent_id
AND a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1 FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
LIMIT 1'
);
$stmt->execute([
'agent_id' => $agentId,
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
return $stmt->fetchColumn() !== false;
}
}

19
app/Core/Controller.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Core;
abstract class Controller
{
protected function render(string $view, array $data = []): void
{
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
if (!is_file($viewFile)) {
throw new \RuntimeException('Vue introuvable : ' . $view);
}
extract($data, EXTR_SKIP);
require $viewFile;
}
}

31
app/Core/Csrf.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Core;
final class Csrf
{
public static function ensureToken(): string
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['csrf_token'];
}
public static function assertPayload(array $payload): void
{
$expected = self::ensureToken();
$provided = (string) ($payload['csrf_token'] ?? '');
if ($provided === '' || !hash_equals($expected, $provided)) {
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
}
}
}

51
app/Core/JsonResponse.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Core;
final class JsonResponse
{
public static function bootstrap(): void
{
ini_set('display_errors', '0');
ini_set('html_errors', '0');
error_reporting(E_ALL);
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(static function (\Throwable $exception): void {
error_log(sprintf(
'[PTA API] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
$payload = ['error' => 'Une erreur serveur est survenue.'];
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
$payload['details'] = $exception->getMessage();
}
self::send(500, $payload);
});
}
public static function send(int $status, array $payload): void
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
}

27
app/Core/PdfResponse.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Core;
final class PdfResponse
{
public static function inline(string $content, string $filename): void
{
$safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf';
if (!str_ends_with(strtolower($safeFilename), '.pdf')) {
$safeFilename .= '.pdf';
}
if (!headers_sent()) {
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
header('Content-Length: ' . strlen($content));
header('Cache-Control: private, max-age=0, must-revalidate');
header('Pragma: public');
}
echo $content;
exit;
}
}

25
app/Core/Request.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Core;
final class Request
{
public static function json(): array
{
$payload = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($payload)) {
JsonResponse::send(400, ['error' => 'Corps JSON invalide.']);
}
return $payload;
}
public static function requireMethod(string $method): void
{
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) {
JsonResponse::send(405, ['error' => 'Méthode non autorisée.']);
}
}
}

440
app/Models/AgentModel.php Normal file
View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
use Throwable;
final class AgentModel extends BaseModel
{
private const AGENT_SELECT = <<<'SQL'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
a.formation_repartition_annuelle, a.commentaire_pta,
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
po.famille AS poste_famille, po.heures_mercredi_minutes,
po.heures_extrascolaire_minutes, po.autorise_preparation,
po.peut_assurer_animation, po.preparation_lundi_si_100,
a.id_structure,
s.nom AS structure_nom,
s.nom AS structure_principale_nom,
s.type_affectation AS structure_type_affectation,
s.type_affectation AS structure_principale_type_affectation
FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
SQL;
public function allActive(): array
{
return $this->pdo->query(
self::AGENT_SELECT . " WHERE a.actif = TRUE ORDER BY a.nom, a.prenom"
)->fetchAll();
}
public function allVisibleForStructure(int $structureId): array
{
$stmt = $this->pdo->prepare(
self::AGENT_SELECT . "
WHERE a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1
FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
return $stmt->fetchAll();
}
public function allEligibleForPta(): array
{
return $this->pdo->query(
self::AGENT_SELECT . "
WHERE a.actif = TRUE
AND a.type_contrat = 'PERMANENT'
AND a.id_poste IS NOT NULL
ORDER BY a.nom, a.prenom"
)->fetchAll();
}
public function allPosts(): array
{
return $this->pdo->query(
"SELECT id_poste, code, libelle, famille, heures_mercredi_minutes,
heures_extrascolaire_minutes, autorise_preparation,
peut_assurer_animation, preparation_lundi_si_100
FROM poste_agent
WHERE actif = TRUE
ORDER BY ordre_affichage, libelle"
)->fetchAll();
}
public function listForLocation(int $selectedLocation = 0): array
{
$stmt = $this->pdo->prepare(
"SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
a.id_poste, po.libelle AS poste_libelle,
s.nom AS structure_nom,
s.type_affectation AS structure_type_affectation,
CASE WHEN a.id_structure = :selected_location THEN 1 ELSE 0 END AS lieu_principal_selectionne
FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.actif = TRUE
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
);
$stmt->execute(['selected_location' => $selectedLocation]);
return $stmt->fetchAll();
}
public function findActive(int $agentId): ?array
{
$stmt = $this->pdo->prepare(
self::AGENT_SELECT . " WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1"
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
}
public function matriculeExists(string $matricule): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
$stmt->execute(['matricule' => $matricule]);
return $stmt->fetchColumn() !== false;
}
public function createWithDefaults(array $data): array
{
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
$contractEnd = !empty($data['date_fin_contrat'])
? new DateTimeImmutable((string) $data['date_fin_contrat'])
: null;
$year = (int) $contractStart->format('Y');
// Le quota PTA est suivi sur l'année civile, indépendamment de la date
// anniversaire du contrat. La date de contrat reste une donnée RH.
$periodStart = new DateTimeImmutable(sprintf('%04d-01-01', $year));
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
$quotaReference = QuotaModel::REFERENCE_MINUTES;
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840;
try {
$this->pdo->beginTransaction();
$insertAgent = $this->pdo->prepare(
'INSERT INTO agent (
matricule, nom, prenom, email, telephone, adresse,
est_diplome, diplome_libelle, date_debut_contrat,
id_poste, type_contrat, date_fin_contrat,
formation_repartition_annuelle, commentaire_pta,
id_structure, actif
) VALUES (
:matricule, :nom, :prenom, :email, :telephone, :adresse,
:est_diplome, :diplome_libelle, :date_debut_contrat,
:id_poste, :type_contrat, :date_fin_contrat,
:formation_repartition_annuelle, :commentaire_pta,
:structure_id, TRUE
)'
);
$insertAgent->execute([
'matricule' => $data['matricule'],
'nom' => $data['nom'],
'prenom' => $data['prenom'],
'email' => $data['email'],
'telephone' => $data['telephone'],
'adresse' => $data['adresse'],
'est_diplome' => $data['est_diplome'] ? 1 : 0,
'diplome_libelle' => $data['est_diplome'] ? $data['diplome_libelle'] : null,
'date_debut_contrat' => $contractStart->format('Y-m-d'),
'id_poste' => $data['id_poste'],
'type_contrat' => $data['type_contrat'],
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0,
'commentaire_pta' => $data['commentaire_pta'],
'structure_id' => $data['structure_id'],
]);
$agentId = (int) $this->pdo->lastInsertId();
$history = $this->pdo->prepare(
'INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, :date_debut, NULL, TRUE)'
);
$history->execute([
'agent_id' => $agentId,
'structure_id' => $data['structure_id'],
'date_debut' => $contractStart->format('Y-m-d'),
]);
$quota = $this->pdo->prepare(
'INSERT INTO quota_agent_annuel (
id_agent, annee, quotite_travail, quota_reference_minutes,
quota_cible_minutes, commentaire
) VALUES (
:agent_id, :annee, :quotite, :quota_reference,
:quota_cible, :commentaire
)'
);
$quota->execute([
'agent_id' => $agentId,
'annee' => $year,
'quotite' => $data['quotite_travail'],
'quota_reference' => $quotaReference,
'quota_cible' => $quotaTarget,
'commentaire' => 'Quota PTA de référence - année civile',
]);
$pta = $this->pdo->prepare(
'INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail,
quota_reference_minutes, quota_cible_minutes,
enveloppe_formation_minutes, statut
) VALUES (
:agent_id, :date_debut, :date_fin, :quotite,
:quota_reference, :quota_cible,
:formation, \'BROUILLON\'
)'
);
$pta->execute([
'agent_id' => $agentId,
'date_debut' => $periodStart->format('Y-m-d'),
'date_fin' => $periodEnd->format('Y-m-d'),
'quotite' => $data['quotite_travail'],
'quota_reference' => $quotaReference,
'quota_cible' => $quotaTarget,
'formation' => $trainingEnvelope,
]);
$this->pdo->commit();
return [
'id_agent' => $agentId,
'annee_quota' => $year,
'date_debut_contrat' => $contractStart->format('Y-m-d'),
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'quota_cible_minutes' => $quotaTarget,
'id_pta' => (int) $this->pdo->lastInsertId(),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function updateProfile(int $agentId, array $data): void
{
try {
$this->pdo->beginTransaction();
$currentStmt = $this->pdo->prepare('SELECT id_structure FROM agent WHERE id_agent = :agent_id FOR UPDATE');
$currentStmt->execute(['agent_id' => $agentId]);
$currentStructure = $currentStmt->fetchColumn();
if ($currentStructure === false) {
throw new \RuntimeException('Agent introuvable.');
}
$currentStructureId = $currentStructure !== null ? (int) $currentStructure : null;
$newStructureId = $data['structure_id'];
$update = $this->pdo->prepare(
'UPDATE agent
SET email = :email,
telephone = :telephone,
adresse = :adresse,
est_diplome = :est_diplome,
diplome_libelle = :diplome_libelle,
date_debut_contrat = :date_debut_contrat,
id_poste = :id_poste,
type_contrat = :type_contrat,
date_fin_contrat = :date_fin_contrat,
formation_repartition_annuelle = :formation_repartition_annuelle,
commentaire_pta = :commentaire_pta,
id_structure = :structure_id
WHERE id_agent = :agent_id'
);
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
$update->bindValue(':email', $data['email']);
$update->bindValue(':telephone', $data['telephone']);
$update->bindValue(':adresse', $data['adresse']);
$update->bindValue(':est_diplome', $data['est_diplome'] ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':diplome_libelle', $data['est_diplome'] ? $data['diplome_libelle'] : null);
$update->bindValue(':date_debut_contrat', $data['date_debut_contrat']);
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
$update->bindValue(':type_contrat', $data['type_contrat']);
$update->bindValue(':date_fin_contrat', $data['date_fin_contrat']);
$update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
if ($newStructureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
} else {
$update->bindValue(':structure_id', $newStructureId, PDO::PARAM_INT);
}
$update->execute();
if ($currentStructureId !== $newStructureId) {
$close = $this->pdo->prepare(
"UPDATE agent_structure
SET actif = FALSE,
date_fin = CASE
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
ELSE CURRENT_DATE
END
WHERE id_agent = :agent_id AND actif = TRUE"
);
$close->execute(['agent_id' => $agentId]);
if ($newStructureId !== null) {
$history = $this->pdo->prepare(
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
);
$history->execute(['agent_id' => $agentId, 'structure_id' => $newStructureId]);
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function updateAssignment(int $agentId, ?int $structureId): void
{
try {
$this->pdo->beginTransaction();
$update = $this->pdo->prepare('UPDATE agent SET id_structure = :structure_id WHERE id_agent = :agent_id');
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
if ($structureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
} else {
$update->bindValue(':structure_id', $structureId, PDO::PARAM_INT);
}
$update->execute();
$close = $this->pdo->prepare(
"UPDATE agent_structure
SET actif = FALSE,
date_fin = CASE
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
ELSE CURRENT_DATE
END
WHERE id_agent = :agent_id AND actif = TRUE"
);
$close->execute(['agent_id' => $agentId]);
if ($structureId !== null) {
$history = $this->pdo->prepare(
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
);
$history->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function entriesForWeek(int $agentId, int $year, int $week): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
s2.id_structure, s2.nom AS structure_nom,
s2.type_affectation AS structure_type_affectation,
c.id_creneau, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
FROM planning p
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
ORDER BY c.date_jour, c.heure_debut, s2.nom"
);
$stmt->execute(['agent_id' => $agentId, 'annee' => $year, 'semaine' => $week]);
return $stmt->fetchAll();
}
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
s2.id_structure, s2.nom AS structure_nom,
s2.type_affectation AS structure_type_affectation,
c.id_creneau, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
FROM planning p
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
ORDER BY c.date_jour, c.heure_debut, s2.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT UPPER(m.code) AS motif_code,
m.libelle AS motif_libelle,
m.compte_dans_quota,
COALESCE(SUM(TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))), 0) AS minutes_total,
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_brouillon
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
GROUP BY UPPER(m.code), m.libelle, m.compte_dans_quota
ORDER BY m.compte_dans_quota DESC, m.libelle"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
public function timeSummaryForYear(int $agentId, int $year): array
{
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
}
}

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use RuntimeException;
final class AnnualOverviewModel extends BaseModel
{
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
public function overview(int $agentId, int $year): array
{
$agentModel = new AgentModel($this->pdo);
$agent = $agentModel->findActive($agentId);
if ($agent === null) {
throw new RuntimeException('Agent introuvable.');
}
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$entries = $agentModel->entriesForDateRange($agentId, $start, $end);
$vacations = (new VacationModel($this->pdo))->periodsBetween($start, $end);
$quotaPeriods = $this->quotaPeriodsForYear($agentId, $year, $agent);
$entriesByDate = [];
foreach ($entries as $entry) {
$entriesByDate[(string) $entry['date_jour']][] = $entry;
}
$months = [];
$yearTotals = array_fill_keys(self::CELL_KEYS, 0);
$statusTotals = ['VALIDE' => 0, 'BROUILLON' => 0];
$specialTotals = [];
for ($month = 1; $month <= 12; $month++) {
$monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
$monthEnd = $monthStart->modify('last day of this month');
$days = [];
$monthTotals = array_fill_keys(self::CELL_KEYS, 0);
$period = new DatePeriod($monthStart, new DateInterval('P1D'), $monthEnd->modify('+1 day'));
foreach ($period as $date) {
$dateValue = $date->format('Y-m-d');
$vacation = $this->vacationForDate($dateValue, $vacations);
$day = $this->buildDay($date, $entriesByDate[$dateValue] ?? [], $vacation, $agent);
$days[] = $day;
foreach (self::CELL_KEYS as $key) {
$minutes = (int) $day['cells'][$key]['minutes'];
$monthTotals[$key] += $minutes;
$yearTotals[$key] += $minutes;
}
foreach ($day['status_minutes'] as $status => $minutes) {
$statusTotals[$status] = ($statusTotals[$status] ?? 0) + $minutes;
}
foreach ($day['special_minutes'] as $code => $minutes) {
$specialTotals[$code] = ($specialTotals[$code] ?? 0) + $minutes;
}
}
$months[] = [
'number' => $month,
'name' => $this->monthName($month),
'days' => $days,
'totals' => $this->formatTotals($monthTotals),
];
}
$calendarMinutes = array_sum($yearTotals);
return [
'agent' => $agent,
'year' => $year,
'generated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
'quota_periods' => $quotaPeriods,
'calendar_totals' => [
'minutes' => $calendarMinutes,
'duration' => $this->formatMinutes($calendarMinutes),
'cells' => $this->formatTotals($yearTotals),
'validated' => $this->formatMinutes((int) ($statusTotals['VALIDE'] ?? 0)),
'draft' => $this->formatMinutes((int) ($statusTotals['BROUILLON'] ?? 0)),
'specials' => array_map(fn(int $minutes): string => $this->formatMinutes($minutes), $specialTotals),
],
'months' => $months,
'legend' => [
['code' => 'TRAVAIL', 'label' => 'Temps de travail', 'class' => 'annual-work'],
['code' => 'PREPA', 'label' => 'Temps de préparation', 'class' => 'annual-preparation'],
['code' => 'FOR', 'label' => 'Formation', 'class' => 'annual-training'],
['code' => 'CA', 'label' => 'Congé annuel', 'class' => 'annual-leave'],
['code' => 'ABS', 'label' => 'Absence', 'class' => 'annual-absence'],
['code' => 'JNT', 'label' => 'Journée non travaillée', 'class' => 'annual-jnt'],
['code' => 'JF', 'label' => 'Journée de fractionnement', 'class' => 'annual-fraction'],
['code' => 'VAC', 'label' => 'Vacances scolaires', 'class' => 'annual-vacation'],
],
];
}
private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
{
$cells = [];
foreach (self::CELL_KEYS as $key) {
$cells[$key] = [
'minutes' => 0,
'display' => '',
'details' => [],
'codes' => [],
'class' => '',
'structure_id' => null,
];
}
$statusMinutes = ['VALIDE' => 0, 'BROUILLON' => 0];
$specialMinutes = [];
$hasDraft = false;
$firstStructureId = null;
foreach ($entries as $entry) {
$minutes = $this->minutesBetween((string) $entry['heure_debut'], (string) $entry['heure_fin']);
if ($minutes <= 0) {
continue;
}
$code = strtoupper((string) $entry['motif_code']);
$cellKey = $this->cellForEntry($code, $vacation !== null);
$shortCode = $this->shortCode($code);
$status = strtoupper((string) $entry['statut']) === 'VALIDE' ? 'VALIDE' : 'BROUILLON';
$statusMinutes[$status] += $minutes;
$hasDraft = $hasDraft || $status === 'BROUILLON';
$firstStructureId ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
$cells[$cellKey]['minutes'] += $minutes;
$cells[$cellKey]['structure_id'] ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
$cells[$cellKey]['details'][] = sprintf(
'%s%s · %s · %s%s',
(string) $entry['heure_debut'],
(string) $entry['heure_fin'],
(string) $entry['motif_libelle'],
(string) $entry['structure_nom'],
$status === 'BROUILLON' ? ' · Brouillon' : ''
);
if ($shortCode !== null) {
$cells[$cellKey]['codes'][$shortCode] = true;
$specialMinutes[$code] = ($specialMinutes[$code] ?? 0) + $minutes;
}
$cells[$cellKey]['class'] = $this->strongestClass(
(string) $cells[$cellKey]['class'],
$this->classForCode($code, $cellKey)
);
}
foreach (self::CELL_KEYS as $key) {
$codes = array_keys($cells[$key]['codes']);
$cells[$key]['display'] = $this->cellDisplay((int) $cells[$key]['minutes'], $codes);
$cells[$key]['title'] = implode("\n", $cells[$key]['details']);
unset($cells[$key]['details'], $cells[$key]['codes']);
}
$weekday = (int) $date->format('N');
$week = (int) $date->format('W');
$principalStructure = isset($agent['id_structure']) && $agent['id_structure'] !== null
? (int) $agent['id_structure']
: null;
return [
'date' => $date->format('Y-m-d'),
'day' => (int) $date->format('j'),
'weekday' => $weekday,
'weekday_short' => $this->weekdayShort($weekday),
'week' => $week,
'week_year' => (int) $date->format('o'),
'show_week' => $weekday === 1 || (int) $date->format('j') === 1,
'is_weekend' => $weekday >= 6,
'is_vacation' => $vacation !== null,
'vacation_label' => $vacation['libelle'] ?? null,
'has_entries' => $entries !== [],
'has_draft' => $hasDraft,
'structure_id' => $firstStructureId ?? $principalStructure,
'cells' => $cells,
'status_minutes' => $statusMinutes,
'special_minutes' => $specialMinutes,
];
}
private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
{
$quotaModel = new QuotaModel($this->pdo);
$contexts = [sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)];
$contractStart = trim((string) ($agent['date_debut_contrat'] ?? ''));
if ($contractStart !== '' && str_starts_with($contractStart, (string) $year)) {
$contexts[] = $contractStart;
}
$periods = [];
foreach ($contexts as $context) {
$quota = $quotaModel->forDateForApi($agentId, $context);
$key = $quota['date_debut_periode'] . '|' . $quota['date_fin_periode'];
$periods[$key] = $quota;
}
uasort($periods, static fn(array $a, array $b): int => strcmp(
(string) $a['date_debut_periode'],
(string) $b['date_debut_periode']
));
return array_values($periods);
}
private function cellForEntry(string $code, bool $isVacation): string
{
return match ($code) {
'PREPARATION_PERISCOLAIRE', 'PREPARATION_LUNDI' => 'PP',
'PREPARATION_EXTRASCOLAIRE' => 'PE',
'EXTRASCOLAIRE' => 'AE',
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', 'MERCREDI_PERISCOLAIRE' => 'AP',
'JOUR_FRACTIONNEMENT' => 'AP',
default => $isVacation ? 'AE' : 'AP',
};
}
private function shortCode(string $code): ?string
{
return match ($code) {
'FORMATION' => 'FOR',
'CONGE' => 'CA',
'ABSENCE', 'MALADIE' => 'ABS',
'AUTRE_ABSENCE' => 'A.ND',
'JNT' => 'JNT',
'JOUR_FRACTIONNEMENT' => 'JF',
'MENAGE_FOND' => 'MF',
default => null,
};
}
private function classForCode(string $code, string $cellKey): string
{
return match ($code) {
'FORMATION' => 'annual-training',
'CONGE' => 'annual-leave',
'ABSENCE', 'MALADIE' => 'annual-absence',
'AUTRE_ABSENCE' => 'annual-other-absence',
'JNT' => 'annual-jnt',
'JOUR_FRACTIONNEMENT' => 'annual-fraction',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'annual-preparation',
'EXTRASCOLAIRE' => 'annual-extra',
default => in_array($cellKey, ['PP', 'PE'], true) ? 'annual-preparation' : 'annual-work',
};
}
private function strongestClass(string $current, string $candidate): string
{
$priority = [
'' => 0,
'annual-work' => 1,
'annual-extra' => 2,
'annual-preparation' => 3,
'annual-fraction' => 4,
'annual-training' => 5,
'annual-leave' => 6,
'annual-other-absence' => 7,
'annual-absence' => 8,
'annual-jnt' => 9,
];
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
}
private function cellDisplay(int $minutes, array $codes): string
{
if ($minutes <= 0) {
return '';
}
if ($codes !== []) {
return implode('/', $codes);
}
return $this->formatCompactMinutes($minutes);
}
private function vacationForDate(string $date, array $vacations): ?array
{
foreach ($vacations as $vacation) {
if ($date >= (string) $vacation['date_debut'] && $date <= (string) $vacation['date_fin']) {
return $vacation;
}
}
return null;
}
private function minutesBetween(string $start, string $end): int
{
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
[$endHour, $endMinute] = array_map('intval', explode(':', $end));
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
}
private function formatTotals(array $totals): array
{
$formatted = [];
foreach ($totals as $key => $minutes) {
$formatted[$key] = [
'minutes' => (int) $minutes,
'duration' => $this->formatMinutes((int) $minutes),
'compact' => $this->formatCompactMinutes((int) $minutes),
];
}
return $formatted;
}
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$minutes = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
}
private function formatCompactMinutes(int $minutes): string
{
if ($minutes <= 0) {
return '';
}
$hours = intdiv($minutes, 60);
$mins = $minutes % 60;
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
}
private function monthName(int $month): string
{
return [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
][$month];
}
private function weekdayShort(int $weekday): string
{
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];
}
}

14
app/Models/BaseModel.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models;
use PDO;
abstract class BaseModel
{
public function __construct(protected PDO $pdo)
{
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
use Throwable;
final class CoverageModel extends BaseModel
{
private const DAY_LABELS = [
1 => 'Lundi',
2 => 'Mardi',
3 => 'Mercredi',
4 => 'Jeudi',
5 => 'Vendredi',
];
public function rulesForStructure(int $structureId): array
{
$stmt = $this->pdo->prepare(
"SELECT id_couverture, id_structure, jour_semaine,
TIME_FORMAT(heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(heure_fin, '%H:%i') AS heure_fin,
minimum_agents
FROM structure_couverture_horaire
WHERE id_structure = :structure_id
ORDER BY jour_semaine, heure_debut, heure_fin"
);
$stmt->execute(['structure_id' => $structureId]);
return $stmt->fetchAll();
}
public function replaceRules(int $structureId, array $rules): void
{
try {
$this->pdo->beginTransaction();
$delete = $this->pdo->prepare(
'DELETE FROM structure_couverture_horaire WHERE id_structure = :structure_id'
);
$delete->execute(['structure_id' => $structureId]);
if ($rules !== []) {
$insert = $this->pdo->prepare(
'INSERT INTO structure_couverture_horaire
(id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents)
VALUES
(:structure_id, :jour_semaine, :heure_debut, :heure_fin, :minimum_agents)'
);
foreach ($rules as $rule) {
$insert->execute([
'structure_id' => $structureId,
'jour_semaine' => (int) $rule['jour_semaine'],
'heure_debut' => $rule['heure_debut'],
'heure_fin' => $rule['heure_fin'],
'minimum_agents' => (int) $rule['minimum_agents'],
]);
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function reportForStructure(int $structureId, int $year, int $weekNumber): array
{
$rules = $this->rulesForStructure($structureId);
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+4 days');
if ($rules === []) {
return [
'configured' => false,
'rules' => [],
'gaps' => [],
'validation_warnings' => [],
'summary' => [
'gap_count' => 0,
'gap_minutes' => 0,
'validation_warning_count' => 0,
],
];
}
$entries = $this->workEntries(
$structureId,
$weekStart->format('Y-m-d'),
$weekEnd->format('Y-m-d')
);
$entriesByDate = [];
foreach ($entries as $entry) {
$entriesByDate[$entry['date_jour']][] = $entry;
}
$gaps = [];
$validationWarnings = [];
foreach ($rules as $rule) {
$dayNumber = (int) $rule['jour_semaine'];
$date = $weekStart->modify('+' . ($dayNumber - 1) . ' days')->format('Y-m-d');
$start = $this->timeToMinutes((string) $rule['heure_debut']);
$end = $this->timeToMinutes((string) $rule['heure_fin']);
$minimum = (int) $rule['minimum_agents'];
for ($slotStart = $start; $slotStart < $end; $slotStart += 15) {
$slotEnd = min($slotStart + 15, $end);
$plannedAgents = [];
$validatedAgents = [];
foreach ($entriesByDate[$date] ?? [] as $entry) {
$entryStart = $this->timeToMinutes((string) $entry['heure_debut']);
$entryEnd = $this->timeToMinutes((string) $entry['heure_fin']);
if ($entryStart >= $slotEnd || $entryEnd <= $slotStart) {
continue;
}
$agentId = (int) $entry['id_agent'];
$plannedAgents[$agentId] = true;
if ($entry['statut'] === 'VALIDE') {
$validatedAgents[$agentId] = true;
}
}
$plannedCount = count($plannedAgents);
$validatedCount = count($validatedAgents);
$base = [
'date' => $date,
'jour_semaine' => $dayNumber,
'jour_libelle' => self::DAY_LABELS[$dayNumber] ?? 'Jour',
'heure_debut' => $this->minutesToTime($slotStart),
'heure_fin' => $this->minutesToTime($slotEnd),
'minimum_agents' => $minimum,
'agents_planifies' => $plannedCount,
'agents_valides' => $validatedCount,
];
if ($plannedCount < $minimum) {
$base['agents_manquants'] = $minimum - $plannedCount;
$this->appendMergedInterval($gaps, $base, ['minimum_agents', 'agents_planifies', 'agents_manquants']);
} elseif ($validatedCount < $minimum) {
$base['validations_manquantes'] = $minimum - $validatedCount;
$this->appendMergedInterval($validationWarnings, $base, ['minimum_agents', 'agents_valides', 'validations_manquantes']);
}
}
}
$gapMinutes = 0;
foreach ($gaps as $gap) {
$gapMinutes += $this->timeToMinutes($gap['heure_fin']) - $this->timeToMinutes($gap['heure_debut']);
}
return [
'configured' => true,
'rules' => $rules,
'gaps' => $gaps,
'validation_warnings' => $validationWarnings,
'summary' => [
'gap_count' => count($gaps),
'gap_minutes' => $gapMinutes,
'validation_warning_count' => count($validationWarnings),
],
];
}
private function workEntries(int $structureId, string $dateStart, string $dateEnd): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_agent, p.statut, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM planning p
INNER JOIN agent a ON a.id_agent = p.id_agent AND a.actif = TRUE
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_structure = :structure_id
AND c.date_jour BETWEEN :date_start AND :date_end
AND m.code = 'TRAVAIL'
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
);
$stmt->execute([
'structure_id' => $structureId,
'date_start' => $dateStart,
'date_end' => $dateEnd,
]);
return $stmt->fetchAll();
}
private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void
{
$lastIndex = count($target) - 1;
if ($lastIndex >= 0) {
$last = $target[$lastIndex];
$same = $last['date'] === $slot['date'] && $last['heure_fin'] === $slot['heure_debut'];
foreach ($comparisonKeys as $key) {
$same = $same && (int) $last[$key] === (int) $slot[$key];
}
if ($same) {
$target[$lastIndex]['heure_fin'] = $slot['heure_fin'];
return;
}
}
$target[] = $slot;
}
private function timeToMinutes(string $time): int
{
[$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5)));
return ($hours * 60) + $minutes;
}
private function minutesToTime(int $minutes): string
{
return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
}
}

26
app/Models/MotifModel.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Models;
final class MotifModel extends BaseModel
{
public function allActive(): array
{
return $this->pdo->query(
"SELECT id_motif, code, libelle, compte_dans_quota
FROM motif_planning
WHERE actif = TRUE
ORDER BY ordre_affichage, libelle"
)->fetchAll();
}
public function activeIds(): array
{
return array_map(
'intval',
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
);
}
}

View File

@@ -0,0 +1,986 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use Throwable;
final class PlanningModel extends BaseModel
{
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut, p.commentaire, s.date_debut, s.date_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
WHERE p.id_structure = :structure_id
AND p.id_agent = :agent_id
AND s.annee = :annee
AND s.numero_semaine = :semaine
LIMIT 1"
);
$stmt->execute([
'structure_id' => $structureId,
'agent_id' => $agentId,
'annee' => $year,
'semaine' => $week,
]);
return $stmt->fetch() ?: null;
}
public function entries(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
m.compte_dans_quota
FROM creneau_horaire c
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE c.id_planning = :planning_id
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
st.id_structure, st.code AS structure_code, st.nom AS structure_nom,
st.type_affectation AS structure_type_affectation,
c.id_creneau, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
m.compte_dans_quota
FROM planning p
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
AND p.id_structure <> :structure_id
ORDER BY c.date_jour, c.heure_debut, st.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'semaine' => $week,
'structure_id' => $structureId,
]);
return $stmt->fetchAll();
}
public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+6 days');
try {
$this->pdo->beginTransaction();
$weekStmt = $this->pdo->prepare(
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
);
$weekStmt->execute([
'annee' => $year,
'numero' => $weekNumber,
'date_debut' => $weekStart->format('Y-m-d'),
'date_fin' => $weekEnd->format('Y-m-d'),
]);
$weekId = (int) $this->pdo->lastInsertId();
if ($weekId === 0) {
$findWeek = $this->pdo->prepare(
'SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero'
);
$findWeek->execute(['annee' => $year, 'numero' => $weekNumber]);
$weekId = (int) $findWeek->fetchColumn();
}
$planningStmt = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id
AND id_semaine = :semaine_id
AND id_structure = :structure_id
FOR UPDATE"
);
$planningStmt->execute([
'agent_id' => $agentId,
'semaine_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $planningStmt->fetch();
if ($planning && $planning['statut'] === 'VALIDE') {
throw new \DomainException('Ce planning est déjà validé et doit être rouvert avant modification.');
}
if ($planning) {
$planningId = (int) $planning['id_planning'];
$delete = $this->pdo->prepare('DELETE FROM creneau_horaire WHERE id_planning = :planning_id');
$delete->execute(['planning_id' => $planningId]);
} else {
$insertPlanning = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :semaine_id, :structure_id, 'BROUILLON')"
);
$insertPlanning->execute([
'agent_id' => $agentId,
'semaine_id' => $weekId,
'structure_id' => $structureId,
]);
$planningId = (int) $this->pdo->lastInsertId();
}
$insertEntry = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
);
foreach ($entries as $entry) {
$insertEntry->execute([
'planning_id' => $planningId,
'motif_id' => $entry['motif_id'],
'date_jour' => $entry['date'],
'heure_debut' => $entry['start'],
'heure_fin' => $entry['end'],
]);
}
$this->pdo->commit();
return $planningId;
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function pendingValidation(): array
{
$stmt = $this->pdo->query(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
p.date_creation, p.date_modification,
a.matricule, a.nom AS agent_nom, a.prenom AS agent_prenom,
st.nom AS structure_nom, st.type_affectation AS structure_type_affectation,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
DATE_ADD(sem.date_debut, INTERVAL 4 DAY) AS date_fin_ouvrable,
COUNT(c.id_creneau) AS nombre_creneaux,
COALESCE(SUM(TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
)), 0) AS total_minutes,
COALESCE(SUM(CASE WHEN m.compte_dans_quota = 1 THEN TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
) ELSE 0 END), 0) AS minutes_decomptees
FROM planning p
INNER JOIN agent a ON a.id_agent = p.id_agent
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
LEFT JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.statut = 'BROUILLON'
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
p.date_creation, p.date_modification,
a.matricule, a.nom, a.prenom,
st.nom, st.type_affectation,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
HAVING COUNT(c.id_creneau) > 0
ORDER BY sem.date_debut ASC, a.nom ASC, a.prenom ASC, st.nom ASC"
);
return $stmt->fetchAll();
}
/**
* Plannings en brouillon de l'agent qui contiennent au moins un créneau
* dans l'année civile demandée.
*/
public function pendingValidationForAgentYear(int $agentId, int $year): array
{
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
st.nom AS structure_nom,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
COUNT(c.id_creneau) AS nombre_creneaux,
COALESCE(SUM(TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
)), 0) AS total_minutes
FROM planning p
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND p.statut = 'BROUILLON'
AND EXISTS (
SELECT 1
FROM creneau_horaire cy
WHERE cy.id_planning = p.id_planning
AND cy.date_jour BETWEEN :date_debut AND :date_fin
)
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
st.nom, sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
HAVING COUNT(c.id_creneau) > 0
ORDER BY sem.date_debut, st.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $start,
'date_fin' => $end,
]);
return $stmt->fetchAll();
}
/**
* Chevauchements avec n'importe quel autre planning de l'agent,
* qu'il soit déjà validé ou encore en brouillon.
*/
public function externalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2,
c1.date_jour, st.nom AS structure_nom, p2.statut AS autre_statut
FROM planning p1
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
INNER JOIN planning p2
ON p2.id_agent = p1.id_agent
AND p2.id_planning <> p1.id_planning
INNER JOIN structure st ON st.id_structure = p2.id_structure
INNER JOIN creneau_horaire c2
ON c2.id_planning = p2.id_planning
AND c2.date_jour = c1.date_jour
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE p1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function findDetailed(int $planningId): ?array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
s.annee, s.numero_semaine, s.date_debut, s.date_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
WHERE p.id_planning = :planning_id
LIMIT 1"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetch() ?: null;
}
public function countEntries(int $planningId): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id');
$stmt->execute(['planning_id' => $planningId]);
return (int) $stmt->fetchColumn();
}
public function entriesOutsideWeek(int $planningId, string $start, string $end): array
{
$stmt = $this->pdo->prepare(
"SELECT id_creneau, date_jour
FROM creneau_horaire
WHERE id_planning = :planning_id
AND date_jour NOT BETWEEN :date_debut AND :date_fin"
);
$stmt->execute(['planning_id' => $planningId, 'date_debut' => $start, 'date_fin' => $end]);
return $stmt->fetchAll();
}
public function internalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour
FROM creneau_horaire c1
INNER JOIN creneau_horaire c2
ON c2.id_planning = c1.id_planning
AND c2.date_jour = c1.date_jour
AND c2.id_creneau > c1.id_creneau
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE c1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function validatedExternalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour, st.nom AS structure_nom
FROM planning p1
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
INNER JOIN planning p2
ON p2.id_agent = p1.id_agent
AND p2.id_planning <> p1.id_planning
AND p2.statut = 'VALIDE'
INNER JOIN structure st ON st.id_structure = p2.id_structure
INNER JOIN creneau_horaire c2
ON c2.id_planning = p2.id_planning
AND c2.date_jour = c1.date_jour
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE p1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function findEntryDetailed(int $entryId): ?array
{
$stmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
LIMIT 1"
);
$stmt->execute(['entry_id' => $entryId]);
return $stmt->fetch() ?: null;
}
/**
* Modifie un créneau depuis la vue agent, y compris vers une autre semaine.
* Le planning cible est créé si nécessaire et tout planning validé modifié
* repasse automatiquement en brouillon.
*/
public function updateEntryFromAgentView(
int $entryId,
int $targetStructureId,
int $motifId,
string $date,
string $start,
string $end
): array {
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à modifier est introuvable.');
}
$targetDate = new DateTimeImmutable($date);
if ((int) $targetDate->format('N') > 5) {
throw new \DomainException('Le créneau doit être positionné entre le lundi et le vendredi.');
}
$conflictStmt = $this->pdo->prepare(
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND c.id_creneau <> :entry_id
AND c.date_jour = :date_jour
AND c.heure_debut < :heure_fin
AND c.heure_fin > :heure_debut
LIMIT 1
FOR UPDATE"
);
$conflictStmt->execute([
'agent_id' => (int) $entry['id_agent'],
'entry_id' => $entryId,
'date_jour' => $date,
'heure_debut' => $start,
'heure_fin' => $end,
]);
$conflict = $conflictStmt->fetch();
if ($conflict) {
throw new \DomainException(sprintf(
'Double affectation impossible : un autre créneau existe déjà sur le lieu « %s » de %s à %s (%s).',
$conflict['structure_nom'],
$conflict['heure_debut'],
$conflict['heure_fin'],
strtolower((string) $conflict['statut'])
));
}
$targetYear = (int) $targetDate->format('o');
$targetWeekNumber = (int) $targetDate->format('W');
$targetWeekStart = $targetDate->modify('-' . ((int) $targetDate->format('N') - 1) . ' days')->setTime(0, 0);
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetWeekStart, $targetWeekStart->modify('+6 days'));
$sourcePlanningId = (int) $entry['id_planning'];
$targetPlanning = $this->findOrCreatePlanning(
(int) $entry['id_agent'],
$targetWeekId,
$targetStructureId
);
$targetPlanningId = (int) $targetPlanning['id_planning'];
$updateEntry = $this->pdo->prepare(
"UPDATE creneau_horaire
SET id_planning = :planning_id,
id_motif = :motif_id,
date_jour = :date_jour,
heure_debut = :heure_debut,
heure_fin = :heure_fin
WHERE id_creneau = :entry_id"
);
$updateEntry->execute([
'planning_id' => $targetPlanningId,
'motif_id' => $motifId,
'date_jour' => $date,
'heure_debut' => $start,
'heure_fin' => $end,
'entry_id' => $entryId,
]);
$reopenedPlanningIds = [];
if ((string) $entry['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $sourcePlanningId;
}
if ((string) $targetPlanning['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $targetPlanningId;
}
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
$this->pdo->commit();
return [
'entry_id' => $entryId,
'agent_id' => (int) $entry['id_agent'],
'year' => $targetYear,
'week' => $targetWeekNumber,
'source_planning_id' => $sourcePlanningId,
'target_planning_id' => $targetPlanningId,
'reopened_planning_ids' => $reopenedPlanningIds,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Supprime un créneau depuis la vue par agent.
* Si le planning était validé, il repasse en brouillon afin de signaler
* que son contenu a été modifié. Le planning est conservé même s'il ne
* contient plus aucun créneau.
*/
public function deleteEntryFromAgentView(int $entryId): array
{
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.date_jour,
p.id_agent, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à supprimer est introuvable.');
}
$deleteStmt = $this->pdo->prepare(
'DELETE FROM creneau_horaire WHERE id_creneau = :entry_id'
);
$deleteStmt->execute(['entry_id' => $entryId]);
if ($deleteStmt->rowCount() !== 1) {
throw new \DomainException('Le créneau a déjà été supprimé ou modifié. Rechargez la page.');
}
$reopened = (string) $entry['statut'] === 'VALIDE';
if ($reopened) {
$reopenStmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'BROUILLON'
WHERE id_planning = :planning_id"
);
$reopenStmt->execute(['planning_id' => (int) $entry['id_planning']]);
}
$countStmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id'
);
$countStmt->execute(['planning_id' => (int) $entry['id_planning']]);
$remainingEntries = (int) $countStmt->fetchColumn();
$this->pdo->commit();
return [
'entry_id' => $entryId,
'planning_id' => (int) $entry['id_planning'],
'agent_id' => (int) $entry['id_agent'],
'year' => (int) $entry['annee'],
'week' => (int) $entry['numero_semaine'],
'date' => (string) $entry['date_jour'],
'reopened' => $reopened,
'remaining_entries' => $remainingEntries,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Déplace ou duplique un créneau vers n'importe quel jour ouvré.
* Le lieu, le motif et les horaires sont conservés, y compris lors d'un
* changement de semaine.
*/
public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array
{
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à déplacer est introuvable.');
}
$targetDateObject = new DateTimeImmutable($targetDate);
if ((int) $targetDateObject->format('N') > 5) {
throw new \DomainException('Le jour cible doit être compris entre le lundi et le vendredi.');
}
if (!$duplicate && $targetDate === (string) $entry['date_jour']) {
$this->pdo->commit();
return [
'entry_id' => $entryId,
'created_entry_id' => null,
'agent_id' => (int) $entry['id_agent'],
'year' => (int) $entry['annee'],
'week' => (int) $entry['numero_semaine'],
'planning_id' => (int) $entry['id_planning'],
'target_planning_id' => (int) $entry['id_planning'],
'reopened_planning_ids' => [],
'action' => 'noop',
];
}
$excludeEntryId = $duplicate ? 0 : $entryId;
$conflictStmt = $this->pdo->prepare(
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND (:exclude_entry_id = 0 OR c.id_creneau <> :exclude_entry_id_compare)
AND c.date_jour = :date_jour
AND c.heure_debut < :heure_fin
AND c.heure_fin > :heure_debut
LIMIT 1
FOR UPDATE"
);
$conflictStmt->execute([
'agent_id' => (int) $entry['id_agent'],
'exclude_entry_id' => $excludeEntryId,
'exclude_entry_id_compare' => $excludeEntryId,
'date_jour' => $targetDate,
'heure_fin' => $entry['heure_fin'],
'heure_debut' => $entry['heure_debut'],
]);
$conflict = $conflictStmt->fetch();
if ($conflict) {
throw new \DomainException(sprintf(
'Action impossible : lagent possède déjà un créneau sur le lieu « %s » de %s à %s (%s).',
$conflict['structure_nom'],
$conflict['heure_debut'],
$conflict['heure_fin'],
strtolower((string) $conflict['statut'])
));
}
$targetYear = (int) $targetDateObject->format('o');
$targetWeekNumber = (int) $targetDateObject->format('W');
$targetMonday = $targetDateObject->modify('-' . ((int) $targetDateObject->format('N') - 1) . ' days')->setTime(0, 0);
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
$targetPlanning = $this->findOrCreatePlanning(
(int) $entry['id_agent'],
$targetWeekId,
(int) $entry['id_structure']
);
$targetPlanningId = (int) $targetPlanning['id_planning'];
$sourcePlanningId = (int) $entry['id_planning'];
$createdEntryId = null;
if ($duplicate) {
$insertStmt = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
);
$insertStmt->execute([
'planning_id' => $targetPlanningId,
'motif_id' => (int) $entry['id_motif'],
'date_jour' => $targetDate,
'heure_debut' => $entry['heure_debut'],
'heure_fin' => $entry['heure_fin'],
]);
$createdEntryId = (int) $this->pdo->lastInsertId();
} else {
$moveStmt = $this->pdo->prepare(
"UPDATE creneau_horaire
SET id_planning = :target_planning_id,
date_jour = :date_jour
WHERE id_creneau = :entry_id"
);
$moveStmt->execute([
'target_planning_id' => $targetPlanningId,
'date_jour' => $targetDate,
'entry_id' => $entryId,
]);
}
$reopenedPlanningIds = [];
if (!$duplicate && (string) $entry['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $sourcePlanningId;
}
if ((string) $targetPlanning['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $targetPlanningId;
}
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
$this->pdo->commit();
return [
'entry_id' => $entryId,
'created_entry_id' => $createdEntryId,
'agent_id' => (int) $entry['id_agent'],
'year' => $targetYear,
'week' => $targetWeekNumber,
'planning_id' => $sourcePlanningId,
'target_planning_id' => $targetPlanningId,
'reopened_planning_ids' => $reopenedPlanningIds,
'action' => $duplicate ? 'duplicate' : 'move',
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function copyAgentWeek(
int $agentId,
int $sourceYear,
int $sourceWeekNumber,
int $targetYear,
int $targetWeekNumber,
string $mode
): array {
if (!in_array($mode, ['merge', 'replace'], true)) {
throw new \DomainException('Mode de duplication invalide.');
}
if ($sourceYear === $targetYear && $sourceWeekNumber === $targetWeekNumber) {
throw new \DomainException('La semaine cible doit être différente de la semaine source.');
}
$sourceStmt = $this->pdo->prepare(
"SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine,
p.id_structure, c.id_motif,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin
FROM planning p
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
ORDER BY c.date_jour, c.heure_debut"
);
$sourceStmt->execute([
'agent_id' => $agentId,
'annee' => $sourceYear,
'semaine' => $sourceWeekNumber,
]);
$sourceEntries = $sourceStmt->fetchAll();
if (!$sourceEntries) {
throw new \DomainException('La semaine source ne contient aucun créneau à dupliquer.');
}
$targetMonday = (new DateTimeImmutable())->setISODate($targetYear, $targetWeekNumber, 1)->setTime(0, 0);
$targetEntries = array_map(static function (array $entry) use ($targetMonday): array {
return [
'date_jour' => $targetMonday->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d'),
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
'heure_debut' => (string) $entry['heure_debut'],
'heure_fin' => (string) $entry['heure_fin'],
];
}, $sourceEntries);
try {
$this->pdo->beginTransaction();
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
$existingPlanningStmt = $this->pdo->prepare(
"SELECT id_planning, id_structure, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id
FOR UPDATE"
);
$existingPlanningStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
$existingPlannings = $existingPlanningStmt->fetchAll();
$reopened = [];
if ($mode === 'replace') {
if ($existingPlannings) {
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
foreach ($existingPlannings as $planning) {
if ((string) $planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
$delete->execute($ids);
}
} else {
$existingEntriesStmt = $this->pdo->prepare(
"SELECT c.date_jour, c.heure_debut, c.heure_fin, st.nom AS structure_nom
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
FOR UPDATE"
);
$existingEntriesStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
$existingEntries = $existingEntriesStmt->fetchAll();
foreach ($targetEntries as $candidate) {
foreach ($existingEntries as $existing) {
if ($candidate['date_jour'] === $existing['date_jour']
&& $candidate['heure_debut'] < $existing['heure_fin']
&& $candidate['heure_fin'] > $existing['heure_debut']) {
throw new \DomainException(sprintf(
'Duplication impossible : un créneau existe déjà le %s de %s à %s sur « %s ».',
$candidate['date_jour'],
substr((string) $existing['heure_debut'], 0, 5),
substr((string) $existing['heure_fin'], 0, 5),
$existing['structure_nom']
));
}
}
}
}
$planningCache = [];
foreach ($existingPlannings as $planning) {
$planningCache[(int) $planning['id_structure']] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
}
$insertEntry = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
);
foreach ($targetEntries as $candidate) {
$structureId = (int) $candidate['id_structure'];
if (!isset($planningCache[$structureId])) {
$planningCache[$structureId] = $this->findOrCreatePlanning($agentId, $targetWeekId, $structureId);
}
$planning = $planningCache[$structureId];
if ((string) $planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
$insertEntry->execute([
'planning_id' => (int) $planning['id_planning'],
'motif_id' => (int) $candidate['id_motif'],
'date_jour' => $candidate['date_jour'],
'heure_debut' => $candidate['heure_debut'],
'heure_fin' => $candidate['heure_fin'],
]);
}
$reopened = $this->reopenPlanningIds($reopened);
$this->pdo->commit();
return [
'agent_id' => $agentId,
'year' => $targetYear,
'week' => $targetWeekNumber,
'copied_entries' => count($targetEntries),
'reopened_planning_ids' => $reopened,
'mode' => $mode,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
);
$stmt->execute([
'annee' => $year,
'numero' => $weekNumber,
'date_debut' => $weekStart->format('Y-m-d'),
'date_fin' => $weekEnd->format('Y-m-d'),
]);
$weekId = (int) $this->pdo->lastInsertId();
if ($weekId > 0) {
return $weekId;
}
$find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero');
$find->execute(['annee' => $year, 'numero' => $weekNumber]);
$weekId = (int) $find->fetchColumn();
if ($weekId <= 0) {
throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.');
}
return $weekId;
}
/** @return array{id_planning:int,statut:string} */
private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array
{
$stmt = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
FOR UPDATE"
);
$stmt->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $stmt->fetch();
if ($planning) {
return ['id_planning' => (int) $planning['id_planning'], 'statut' => (string) $planning['statut']];
}
$insert = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
);
$insert->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON'];
}
/** @return int[] */
private function reopenPlanningIds(array $planningIds): array
{
$planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds))));
if (!$planningIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
$stmt = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)"
);
$stmt->execute($planningIds);
return $planningIds;
}
/** @param int[] $planningIds */
public function markValidatedMany(array $planningIds): int
{
$planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0)));
if ($planningIds === []) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
$stmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP
WHERE statut = 'BROUILLON' AND id_planning IN ($placeholders)"
);
$stmt->execute($planningIds);
return $stmt->rowCount();
}
public function markValidated(int $planningId): void
{
$stmt = $this->pdo->prepare(
"UPDATE planning SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP WHERE id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
}
public function reopen(int $planningId): bool
{
$stmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'BROUILLON', date_validation = NULL
WHERE id_planning = :planning_id AND statut = 'VALIDE'"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->rowCount() === 1;
}
}

642
app/Models/PtaModel.php Normal file
View File

@@ -0,0 +1,642 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use PDO;
use RuntimeException;
final class PtaModel extends BaseModel
{
private const WORK_CODES = [
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR',
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
];
private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
];
public function summary(int $agentId, string $contextDate): array
{
$agentModel = new AgentModel($this->pdo);
$agent = $agentModel->findActive($agentId);
if ($agent === null) {
throw new RuntimeException('Agent introuvable.');
}
$quota = (new QuotaModel($this->pdo))->forDateForApi($agentId, $contextDate);
$pta = $this->ensurePta($agent, $quota);
$entries = $agentModel->entriesForDateRange(
$agentId,
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$vacations = (new VacationModel($this->pdo))->periodsBetween(
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$analysis = $this->analyseEntries($agent, $entries, $vacations, $quota);
$constraints = $this->constraintsForAgent(
$agentId,
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$wishes = $this->wishesForAgent($agentId);
$controls = array_merge(
$this->eligibilityControls($agent),
$analysis['controls'],
$this->constraintControls($analysis, $constraints),
$this->deepCleaningControls($agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode'])
);
usort($controls, static function (array $a, array $b): int {
$order = ['ERREUR' => 0, 'ALERTE' => 1, 'INFO' => 2];
return ($order[$a['niveau']] ?? 9) <=> ($order[$b['niveau']] ?? 9);
});
return [
'agent' => $agent,
'pta' => $pta,
'quota' => $quota,
'categories' => $analysis['categories'],
'counters' => $analysis['counters'],
'controls' => $controls,
'constraints' => $constraints,
'wishes' => $wishes,
'vacations' => $vacations,
];
}
public function saveConstraint(array $data): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO agent_contrainte (
id_agent, type_contrainte, date_debut, date_fin,
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
interdit_matin, interdit_midi, interdit_soir, commentaire, actif
) VALUES (
:id_agent, :type_contrainte, :date_debut, :date_fin,
:quotite_temporaire, :maximum_minutes_jour, :maximum_minutes_semaine,
:interdit_matin, :interdit_midi, :interdit_soir, :commentaire, TRUE
)"
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
public function deleteConstraint(int $constraintId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM agent_contrainte WHERE id_contrainte = :id AND id_agent = :agent_id'
);
$stmt->execute(['id' => $constraintId, 'agent_id' => $agentId]);
return $stmt->rowCount() > 0;
}
public function saveWish(array $data): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO agent_structure_souhait (
id_agent, id_structure, type_souhait, priorite, distance_km, commentaire, actif
) VALUES (
:id_agent, :id_structure, :type_souhait, :priorite, :distance_km, :commentaire, TRUE
)
ON DUPLICATE KEY UPDATE
type_souhait = VALUES(type_souhait),
priorite = VALUES(priorite),
distance_km = VALUES(distance_km),
commentaire = VALUES(commentaire),
actif = TRUE"
);
$stmt->execute($data);
return (int) ($this->pdo->lastInsertId() ?: 0);
}
public function deleteWish(int $wishId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM agent_structure_souhait WHERE id_souhait = :id AND id_agent = :agent_id'
);
$stmt->execute(['id' => $wishId, 'agent_id' => $agentId]);
return $stmt->rowCount() > 0;
}
private function ensurePta(array $agent, array $quota): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM pta_annuel
WHERE id_agent = :agent_id
AND date_debut = :date_debut
AND date_fin = :date_fin
LIMIT 1'
);
$stmt->execute([
'agent_id' => $agent['id_agent'],
'date_debut' => $quota['date_debut_periode'],
'date_fin' => $quota['date_fin_periode'],
]);
$pta = $stmt->fetch();
if ($pta) {
return $pta;
}
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
$insert = $this->pdo->prepare(
"INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail,
quota_reference_minutes, quota_cible_minutes,
enveloppe_formation_minutes, statut
) VALUES (
:agent_id, :date_debut, :date_fin, :quotite,
:quota_reference, :quota_cible, :formation, 'BROUILLON'
)"
);
$insert->execute([
'agent_id' => $agent['id_agent'],
'date_debut' => $quota['date_debut_periode'],
'date_fin' => $quota['date_fin_periode'],
'quotite' => $quota['quotite_travail'],
'quota_reference' => $quota['quota_reference_minutes'],
'quota_cible' => $quota['quota_cible_minutes'],
'formation' => $training,
]);
$stmt->execute([
'agent_id' => $agent['id_agent'],
'date_debut' => $quota['date_debut_periode'],
'date_fin' => $quota['date_fin_periode'],
]);
return $stmt->fetch() ?: [];
}
private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array
{
$categories = [
'PERISCOLAIRE' => 0,
'MERCREDI_PERISCOLAIRE' => 0,
'EXTRASCOLAIRE' => 0,
'FORMATION' => 0,
'CONGE' => 0,
'JOUR_FRACTIONNEMENT' => 0,
'PREPARATION' => 0,
'MENAGE_FOND' => 0,
'ABSENCE' => 0,
'NON_DECOMPTE' => 0,
];
$daysByCategory = [];
$daily = [];
$weekly = [];
$controls = [];
foreach ($entries as $entry) {
$date = (string) $entry['date_jour'];
$start = (string) $entry['heure_debut'];
$end = (string) $entry['heure_fin'];
$minutes = $this->minutesBetween($start, $end);
$code = strtoupper((string) $entry['motif_code']);
$isVacation = $this->vacationForDate($date, $vacations) !== null;
$weekday = (int) (new DateTimeImmutable($date))->format('N');
$category = $this->categoryForEntry($code, $isVacation, $weekday, (bool) $entry['compte_dans_quota']);
$categories[$category] = ($categories[$category] ?? 0) + $minutes;
$daysByCategory[$category][$date] = true;
$daily[$date] ??= ['minutes' => 0, 'starts' => [], 'ends' => [], 'entries' => []];
$daily[$date]['minutes'] += $minutes;
$daily[$date]['starts'][] = $start;
$daily[$date]['ends'][] = $end;
$daily[$date]['entries'][] = [
'start' => $start,
'end' => $end,
'minutes' => $minutes,
'code' => $code,
'category' => $category,
];
$monday = (new DateTimeImmutable($date))->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
$weekly[$monday] = ($weekly[$monday] ?? 0) + $minutes;
}
foreach ($daily as $date => &$day) {
sort($day['starts']);
sort($day['ends']);
usort($day['entries'], static fn(array $a, array $b): int => strcmp($a['start'], $b['start']));
$first = min($day['starts']);
$last = max($day['ends']);
$day['amplitude_minutes'] = $this->minutesBetween($first, $last);
$day['first'] = $first;
$day['last'] = $last;
}
unset($day);
foreach ($daily as $date => $day) {
if ($day['minutes'] > 600) {
$controls[] = $this->control('ERREUR', 'DUREE_QUOTIDIENNE', sprintf(
'%s : %s planifiées, au-delà de 10 h de travail quotidien.',
$this->formatDate($date), $this->formatMinutes($day['minutes'])
));
}
if ($day['amplitude_minutes'] > 720) {
$controls[] = $this->control('ERREUR', 'AMPLITUDE_QUOTIDIENNE', sprintf(
'%s : amplitude de %s, au-delà de 12 h.',
$this->formatDate($date), $this->formatMinutes($day['amplitude_minutes'])
));
}
$continuous = 0;
$previousEnd = null;
foreach ($day['entries'] as $entry) {
if ($previousEnd === null || $this->minutesBetween($previousEnd, $entry['start']) >= 20) {
$continuous = $entry['minutes'];
} else {
$continuous += max(0, $this->minutesBetween(max($previousEnd, $entry['start']), $entry['end']));
}
$previousEnd = $previousEnd === null || $entry['end'] > $previousEnd ? $entry['end'] : $previousEnd;
if ($continuous > 360) {
$controls[] = $this->control('ERREUR', 'PAUSE_OBLIGATOIRE', sprintf(
'%s : plus de 6 h consécutives sans coupure dau moins 20 minutes.',
$this->formatDate($date)
));
break;
}
}
}
$dates = array_keys($daily);
sort($dates);
for ($i = 1, $count = count($dates); $i < $count; $i++) {
$previousDate = $dates[$i - 1];
$currentDate = $dates[$i];
$previousEnd = new DateTimeImmutable($previousDate . ' ' . $daily[$previousDate]['last']);
$currentStart = new DateTimeImmutable($currentDate . ' ' . $daily[$currentDate]['first']);
$restMinutes = (int) round(($currentStart->getTimestamp() - $previousEnd->getTimestamp()) / 60);
if ($restMinutes < 660) {
$controls[] = $this->control('ERREUR', 'REPOS_QUOTIDIEN', sprintf(
'Repos insuffisant entre le %s et le %s : %s au lieu de 11 h minimum.',
$this->formatDate($previousDate), $this->formatDate($currentDate), $this->formatMinutes($restMinutes)
));
}
}
foreach ($weekly as $monday => $minutes) {
if ($minutes > 2880) {
$controls[] = $this->control('ERREUR', 'DUREE_HEBDOMADAIRE', sprintf(
'Semaine du %s : %s planifiées, au-delà de 48 h.',
$this->formatDate($monday), $this->formatMinutes($minutes)
));
}
}
// Contrôle de la moyenne maximale de 44 h sur 12 semaines consécutives.
$periodStart = new DateTimeImmutable((string) $quota['date_debut_periode']);
$periodEnd = new DateTimeImmutable((string) $quota['date_fin_periode']);
$firstMonday = $periodStart->modify('-' . ((int) $periodStart->format('N') - 1) . ' days');
$lastMonday = $periodEnd->modify('-' . ((int) $periodEnd->format('N') - 1) . ' days');
$weekSeries = [];
for ($monday = $firstMonday; $monday <= $lastMonday; $monday = $monday->modify('+7 days')) {
$key = $monday->format('Y-m-d');
$weekSeries[] = ['date' => $key, 'minutes' => (int) ($weekly[$key] ?? 0)];
}
for ($index = 0, $totalWeeks = count($weekSeries); $index + 11 < $totalWeeks; $index++) {
$window = array_slice($weekSeries, $index, 12);
$average = (int) round(array_sum(array_column($window, 'minutes')) / 12);
if ($average > 2640) {
$controls[] = $this->control('ERREUR', 'MOYENNE_12_SEMAINES', sprintf(
'Du %s au %s : moyenne de %s par semaine sur 12 semaines, au-delà de 44 h.',
$this->formatDate($window[0]['date']),
$this->formatDate((new DateTimeImmutable($window[11]['date']))->modify('+6 days')->format('Y-m-d')),
$this->formatMinutes($average)
));
break;
}
}
$expectedWednesday = (int) ($agent['heures_mercredi_minutes'] ?? 0);
$expectedExtra = (int) ($agent['heures_extrascolaire_minutes'] ?? 0);
$vacationWeeks = [];
foreach ($daily as $date => $day) {
$dateObject = new DateTimeImmutable($date);
$weekday = (int) $dateObject->format('N');
$vacation = $this->vacationForDate($date, $vacations);
$workingMinutes = 0;
foreach ($day['entries'] as $entry) {
if (in_array($entry['code'], self::WORK_CODES, true)) {
$workingMinutes += $entry['minutes'];
}
}
if ($weekday === 3 && $vacation === null && $workingMinutes > 0 && $expectedWednesday > 0 && $workingMinutes !== $expectedWednesday) {
$controls[] = $this->control('ALERTE', 'MERCREDI_DUREE', sprintf(
'%s : %s de travail le mercredi scolaire, contre %s attendues pour le poste.',
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedWednesday)
));
}
if ($vacation !== null && $workingMinutes > 0 && $expectedExtra > 0 && $workingMinutes !== $expectedExtra) {
$controls[] = $this->control('ALERTE', 'EXTRA_DUREE', sprintf(
'%s : %s de travail extrascolaire, contre %s attendues pour le poste.',
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedExtra)
));
}
if ($vacation !== null && $workingMinutes > 0) {
$weekKey = $dateObject->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
$vacationWeeks[$weekKey][$date] = true;
}
}
if (($agent['poste_famille'] ?? null) === 'ANIMATION') {
foreach ($vacationWeeks as $weekStart => $workedDays) {
$count = count($workedDays);
if ($count > 0 && $count < 5) {
$controls[] = $this->control('ALERTE', 'VACANCES_SEMAINE_INCOMPLETE', sprintf(
'Semaine de vacances du %s : seulement %d jour(s) travaillé(s). La préférence métier est une semaine complète.',
$this->formatDate($weekStart), $count
));
}
}
}
if (($agent['poste_famille'] ?? null) === 'RESTAURATION_ENTRETIEN') {
foreach (self::PREPARATION_CODES as $code) {
$hasPreparation = false;
foreach ($entries as $entry) {
if (strtoupper((string) $entry['motif_code']) === $code) {
$hasPreparation = true;
break;
}
}
if ($hasPreparation) {
$controls[] = $this->control('ERREUR', 'PREPARATION_NON_AUTORISEE',
'Un agent de restauration collective et dentretien ne doit pas recevoir de temps de préparation.');
break;
}
}
}
$formationMinutes = $categories['FORMATION'];
$formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
$this->formatMinutes($formationMinutes)
));
}
if ($formationTarget > 0 && $formationMinutes < $formationTarget) {
$controls[] = $this->control('INFO', 'FORMATION_RESTANTE', sprintf(
'Il reste %s dans lenveloppe de formation CNFPT de 14 h.',
$this->formatMinutes($formationTarget - $formationMinutes)
));
}
$caDays = count($daysByCategory['CONGE'] ?? []);
$jfDays = count($daysByCategory['JOUR_FRACTIONNEMENT'] ?? []);
if ($caDays !== 25) {
$controls[] = $this->control('ALERTE', 'CONGES_ANNUELS', sprintf(
'%d jour(s) de congé annuel positionné(s) sur un objectif métier de 25 jours.',
$caDays
));
}
if ($jfDays !== 2) {
$controls[] = $this->control('ALERTE', 'JOURS_FRACTIONNEMENT', sprintf(
'%d journée(s) de fractionnement positionnée(s) sur un objectif de 2.',
$jfDays
));
}
if ($controls === []) {
$controls[] = $this->control('INFO', 'OK', 'Aucune anomalie détectée sur le PTA de cette période.');
}
$formattedCategories = [];
foreach ($categories as $code => $minutes) {
$formattedCategories[] = [
'code' => $code,
'minutes' => $minutes,
'libelle' => $this->categoryLabel($code),
'duree' => $this->formatMinutes($minutes),
'jours' => count($daysByCategory[$code] ?? []),
];
}
return [
'categories' => $formattedCategories,
'counters' => [
'conges_annuels_jours' => $caDays,
'conges_annuels_cible' => 25,
'jours_fractionnement' => $jfDays,
'jours_fractionnement_cible' => 2,
'formation_minutes' => $formationMinutes,
'formation_cible_minutes' => $formationTarget,
'formation_duree' => $this->formatMinutes($formationMinutes),
'formation_cible_duree' => $this->formatMinutes($formationTarget),
'quota_cible' => $quota['quota_cible'],
'heures_affectees' => $quota['affectees'],
'heures_restantes' => $quota['restantes'],
],
'controls' => $controls,
'daily' => $daily,
'weekly' => $weekly,
];
}
private function eligibilityControls(array $agent): array
{
$controls = [];
if (($agent['type_contrat'] ?? '') !== 'PERMANENT') {
$controls[] = $this->control('ERREUR', 'CONTRAT_NON_PERMANENT',
'Le dispositif PTA est réservé ici aux agents en contrat permanent.');
}
if (empty($agent['id_poste'])) {
$controls[] = $this->control('ERREUR', 'POSTE_MANQUANT',
'Le poste de lagent doit être renseigné pour appliquer les règles du PTA.');
}
return $controls;
}
private function constraintControls(array $analysis, array $constraints): array
{
$controls = [];
foreach ($constraints as $constraint) {
$label = $constraint['type_contrainte'] === 'TEMPS_PARTIEL_THERAPEUTIQUE'
? 'Temps partiel thérapeutique'
: 'Préconisation médicale';
$controls[] = $this->control('ALERTE', 'CONTRAINTE_PRIORITAIRE', sprintf(
'%s du %s au %s : %s',
$label,
$this->formatDate((string) $constraint['date_debut']),
$constraint['date_fin'] ? $this->formatDate((string) $constraint['date_fin']) : 'sans date de fin',
(string) $constraint['commentaire']
));
foreach ($analysis['daily'] as $date => $day) {
if ($date < $constraint['date_debut'] || ($constraint['date_fin'] && $date > $constraint['date_fin'])) {
continue;
}
if ($constraint['maximum_minutes_jour'] !== null
&& $day['minutes'] > (int) $constraint['maximum_minutes_jour']) {
$controls[] = $this->control('ERREUR', 'CONTRAINTE_MAX_JOUR', sprintf(
'%s : %s planifiées alors que la contrainte limite la journée à %s.',
$this->formatDate($date), $this->formatMinutes($day['minutes']),
$this->formatMinutes((int) $constraint['maximum_minutes_jour'])
));
}
}
}
return $controls;
}
private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT c.date_jour, p.id_structure, s.nom AS structure_nom,
COUNT(DISTINCT p.id_agent) AS agents,
SUM(CASE WHEN p.id_agent = :agent_id THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END) AS minutes_agent
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
JOIN motif_planning m ON m.id_motif = c.id_motif
JOIN structure s ON s.id_structure = p.id_structure
WHERE m.code = 'MENAGE_FOND'
AND c.date_jour BETWEEN :date_debut AND :date_fin
AND EXISTS (
SELECT 1 FROM planning px
JOIN creneau_horaire cx ON cx.id_planning = px.id_planning
JOIN motif_planning mx ON mx.id_motif = cx.id_motif
WHERE px.id_agent = :agent_exists
AND px.id_structure = p.id_structure
AND cx.date_jour = c.date_jour
AND mx.code = 'MENAGE_FOND'
)
GROUP BY c.date_jour, p.id_structure, s.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'agent_exists' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
$controls = [];
foreach ($stmt->fetchAll() as $row) {
if ((int) $row['agents'] < 2) {
$controls[] = $this->control('ERREUR', 'MENAGE_EQUIPE', sprintf(
'%s — %s : le ménage de fond ne compte que %d agent(s), alors que 2 sont requis.',
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], (int) $row['agents']
));
}
if ((int) $row['minutes_agent'] !== 420) {
$controls[] = $this->control('ALERTE', 'MENAGE_DUREE', sprintf(
'%s — %s : %s de ménage de fond pour lagent, au lieu de 7 h.',
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'],
$this->formatMinutes((int) $row['minutes_agent'])
));
}
}
return $controls;
}
private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT id_contrainte, type_contrainte, date_debut, date_fin,
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
interdit_matin, interdit_midi, interdit_soir, commentaire
FROM agent_contrainte
WHERE id_agent = :agent_id
AND actif = TRUE
AND date_debut <= :date_fin
AND COALESCE(date_fin, '9999-12-31') >= :date_debut
ORDER BY date_debut DESC"
);
$stmt->execute(['agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate]);
return $stmt->fetchAll();
}
private function wishesForAgent(int $agentId): array
{
$stmt = $this->pdo->prepare(
"SELECT sw.id_souhait, sw.id_structure, s.nom AS structure_nom,
sw.type_souhait, sw.priorite, sw.distance_km, sw.commentaire
FROM agent_structure_souhait sw
JOIN structure s ON s.id_structure = sw.id_structure
WHERE sw.id_agent = :agent_id AND sw.actif = TRUE
ORDER BY FIELD(sw.type_souhait, 'INTERDICTION', 'PREFERENCE'), sw.priorite DESC, s.nom"
);
$stmt->execute(['agent_id' => $agentId]);
return $stmt->fetchAll();
}
private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string
{
return match ($code) {
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR' => 'PERISCOLAIRE',
'MERCREDI_PERISCOLAIRE' => 'MERCREDI_PERISCOLAIRE',
'EXTRASCOLAIRE' => 'EXTRASCOLAIRE',
'FORMATION' => 'FORMATION',
'CONGE' => 'CONGE',
'JOUR_FRACTIONNEMENT' => 'JOUR_FRACTIONNEMENT',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'PREPARATION',
'MENAGE_FOND' => 'MENAGE_FOND',
'ABSENCE' => 'ABSENCE',
'AUTRE_ABSENCE', 'JNT' => 'NON_DECOMPTE',
'TRAVAIL' => $isVacation ? 'EXTRASCOLAIRE' : ($weekday === 3 ? 'MERCREDI_PERISCOLAIRE' : 'PERISCOLAIRE'),
default => $counts ? 'PERISCOLAIRE' : 'NON_DECOMPTE',
};
}
private function categoryLabel(string $code): string
{
return match ($code) {
'PERISCOLAIRE' => 'Temps périscolaire lundi, mardi, jeudi et vendredi',
'MERCREDI_PERISCOLAIRE' => 'Mercredis périscolaires',
'EXTRASCOLAIRE' => 'Temps extrascolaire',
'FORMATION' => 'Formation',
'CONGE' => 'Congés annuels non décomptés',
'JOUR_FRACTIONNEMENT' => 'Journées de fractionnement',
'PREPARATION' => 'Temps de préparation',
'MENAGE_FOND' => 'Ménages de fond',
'ABSENCE' => 'Absences décomptées',
'NON_DECOMPTE' => 'JNT et absences non décomptées',
default => $code,
};
}
private function vacationForDate(string $date, array $vacations): ?array
{
foreach ($vacations as $vacation) {
if ($date >= $vacation['date_debut'] && $date <= $vacation['date_fin']) {
return $vacation;
}
}
return null;
}
private function minutesBetween(string $start, string $end): int
{
[$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5)));
[$eh, $em] = array_map('intval', explode(':', substr($end, 0, 5)));
return ($eh * 60 + $em) - ($sh * 60 + $sm);
}
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$minutes = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
}
private function formatDate(string $date): string
{
return (new DateTimeImmutable($date))->format('d/m/Y');
}
private function control(string $level, string $code, string $message): array
{
return ['niveau' => $level, 'code' => $code, 'message' => $message];
}
}

209
app/Models/QuotaModel.php Normal file
View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Suivi du quota PTA sur l'année CIVILE.
*
* Le PTA est planifié du 1er janvier au 31 décembre de l'année concernée.
* La date de début du contrat reste une information RH utile, mais elle ne sert
* plus d'ancre pour décaler la période annuelle du quota.
*/
final class QuotaModel extends BaseModel
{
public const REFERENCE_MINUTES = 1607 * 60;
public function createForAgent(int $agentId, int $year, float $rate): void
{
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
$stmt = $this->pdo->prepare(
'INSERT INTO quota_agent_annuel (
id_agent, annee, quotite_travail, quota_reference_minutes, quota_cible_minutes, commentaire
) VALUES (
:agent_id, :annee, :quotite, :quota_reference, :quota_cible, :commentaire
)'
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'quotite' => $rate,
'quota_reference' => self::REFERENCE_MINUTES,
'quota_cible' => $target,
'commentaire' => 'Quota PTA de référence pour lannée civile',
]);
}
/**
* Retourne le quota de l'année civile contenant la date fournie.
*
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
*/
public function forDate(int $agentId, string $contextDate): array
{
$period = $this->referencePeriod($agentId, $contextDate);
$quota = $this->quotaProfile($agentId, (int) $period['annee_periode']);
$usedStmt = $this->pdo->prepare(
"SELECT
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_brouillon
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
AND m.compte_dans_quota = TRUE"
);
$usedStmt->execute([
'agent_id' => $agentId,
'date_debut' => $period['date_debut_periode'],
'date_fin' => $period['date_fin_periode'],
]);
$used = $usedStmt->fetch() ?: ['minutes_valides' => 0, 'minutes_brouillon' => 0];
$validated = (int) $used['minutes_valides'];
$draft = (int) $used['minutes_brouillon'];
$target = (int) $quota['quota_cible_minutes'];
$allocated = $validated + $draft;
return array_merge($period, [
'annee' => (int) $period['annee_periode'],
'quotite_travail' => (float) $quota['quotite_travail'],
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
'quota_cible_minutes' => $target,
'quota_source_annee' => (int) $quota['quota_source_annee'],
'minutes_valides' => $validated,
'minutes_brouillon' => $draft,
'minutes_affectees' => $allocated,
'minutes_restantes' => $target - $allocated,
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 2) : 0,
]);
}
public function forDateForApi(int $agentId, string $contextDate): array
{
return $this->enrich($this->forDate($agentId, $contextDate));
}
public function annual(int $agentId, int $year): array
{
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
}
public function annualForApi(int $agentId, int $year): array
{
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
}
/**
* Période de référence PTA : toujours l'année civile.
* La date de contrat est retournée uniquement à titre informatif.
*/
public function referencePeriod(int $agentId, string $contextDate): array
{
$context = DateTimeImmutable::createFromFormat('!Y-m-d', $contextDate);
if (!$context || $context->format('Y-m-d') !== $contextDate) {
throw new InvalidArgumentException('Date de référence du quota invalide.');
}
$stmt = $this->pdo->prepare(
'SELECT date_debut_contrat FROM agent WHERE id_agent = :agent_id LIMIT 1'
);
$stmt->execute(['agent_id' => $agentId]);
$contractStartValue = $stmt->fetchColumn();
$year = (int) $context->format('Y');
$start = new DateTimeImmutable(sprintf('%04d-01-01', $year));
$end = new DateTimeImmutable(sprintf('%04d-12-31', $year));
return [
'date_reference' => $context->format('Y-m-d'),
'date_debut_contrat' => $contractStartValue ? (string) $contractStartValue : null,
'date_debut_periode' => $start->format('Y-m-d'),
'date_fin_periode' => $end->format('Y-m-d'),
'annee_periode' => $year,
'periode_libelle' => sprintf('année civile %d · du %s au %s', $year, $start->format('d/m/Y'), $end->format('d/m/Y')),
'periode_contractuelle' => false,
'periode_civile' => true,
// Conservé pour compatibilité avec les anciens écrans : la date de
// contrat n'est plus requise pour calculer la période du quota.
'date_contrat_manquante' => false,
];
}
public function enrich(array $quota): array
{
return array_merge($quota, [
'quota_cible' => $this->formatMinutes((int) $quota['quota_cible_minutes']),
'valides' => $this->formatMinutes((int) $quota['minutes_valides']),
'brouillons' => $this->formatMinutes((int) $quota['minutes_brouillon']),
'affectees' => $this->formatMinutes((int) $quota['minutes_affectees']),
'restantes' => $this->formatMinutes((int) $quota['minutes_restantes']),
]);
}
public function formatMinutes(int $minutes): array
{
$sign = $minutes < 0 ? -1 : 1;
$absolute = abs($minutes);
$hours = intdiv($absolute, 60);
$mins = $absolute % 60;
return [
'minutes' => $minutes,
'heures_decimales' => round($minutes / 60, 2),
'libelle' => ($sign < 0 ? '-' : '') . $hours . ' h ' . str_pad((string) $mins, 2, '0', STR_PAD_LEFT),
];
}
private function quotaProfile(int $agentId, int $year): array
{
$stmt = $this->pdo->prepare(
"SELECT annee, quotite_travail, quota_reference_minutes, quota_cible_minutes
FROM quota_agent_annuel
WHERE id_agent = :agent_id
ORDER BY CASE
WHEN annee = :year_exact THEN 0
WHEN annee < :year_past THEN 1
ELSE 2
END,
CASE WHEN annee <= :year_latest THEN annee END DESC,
CASE WHEN annee > :year_future THEN annee END ASC
LIMIT 1"
);
$stmt->execute([
'agent_id' => $agentId,
'year_exact' => $year,
'year_past' => $year,
'year_latest' => $year,
'year_future' => $year,
]);
$quota = $stmt->fetch();
if (!$quota) {
return [
'quotite_travail' => 100.00,
'quota_reference_minutes' => self::REFERENCE_MINUTES,
'quota_cible_minutes' => self::REFERENCE_MINUTES,
'quota_source_annee' => $year,
];
}
return [
'quotite_travail' => (float) $quota['quotite_travail'],
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
'quota_cible_minutes' => (int) $quota['quota_cible_minutes'],
'quota_source_annee' => (int) $quota['annee'],
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Models;
final class SchemaModel extends BaseModel
{
public function assertCurrent(): void
{
$requiredTables = [
'agent',
'structure',
'agent_structure',
'motif_planning',
'semaine',
'planning',
'creneau_horaire',
'quota_agent_annuel',
'modele_semaine_agent',
'modele_semaine_creneau',
'structure_couverture_horaire',
'periode_vacance',
'poste_agent',
'pta_annuel',
'agent_structure_souhait',
'agent_contrainte',
'equipe_pta',
'equipe_pta_membre',
'structure_creneau_reference',
];
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
$stmt = $this->pdo->prepare(
"SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ($placeholders)"
);
$stmt->execute($requiredTables);
$existing = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$missingTables = array_values(array_diff($requiredTables, $existing));
if ($missingTables) {
throw new \RuntimeException(
'Base PTA incomplète : table(s) manquante(s) : '
. implode(', ', $missingTables)
. '. Exécutez database/install_complet.sql ou les migrations nécessaires.'
);
}
$requiredColumns = [
'creneau_horaire' => ['id_motif'],
'planning' => ['date_validation'],
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'commentaire_pta'],
'structure' => ['type_affectation'],
];
foreach ($requiredColumns as $table => $columns) {
$columnPlaceholders = implode(',', array_fill(0, count($columns), '?'));
$params = array_merge([$table], $columns);
$columnStmt = $this->pdo->prepare(
"SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME IN ($columnPlaceholders)"
);
$columnStmt->execute($params);
$existingColumns = $columnStmt->fetchAll(\PDO::FETCH_COLUMN);
$missingColumns = array_values(array_diff($columns, $existingColumns));
if ($missingColumns) {
throw new \RuntimeException(sprintf(
'Base PTA incomplète : colonne(s) manquante(s) dans %s : %s.',
$table,
implode(', ', $missingColumns)
));
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Models;
final class StructureModel extends BaseModel
{
public function allActive(): array
{
return $this->pdo->query(
'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE actif = TRUE ORDER BY nom'
)->fetchAll();
}
public function findActive(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1'
);
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
public function codeExists(string $code): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1');
$stmt->execute(['code' => $code]);
return $stmt->fetchColumn() !== false;
}
public function create(string $code, string $name, ?string $address, string $typeAffectation): array
{
$stmt = $this->pdo->prepare(
'INSERT INTO structure (code, nom, adresse, type_affectation, actif)
VALUES (:code, :nom, :adresse, :type_affectation, TRUE)'
);
$stmt->execute([
'code' => $code,
'nom' => $name,
'adresse' => $address,
'type_affectation' => $typeAffectation,
]);
return [
'id_structure' => (int) $this->pdo->lastInsertId(),
'code' => $code,
'nom' => $name,
'adresse' => $address,
'type_affectation' => $typeAffectation,
];
}
public function updateType(int $structureId, string $typeAffectation): void
{
$stmt = $this->pdo->prepare(
'UPDATE structure SET type_affectation = :type_affectation WHERE id_structure = :id_structure'
);
$stmt->execute([
'type_affectation' => $typeAffectation,
'id_structure' => $structureId,
]);
}
public function agentsForOverview(int $structureId, int $year, int $week): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT a.id_agent, a.matricule, a.nom, a.prenom,
a.id_structure AS id_structure_principale
FROM agent a
LEFT JOIN planning p
ON p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
LEFT JOIN semaine sem
ON sem.id_semaine = p.id_semaine
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
WHERE a.actif = TRUE
AND (a.id_structure = :structure_id_default OR sem.id_semaine IS NOT NULL)
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'structure_id_planning' => $structureId,
'structure_id_default' => $structureId,
'annee' => $year,
'semaine' => $week,
]);
return $stmt->fetchAll();
}
public function entriesForOverview(int $structureId, int $year, int $week): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_agent, p.statut, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_structure = :structure_id
AND s.annee = :annee
AND s.numero_semaine = :semaine
ORDER BY p.id_agent, c.date_jour, c.heure_debut"
);
$stmt->execute([
'structure_id' => $structureId,
'annee' => $year,
'semaine' => $week,
]);
return $stmt->fetchAll();
}
}

236
app/Models/TeamModel.php Normal file
View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Throwable;
final class TeamModel extends BaseModel
{
public function all(?string $startDate = null, ?string $endDate = null): array
{
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom,
e.type_equipe, e.libelle, e.date_debut, e.date_fin,
e.nombre_enfants_moins_6, e.nombre_enfants_6_plus,
e.ratio_moins_6, e.ratio_6_plus, e.minimum_agents,
e.pourcentage_diplomes_min, e.statut, e.commentaire,
GREATEST(e.minimum_agents,
CEIL(e.nombre_enfants_moins_6 / e.ratio_moins_6)
+ CEIL(e.nombre_enfants_6_plus / e.ratio_6_plus)
) AS agents_requis,
COUNT(em.id_agent) AS agents_affectes,
SUM(CASE WHEN a.est_diplome = TRUE THEN 1 ELSE 0 END) AS agents_diplomes,
SUM(CASE WHEN em.fonction_equipe = 'RESPONSABLE' THEN 1 ELSE 0 END) AS responsables
FROM equipe_pta e
JOIN structure s ON s.id_structure = e.id_structure
LEFT JOIN equipe_pta_membre em ON em.id_equipe = e.id_equipe
LEFT JOIN agent a ON a.id_agent = em.id_agent";
$params = [];
$conditions = [];
if ($startDate !== null) {
$conditions[] = 'e.date_fin >= :date_debut';
$params['date_debut'] = $startDate;
}
if ($endDate !== null) {
$conditions[] = 'e.date_debut <= :date_fin';
$params['date_fin'] = $endDate;
}
if ($conditions !== []) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$sql .= " GROUP BY e.id_equipe, e.id_structure, s.nom, e.type_equipe, e.libelle,
e.date_debut, e.date_fin, e.nombre_enfants_moins_6,
e.nombre_enfants_6_plus, e.ratio_moins_6, e.ratio_6_plus,
e.minimum_agents, e.pourcentage_diplomes_min, e.statut,
e.commentaire
ORDER BY e.date_debut, s.nom, e.libelle";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$teams = $stmt->fetchAll();
foreach ($teams as &$team) {
$team['members'] = $this->members((int) $team['id_equipe']);
$team['controls'] = $this->controls($team, $team['members']);
}
unset($team);
return $teams;
}
public function find(int $teamId): ?array
{
foreach ($this->all() as $team) {
if ((int) $team['id_equipe'] === $teamId) {
return $team;
}
}
return null;
}
public function create(array $data): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO equipe_pta (
id_structure, type_equipe, libelle, date_debut, date_fin,
nombre_enfants_moins_6, nombre_enfants_6_plus,
ratio_moins_6, ratio_6_plus, minimum_agents,
pourcentage_diplomes_min, statut, commentaire
) VALUES (
:id_structure, :type_equipe, :libelle, :date_debut, :date_fin,
:nombre_enfants_moins_6, :nombre_enfants_6_plus,
:ratio_moins_6, :ratio_6_plus, :minimum_agents,
:pourcentage_diplomes_min, 'BROUILLON', :commentaire
)"
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void
{
$team = $this->teamDates($teamId);
if ($team === null) {
throw new \RuntimeException('Équipe introuvable.');
}
$forbidden = $this->pdo->prepare(
"SELECT 1
FROM agent_structure_souhait
WHERE id_agent = :agent_id
AND id_structure = :structure_id
AND type_souhait = 'INTERDICTION'
AND actif = TRUE
LIMIT 1"
);
$forbidden->execute(['agent_id' => $agentId, 'structure_id' => $team['id_structure']]);
if ($forbidden->fetchColumn() !== false) {
throw new \RuntimeException('Cet agent a une interdiction active pour ce lieu.');
}
// Les contraintes médicales et thérapeutiques restent prioritaires, mais
// laffectation nest pas bloquée automatiquement : elles sont signalées
// dans les contrôles de léquipe pour validation humaine.
$stmt = $this->pdo->prepare(
"INSERT INTO equipe_pta_membre (id_equipe, id_agent, fonction_equipe, commentaire)
VALUES (:id_equipe, :id_agent, :fonction_equipe, :commentaire)
ON DUPLICATE KEY UPDATE
fonction_equipe = VALUES(fonction_equipe),
commentaire = VALUES(commentaire)"
);
$stmt->execute([
'id_equipe' => $teamId,
'id_agent' => $agentId,
'fonction_equipe' => $function,
'commentaire' => $comment,
]);
}
public function removeMember(int $teamId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM equipe_pta_membre WHERE id_equipe = :team_id AND id_agent = :agent_id'
);
$stmt->execute(['team_id' => $teamId, 'agent_id' => $agentId]);
return $stmt->rowCount() > 0;
}
public function setStatus(int $teamId, string $status): void
{
$stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id');
$stmt->execute(['statut' => $status, 'id' => $teamId]);
}
private function members(int $teamId): array
{
$stmt = $this->pdo->prepare(
"SELECT em.id_agent, em.fonction_equipe, em.commentaire,
a.matricule, a.nom, a.prenom, a.est_diplome,
a.diplome_libelle, a.adresse, a.id_structure,
po.code AS poste_code, po.libelle AS poste_libelle,
s.nom AS structure_principale_nom,
sw.type_souhait, sw.priorite, sw.distance_km,
(SELECT COUNT(*)
FROM agent_contrainte ac
WHERE ac.id_agent = a.id_agent
AND ac.actif = TRUE
AND ac.date_debut <= e.date_fin
AND COALESCE(ac.date_fin, '9999-12-31') >= e.date_debut) AS contraintes_actives
FROM equipe_pta_membre em
JOIN agent a ON a.id_agent = em.id_agent
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
LEFT JOIN equipe_pta e ON e.id_equipe = em.id_equipe
LEFT JOIN agent_structure_souhait sw
ON sw.id_agent = a.id_agent
AND sw.id_structure = e.id_structure
AND sw.actif = TRUE
WHERE em.id_equipe = :team_id
ORDER BY FIELD(em.fonction_equipe, 'RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN'), a.nom, a.prenom"
);
$stmt->execute(['team_id' => $teamId]);
return $stmt->fetchAll();
}
private function controls(array $team, array $members): array
{
$controls = [];
$required = (int) $team['agents_requis'];
$assigned = (int) $team['agents_affectes'];
if ($assigned < $required) {
$controls[] = [
'niveau' => 'ERREUR',
'code' => 'ENCADREMENT_INSUFFISANT',
'message' => sprintf('%d agent(s) affecté(s) pour %d requis.', $assigned, $required),
];
}
$qualified = (int) $team['agents_diplomes'];
$qualifiedRate = $assigned > 0 ? round(($qualified / $assigned) * 100, 2) : 0;
if ($qualifiedRate < (float) $team['pourcentage_diplomes_min']) {
$controls[] = [
'niveau' => 'ERREUR',
'code' => 'QUALIFICATION_INSUFFISANTE',
'message' => sprintf('%.2f %% dagents diplômés pour un minimum paramétré à %.2f %%.', $qualifiedRate, (float) $team['pourcentage_diplomes_min']),
];
}
if ((int) $team['responsables'] < 1) {
$controls[] = [
'niveau' => 'ALERTE',
'code' => 'RESPONSABLE_MANQUANT',
'message' => 'Aucun responsable nest identifié dans léquipe.',
];
}
foreach ($members as $member) {
if ((int) ($member['contraintes_actives'] ?? 0) > 0) {
$controls[] = [
'niveau' => 'ALERTE',
'code' => 'CONTRAINTE_PRIORITAIRE',
'message' => sprintf('%s %s possède une préconisation médicale ou un temps partiel thérapeutique actif sur la période.', $member['prenom'], $member['nom']),
];
}
if (($member['type_souhait'] ?? null) === 'PREFERENCE') {
$controls[] = [
'niveau' => 'INFO',
'code' => 'PREFERENCE_RESPECTEE',
'message' => sprintf('%s %s a exprimé une préférence pour ce lieu.', $member['prenom'], $member['nom']),
];
}
}
if ($controls === []) {
$controls[] = ['niveau' => 'INFO', 'code' => 'OK', 'message' => 'Équipe conforme aux paramètres enregistrés.'];
}
return $controls;
}
private function teamDates(int $teamId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id_structure, date_debut, date_fin FROM equipe_pta WHERE id_equipe = :id LIMIT 1'
);
$stmt->execute(['id' => $teamId]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
final class VacationModel extends BaseModel
{
public function all(?string $schoolYear = null): array
{
$sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire,
academie, zone, source, date_creation
FROM periode_vacance
WHERE actif = TRUE";
$params = [];
if ($schoolYear !== null && $schoolYear !== '') {
$sql .= ' AND annee_scolaire = :annee_scolaire';
$params['annee_scolaire'] = $schoolYear;
}
$sql .= ' ORDER BY date_debut, libelle';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function saveMany(array $periods): int
{
if ($periods === []) {
return 0;
}
$sql = "INSERT INTO periode_vacance
(libelle, date_debut, date_fin, annee_scolaire, academie, zone, source, actif)
VALUES
(:libelle, :date_debut, :date_fin, :annee_scolaire, :academie, :zone, :source, TRUE)
ON DUPLICATE KEY UPDATE
libelle = VALUES(libelle),
zone = VALUES(zone),
source = VALUES(source),
actif = TRUE";
$stmt = $this->pdo->prepare($sql);
$this->pdo->beginTransaction();
try {
foreach ($periods as $period) {
$stmt->execute([
'libelle' => $period['libelle'],
'date_debut' => $period['date_debut'],
'date_fin' => $period['date_fin'],
'annee_scolaire' => $period['annee_scolaire'],
'academie' => $period['academie'],
'zone' => $period['zone'],
'source' => $period['source'],
]);
}
$this->pdo->commit();
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
return count($periods);
}
public function delete(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM periode_vacance WHERE id_periode = :id');
$stmt->execute(['id' => $id]);
return $stmt->rowCount() > 0;
}
public function periodsBetween(string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire, academie, zone, source
FROM periode_vacance
WHERE actif = TRUE
AND date_debut <= :date_fin
AND date_fin >= :date_debut
ORDER BY date_debut"
);
$stmt->execute([
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
/**
* Retourne le type de période pour les cinq jours ouvrés d'une semaine ISO.
* Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE,
* sinon elle est PERISCOLAIRE.
*/
public function typesForIsoWeek(int $year, int $week): array
{
$monday = (new DateTimeImmutable())->setISODate($year, $week, 1)->setTime(0, 0);
$friday = $monday->modify('+4 days');
$periods = $this->periodsBetween($monday->format('Y-m-d'), $friday->format('Y-m-d'));
$days = [];
for ($offset = 0; $offset < 5; $offset++) {
$date = $monday->modify('+' . $offset . ' days')->format('Y-m-d');
$matchingPeriod = null;
foreach ($periods as $period) {
if ($date >= $period['date_debut'] && $date <= $period['date_fin']) {
$matchingPeriod = $period;
break;
}
}
$days[] = [
'date' => $date,
'type_affectation' => $matchingPeriod ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE',
'vacances' => $matchingPeriod ? [
'id_periode' => (int) $matchingPeriod['id_periode'],
'libelle' => (string) $matchingPeriod['libelle'],
'annee_scolaire' => (string) $matchingPeriod['annee_scolaire'],
] : null,
];
}
return $days;
}
}

View File

@@ -0,0 +1,861 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use DomainException;
use Throwable;
final class WeekTemplateModel extends BaseModel
{
public function listForAgent(int $agentId): array
{
$stmt = $this->pdo->prepare(
"SELECT m.id_modele, m.nom, m.date_creation, m.date_modification,
COUNT(c.id_modele_creneau) AS nombre_creneaux,
COUNT(DISTINCT c.id_structure) AS nombre_lieux
FROM modele_semaine_agent m
LEFT JOIN modele_semaine_creneau c ON c.id_modele = m.id_modele
WHERE m.id_agent = :agent_id
GROUP BY m.id_modele, m.nom, m.date_creation, m.date_modification
ORDER BY m.nom"
);
$stmt->execute(['agent_id' => $agentId]);
return $stmt->fetchAll();
}
public function findForAgent(int $templateId, int $agentId): ?array
{
$stmt = $this->pdo->prepare(
"SELECT id_modele, id_agent, nom, date_creation, date_modification
FROM modele_semaine_agent
WHERE id_modele = :template_id AND id_agent = :agent_id
LIMIT 1"
);
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
return $stmt->fetch() ?: null;
}
public function entries(int $templateId): array
{
$stmt = $this->pdo->prepare(
"SELECT c.id_modele_creneau, c.jour_semaine, c.id_structure, c.id_motif,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
s.nom AS structure_nom,
s.type_affectation AS structure_type_affectation,
s.actif AS structure_actif,
m.libelle AS motif_libelle,
m.compte_dans_quota,
m.actif AS motif_actif
FROM modele_semaine_creneau c
INNER JOIN structure s ON s.id_structure = c.id_structure
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE c.id_modele = :template_id
ORDER BY c.jour_semaine, c.heure_debut, c.heure_fin, s.nom"
);
$stmt->execute(['template_id' => $templateId]);
return $stmt->fetchAll();
}
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
{
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
if ($name === '' || strlen($name) > 240) {
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
}
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
if (!$sourceEntries) {
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
}
try {
$this->pdo->beginTransaction();
$existingStmt = $this->pdo->prepare(
'SELECT id_modele FROM modele_semaine_agent WHERE id_agent = :agent_id AND nom = :nom FOR UPDATE'
);
$existingStmt->execute(['agent_id' => $agentId, 'nom' => $name]);
$existingId = (int) ($existingStmt->fetchColumn() ?: 0);
if ($existingId > 0 && !$replaceExisting) {
throw new DomainException('Une semaine type porte déjà ce nom pour cet agent.');
}
if ($existingId > 0) {
$templateId = $existingId;
$delete = $this->pdo->prepare('DELETE FROM modele_semaine_creneau WHERE id_modele = :template_id');
$delete->execute(['template_id' => $templateId]);
$touch = $this->pdo->prepare('UPDATE modele_semaine_agent SET date_modification = CURRENT_TIMESTAMP WHERE id_modele = :template_id');
$touch->execute(['template_id' => $templateId]);
} else {
$insertTemplate = $this->pdo->prepare(
'INSERT INTO modele_semaine_agent (id_agent, nom) VALUES (:agent_id, :nom)'
);
$insertTemplate->execute(['agent_id' => $agentId, 'nom' => $name]);
$templateId = (int) $this->pdo->lastInsertId();
}
$insertEntry = $this->pdo->prepare(
"INSERT INTO modele_semaine_creneau
(id_modele, jour_semaine, id_structure, id_motif, heure_debut, heure_fin)
VALUES
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
);
foreach ($sourceEntries as $entry) {
$insertEntry->execute([
'template_id' => $templateId,
'weekday' => (int) $entry['jour_semaine'],
'structure_id' => (int) $entry['id_structure'],
'motif_id' => (int) $entry['id_motif'],
'start' => $entry['heure_debut'],
'end' => $entry['heure_fin'],
]);
}
$this->pdo->commit();
return [
'id_modele' => $templateId,
'nom' => $name,
'nombre_creneaux' => count($sourceEntries),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function applyToWeek(int $templateId, int $agentId, int $year, int $weekNumber, string $mode): array
{
if (!in_array($mode, ['merge', 'replace'], true)) {
throw new DomainException('Mode dimport invalide.');
}
$template = $this->findForAgent($templateId, $agentId);
if ($template === null) {
throw new DomainException('Semaine type introuvable pour cet agent.');
}
$templateEntries = $this->entries($templateId);
if (!$templateEntries) {
throw new DomainException('Cette semaine type ne contient aucun créneau.');
}
foreach ($templateEntries as $entry) {
if (!(bool) $entry['structure_actif']) {
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
}
if (!(bool) $entry['motif_actif']) {
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
}
}
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+6 days');
$targetEntries = array_map(static function (array $entry) use ($weekStart): array {
$date = $weekStart->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d');
return [
'date' => $date,
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
'heure_debut' => $entry['heure_debut'],
'heure_fin' => $entry['heure_fin'],
'structure_nom' => $entry['structure_nom'],
];
}, $templateEntries);
$this->assertTemplateNoOverlap($targetEntries);
try {
$this->pdo->beginTransaction();
$weekId = $this->ensureWeek($year, $weekNumber, $weekStart, $weekEnd);
$existingPlanningsStmt = $this->pdo->prepare(
"SELECT id_planning, id_structure, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id
FOR UPDATE"
);
$existingPlanningsStmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
$existingPlannings = $existingPlanningsStmt->fetchAll();
$reopened = [];
if ($mode === 'replace') {
foreach ($existingPlannings as $planning) {
if ($planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
}
if ($existingPlannings) {
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
$delete->execute($ids);
$reopen = $this->pdo->prepare("UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)");
$reopen->execute($ids);
}
} else {
$this->assertNoExistingOverlap($agentId, $weekId, $targetEntries);
}
$planningByStructure = [];
foreach ($existingPlannings as $planning) {
$planningByStructure[(int) $planning['id_structure']] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
}
$selectPlanning = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
FOR UPDATE"
);
$insertPlanning = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
);
$reopenPlanning = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
);
$insertEntry = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
);
$inserted = 0;
foreach ($targetEntries as $entry) {
$structureId = (int) $entry['id_structure'];
if (!isset($planningByStructure[$structureId])) {
$selectPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $selectPlanning->fetch();
if ($planning) {
$planningByStructure[$structureId] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
} else {
$insertPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planningByStructure[$structureId] = [
'id_planning' => (int) $this->pdo->lastInsertId(),
'statut' => 'BROUILLON',
];
}
}
$planningInfo = &$planningByStructure[$structureId];
if ($planningInfo['statut'] === 'VALIDE') {
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
$reopened[] = $planningInfo['id_planning'];
$planningInfo['statut'] = 'BROUILLON';
}
$insertEntry->execute([
'planning_id' => $planningInfo['id_planning'],
'motif_id' => $entry['id_motif'],
'date_jour' => $entry['date'],
'start' => $entry['heure_debut'],
'end' => $entry['heure_fin'],
]);
$inserted++;
unset($planningInfo);
}
$this->pdo->commit();
return [
'template' => $template,
'inserted' => $inserted,
'mode' => $mode,
'reopened_planning_ids' => array_values(array_unique($reopened)),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function previewPeriod(
int $templateId,
int $agentId,
string $startDate,
string $endDate,
string $mode,
string $periodFilter = 'all'
): array {
return $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, true);
}
public function applyToPeriod(
int $templateId,
int $agentId,
string $startDate,
string $endDate,
string $mode,
string $periodFilter = 'all'
): array {
$plan = $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, false);
$targetEntries = $plan['target_entries'];
if ($targetEntries === []) {
throw new DomainException(
'Aucun créneau de cette semaine type ne correspond au filtre de calendrier sélectionné sur cette période. Vérifiez les dates et les vacances enregistrées dans PTA.'
);
}
$targetDates = array_values(array_unique(array_column($targetEntries, 'date')));
$targetDateLookup = array_fill_keys($targetDates, true);
$reopened = [];
$deleted = 0;
try {
$this->pdo->beginTransaction();
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate, true);
if ($mode === 'merge') {
$conflicts = $this->findConflicts($targetEntries, $existing);
if ($conflicts !== []) {
$first = $conflicts[0];
throw new DomainException(sprintf(
'Application impossible en mode « Compléter » : %d conflit%s détecté%s. Premier conflit le %s entre %s-%s et un créneau existant %s-%s à « %s ». Utilisez laperçu puis le mode « Remplacer les jours concernés » ou corrigez le planning existant.',
count($conflicts),
count($conflicts) > 1 ? 's' : '',
count($conflicts) > 1 ? 's' : '',
$first['date'],
substr($first['template_start'], 0, 5),
substr($first['template_end'], 0, 5),
substr($first['existing_start'], 0, 5),
substr($first['existing_end'], 0, 5),
$first['existing_structure']
));
}
} else {
$entriesToDelete = array_values(array_filter(
$existing,
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
));
if ($entriesToDelete !== []) {
$entryIds = array_map(static fn(array $entry): int => (int) $entry['id_creneau'], $entriesToDelete);
$planningIds = [];
foreach ($entriesToDelete as $entry) {
if ((string) $entry['statut'] === 'VALIDE') {
$planningIds[(int) $entry['id_planning']] = true;
}
}
$placeholders = implode(',', array_fill(0, count($entryIds), '?'));
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_creneau IN ($placeholders)");
$delete->execute($entryIds);
$deleted = count($entryIds);
if ($planningIds !== []) {
$ids = array_keys($planningIds);
$planningPlaceholders = implode(',', array_fill(0, count($ids), '?'));
$reopen = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($planningPlaceholders)"
);
$reopen->execute($ids);
$reopened = array_merge($reopened, $ids);
}
}
}
$weekIds = [];
$planningCache = [];
$selectPlanning = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
FOR UPDATE"
);
$insertPlanning = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
);
$reopenPlanning = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
);
$insertEntry = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
);
$inserted = 0;
foreach ($targetEntries as $entry) {
$date = new DateTimeImmutable($entry['date']);
$isoYear = (int) $date->format('o');
$weekNumber = (int) $date->format('W');
$weekKey = sprintf('%04d-W%02d', $isoYear, $weekNumber);
if (!isset($weekIds[$weekKey])) {
$weekStart = (new DateTimeImmutable())->setISODate($isoYear, $weekNumber, 1)->setTime(0, 0);
$weekIds[$weekKey] = $this->ensureWeek(
$isoYear,
$weekNumber,
$weekStart,
$weekStart->modify('+6 days')
);
}
$weekId = $weekIds[$weekKey];
$structureId = (int) $entry['id_structure'];
$planningKey = $weekId . ':' . $structureId;
if (!isset($planningCache[$planningKey])) {
$selectPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $selectPlanning->fetch();
if ($planning) {
$planningCache[$planningKey] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
} else {
$insertPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planningCache[$planningKey] = [
'id_planning' => (int) $this->pdo->lastInsertId(),
'statut' => 'BROUILLON',
];
}
}
$planningInfo = &$planningCache[$planningKey];
if ($planningInfo['statut'] === 'VALIDE') {
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
$reopened[] = $planningInfo['id_planning'];
$planningInfo['statut'] = 'BROUILLON';
}
$insertEntry->execute([
'planning_id' => $planningInfo['id_planning'],
'motif_id' => (int) $entry['id_motif'],
'date_jour' => $entry['date'],
'start' => $entry['heure_debut'],
'end' => $entry['heure_fin'],
]);
$inserted++;
unset($planningInfo);
}
$this->pdo->commit();
return [
'template' => $plan['template'],
'inserted' => $inserted,
'deleted' => $deleted,
'mode' => $mode,
'period_filter' => $plan['period_filter'],
'start_date' => $startDate,
'end_date' => $endDate,
'weeks_touched' => count($weekIds),
'days_touched' => count($targetDates),
'skipped_count' => $plan['summary']['skipped_count'],
'skipped_vacation_count' => $plan['summary']['skipped_vacation_count'],
'skipped_school_count' => $plan['summary']['skipped_school_count'],
'reopened_planning_ids' => array_values(array_unique(array_map('intval', $reopened))),
'years' => $plan['summary']['years'],
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function delete(int $templateId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM modele_semaine_agent WHERE id_modele = :template_id AND id_agent = :agent_id'
);
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
return $stmt->rowCount() === 1;
}
private function sourceWeekEntries(int $agentId, int $year, int $weekNumber): array
{
$stmt = $this->pdo->prepare(
"SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine,
p.id_structure, c.id_motif,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND s.annee = :annee
AND s.numero_semaine = :week_number
AND c.date_jour BETWEEN s.date_debut AND s.date_fin
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
ORDER BY c.date_jour, c.heure_debut, p.id_structure"
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'week_number' => $weekNumber,
]);
return $stmt->fetchAll();
}
private function buildPeriodPlan(
int $templateId,
int $agentId,
string $startDate,
string $endDate,
string $mode,
string $periodFilter,
bool $includeExistingAnalysis
): array {
if (!in_array($mode, ['merge', 'replace'], true)) {
throw new DomainException('Mode dapplication invalide.');
}
if (!in_array($periodFilter, ['all', 'school', 'extra'], true)) {
throw new DomainException('Filtre de calendrier invalide.');
}
$start = $this->parseStrictDate($startDate, 'date de début');
$end = $this->parseStrictDate($endDate, 'date de fin');
if ($end < $start) {
throw new DomainException('La date de fin doit être postérieure ou égale à la date de début.');
}
if ((int) $start->diff($end)->days > 730) {
throw new DomainException('La période ne peut pas dépasser deux ans.');
}
$template = $this->findForAgent($templateId, $agentId);
if ($template === null) {
throw new DomainException('Semaine type introuvable pour cet agent.');
}
$templateEntries = $this->entries($templateId);
if ($templateEntries === []) {
throw new DomainException('Cette semaine type ne contient aucun créneau.');
}
foreach ($templateEntries as $entry) {
if (!(bool) $entry['structure_actif']) {
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
}
if (!(bool) $entry['motif_actif']) {
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
}
}
$entriesByWeekday = [];
foreach ($templateEntries as $entry) {
$entriesByWeekday[(int) $entry['jour_semaine']][] = $entry;
}
$vacationPeriods = (new VacationModel($this->pdo))->periodsBetween($startDate, $endDate);
$targetEntries = [];
$skippedEntries = [];
$days = [];
$weeks = [];
$years = [];
for ($date = $start; $date <= $end; $date = $date->modify('+1 day')) {
$weekday = (int) $date->format('N');
if ($weekday > 5) {
continue;
}
$dateValue = $date->format('Y-m-d');
$vacation = $this->vacationForDate($dateValue, $vacationPeriods);
$periodType = $vacation ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE';
$dayEntries = $entriesByWeekday[$weekday] ?? [];
$eligibleCount = 0;
$skippedCount = 0;
foreach ($dayEntries as $entry) {
$dateIsEligible = match ($periodFilter) {
'school' => $periodType === 'PERISCOLAIRE',
'extra' => $periodType === 'EXTRASCOLAIRE',
default => true,
};
if (!$dateIsEligible) {
$skippedCount++;
$skippedEntries[] = [
'date' => $dateValue,
'reason' => $periodType === 'EXTRASCOLAIRE' ? 'VACANCES' : 'HORS_VACANCES',
'period_type' => $periodType,
'vacation_label' => $vacation['libelle'] ?? null,
'structure_name' => (string) $entry['structure_nom'],
'start' => (string) $entry['heure_debut'],
'end' => (string) $entry['heure_fin'],
];
continue;
}
$eligibleCount++;
$targetEntries[] = [
'date' => $dateValue,
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
'heure_debut' => (string) $entry['heure_debut'],
'heure_fin' => (string) $entry['heure_fin'],
'structure_nom' => (string) $entry['structure_nom'],
'motif_libelle' => (string) $entry['motif_libelle'],
'period_type' => $periodType,
'vacation_label' => $vacation['libelle'] ?? null,
];
$weeks[$date->format('o-W')] = true;
$years[(int) $date->format('Y')] = true;
}
if ($dayEntries !== []) {
$days[$dateValue] = [
'date' => $dateValue,
'weekday' => $weekday,
'period_type' => $periodType,
'vacation_label' => $vacation['libelle'] ?? null,
'template_entry_count' => count($dayEntries),
'eligible_count' => $eligibleCount,
'skipped_count' => $skippedCount,
'conflict_count' => 0,
];
}
}
$this->assertTemplateNoOverlap($targetEntries);
$existing = [];
$conflicts = [];
$existingOnTargetDates = [];
if ($includeExistingAnalysis && $targetEntries !== []) {
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate);
$conflicts = $this->findConflicts($targetEntries, $existing);
$targetDateLookup = array_fill_keys(array_unique(array_column($targetEntries, 'date')), true);
$existingOnTargetDates = array_values(array_filter(
$existing,
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
));
foreach ($conflicts as $conflict) {
if (isset($days[$conflict['date']])) {
$days[$conflict['date']]['conflict_count']++;
}
}
}
$skippedVacationCount = count(array_filter(
$skippedEntries,
static fn(array $entry): bool => $entry['reason'] === 'VACANCES'
));
$skippedSchoolCount = count($skippedEntries) - $skippedVacationCount;
return [
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
],
'start_date' => $startDate,
'end_date' => $endDate,
'mode' => $mode,
'period_filter' => $periodFilter,
'target_entries' => $targetEntries,
'skipped_entries' => $skippedEntries,
'conflicts' => $conflicts,
'days' => array_values($days),
'vacation_periods' => array_map(static fn(array $period): array => [
'id' => (int) $period['id_periode'],
'label' => (string) $period['libelle'],
'start_date' => (string) $period['date_debut'],
'end_date' => (string) $period['date_fin'],
'school_year' => (string) $period['annee_scolaire'],
], $vacationPeriods),
'summary' => [
'entry_count' => count($targetEntries),
'target_day_count' => count(array_unique(array_column($targetEntries, 'date'))),
'week_count' => count($weeks),
'skipped_count' => count($skippedEntries),
'skipped_vacation_count' => $skippedVacationCount,
'skipped_school_count' => $skippedSchoolCount,
'conflict_count' => count($conflicts),
'existing_entry_count_on_target_dates' => count($existingOnTargetDates),
'years' => array_values(array_map('intval', array_keys($years))),
'vacation_period_count' => count($vacationPeriods),
],
];
}
private function parseStrictDate(string $value, string $label): DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = DateTimeImmutable::getLastErrors();
$hasErrors = is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0);
if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) {
throw new DomainException(sprintf('La %s est invalide.', $label));
}
return $date;
}
private function vacationForDate(string $date, array $periods): ?array
{
foreach ($periods as $period) {
if ($date >= $period['date_debut'] && $date <= $period['date_fin']) {
return $period;
}
}
return null;
}
private function existingEntriesBetween(int $agentId, string $startDate, string $endDate, bool $forUpdate = false): array
{
$sql = "SELECT c.id_creneau, c.id_planning, p.statut, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
s.nom AS structure_nom
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN structure s ON s.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :start_date AND :end_date
ORDER BY c.date_jour, c.heure_debut";
if ($forUpdate) {
$sql .= ' FOR UPDATE';
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
'agent_id' => $agentId,
'start_date' => $startDate,
'end_date' => $endDate,
]);
return $stmt->fetchAll();
}
private function findConflicts(array $targetEntries, array $existingEntries): array
{
$conflicts = [];
foreach ($targetEntries as $target) {
foreach ($existingEntries as $existing) {
if ($target['date'] !== $existing['date_jour']) {
continue;
}
if ($target['heure_debut'] < $existing['heure_fin'] && $target['heure_fin'] > $existing['heure_debut']) {
$conflicts[] = [
'date' => $target['date'],
'template_start' => $target['heure_debut'],
'template_end' => $target['heure_fin'],
'template_structure' => $target['structure_nom'],
'existing_entry_id' => (int) $existing['id_creneau'],
'existing_start' => $existing['heure_debut'],
'existing_end' => $existing['heure_fin'],
'existing_structure' => $existing['structure_nom'],
];
}
}
}
return $conflicts;
}
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
);
$stmt->execute([
'annee' => $year,
'numero' => $weekNumber,
'date_debut' => $weekStart->format('Y-m-d'),
'date_fin' => $weekEnd->format('Y-m-d'),
]);
$weekId = (int) $this->pdo->lastInsertId();
if ($weekId > 0) {
return $weekId;
}
$find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero');
$find->execute(['annee' => $year, 'numero' => $weekNumber]);
$weekId = (int) $find->fetchColumn();
if ($weekId <= 0) {
throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.');
}
return $weekId;
}
private function assertTemplateNoOverlap(array $entries): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$first = $entries[$i];
$second = $entries[$j];
if ($first['date'] === $second['date']
&& $first['heure_debut'] < $second['heure_fin']
&& $first['heure_fin'] > $second['heure_debut']) {
throw new DomainException(sprintf(
'La semaine type contient un chevauchement le %s entre %s-%s (%s) et %s-%s (%s).',
$first['date'],
substr($first['heure_debut'], 0, 5),
substr($first['heure_fin'], 0, 5),
$first['structure_nom'],
substr($second['heure_debut'], 0, 5),
substr($second['heure_fin'], 0, 5),
$second['structure_nom']
));
}
}
}
}
private function assertNoExistingOverlap(int $agentId, int $weekId, array $entries): void
{
$stmt = $this->pdo->prepare(
"SELECT c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
s.nom AS structure_nom
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN structure s ON s.id_structure = p.id_structure
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
ORDER BY c.date_jour, c.heure_debut"
);
$stmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
$existing = $stmt->fetchAll();
foreach ($entries as $entry) {
foreach ($existing as $current) {
if ($entry['date'] === $current['date_jour']
&& $entry['heure_debut'] < $current['heure_fin']
&& $entry['heure_fin'] > $current['heure_debut']) {
throw new DomainException(sprintf(
'Import impossible en mode « compléter » : le modèle %s-%s chevauche un créneau existant %s-%s à « %s » le %s.',
substr($entry['heure_debut'], 0, 5),
substr($entry['heure_fin'], 0, 5),
substr($current['heure_debut'], 0, 5),
substr($current['heure_fin'], 0, 5),
$current['structure_nom'],
$entry['date']
));
}
}
}
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
/**
* Génère le document annuel de validation du quota horaire d'un agent.
* Le PDF est autonome et ne nécessite aucune dépendance Composer.
*/
final class AgentHoursContractPdfService
{
private const PAGE_WIDTH = 595.28;
private const PAGE_HEIGHT = 841.89;
private const MARGIN = 42.0;
public function annualContract(array $agent, array $quota): string
{
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
$this->drawHeader($pdf, $agent, $periodLabel, $generatedAt);
$this->drawAgentIdentity($pdf, $agent);
$this->drawQuotaSummary($pdf, $quota);
$this->drawValidationClause($pdf, $periodLabel, $generatedAt);
$this->drawSignatures($pdf);
$pdf->text(
self::MARGIN,
813,
sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')),
7
);
return $pdf->output();
}
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
{
$pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true);
$pdf->text(self::MARGIN, 71, 'Validation du quota annuel de travail', 12, true);
$pdf->text(self::MARGIN, 92, 'Période de référence : ' . $periodLabel, 10);
$pdf->text(self::MARGIN + 315, 92, 'Édité le : ' . $generatedAt->format('d/m/Y'), 10);
$pdf->line(self::MARGIN, 108, self::PAGE_WIDTH - self::MARGIN, 108, 0.35);
$pdf->text(
self::MARGIN,
130,
sprintf('%s %s', (string) ($agent['prenom'] ?? ''), (string) ($agent['nom'] ?? '')),
14,
true
);
}
private function drawAgentIdentity(SimplePdf $pdf, array $agent): void
{
$x = self::MARGIN;
$y = 150.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$h = 140.0;
$pdf->fillRect($x, $y, $w, 28, 0.93);
$pdf->rect($x, $y, $w, $h, 0.65);
$pdf->text($x + 12, $y + 19, 'Informations de l\'agent', 11, true);
$leftX = $x + 14;
$rightX = $x + 278;
$row1 = $y + 52;
$row2 = $y + 78;
$row3 = $y + 102;
$row4 = $y + 128;
$qualification = !empty($agent['est_diplome'])
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' - ' . $agent['diplome_libelle'] : '')
: 'Non diplômé';
$pdf->text($leftX, $row1, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9);
$pdf->text($rightX, $row1, 'E-mail : ' . (($agent['email'] ?? '') ?: 'Non renseigné'), 9);
$pdf->text($leftX, $row2, 'Téléphone : ' . (($agent['telephone'] ?? '') ?: 'Non renseigné'), 9);
$pdf->text($rightX, $row2, 'Qualification : ' . $qualification, 9);
$pdf->text($leftX, $row3, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9);
$pdf->text($rightX, $row3, 'Début du contrat : ' . (!empty($agent['date_debut_contrat']) ? (new DateTimeImmutable((string) $agent['date_debut_contrat']))->format('d/m/Y') : 'Non renseigné'), 9);
$address = (string) (($agent['adresse'] ?? '') ?: 'Non renseignée');
$addressLines = $pdf->wrap('Adresse : ' . $address, $w - 28, 8.5);
$pdf->text($leftX, $row4, (string) ($addressLines[0] ?? 'Adresse : Non renseignée'), 8.5);
$pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5);
}
private function drawQuotaSummary(SimplePdf $pdf, array $quota): void
{
$x = self::MARGIN;
$y = 318.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$headerH = 30.0;
$rowH = 31.0;
$rows = [
['Quota annuel de référence à 100 %', $this->minutesLabel((int) ($quota['quota_reference_minutes'] ?? 0))],
['Quotité de travail applicable', $this->percentLabel((float) ($quota['quotite_travail'] ?? 100))],
['Quota annuel cible de l\'agent', (string) ($quota['quota_cible']['libelle'] ?? $this->minutesLabel((int) ($quota['quota_cible_minutes'] ?? 0)))],
['Heures validées dans PTA à la date d\'édition', (string) ($quota['valides']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_valides'] ?? 0)))],
['Heures encore en brouillon', (string) ($quota['brouillons']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_brouillon'] ?? 0)))],
['Solde restant à planifier / affecter', (string) ($quota['restantes']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_restantes'] ?? 0)))],
];
$pdf->fillRect($x, $y, $w, $headerH, 0.90);
$pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.60);
$pdf->text($x + 12, $y + 20, 'Synthèse du quota horaire', 11, true);
$labelWidth = 360.0;
$valueWidth = $w - $labelWidth;
$cursorY = $y + $headerH;
foreach ($rows as $index => [$label, $value]) {
if ($index % 2 === 1) {
$pdf->fillRect($x, $cursorY, $w, $rowH, 0.975);
}
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.82);
$pdf->line($x + $labelWidth, $cursorY, $x + $labelWidth, $cursorY + $rowH, 0.82);
$pdf->text($x + 12, $cursorY + 20, (string) $label, 8.6, $index === 2);
$pdf->text($x + $labelWidth + 12, $cursorY + 20, (string) $value, 9.2, $index === 2);
$cursorY += $rowH;
}
}
private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void
{
$x = self::MARGIN;
$y = 557.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$pdf->text($x, $y, 'Validation', 11, true);
$paragraphs = [
sprintf(
'Le présent document fixe et récapitule le quota annuel de travail applicable à l\'agent pour la période %s. La signature de l\'agent et la contre-signature du responsable valent validation du quota annuel indiqué ci-dessus.',
$periodLabel
),
'Les heures validées et les heures en brouillon constituent un état de suivi à la date d\'édition. Toute modification ultérieure du planning doit faire l\'objet d\'une nouvelle validation dans PTA lorsque cela est nécessaire.',
];
$cursorY = $y + 22;
foreach ($paragraphs as $paragraph) {
foreach ($pdf->wrap($paragraph, $w, 9) as $line) {
$pdf->text($x, $cursorY, $line, 9);
$cursorY += 13;
}
$cursorY += 8;
}
$pdf->text($x, 638, 'Fait à : ____________________________________', 9);
$pdf->text($x + 286, 638, 'Le : ____ / ____ / ________', 9);
$pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8);
}
private function drawSignatures(SimplePdf $pdf): void
{
$x = self::MARGIN;
$y = 680.0;
$gap = 18.0;
$w = (self::PAGE_WIDTH - (self::MARGIN * 2) - $gap) / 2;
$h = 120.0;
$pdf->rect($x, $y, $w, $h, 0.55);
$pdf->rect($x + $w + $gap, $y, $w, $h, 0.55);
$pdf->fillRect($x, $y, $w, 28, 0.94);
$pdf->fillRect($x + $w + $gap, $y, $w, 28, 0.94);
$pdf->text($x + 10, $y + 19, 'Signature de l\'agent', 10, true);
$pdf->text($x + $w + $gap + 10, $y + 19, 'Contre-signature du responsable', 10, true);
$pdf->text($x + 10, $y + 48, 'Nom : ______________________________', 8.5);
$pdf->text($x + 10, $y + 68, 'Mention « Lu et approuvé » :', 8.5);
$pdf->text($x + 10, $y + 101, 'Signature :', 8.5);
$rightX = $x + $w + $gap + 10;
$pdf->text($rightX, $y + 48, 'Nom / qualité : _____________________', 8.5);
$pdf->text($rightX, $y + 68, 'Mention « Bon pour validation » :', 8.5);
$pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5);
}
private function typeLabel(string $type): string
{
return match (strtoupper($type)) {
'EXTRASCOLAIRE' => 'Extrascolaire',
'PERISCOLAIRE' => 'Périscolaire',
default => 'Non renseigné',
};
}
private function percentLabel(float $rate): string
{
$formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ',');
return $formatted . ' %';
}
private function minutesLabel(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$absolute = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
/**
* Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif.
*/
final class AgentTimeSummaryPdfService
{
private const PAGE_WIDTH = 595.28;
private const PAGE_HEIGHT = 841.89;
private const MARGIN = 42.0;
public function annualSummary(array $agent, array $quota, array $rows): string
{
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
$totals = $this->normalizeTotals($rows);
$this->drawHeader($pdf, $agent, $periodLabel, $generatedAt);
$this->drawIdentity($pdf, $agent);
$this->drawBreakdown($pdf, $totals);
$this->drawResult($pdf, $quota, $totals);
$this->drawFooter($pdf, $agent, $generatedAt);
return $pdf->output();
}
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
{
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
$pdf->text(self::MARGIN, 58, 'Compte-rendu du temps de l\'agent', 17, true);
$pdf->text(self::MARGIN, 79, 'Synthèse des heures planifiées et décomptées pour la période ' . $periodLabel, 9.5);
$pdf->text(self::PAGE_WIDTH - 190, 34, 'Édité le ' . $generatedAt->format('d/m/Y'), 8);
$pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72);
}
private function drawIdentity(SimplePdf $pdf, array $agent): void
{
$x = self::MARGIN;
$y = 118.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$h = 90.0;
$pdf->fillRect($x, $y, $w, 28, 0.93);
$pdf->rect($x, $y, $w, $h, 0.68);
$pdf->text($x + 12, $y + 19, 'Agent', 10.5, true);
$pdf->text($x + 12, $y + 50, 'Nom : ' . (($agent['nom'] ?? '') ?: '-'), 9.2);
$pdf->text($x + 270, $y + 50, 'Prénom : ' . (($agent['prenom'] ?? '') ?: '-'), 9.2);
$pdf->text($x + 12, $y + 73, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9.2);
$pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2);
}
private function drawBreakdown(SimplePdf $pdf, array $totals): void
{
$x = self::MARGIN;
$y = 232.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$headerH = 31.0;
$rowH = 43.0;
$rows = [
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']],
['MALADIE', 'Absence', $totals['MALADIE']],
['FORMATION', 'Formation', $totals['FORMATION']],
['CONGE', 'Congé', $totals['CONGE']],
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']],
];
$pdf->fillRect($x, $y, $w, $headerH, 0.92);
$pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.65);
$pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
$cursorY = $y + $headerH;
foreach ($rows as [$code, $label, $minutes]) {
$palette = $this->palette($code);
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true);
$cursorY += $rowH;
}
}
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
{
$counted = $totals['TRAVAIL'] + $totals['MALADIE'] + $totals['FORMATION'] + $totals['CONGE'] + $totals['OTHER_COUNTED'];
$target = (int) ($quota['quota_cible_minutes'] ?? 0);
$remaining = $target - $counted;
$validated = (int) ($quota['minutes_valides'] ?? 0);
$draft = (int) ($quota['minutes_brouillon'] ?? 0);
$x = self::MARGIN;
$y = 507.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$pdf->line($x, $y, $x + $w, $y, 0.55);
$pdf->text($x, $y + 32, 'Résultat - heures décomptées', 12, true);
$pdf->text($x + $w - 150, $y + 32, $this->minutesLabel($counted), 13, true);
$pdf->text($x, $y + 55, sprintf('Dont %s validées et %s en brouillon.', $this->minutesLabel($validated), $this->minutesLabel($draft)), 8.5);
$boxY = $y + 82;
$boxH = 126.0;
$pdf->fillRect($x, $boxY, $w, $boxH, 0.965);
$pdf->rect($x, $boxY, $w, $boxH, 0.65);
$pdf->text($x + 14, $boxY + 27, 'Quota à effectuer sur la période', 10, true);
$pdf->text($x + $w - 160, $boxY + 27, $this->minutesLabel($target), 11, true);
$pdf->line($x + 12, $boxY + 43, $x + $w - 12, $boxY + 43, 0.85);
$pdf->text($x + 14, $boxY + 72, 'Heures décomptées à ce jour', 10, true);
$pdf->text($x + $w - 160, $boxY + 72, $this->minutesLabel($counted), 11, true);
$pdf->line($x + 12, $boxY + 88, $x + $w - 12, $boxY + 88, 0.85);
$pdf->text($x + 14, $boxY + 114, $remaining >= 0 ? 'Heures restant à effectuer' : 'Dépassement du quota', 10.5, true);
$pdf->textColor($x + $w - 160, $boxY + 114, $this->minutesLabel(abs($remaining)), $remaining >= 0 ? '#1D4ED8' : '#B91C1C', 12, true);
$noteY = $boxY + $boxH + 27;
foreach ($pdf->wrap('Le résultat ci-dessus additionne uniquement les motifs qui décomptent le quota annuel. Les absences non décomptées apparaissent dans le détail mais ne réduisent pas le nombre dheures restant à effectuer.', $w, 8.5) as $line) {
$pdf->text($x, $noteY, $line, 8.5);
$noteY += 12;
}
}
private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void
{
$pdf->text(
self::MARGIN,
812,
sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')),
7
);
}
private function normalizeTotals(array $rows): array
{
$totals = [
'TRAVAIL' => 0,
'MALADIE' => 0,
'FORMATION' => 0,
'CONGE' => 0,
'AUTRE' => 0,
'OTHER_COUNTED' => 0,
];
foreach ($rows as $row) {
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE'));
$minutes = (int) ($row['minutes_total'] ?? 0);
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') {
$totals[$code] += $minutes;
continue;
}
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
$totals['OTHER_COUNTED'] += $minutes;
} else {
$totals['AUTRE'] += $minutes;
}
}
return $totals;
}
/** @return array{accent:string} */
private function palette(string $code): array
{
return match (strtoupper($code)) {
'TRAVAIL' => ['accent' => '#2563EB'],
'FORMATION' => ['accent' => '#7C3AED'],
'CONGE' => ['accent' => '#059669'],
'MALADIE' => ['accent' => '#DC2626'],
default => ['accent' => '#64748B'],
};
}
private function minutesLabel(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$absolute = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
}
}

View File

@@ -0,0 +1,426 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
final class PlanningPdfService
{
private const MARGIN = 28.0;
private const WEEK_DAYS = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi'];
/**
* Compatibilité avec l'ancien export d'une seule semaine.
*/
public function agentWeek(array $agent, array $entries, array $week, array $quota): string
{
return $this->agentWeeks($agent, $entries, [$week], $quota);
}
/**
* Génère un planning compact de 1 à 7 semaines. Avec 7 semaines, le rendu
* tient volontairement sur deux pages A4 paysage maximum (4 + 3 semaines).
*/
public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string
{
$weeks = array_values(array_slice($weeks, 0, 7));
if ($weeks === []) {
throw new \InvalidArgumentException('Au moins une semaine est nécessaire pour générer le planning.');
}
$pdf = new SimplePdf();
$entriesByDate = $this->entriesByDate($entries);
$chunks = array_chunk($weeks, 4);
foreach ($chunks as $pageIndex => $pageWeeks) {
if ($pageIndex > 0) {
$pdf->addPage();
}
$this->drawAgentMultiWeekHeader($pdf, $agent, $weeks, $quota, $pageIndex + 1, count($chunks));
$sectionY = 96.0;
foreach ($pageWeeks as $week) {
$this->drawCompactWeek($pdf, $week, $entriesByDate, $sectionY);
$sectionY += 112.0;
}
$this->drawLegend($pdf, self::MARGIN, 563);
$pdf->text(
self::MARGIN,
584,
sprintf('Document genere depuis PTA - %d semaine%s affichee%s.', count($weeks), count($weeks) > 1 ? 's' : '', count($weeks) > 1 ? 's' : ''),
7
);
}
return $pdf->output();
}
public function structureWeek(array $structure, array $agents, array $week): string
{
$pdf = new SimplePdf();
$this->drawDocumentHeader(
$pdf,
'Planning hebdomadaire du lieu d\'affectation',
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
$this->weekLabel($week)
);
$weekDays = $this->weekDays($week);
$margin = self::MARGIN;
$tableY = 100.0;
$agentWidth = 126.0;
$quotaWidth = 84.0;
$usableWidth = $pdf->width() - ($margin * 2);
$dayWidth = ($usableWidth - $agentWidth - $quotaWidth) / 5;
$headerHeight = 38.0;
$bottomLimit = 548.0;
$drawHeader = function () use ($pdf, $weekDays, $margin, $tableY, $agentWidth, $quotaWidth, $dayWidth, $headerHeight): void {
$x = $margin;
$headers = [['Agent', $agentWidth]];
foreach ($weekDays as $day) {
$headers[] = [$day['label'] . ' ' . $day['display'], $dayWidth];
}
$headers[] = ['Quota restant', $quotaWidth];
foreach ($headers as [$label, $width]) {
$pdf->fillRect($x, $tableY, $width, $headerHeight, 0.92);
$pdf->rect($x, $tableY, $width, $headerHeight, 0.72);
$lines = $pdf->wrap((string) $label, $width - 10, 8);
$lineY = $tableY + 15;
foreach (array_slice($lines, 0, 2) as $line) {
$pdf->text($x + 5, $lineY, $line, 8, true);
$lineY += 10;
}
$x += $width;
}
};
$drawHeader();
$y = $tableY + $headerHeight;
foreach ($agents as $agent) {
$entriesByDate = $this->entriesByDate($agent['entries'] ?? []);
$dayBlocks = [];
$maxCellHeight = 34.0;
foreach ($weekDays as $day) {
$blocks = [];
foreach ($entriesByDate[$day['date']] ?? [] as $entry) {
$labelLines = $this->structureEntryLines($pdf, $entry, $dayWidth - 17);
$blockHeight = max(16.0, 6.0 + (count($labelLines) * 8.0));
$blocks[] = [
'entry' => $entry,
'lines' => $labelLines,
'height' => $blockHeight,
];
}
$dayBlocks[] = $blocks;
$cellHeight = 8.0;
foreach ($blocks as $block) {
$cellHeight += $block['height'] + 4.0;
}
$maxCellHeight = max($maxCellHeight, $cellHeight);
}
$agentLines = $pdf->wrap(
sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']),
$agentWidth - 10,
7.5
);
$rowHeight = max(44.0, $maxCellHeight, 14.0 + (count($agentLines) * 9.0));
if ($y + $rowHeight > $bottomLimit) {
$pdf->addPage();
$this->drawDocumentHeader(
$pdf,
'Planning hebdomadaire du lieu d\'affectation - suite',
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
$this->weekLabel($week)
);
$drawHeader();
$y = $tableY + $headerHeight;
}
$x = $margin;
$widths = [$agentWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $quotaWidth];
foreach ($widths as $width) {
$pdf->rect($x, $y, $width, $rowHeight, 0.80);
$x += $width;
}
$lineY = $y + 13;
foreach ($agentLines as $index => $line) {
$pdf->text($margin + 5, $lineY, $line, 7.5, $index === 0);
$lineY += 9;
}
foreach ($dayBlocks as $dayIndex => $blocks) {
$cellX = $margin + $agentWidth + ($dayIndex * $dayWidth);
if ($blocks === []) {
$pdf->text($cellX + 6, $y + 18, '-', 7.2);
continue;
}
$blockY = $y + 5;
foreach ($blocks as $block) {
$entry = $block['entry'];
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
$blockHeight = (float) $block['height'];
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
$textY = $blockY + 10;
foreach ($block['lines'] as $lineIndex => $line) {
$isFirst = $lineIndex === 0;
$pdf->text($cellX + 10, $textY, $line, 7.0, $isFirst);
$textY += 8;
}
$blockY += $blockHeight + 4;
}
}
$quotaText = (string) ($agent['quota']['restantes']['libelle'] ?? '-');
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 18, $quotaText, 8, true);
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 31, (($agent['lieu_principal'] ?? false) ? 'Principal' : 'Ponctuel'), 7);
$y += $rowHeight;
}
if ($agents === []) {
$pdf->text($margin, $y + 24, 'Aucun agent rattache ou planifie sur ce lieu pour cette semaine.', 10);
}
$this->drawLegend($pdf, self::MARGIN, 562);
$pdf->text(self::MARGIN, 584, 'Document genere depuis PTA - Planning du lundi au vendredi.', 7);
return $pdf->output();
}
private function drawAgentMultiWeekHeader(
SimplePdf $pdf,
array $agent,
array $weeks,
array $quota,
int $page,
int $pageCount
): void {
$first = $weeks[0];
$last = $weeks[count($weeks) - 1];
$periodStart = (new DateTimeImmutable((string) $first['date_debut']))->format('d/m/Y');
$periodEnd = (new DateTimeImmutable((string) $last['date_debut']))->modify('+4 days')->format('d/m/Y');
$principalLocation = (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigne');
$principalType = $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? ''));
$pdf->text(self::MARGIN, 25, 'PTA', 9, true);
$pdf->text(self::MARGIN, 44, 'Planning de l\'agent - vue multi-semaines', 16, true);
$pdf->text(self::MARGIN, 60, sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']), 9.5, true);
$pdf->text(self::MARGIN, 74, 'Lieu principal : ' . $principalLocation . ' - ' . $principalType, 8);
$pdf->text($pdf->width() - 260, 44, sprintf('Du %s au %s', $periodStart, $periodEnd), 9.5, true);
$pdf->text($pdf->width() - 260, 60, 'Quota restant : ' . ($quota['restantes']['libelle'] ?? '-'), 8.5, true);
if ($pageCount > 1) {
$pdf->text($pdf->width() - 260, 74, sprintf('Page %d / %d', $page, $pageCount), 8);
}
$pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72);
}
/** @param array<string, array<int, array>> $entriesByDate */
private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void
{
$weekDays = $this->weekDays($week);
$usableWidth = $pdf->width() - (self::MARGIN * 2);
$colWidth = $usableWidth / 5;
$weekTitleHeight = 16.0;
$dayHeaderHeight = 16.0;
$bodyHeight = 77.0;
$pdf->fillRect(self::MARGIN, $y, $usableWidth, $weekTitleHeight, 0.965);
$pdf->rect(self::MARGIN, $y, $usableWidth, $weekTitleHeight + $dayHeaderHeight + $bodyHeight, 0.78);
$pdf->text(self::MARGIN + 6, $y + 11, $this->weekLabel($week), 8.5, true);
$dayHeaderY = $y + $weekTitleHeight;
$bodyY = $dayHeaderY + $dayHeaderHeight;
foreach ($weekDays as $index => $day) {
$colX = self::MARGIN + ($index * $colWidth);
$pdf->fillRect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight, 0.92);
$pdf->rect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight + $bodyHeight, 0.82);
$pdf->text($colX + 5, $dayHeaderY + 11, $this->shortDayLabel($day['label']) . ' ' . substr($day['display'], 0, 5), 7.2, true);
$dayEntries = $entriesByDate[$day['date']] ?? [];
if ($dayEntries === []) {
$pdf->text($colX + 6, $bodyY + 17, 'Aucune affectation', 6.5);
continue;
}
$cursorY = $bodyY + 4;
$rendered = 0;
foreach ($dayEntries as $entryIndex => $entry) {
$lines = $this->compactAgentEntryLines($pdf, $entry, $colWidth - 18);
$cardHeight = max(18.0, 5.0 + (count($lines) * 7.2));
if ($cursorY + $cardHeight > $bodyY + $bodyHeight - 4) {
$remaining = count($dayEntries) - $rendered;
if ($remaining > 0) {
$pdf->text($colX + 7, $bodyY + $bodyHeight - 7, '+' . $remaining . ' autre(s) affectation(s)', 6.2, true);
}
break;
}
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
$pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']);
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
$lineY = $cursorY + 9;
foreach ($lines as $lineIndex => $line) {
$bold = $lineIndex === 0;
$pdf->text($colX + 10, $lineY, $line, 6.5, $bold);
$lineY += 7.2;
}
$cursorY += $cardHeight + 3.0;
$rendered++;
}
}
}
/** @return string[] */
private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array
{
$lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])];
$motifLabel = $this->motifDisplayLabel($entry);
if ($motifLabel !== '') {
foreach ($pdf->wrap($motifLabel, $width, 6.3) as $line) {
$lines[] = $line;
}
}
$location = (string) ($entry['structure_nom'] ?? 'Lieu non renseigne');
foreach ($pdf->wrap('Lieu : ' . $location, $width, 6.1) as $line) {
$lines[] = $line;
}
if (($entry['statut'] ?? 'VALIDE') === 'BROUILLON') {
$lines[] = 'Brouillon';
}
return $lines;
}
/** @return string[] */
private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array
{
$first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']);
$motif = $this->motifDisplayLabel($entry);
if ($motif !== '') {
$first .= ' ' . $motif;
}
return $pdf->wrap($first, $width, 7.0);
}
private function motifDisplayLabel(array $entry): string
{
return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL'
? ''
: (string) ($entry['motif_libelle'] ?? 'Affectation');
}
/** @return array{accent:string,background:string,label:string} */
private function motifPalette(string $code): array
{
return match (strtoupper($code)) {
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'],
};
}
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
{
$items = [
['TRAVAIL', 'Travail'],
['FORMATION', 'Formation'],
['CONGE', 'Conge'],
['MALADIE', 'Absence'],
['AUTRE', 'Autre absence'],
];
foreach ($items as [$code, $label]) {
$palette = $this->motifPalette($code);
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
$pdf->text($x + 12, $y, $label, 6.5);
$x += 76;
}
}
/** @return array<string, array<int, array>> */
private function entriesByDate(array $entries): array
{
$grouped = [];
foreach ($entries as $entry) {
$grouped[(string) $entry['date_jour']][] = $entry;
}
foreach ($grouped as &$dayEntries) {
usort($dayEntries, static fn (array $a, array $b): int => strcmp((string) $a['heure_debut'], (string) $b['heure_debut']));
}
unset($dayEntries);
return $grouped;
}
private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void
{
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
$pdf->text(self::MARGIN, 55, $title, 18, true);
$pdf->text(self::MARGIN, 72, $subtitle, 10);
$pdf->text($pdf->width() - 250, 55, $weekLabel, 10, true);
$pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72);
}
/** @return array<int, array{date:string,label:string,display:string}> */
private function weekDays(array $week): array
{
$start = new DateTimeImmutable((string) $week['date_debut']);
$days = [];
for ($dayNumber = 1; $dayNumber <= 5; $dayNumber++) {
$date = $start->modify('+' . ($dayNumber - 1) . ' days');
$days[] = [
'date' => $date->format('Y-m-d'),
'label' => self::WEEK_DAYS[$dayNumber],
'display' => $date->format('d/m/Y'),
];
}
return $days;
}
private function weekLabel(array $week): string
{
$start = new DateTimeImmutable((string) $week['date_debut']);
$end = $start->modify('+4 days');
return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y'));
}
private function shortDayLabel(string $label): string
{
return match ($label) {
'Lundi' => 'Lun.',
'Mardi' => 'Mar.',
'Mercredi' => 'Mer.',
'Jeudi' => 'Jeu.',
'Vendredi' => 'Ven.',
default => $label,
};
}
private function typeLabel(string $type): string
{
return match (strtoupper($type)) {
'EXTRASCOLAIRE' => 'Extrascolaire',
'PERISCOLAIRE' => 'Periscolaire',
default => 'Non renseigne',
};
}
}

View File

@@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class SchoolHolidayApiService
{
private const DATASET = 'fr-en-calendrier-scolaire';
/**
* data.gouv.fr publie actuellement v2.0 comme URL de base officielle.
* v2.1 et l'ancienne API 1.0 restent des solutions de repli afin que
* la page continue à fonctionner lors d'une évolution du portail.
*/
private const EXPLORE_ENDPOINTS = [
'https://data.education.gouv.fr/api/explore/v2.0/catalog/datasets/' . self::DATASET . '/records',
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/' . self::DATASET . '/records',
];
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
private const ACADEMY_ZONES = [
'aix-marseille' => 'Zone B',
'amiens' => 'Zone B',
'besancon' => 'Zone A',
'bordeaux' => 'Zone A',
'clermont-ferrand' => 'Zone A',
'creteil' => 'Zone C',
'dijon' => 'Zone A',
'grenoble' => 'Zone A',
'lille' => 'Zone B',
'limoges' => 'Zone A',
'lyon' => 'Zone A',
'montpellier' => 'Zone C',
'nancy-metz' => 'Zone B',
'nantes' => 'Zone B',
'nice' => 'Zone B',
'normandie' => 'Zone B',
'orleans-tours' => 'Zone B',
'paris' => 'Zone C',
'poitiers' => 'Zone A',
'reims' => 'Zone B',
'rennes' => 'Zone B',
'strasbourg' => 'Zone B',
'toulouse' => 'Zone C',
'versailles' => 'Zone C',
];
/**
* Compatibilité avec le code existant : retourne uniquement les périodes.
*/
public function previewCalendarYear(string $academy, int $calendarYear): array
{
return $this->previewCalendarYearDetailed($academy, $calendarYear)['periods'];
}
/**
* Retourne toutes les périodes recouvrant une année civile, accompagnées
* d'avertissements non bloquants. Une année civile chevauche deux années
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus
* empêcher l'affichage des dates déjà disponibles.
*
* @return array{periods: array, warnings: array, school_years: array}
*/
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
{
if ($calendarYear < 2000 || $calendarYear > 2100) {
throw new RuntimeException('Année civile invalide.');
}
$academy = trim($academy);
if ($academy === '') {
throw new RuntimeException('Académie manquante.');
}
$schoolYears = [
($calendarYear - 1) . '-' . $calendarYear,
$calendarYear . '-' . ($calendarYear + 1),
];
$all = [];
$warnings = [];
$successfulQueries = 0;
foreach ($schoolYears as $schoolYear) {
try {
$periods = $this->previewSchoolYear($academy, $schoolYear);
$successfulQueries++;
if ($periods === []) {
$warnings[] = sprintf(
'Aucune date publiée pour lacadémie %s et lannée scolaire %s.',
$academy,
$schoolYear
);
}
foreach ($periods as $period) {
$key = $period['libelle'] . '|' . $period['date_debut'] . '|' . $period['date_fin'];
$all[$key] = $period;
}
} catch (Throwable $e) {
$warnings[] = sprintf(
'Les dates de lannée scolaire %s nont pas pu être chargées : %s',
$schoolYear,
$e->getMessage()
);
}
}
if ($successfulQueries === 0) {
throw new RuntimeException(implode(' ', $warnings));
}
$yearStart = sprintf('%04d-01-01', $calendarYear);
$yearEnd = sprintf('%04d-12-31', $calendarYear);
$periods = array_values(array_filter(
$all,
static fn(array $period): bool => $period['date_debut'] <= $yearEnd && $period['date_fin'] >= $yearStart
));
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
return [
'periods' => $periods,
'warnings' => array_values(array_unique($warnings)),
'school_years' => $schoolYears,
];
}
/**
* Compatibilité avec l'ancienne API interne V5.17.
*/
public function preview(string $academy, string $schoolYear): array
{
return $this->previewSchoolYear($academy, $schoolYear);
}
private function previewSchoolYear(string $academy, string $schoolYear): array
{
$errors = [];
// 1. Recherche par académie. On essaie plusieurs graphies afin de ne
// pas rendre la recherche dépendante des accents ou du type de tiret.
foreach ($this->academyVariants($academy) as $academyVariant) {
try {
$records = $this->fetchExploreRecords([
'limit' => 100,
'lang' => 'fr',
'timezone' => 'Europe/Paris',
'order_by' => 'start_date',
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
'refine' => 'location:' . $this->quoteWhereValue($academyVariant),
]);
$periods = $this->normalize($records, $academy, $schoolYear, false);
if ($periods !== []) {
return $periods;
}
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
// 2. Repli par zone. Il est utile lorsque le portail fournit une
// période commune à toute la zone, sans ligne spécifique à l'académie.
$zone = $this->zoneForAcademy($academy);
if ($zone !== null) {
try {
$records = $this->fetchExploreRecords([
'limit' => 100,
'lang' => 'fr',
'timezone' => 'Europe/Paris',
'order_by' => 'start_date',
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
'refine' => 'zones:' . $this->quoteWhereValue($zone),
]);
$periods = $this->normalize($records, $academy, $schoolYear, true);
if ($periods !== []) {
return $periods;
}
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
// 3. Dernier repli sur l'ancienne API Opendatasoft. Elle utilise un
// format JSON différent, normalisé dans fetchLegacyRecords().
foreach ($this->academyVariants($academy) as $academyVariant) {
try {
$records = $this->fetchLegacyRecords($academyVariant, $schoolYear);
$periods = $this->normalize($records, $academy, $schoolYear, false);
if ($periods !== []) {
return $periods;
}
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
if ($errors !== []) {
throw new RuntimeException(end($errors) ?: 'Erreur inconnue de lAPI du calendrier scolaire.');
}
return [];
}
private function fetchExploreRecords(array $parameters): array
{
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
$lastError = null;
foreach (self::EXPLORE_ENDPOINTS as $endpoint) {
try {
$payload = $this->fetchJson($endpoint . '?' . $query);
$records = $payload['results'] ?? null;
if (!is_array($records)) {
throw new RuntimeException('Réponse inattendue de lAPI Explore.');
}
return $records;
} catch (RuntimeException $e) {
$lastError = $e;
}
}
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
}
private function fetchLegacyRecords(string $academy, string $schoolYear): array
{
$query = http_build_query([
'dataset' => self::DATASET,
'rows' => 100,
'sort' => 'start_date',
'refine.location' => $academy,
'refine.annee_scolaire' => $schoolYear,
], '', '&', PHP_QUERY_RFC3986);
$payload = $this->fetchJson(self::LEGACY_ENDPOINT . '?' . $query);
$records = $payload['records'] ?? null;
if (!is_array($records)) {
throw new RuntimeException('Réponse inattendue de lancienne API du calendrier scolaire.');
}
return array_values(array_filter(array_map(
static fn(mixed $record): ?array => is_array($record) && is_array($record['fields'] ?? null)
? $record['fields']
: null,
$records
)));
}
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
{
$periods = [];
$requestedAcademy = $this->normalizeAcademy($academy);
foreach ($records as $record) {
if (!is_array($record)) {
continue;
}
// Certaines vacances communes sont publiées avec population "-".
// En filtrant uniquement "Élèves", elles disparaissaient du résultat.
if (!$this->isStudentPopulation($record['population'] ?? '')) {
continue;
}
$recordAcademy = trim($this->stringValue($record['location'] ?? ''));
if (!$zoneFallback && $recordAcademy !== '' && $this->normalizeAcademy($recordAcademy) !== $requestedAcademy) {
continue;
}
$description = trim($this->stringValue($record['description'] ?? ''));
if (!$this->isVacationDescription($description)) {
continue;
}
$startRaw = $this->stringValue($record['start_date'] ?? '');
$endRaw = $this->stringValue($record['end_date'] ?? '');
$start = $this->dateOnly($startRaw);
$returnDate = $this->dateOnly($endRaw);
if ($start === null || $returnDate === null) {
continue;
}
// L'API donne la date de reprise. PTA utilise des bornes inclusives,
// donc le dernier jour de vacances est la veille de la reprise.
$end = (new DateTimeImmutable($returnDate))->modify('-1 day')->format('Y-m-d');
if ($end < $start) {
$end = $start;
}
$zone = trim($this->stringValue($record['zones'] ?? ($record['zone'] ?? '')));
$recordSchoolYear = trim($this->stringValue($record['annee_scolaire'] ?? $schoolYear));
$key = $description . '|' . $start . '|' . $end;
$periods[$key] = [
'libelle' => $description,
'date_debut' => $start,
'date_fin' => $end,
'date_reprise_api' => $returnDate,
'annee_scolaire' => $recordSchoolYear !== '' ? $recordSchoolYear : $schoolYear,
'academie' => $recordAcademy !== '' && !$zoneFallback ? $recordAcademy : $academy,
'zone' => $zone !== '' ? $zone : ($this->zoneForAcademy($academy) ?? ''),
'source' => 'DATA_GOUV',
];
}
$periods = array_values($periods);
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
return $periods;
}
private function isVacationDescription(string $description): bool
{
$normalized = $this->normalizeText($description);
if ($normalized === '') {
return false;
}
if (str_contains($normalized, 'rentree') || str_contains($normalized, 'prerentree')) {
return false;
}
foreach (['vacance', 'conge', 'pont', 'ete austral', 'hiver austral'] as $keyword) {
if (str_contains($normalized, $keyword)) {
return true;
}
}
return false;
}
private function academyVariants(string $academy): array
{
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
$variants = [$academy];
$variants[] = str_replace(['', '—', ''], '-', $academy);
$variants[] = $this->stripAccents($academy);
$variants[] = str_replace('-', ' ', $academy);
$variants[] = str_replace('-', ' ', $this->stripAccents($academy));
return array_values(array_unique(array_filter(array_map('trim', $variants))));
}
private function zoneForAcademy(string $academy): ?string
{
$key = $this->normalizeAcademy($academy);
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
}
private function fetchJson(string $url): array
{
$body = false;
$status = 0;
if (function_exists('curl_init')) {
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Impossible dinitialiser cURL.');
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 20,
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.3'],
]);
$body = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($body === false) {
throw new RuntimeException($error ?: 'Erreur réseau cURL.');
}
} else {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 20,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nUser-Agent: PTA/7.3\r\n",
],
]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
throw new RuntimeException('La lecture dURL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.');
}
foreach ($http_response_header ?? [] as $header) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) {
$status = (int) $matches[1];
break;
}
}
}
if ($status >= 400) {
throw new RuntimeException('LAPI a répondu avec le code HTTP ' . $status . '.');
}
$data = json_decode((string) $body, true);
if (!is_array($data)) {
throw new RuntimeException('Le contenu reçu depuis lAPI nest pas un JSON valide.');
}
return $data;
}
private function quoteWhereValue(string $value): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
private function dateOnly(string $value): ?string
{
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
return null;
}
return $matches[1];
}
private function stringValue(mixed $value): string
{
if (is_array($value)) {
return implode(', ', array_map(static fn(mixed $item): string => (string) $item, $value));
}
return (string) $value;
}
private function isStudentPopulation(mixed $value): bool
{
$values = is_array($value) ? $value : [$value];
foreach ($values as $item) {
$population = $this->normalizeText((string) $item);
if ($population === '' || in_array($population, ['-', 'eleves', 'tous', 'tout public'], true)) {
return true;
}
}
return false;
}
private function normalizeAcademy(string $value): string
{
$value = str_replace(['', '—', '', '_', '-'], ' ', $value);
return $this->normalizeText($value);
}
private function normalizeText(string $value): string
{
$value = $this->stripAccents($value);
$value = str_replace(['', '—', '', '_'], '-', $value);
$value = strtolower($value);
$value = preg_replace('/[^a-z0-9-]+/', ' ', $value) ?? $value;
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
return trim($value);
}
private function stripAccents(string $value): string
{
return strtr($value, [
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
'Ç' => 'C', 'ç' => 'c',
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
'Ñ' => 'N', 'ñ' => 'n',
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
'Ý' => 'Y', 'Ÿ' => 'Y', 'ý' => 'y', 'ÿ' => 'y',
'Œ' => 'OE', 'œ' => 'oe', 'Æ' => 'AE', 'æ' => 'ae',
]);
}
}

291
app/Services/SimplePdf.php Normal file
View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Services;
/**
* Petit générateur PDF autonome, volontairement limité aux besoins des plannings.
* Il utilise les polices standard PDF Helvetica / Helvetica-Bold et ne requiert
* aucune dépendance Composer.
*/
final class SimplePdf
{
public const A4_LANDSCAPE_WIDTH = 841.89;
public const A4_LANDSCAPE_HEIGHT = 595.28;
/** @var array<int, string> */
private array $pages = [];
private string $currentPage = '';
public function __construct(
private float $width = self::A4_LANDSCAPE_WIDTH,
private float $height = self::A4_LANDSCAPE_HEIGHT,
) {
$this->addPage();
}
public function width(): float
{
return $this->width;
}
public function height(): float
{
return $this->height;
}
public function addPage(): void
{
if ($this->currentPage !== '') {
$this->pages[] = $this->currentPage;
}
$this->currentPage = '';
}
public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void
{
$font = $bold ? 'F2' : 'F1';
$encoded = $this->escapeText($text);
$pdfY = $this->height - $y;
$this->currentPage .= sprintf(
"BT /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET\n",
$font,
$size,
$x,
$pdfY,
$encoded
);
}
public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void
{
$this->currentPage .= sprintf(
"%.3F G %.2F %.2F m %.2F %.2F l S\n",
$gray,
$x1,
$this->height - $y1,
$x2,
$this->height - $y2
);
}
public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void
{
$this->currentPage .= sprintf(
"%.3F G %.2F %.2F %.2F %.2F re S\n",
$strokeGray,
$x,
$this->height - $y - $h,
$w,
$h
);
}
public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void
{
$this->currentPage .= sprintf(
"%.3F g %.2F %.2F %.2F %.2F re f 0 g\n",
$gray,
$x,
$this->height - $y - $h,
$w,
$h
);
}
public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void
{
[$r, $g, $b] = $this->hexToRgb($hex);
$this->currentPage .= sprintf(
"%.3F %.3F %.3F rg %.2F %.2F %.2F %.2F re f 0 g\n",
$r,
$g,
$b,
$x,
$this->height - $y - $h,
$w,
$h
);
}
public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void
{
[$r, $g, $b] = $this->hexToRgb($hex);
$this->currentPage .= sprintf(
"%.3F %.3F %.3F RG %.2F w %.2F %.2F %.2F %.2F re S 0 G 1 w\n",
$r,
$g,
$b,
$lineWidth,
$x,
$this->height - $y - $h,
$w,
$h
);
}
public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void
{
[$r, $g, $b] = $this->hexToRgb($hex);
$font = $bold ? 'F2' : 'F1';
$encoded = $this->escapeText($text);
$pdfY = $this->height - $y;
$this->currentPage .= sprintf(
"BT %.3F %.3F %.3F rg /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET 0 g\n",
$r,
$g,
$b,
$font,
$size,
$x,
$pdfY,
$encoded
);
}
/**
* @return string[]
*/
public function wrap(string $text, float $maxWidth, float $fontSize = 9): array
{
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
if ($text === '') {
return [''];
}
// Helvetica moyenne : environ 0,52 em par caractère. Une estimation
// conservatrice évite les débordements sans embarquer les métriques AFM.
$maxChars = max(4, (int) floor($maxWidth / max(1.0, $fontSize * 0.52)));
$words = preg_split('/\s+/u', $text) ?: [$text];
$lines = [];
$line = '';
foreach ($words as $word) {
$candidate = $line === '' ? $word : $line . ' ' . $word;
if ($this->stringLength($candidate) <= $maxChars) {
$line = $candidate;
continue;
}
if ($line !== '') {
$lines[] = $line;
}
while ($this->stringLength($word) > $maxChars) {
$lines[] = $this->stringSlice($word, 0, $maxChars);
$word = $this->stringSlice($word, $maxChars);
}
$line = $word;
}
if ($line !== '') {
$lines[] = $line;
}
return $lines ?: [''];
}
public function output(): string
{
if ($this->currentPage !== '' || $this->pages === []) {
$this->pages[] = $this->currentPage;
$this->currentPage = '';
}
$objects = [];
$objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
$objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>';
$objects[4] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>';
$pageObjectIds = [];
$nextObjectId = 5;
foreach ($this->pages as $content) {
$pageObjectId = $nextObjectId++;
$contentObjectId = $nextObjectId++;
$pageObjectIds[] = $pageObjectId;
$objects[$pageObjectId] = sprintf(
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>',
$this->width,
$this->height,
$contentObjectId
);
$objects[$contentObjectId] = "<< /Length " . strlen($content) . ">>\nstream\n" . $content . "endstream";
}
$kids = implode(' ', array_map(static fn (int $id): string => $id . ' 0 R', $pageObjectIds));
$objects[2] = sprintf('<< /Type /Pages /Kids [%s] /Count %d >>', $kids, count($pageObjectIds));
ksort($objects);
$pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
$offsets = [0 => 0];
foreach ($objects as $id => $body) {
$offsets[$id] = strlen($pdf);
$pdf .= $id . " 0 obj\n" . $body . "\nendobj\n";
}
$xrefOffset = strlen($pdf);
$maxObjectId = max(array_keys($objects));
$pdf .= "xref\n0 " . ($maxObjectId + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
for ($id = 1; $id <= $maxObjectId; $id++) {
$offset = $offsets[$id] ?? 0;
$pdf .= sprintf('%010d 00000 n ', $offset) . "\n";
}
$pdf .= "trailer\n<< /Size " . ($maxObjectId + 1) . " /Root 1 0 R >>\n";
$pdf .= "startxref\n" . $xrefOffset . "\n%%EOF";
return $pdf;
}
private function stringLength(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
}
private function stringSlice(string $value, int $start, ?int $length = null): string
{
if (function_exists('mb_substr')) {
return mb_substr($value, $start, $length, 'UTF-8');
}
return $length === null ? substr($value, $start) : substr($value, $start, $length);
}
/** @return array{0:float,1:float,2:float} */
private function hexToRgb(string $hex): array
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
$hex = '000000';
}
return [
hexdec(substr($hex, 0, 2)) / 255,
hexdec(substr($hex, 2, 2)) / 255,
hexdec(substr($hex, 4, 2)) / 255,
];
}
private function escapeText(string $text): string
{
$converted = function_exists('iconv')
? iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text)
: false;
if ($converted === false) {
$converted = preg_replace('/[^\x20-\x7E]/', '?', $text) ?? $text;
}
return str_replace(
['\\', '(', ')', "\r", "\n"],
['\\\\', '\\(', '\\)', '', ' '],
$converted
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/** @var string $csrfToken */
/** @var array $motifs */
/** @var array $scripts */
/** @var array $modules */
/** @var array $pageConfig */
/** @var array $accessProfile */
/** @var array $permissions */
?>
</main>
<script>
window.PTA_CONFIG = {
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
permissions: <?= json_encode($permissions, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
motifs: <?= json_encode(array_map(static function (array $motif): array {
return [
'id' => (int) $motif['id_motif'],
'code' => (string) $motif['code'],
'label' => (string) $motif['libelle'],
'quota' => (int) $motif['compte_dans_quota'] === 1,
];
}, $motifs), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>
};
window.PTA_PAGE_CONFIG = <?= json_encode([
'modules' => $modules,
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/core.js?v=7.7"></script>
<?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
<?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
</body>
</html>

View File

@@ -0,0 +1,63 @@
<?php
/** @var string $pageTitle */
/** @var string $activePage */
/** @var array $allowedPages */
/** @var array $accessProfile */
$navigation = [
'planning' => ['planning.php', 'Saisie'],
'pending' => ['pending.php', 'À valider'],
'coverage' => ['coverage.php', 'Couverture'],
'structures' => ['structures.php', 'Par lieu'],
'agents' => ['agents.php', ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Par agent'],
'annual' => ['annual.php', 'Vue annuelle'],
'pta' => ['pta.php', 'PTA détaillé'],
'teams' => ['teams.php', 'Équipes'],
'vacations' => ['vacations.php', 'Vacances'],
'administration' => ['administration.php', 'Administration'],
];
?>
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.7">
</head>
<body>
<main class="app-shell">
<header class="page-header">
<div>
<p class="eyebrow">PTA</p>
<h1><?= htmlspecialchars($pageTitle) ?></h1>
<p class="subtitle">Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.</p>
</div>
<div class="header-actions">
<?php if ($activePage === 'planning'): ?>
<div id="planning-status" class="status-badge status-neutral">Nouveau planning</div>
<?php endif; ?>
<div class="access-profile-badge">
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
<?php if (!empty($accessProfile['structure_name']) && ($accessProfile['role'] ?? '') === 'AGENT'): ?>
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
<?php endif; ?>
</div>
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
</div>
</header>
<?php if (!empty($_GET['access_denied'])): ?>
<div class="message message-warning role-access-warning">Cette page nest pas disponible avec votre rôle actuel.</div>
<?php endif; ?>
<nav class="view-tabs" aria-label="Navigation principale">
<?php foreach ($navigation as $key => [$href, $label]): ?>
<?php if (!in_array($key, $allowedPages, true)) continue; ?>
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
<?= htmlspecialchars($label) ?>
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
</a>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/administration.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/agent_overview.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/annual_overview.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/coverage_alerts.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/pending_validation.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/planning_editor.php';
require dirname(__DIR__) . '/layouts/footer.php';

4
app/Views/pages/pta.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/pta.php';
require dirname(__DIR__) . '/layouts/footer.php';

120
app/Views/pages/role.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
/** @var string $csrfToken */
/** @var array $agents */
/** @var array $structures */
/** @var string $error */
/** @var array|null $currentProfile */
?>
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choisir votre accès — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.4">
</head>
<body class="role-page-body">
<main class="role-page-shell">
<section class="role-intro">
<p class="eyebrow">PTA</p>
<h1>Quel est votre rôle ?</h1>
<p>Votre choix détermine les pages visibles et les actions autorisées pendant cette session.</p>
<?php if ($currentProfile): ?>
<div class="role-current-profile">
Profil actuel : <strong><?= htmlspecialchars((string) $currentProfile['role_label']) ?></strong>
— <?= htmlspecialchars((string) $currentProfile['label']) ?>
</div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="message message-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
</section>
<form method="post" class="role-selection-form" id="role-selection-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
<div class="role-choice-grid">
<label class="role-choice-card">
<input type="radio" name="role" value="AGENT" required>
<span class="role-choice-content">
<strong>Agent</strong>
<small>Lecture de mon planning et du planning de mon lieu de rattachement.</small>
</span>
</label>
<label class="role-choice-card">
<input type="radio" name="role" value="RESPONSABLE_STRUCTURE" required>
<span class="role-choice-content">
<strong>Responsable de structure</strong>
<small>Saisie et modification des horaires et motifs de mon lieu, uniquement en brouillon.</small>
</span>
</label>
<label class="role-choice-card">
<input type="radio" name="role" value="SERVICE_ENFANCE" required>
<span class="role-choice-content">
<strong>Service Enfance</strong>
<small>Contrôle complet, prévisionnel N+1, administration et validation des plannings.</small>
</span>
</label>
</div>
<div id="agent-context" class="role-context-card" hidden>
<label class="field">
<span>Je suis lagent</span>
<select name="agent_id" id="role-agent-select">
<option value="">Choisir mon identité</option>
<?php foreach ($agents as $agent): ?>
<option value="<?= (int) $agent['id_agent'] ?>">
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' (' . $agent['matricule'] . ')') ?>
<?= !empty($agent['structure_nom']) ? ' — ' . htmlspecialchars((string) $agent['structure_nom']) : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div id="responsable-context" class="role-context-card" hidden>
<label class="field">
<span>Je suis responsable du lieu</span>
<select name="structure_id" id="role-structure-select">
<option value="">Choisir le lieu daffectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars((string) $structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div class="role-form-actions">
<button type="submit" class="button button-primary">Accéder à PTA</button>
</div>
</form>
<aside class="role-security-note">
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans lapplication, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
</aside>
</main>
<script>
(() => {
const agentContext = document.querySelector('#agent-context');
const responsableContext = document.querySelector('#responsable-context');
const agentSelect = document.querySelector('#role-agent-select');
const structureSelect = document.querySelector('#role-structure-select');
function refresh() {
const role = document.querySelector('input[name="role"]:checked')?.value || '';
agentContext.hidden = role !== 'AGENT';
responsableContext.hidden = role !== 'RESPONSABLE_STRUCTURE';
agentSelect.required = role === 'AGENT';
structureSelect.required = role === 'RESPONSABLE_STRUCTURE';
}
document.querySelectorAll('input[name="role"]').forEach((input) => input.addEventListener('change', refresh));
refresh();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/structure_overview.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/teams.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/vacation_calendar.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,356 @@
<section id="assignment-view" class="app-view page-content">
<div class="admin-grid">
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Ajouter un lieu daffectation</h2>
<p class="section-subtitle">Chaque lieu possède son propre type : périscolaire ou extrascolaire.</p>
</div>
</div>
<form id="create-structure-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Code <strong>*</strong></span>
<input id="new-structure-code" name="code" type="text" maxlength="50" placeholder="Ex. CRECHE_MATUSALEME" required>
</label>
<label class="field">
<span>Nom <strong>*</strong></span>
<input id="new-structure-name" name="nom" type="text" maxlength="150" placeholder="Ex. Crèche Matusalème" required>
</label>
<label class="field">
<span>Type daffectation <strong>*</strong></span>
<select id="new-structure-type" name="type_affectation" required>
<option value="PERISCOLAIRE">Périscolaire</option>
<option value="EXTRASCOLAIRE">Extrascolaire</option>
</select>
</label>
<label class="field admin-field-wide">
<span>Adresse</span>
<input id="new-structure-address" name="adresse" type="text" maxlength="255" placeholder="Adresse du lieu">
</label>
</div>
<div class="admin-form-actions">
<div id="create-structure-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Ajouter le lieu</button>
</div>
</form>
</section>
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Ajouter un agent</h2>
<p class="section-subtitle">Lagent reçoit un lieu principal à sa création, mais peut être intégré aussi bien aux équipes périscolaires quextrascolaires et travailler ponctuellement sur dautres lieux.</p>
</div>
</div>
<form id="create-agent-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Matricule <strong>*</strong></span>
<input id="new-agent-matricule" name="matricule" type="text" maxlength="50" required>
</label>
<label class="field">
<span>Poste PTA <strong>*</strong></span>
<select id="new-agent-post" name="id_poste" required>
<option value="">Choisir un poste</option>
<?php foreach ($posts as $post): ?>
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Type de contrat <strong>*</strong></span>
<select id="new-agent-contract-type" required>
<option value="PERMANENT">Permanent — soumis au PTA</option>
<option value="TEMPORAIRE">Temporaire — hors périmètre PTA</option>
</select>
</label>
<label class="field">
<span>Lieu daffectation principal <strong>*</strong></span>
<select id="new-agent-structure" name="structure_id" required>
<option value="">Choisir un lieu daffectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars($structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Nom <strong>*</strong></span>
<input id="new-agent-name" name="nom" type="text" maxlength="100" required>
</label>
<label class="field">
<span>Prénom <strong>*</strong></span>
<input id="new-agent-firstname" name="prenom" type="text" maxlength="100" required>
</label>
<label class="field">
<span>E-mail</span>
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr">
</label>
<label class="field">
<span>Téléphone</span>
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
</label>
<label class="field">
<span>Qualification</span>
<select id="new-agent-qualified" name="est_diplome">
<option value="0">Non diplômé</option>
<option value="1">Diplômé</option>
</select>
</label>
<label class="field">
<span>Diplôme</span>
<input id="new-agent-diploma" name="diplome_libelle" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…" disabled>
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Adresse de lagent"></textarea>
</label>
<label class="field">
<span>Quotité de travail <strong>*</strong></span>
<div class="input-suffix">
<input id="new-agent-rate" name="quotite_travail" type="number" min="1" max="100" step="0.01" value="100" required>
<span>%</span>
</div>
</label>
<label class="field">
<span>Date de début du contrat <strong>*</strong></span>
<input id="new-agent-contract-start" name="date_debut_contrat" type="date" value="<?= date('Y-m-d') ?>" required>
</label>
<label class="field">
<span>Date de fin du contrat</span>
<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 lenveloppe complémentaire de 14 h</span>
</label>
<label class="field admin-field-wide">
<span>Observations utiles à la construction du PTA</span>
<textarea id="new-agent-pta-comment" maxlength="1000" rows="3" placeholder="Disponibilités particulières, organisation connue…"></textarea>
</label>
</div>
<p class="hint">Le quota PTA est suivi sur lannée civile, du 1er janvier au 31 décembre. À 100 %, la référence est de 1 607 h. La date de début du contrat reste une information RH mais ne décale pas lannée de référence.</p>
<div class="admin-form-actions">
<div id="create-agent-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Ajouter lagent</button>
</div>
</form>
</section>
</div>
<section class="card">
<div class="section-heading">
<div>
<h2>Paramètres des lieux daffectation</h2>
<p class="section-subtitle">Le type daffectation est défini au niveau du lieu : par exemple « Crèche Matusalème — Périscolaire ».</p>
</div>
</div>
<div id="structure-settings-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table assignment-table">
<thead>
<tr>
<th>Lieu</th>
<th>Code</th>
<th>Type daffectation</th>
<th>Action</th>
</tr>
</thead>
<tbody id="structure-settings-body">
<?php foreach ($structures as $structure): ?>
<tr data-structure-id="<?= (int) $structure['id_structure'] ?>">
<td><strong><?= htmlspecialchars($structure['nom']) ?></strong></td>
<td><?= htmlspecialchars($structure['code']) ?></td>
<td>
<select class="structure-type-select" aria-label="Type daffectation de <?= htmlspecialchars($structure['nom']) ?>">
<option value="PERISCOLAIRE" <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'PERISCOLAIRE' ? 'selected' : '' ?>>Périscolaire</option>
<option value="EXTRASCOLAIRE" <?= ($structure['type_affectation'] ?? '') === 'EXTRASCOLAIRE' ? 'selected' : '' ?>>Extrascolaire</option>
</select>
</td>
<td><button type="button" class="button button-secondary save-structure-type">Enregistrer</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section class="card coverage-settings-card">
<div class="section-heading">
<div>
<h2>Horaires de couverture des lieux</h2>
<p class="section-subtitle">Définissez les périodes pendant lesquelles un nombre minimum dagents doit être physiquement présent. Seules les affectations « Heure de travail » comptent comme présence.</p>
</div>
</div>
<div class="selection-grid overview-filters">
<label class="field">
<span>Lieu daffectation</span>
<select id="coverage-rules-structure">
<option value="">Choisir un lieu daffectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="overview-filter-actions">
<button id="load-coverage-rules" class="button button-secondary filter-button" type="button">Charger</button>
</div>
</div>
<div id="coverage-rules-message" class="message" role="status" aria-live="polite"></div>
<div id="coverage-rules-editor" class="coverage-rules-editor">
<div class="empty-state">Choisissez un lieu pour configurer les plages où une présence minimale est obligatoire.</div>
</div>
<div class="coverage-rules-actions">
<button id="save-coverage-rules" class="button button-primary" type="button" disabled>Enregistrer les horaires de couverture</button>
</div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Informations des agents</h2>
<p class="section-subtitle">Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.</p>
</div>
</div>
<div id="assignment-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table assignment-table agent-information-table">
<thead>
<tr>
<th>Agent</th>
<th>Matricule</th>
<th>Poste</th>
<th>Qualification</th>
<th>Téléphone</th>
<th>Début contrat</th>
<th>Lieu principal</th>
<th>Action</th>
</tr>
</thead>
<tbody id="agent-assignment-body">
<?php foreach ($agents as $agent): ?>
<tr
data-agent-id="<?= (int) $agent['id_agent'] ?>"
data-email="<?= htmlspecialchars((string) ($agent['email'] ?? ''), ENT_QUOTES) ?>"
data-telephone="<?= htmlspecialchars((string) ($agent['telephone'] ?? ''), ENT_QUOTES) ?>"
data-adresse="<?= htmlspecialchars((string) ($agent['adresse'] ?? ''), ENT_QUOTES) ?>"
data-est-diplome="<?= !empty($agent['est_diplome']) ? '1' : '0' ?>"
data-diplome-libelle="<?= htmlspecialchars((string) ($agent['diplome_libelle'] ?? ''), ENT_QUOTES) ?>"
data-date-debut-contrat="<?= htmlspecialchars((string) ($agent['date_debut_contrat'] ?? ''), ENT_QUOTES) ?>"
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-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
>
<td><strong class="agent-row-name"><?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom']) ?></strong></td>
<td><?= htmlspecialchars($agent['matricule']) ?></td>
<td class="agent-row-post"><?= htmlspecialchars((string) (($agent['poste_libelle'] ?? '') ?: 'Non renseigné')) ?></td>
<td class="agent-row-qualification">
<?= !empty($agent['est_diplome'])
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' — ' . htmlspecialchars($agent['diplome_libelle']) : '')
: 'Non diplômé' ?>
</td>
<td class="agent-row-phone"><?= htmlspecialchars((string) (($agent['telephone'] ?? '') ?: 'Non renseigné')) ?></td>
<td class="agent-row-contract-start"><?= htmlspecialchars((string) (($agent['date_debut_contrat'] ?? '') ?: 'Non renseignée')) ?></td>
<td class="agent-row-structure"><?= htmlspecialchars((string) (($agent['structure_nom'] ?? '') ?: 'Sans lieu principal')) ?></td>
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</section>
<div id="agent-profile-modal" class="modal-backdrop" hidden>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-profile-modal-title">
<div class="section-heading modal-heading">
<div>
<h2 id="agent-profile-modal-title">Modifier les informations de lagent</h2>
<p id="agent-profile-modal-subtitle" class="section-subtitle"></p>
</div>
<button id="close-agent-profile-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="agent-profile-form" class="admin-form">
<input id="edit-agent-profile-id" type="hidden">
<div class="admin-form-grid">
<label class="field">
<span>Poste PTA <strong>*</strong></span>
<select id="edit-agent-profile-post" required>
<option value="">Choisir un poste</option>
<?php foreach ($posts as $post): ?>
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Type de contrat <strong>*</strong></span>
<select id="edit-agent-profile-contract-type" required>
<option value="PERMANENT">Permanent</option>
<option value="TEMPORAIRE">Temporaire</option>
</select>
</label>
<label class="field">
<span>E-mail</span>
<input id="edit-agent-profile-email" type="email" maxlength="255">
</label>
<label class="field">
<span>Téléphone</span>
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
</label>
<label class="field">
<span>Qualification</span>
<select id="edit-agent-profile-qualified">
<option value="0">Non diplômé</option>
<option value="1">Diplômé</option>
</select>
</label>
<label class="field">
<span>Diplôme</span>
<input id="edit-agent-profile-diploma" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…">
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="edit-agent-profile-address" maxlength="255" rows="3"></textarea>
</label>
<label class="field">
<span>Date de début du contrat <strong>*</strong></span>
<input id="edit-agent-profile-contract-start" type="date" required>
</label>
<label class="field">
<span>Date de fin du contrat</span>
<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>
</label>
<label class="field admin-field-wide">
<span>Observations PTA</span>
<textarea id="edit-agent-profile-pta-comment" maxlength="1000" rows="3"></textarea>
</label>
<label class="field admin-field-wide">
<span>Lieu daffectation principal</span>
<select id="edit-agent-profile-structure">
<option value="">Sans lieu principal</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars($structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div id="agent-profile-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions">
<button id="cancel-agent-profile" type="button" class="button button-secondary">Annuler</button>
<button type="submit" class="button button-primary">Enregistrer les informations</button>
</div>
</form>
</section>
</div>

View File

@@ -0,0 +1,136 @@
<section id="agent-view" class="app-view page-content">
<section class="card">
<div class="section-heading">
<div>
<h2><?= ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Vue individuelle dun agent' ?></h2>
<p class="section-subtitle"><?= ($accessProfile['role'] ?? '') === 'AGENT'
? 'Consultation de votre planning mensuel et suivi de votre quota.'
: (!empty($permissions['can_copy_full_week'])
? 'Planning mensuel, modification directe des créneaux, duplication de semaines et suivi du quota annuel.'
: 'Planning mensuel des agents du lieu, modification des créneaux du lieu et suivi du quota annuel.') ?></p>
</div>
</div>
<div class="selection-grid overview-filters">
<label class="field">
<span>Agent</span>
<select id="overview-agent">
<option value="">Choisir un agent</option>
<?php foreach ($agents as $agent): ?>
<option value="<?= (int) $agent['id_agent'] ?>">
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' (' . $agent['matricule'] . ')') ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Mois</span>
<input id="agent-overview-month" type="month" value="<?= htmlspecialchars((new DateTimeImmutable())->format('Y-m')) ?>">
</label>
<button id="load-agent-overview" class="button button-primary filter-button" type="button">Afficher le mois</button>
</div>
</section>
<section class="card overview-card">
<div id="agent-overview-message" class="message" role="status" aria-live="polite"></div>
<div id="agent-overview-empty" class="empty-state">Choisissez un agent et un mois.</div>
<div id="agent-overview-content" hidden></div>
</section>
</section>
<?php if (!empty($permissions['can_edit_draft'])): ?>
<div id="agent-entry-modal" class="modal-backdrop" hidden>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-entry-modal-title">
<div class="section-heading modal-heading">
<div>
<h2 id="agent-entry-modal-title">Modifier un créneau</h2>
<p class="section-subtitle">Modifiez directement le jour, le lieu, le motif ou les horaires. Un déplacement vers une autre semaine est autorisé.</p>
</div>
<button id="close-agent-entry-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="agent-entry-edit-form" class="admin-form">
<input id="edit-agent-entry-id" type="hidden">
<div class="admin-form-grid">
<label class="field">
<span>Jour</span>
<input id="edit-agent-entry-date" type="date" required>
</label>
<label class="field">
<span>Lieu daffectation</span>
<select id="edit-agent-entry-structure" required>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Motif</span>
<select id="edit-agent-entry-motif" required>
<?php foreach ($motifs as $motif): ?>
<option value="<?= (int) $motif['id_motif'] ?>"><?= htmlspecialchars($motif['libelle']) ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="field edit-time-range-field">
<span>Horaires</span>
<div class="edit-time-range">
<input id="edit-agent-entry-start" type="time" step="900" required aria-label="Heure de début">
<span>→</span>
<input id="edit-agent-entry-end" type="time" step="900" required aria-label="Heure de fin">
</div>
</div>
</div>
<p class="hint">Toute modification dun planning validé le repasse automatiquement en brouillon afin quil soit contrôlé et validé à nouveau.</p>
<div id="agent-entry-edit-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions agent-entry-modal-actions">
<button id="delete-agent-entry-edit" type="button" class="button button-danger">Supprimer le créneau</button>
<span class="modal-action-spacer" aria-hidden="true"></span>
<button id="cancel-agent-entry-edit" type="button" class="button button-secondary">Annuler</button>
<button id="save-agent-entry-edit" type="submit" class="button button-primary">Enregistrer la modification</button>
</div>
</form>
</section>
</div>
<?php endif; ?>
<?php if (!empty($permissions['can_copy_full_week'])): ?>
<div id="agent-week-copy-modal" class="modal-backdrop" hidden>
<section class="modal-card planning-entry-action-card" role="dialog" aria-modal="true" aria-labelledby="agent-week-copy-title">
<div class="section-heading modal-heading">
<div>
<h2 id="agent-week-copy-title">Dupliquer une semaine</h2>
<p id="agent-week-copy-summary" class="section-subtitle"></p>
</div>
<button id="close-agent-week-copy-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="agent-week-copy-form" class="admin-form">
<input id="agent-week-copy-source" type="hidden">
<label class="field">
<span>Semaine cible</span>
<input id="agent-week-copy-target" type="week" required>
</label>
<fieldset class="entry-action-mode">
<legend>Mode de duplication</legend>
<label class="entry-action-option">
<input type="radio" name="agent-week-copy-mode" value="merge" checked>
<span><strong>Compléter</strong><small>Conserve les créneaux déjà présents. Refus en cas de chevauchement.</small></span>
</label>
<label class="entry-action-option">
<input type="radio" name="agent-week-copy-mode" value="replace">
<span><strong>Remplacer</strong><small>Efface les créneaux de la semaine cible avant la copie.</small></span>
</label>
</fieldset>
<div id="agent-week-copy-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions">
<button id="cancel-agent-week-copy" type="button" class="button button-secondary">Annuler</button>
<button id="confirm-agent-week-copy" type="submit" class="button button-primary">Dupliquer la semaine</button>
</div>
</form>
</section>
</div>
<?php endif; ?>

View File

@@ -0,0 +1,101 @@
<?php
$currentYear = (int) date('Y');
?>
<section class="page-content annual-overview-page">
<section class="card annual-filter-card no-print">
<div class="section-heading">
<div>
<h2>Prévisionnel annuel de lagent</h2>
<p class="section-subtitle">Vue de contrôle de janvier à décembre, inspirée du tableau dannualisation utilisé par le service.</p>
</div>
</div>
<div class="selection-grid overview-filters annual-filters">
<label class="field">
<span>Agent</span>
<select id="annual-agent">
<option value="">Choisir un agent</option>
<?php foreach ($agents as $agent): ?>
<option value="<?= (int) $agent['id_agent'] ?>">
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' — ' . ($agent['poste_libelle'] ?? 'Poste non renseigné')) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Année affichée</span>
<input id="annual-year" type="number" min="2000" max="2100" value="<?= $currentYear ?>">
</label>
<div class="overview-filter-actions annual-filter-actions">
<button id="load-annual" class="button button-primary" type="button">Afficher lannée</button>
<button id="annual-print" class="button button-secondary" type="button" disabled>Imprimer</button>
<button id="annual-fullscreen" class="button button-secondary" type="button" disabled>Plein écran</button>
</div>
</div>
<div id="annual-message" class="message" role="status" aria-live="polite"></div>
</section>
<section id="annual-content" class="annual-sheet" hidden>
<header class="annual-sheet-header">
<div class="annual-title-row">
<span class="annual-direction">Direction Enfance</span>
<h2 id="annual-title">ANNUALISATION</h2>
<span id="annual-updated" class="annual-updated"></span>
</div>
<div class="annual-identity-grid">
<div><span>Nom de lagent</span><strong id="annual-agent-name">—</strong></div>
<div><span>Matricule</span><strong id="annual-agent-number">—</strong></div>
<div><span>Quotité de travail</span><strong id="annual-rate">—</strong></div>
<div><span>Nombre dheures</span><strong id="annual-target">—</strong></div>
<div><span>Statut</span><strong id="annual-contract">—</strong></div>
<div><span>Lieu principal</span><strong id="annual-location">—</strong></div>
<div><span>Poste</span><strong id="annual-post">—</strong></div>
<div><span>Reste à affecter</span><strong id="annual-remaining">—</strong></div>
</div>
<div id="annual-quota-periods" class="annual-quota-periods"></div>
<div class="annual-toolbar no-print">
<a id="annual-open-pta" class="button button-secondary" href="pta.php">Ouvrir le PTA détaillé</a>
<a id="annual-open-month" class="button button-secondary" href="agents.php">Ouvrir la vue mensuelle</a>
<p>Cliquer sur une journée pour ouvrir sa semaine dans lécran de planning.</p>
</div>
</header>
<section class="annual-summary-strip">
<article><span>Prévisionnel affiché</span><strong id="annual-calendar-total">0 h 00</strong></article>
<article><span>Heures validées</span><strong id="annual-validated-total">0 h 00</strong></article>
<article><span>Heures en brouillon</span><strong id="annual-draft-total">0 h 00</strong></article>
<article><span>Période visualisée</span><strong id="annual-year-label">—</strong></article>
</section>
<div class="annual-grid-scroll" tabindex="0" aria-label="Tableau annuel défilant horizontalement">
<div id="annual-months" class="annual-months"></div>
</div>
<footer class="annual-sheet-footer">
<div id="annual-legend" class="annual-legend"></div>
<div class="annual-abbreviations">
<strong>Colonnes :</strong>
<span><b>AP</b> Activité périscolaire</span>
<span><b>PP</b> Préparation périscolaire</span>
<span><b>AE</b> Activité extrascolaire</span>
<span><b>PE</b> Préparation extrascolaire</span>
</div>
</footer>
<section class="annual-validation-card no-print" aria-labelledby="annual-validation-title">
<div class="annual-validation-heading">
<div>
<p class="eyebrow">Service Enfance</p>
<h3 id="annual-validation-title">Validation annuelle</h3>
<p>Contrôler puis valider en une fois tous les plannings en brouillon de lagent sur lannée affichée.</p>
</div>
<button id="annual-validate-year" class="button button-primary" type="button">
Valider toutes les semaines de lannée
</button>
</div>
<p class="annual-validation-note">
En cas derreur, aucune semaine nest validée. Les semaines à corriger peuvent être ouvertes dans un nouvel onglet afin de conserver cette vue annuelle ouverte.
</p>
<div id="annual-validation-result" class="annual-validation-result" aria-live="polite"></div>
</section>
</section>
</section>

View File

@@ -0,0 +1,73 @@
<section id="coverage-view" class="app-view page-content">
<section class="card coverage-header-card">
<div class="section-heading">
<div>
<h2>Contrôle de couverture des lieux</h2>
<p class="section-subtitle">Repérez les plages le nombre dagents en « Heure de travail » est inférieur au minimum attendu.</p>
</div>
<button id="refresh-coverage-alerts" class="button button-secondary" type="button">Actualiser</button>
</div>
<div class="selection-grid overview-filters coverage-filters">
<label class="field">
<span>Lieu daffectation</span>
<select id="coverage-structure-filter">
<option value="">Tous les lieux</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Semaine</span>
<input id="coverage-week" type="week" value="<?= htmlspecialchars($currentWeek) ?>">
</label>
</div>
<div class="coverage-summary" aria-live="polite">
<article class="coverage-summary-card coverage-summary-danger">
<span>Créneaux insuffisamment couverts</span>
<strong id="coverage-gap-count">0</strong>
<small>plage(s) à traiter</small>
</article>
<article class="coverage-summary-card">
<span>Lieux concernés</span>
<strong id="coverage-structure-count">0</strong>
<small>avec un manque</small>
</article>
<article class="coverage-summary-card">
<span>Temps non couvert</span>
<strong id="coverage-gap-hours">0 h 00</strong>
<small>durée cumulée</small>
</article>
<article class="coverage-summary-card">
<span>Couvertures non configurées</span>
<strong id="coverage-unconfigured-count">0</strong>
<small>lieu(x)</small>
</article>
</div>
</section>
<section class="card coverage-list-card">
<div id="coverage-message" class="message" role="status" aria-live="polite"></div>
<div id="coverage-loading" class="empty-state" hidden>Analyse de la couverture en cours…</div>
<div id="coverage-empty" class="empty-state" hidden>Aucun manque de couverture détecté sur cette semaine.</div>
<div id="coverage-content" class="coverage-table-wrap" hidden>
<table class="coverage-table">
<thead>
<tr>
<th>Lieu</th>
<th>Jour</th>
<th>Créneau</th>
<th>Minimum attendu</th>
<th>Planifié</th>
<th>Manque</th>
<th></th>
</tr>
</thead>
<tbody id="coverage-alerts-body"></tbody>
</table>
</div>
<div id="coverage-unconfigured" class="coverage-unconfigured" hidden></div>
</section>
</section>

View File

@@ -0,0 +1,51 @@
<section id="pending-validation-view" class="app-view page-content">
<section class="card pending-validation-header-card">
<div class="section-heading">
<div>
<h2>Plannings en attente de validation</h2>
<p class="section-subtitle">Retrouvez tous les brouillons contenant des affectations. Ouvrez un planning pour le contrôler puis le valider directement dans lécran de saisie.</p>
</div>
<button id="refresh-pending-validation" class="button button-secondary" type="button">Actualiser</button>
</div>
<div class="pending-validation-summary" aria-live="polite">
<article class="pending-summary-card">
<span>À valider</span>
<strong id="pending-validation-count">0</strong>
<small>planning(s)</small>
</article>
<article class="pending-summary-card">
<span>Volume en attente</span>
<strong id="pending-validation-hours">0 h 00</strong>
<small>toutes affectations</small>
</article>
<article class="pending-summary-card">
<span>Heures décomptées</span>
<strong id="pending-validation-counted-hours">0 h 00</strong>
<small>impact sur les quotas</small>
</article>
</div>
</section>
<section class="card pending-validation-list-card">
<div id="pending-validation-message" class="message" role="status" aria-live="polite"></div>
<div id="pending-validation-loading" class="empty-state" hidden>Chargement des plannings à valider…</div>
<div id="pending-validation-empty" class="empty-state" hidden>Aucun planning nest actuellement en attente de validation.</div>
<div id="pending-validation-content" class="pending-validation-table-wrap" hidden>
<table class="pending-validation-table">
<thead>
<tr>
<th>Agent</th>
<th>Lieu daffectation</th>
<th>Semaine</th>
<th>Créneaux</th>
<th>Volume</th>
<th>Dernière modification</th>
<th></th>
</tr>
</thead>
<tbody id="pending-validation-body"></tbody>
</table>
</div>
</section>
</section>

View File

@@ -0,0 +1,330 @@
<section id="editor-view" class="app-view page-content">
<?php if (($accessProfile['role'] ?? '') === 'RESPONSABLE_STRUCTURE'): ?>
<div class="message message-info role-page-notice">
Vous pouvez saisir et corriger les horaires et motifs de votre lieu. Les modifications sont enregistrées en <strong>brouillon</strong> ; seul le Service Enfance peut les valider.
</div>
<?php endif; ?>
<section class="card selection-card">
<h2>1. Sélection du planning</h2>
<div class="selection-grid">
<label class="field">
<span>Lieu daffectation <strong>*</strong></span>
<select id="structure" required>
<option value="">Choisir un lieu daffectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Agent <strong>*</strong></span>
<select id="agent" required disabled>
<option value="">Choisir dabord un lieu daffectation</option>
</select>
</label>
<label class="field">
<span>Semaine <strong>*</strong></span>
<input id="week" type="week" value="<?= htmlspecialchars($currentWeek) ?>" required disabled>
</label>
</div>
</section>
<section class="quota-strip" id="editor-quota" hidden>
<article class="quota-card">
<span>Quotité</span>
<strong id="quota-rate">100 %</strong>
</article>
<article class="quota-card">
<span>Quota de la période</span>
<strong id="quota-target">1 607 h 00</strong>
<small id="quota-period">Année civile</small>
</article>
<article class="quota-card">
<span>Déjà affecté</span>
<strong id="quota-used">0 h 00</strong>
<small id="quota-used-detail">Validé + brouillon</small>
</article>
<article class="quota-card quota-card-emphasis">
<span>Reste à affecter</span>
<strong id="quota-remaining">1 607 h 00</strong>
</article>
</section>
<section class="card" id="entry-card" aria-disabled="true">
<div class="section-heading">
<div>
<h2>2. 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>
<div class="weekly-entry-toolbar">
<p class="weekly-entry-help">Les horaires déjà enregistrés sont rechargés automatiquement. Modifiez-les directement, puis appliquez les horaires avant denregistrer.</p>
</div>
<div class="weekly-entry-scroll">
<table class="weekly-entry-table" aria-label="Affectations du lundi au vendredi">
<thead>
<tr>
<th>Jour</th>
<th>Affectations et plages horaires</th>
</tr>
</thead>
<tbody id="weekly-entry-body">
<tr>
<td colspan="2" class="weekly-entry-placeholder">Sélectionnez dabord un lieu daffectation, un agent et une semaine.</td>
</tr>
</tbody>
</table>
</div>
<div class="weekly-entry-actions">
<button id="add-entry" class="button button-primary button-add" type="button" disabled>
Appliquer les horaires renseignés
</button>
</div>
<p class="hint">Chaque plage peut avoir un motif différent : par exemple Travail le matin puis Formation laprès-midi. Les heures sont saisies par pas de 15 minutes. Les créneaux déjà affectés à lagent dans un autre lieu apparaissent dans le planning global et bloquent toute nouvelle saisie qui les chevaucherait.</p>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>3. Planning global de lagent</h2>
<p id="week-label" class="section-subtitle">Toutes les affectations de lagent sur la semaine, quel que soit le lieu daffectation. 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">
<div class="totals">
<span>Total semaine décompté</span>
<strong id="work-total">0 h 00</strong>
</div>
<?php if (!empty($permissions['can_use_templates'])): ?>
<button id="save-week-template" class="button button-secondary" type="button" disabled>
Enregistrer comme semaine type
</button>
<button id="apply-week-template" class="button button-secondary" type="button" disabled>
Importer une semaine type
</button>
<button id="apply-week-template-period" class="button button-secondary" type="button" disabled>
Appliquer sur une période
</button>
<?php endif; ?>
<button id="open-agent-pdf" class="button button-secondary button-pdf" type="button" disabled>
Voir le PDF de lagent (7 semaines)
</button>
</div>
</div>
<div id="empty-state" class="empty-state">Aucune affectation pour le moment.</div>
<div id="planning-board" class="planning-board" hidden></div>
</section>
<section class="actions-bar">
<div id="message" class="message" role="status" aria-live="polite"></div>
<div class="action-buttons">
<?php if (!empty($permissions['can_edit_draft'])): ?>
<button id="modify-planning" class="button button-warning" type="button" hidden>Modifier le planning</button>
<button id="save-draft" class="button button-secondary" type="button" disabled>Enregistrer le brouillon</button>
<?php endif; ?>
<?php if (!empty($permissions['can_validate'])): ?>
<button id="validate-planning" class="button button-success" type="button" disabled>Valider le planning</button>
<?php endif; ?>
</div>
</section>
</section>
<div id="planning-entry-action-modal" class="modal-backdrop" hidden>
<section class="modal-card planning-entry-action-card" role="dialog" aria-modal="true" aria-labelledby="planning-entry-action-title">
<div class="section-heading modal-heading">
<div>
<h2 id="planning-entry-action-title">Déplacer ou dupliquer un créneau</h2>
<p id="planning-entry-action-summary" class="section-subtitle"></p>
</div>
<button id="close-planning-entry-action-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="planning-entry-action-form" class="admin-form">
<fieldset class="entry-action-mode">
<legend>Action à effectuer</legend>
<label class="entry-action-option">
<input type="radio" name="planning-entry-action" value="move" checked>
<span><strong>Déplacer</strong><small>Le créneau change de jour.</small></span>
</label>
<label class="entry-action-option">
<input type="radio" name="planning-entry-action" value="duplicate">
<span><strong>Dupliquer</strong><small>Le créneau dorigine est conservé.</small></span>
</label>
</fieldset>
<label class="field">
<span>Jour cible</span>
<select id="planning-entry-target-date" required></select>
</label>
<p class="hint">Astuce : vous pouvez aussi glisser-déposer directement un créneau vers un autre jour. Maintenez <strong>Ctrl</strong> (ou <strong>⌘</strong> sur Mac) pendant le dépôt pour le dupliquer.</p>
<div id="planning-entry-action-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions">
<button id="cancel-planning-entry-action" type="button" class="button button-secondary">Annuler</button>
<button id="confirm-planning-entry-action" type="submit" class="button button-primary">Appliquer</button>
</div>
</form>
</section>
</div>
<div id="save-week-template-modal" class="modal-backdrop" hidden>
<section class="modal-card week-template-modal-card" role="dialog" aria-modal="true" aria-labelledby="save-week-template-title">
<div class="section-heading modal-heading">
<div>
<h2 id="save-week-template-title">Enregistrer une semaine type</h2>
<p class="section-subtitle">Le modèle mémorisera toutes les affectations de lagent sur la semaine, tous lieux confondus.</p>
</div>
<button id="close-save-week-template-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="save-week-template-form" class="admin-form">
<label class="field">
<span>Nom de la semaine type <strong>*</strong></span>
<input id="week-template-name" type="text" maxlength="120" placeholder="Ex. Semaine scolaire classique" required>
</label>
<div class="week-template-context" id="save-week-template-context"></div>
<div id="save-week-template-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions">
<button id="cancel-save-week-template" type="button" class="button button-secondary">Annuler</button>
<button type="submit" class="button button-primary">Enregistrer le modèle</button>
</div>
</form>
</section>
</div>
<div id="apply-week-template-modal" class="modal-backdrop" hidden>
<section class="modal-card week-template-modal-card" role="dialog" aria-modal="true" aria-labelledby="apply-week-template-title">
<div class="section-heading modal-heading">
<div>
<h2 id="apply-week-template-title">Importer une semaine type</h2>
<p class="section-subtitle">Le modèle sera appliqué à la semaine actuellement sélectionnée pour cet agent.</p>
</div>
<button id="close-apply-week-template-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="apply-week-template-form" class="admin-form">
<label class="field">
<span>Semaine type <strong>*</strong></span>
<select id="week-template-select" required>
<option value="">Chargement...</option>
</select>
</label>
<div id="week-template-details" class="week-template-details"></div>
<fieldset class="entry-action-mode week-template-mode">
<legend>Mode dimport</legend>
<label class="entry-action-option">
<input type="radio" name="week-template-mode" value="merge" checked>
<span><strong>Compléter la semaine</strong><small>Conserve les affectations existantes. Limport est refusé en cas de chevauchement.</small></span>
</label>
<label class="entry-action-option">
<input type="radio" name="week-template-mode" value="replace">
<span><strong>Remplacer la semaine</strong><small>Supprime les affectations existantes de lagent sur cette semaine avant dappliquer le modèle.</small></span>
</label>
</fieldset>
<div class="week-template-danger" id="week-template-replace-warning" hidden>
Le mode « Remplacer » efface les affectations actuelles de lagent sur la semaine sélectionnée, tous lieux confondus.
</div>
<div id="apply-week-template-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions week-template-modal-actions">
<button id="delete-week-template" type="button" class="button button-danger" disabled>Supprimer ce modèle</button>
<span class="modal-action-spacer"></span>
<button id="cancel-apply-week-template" type="button" class="button button-secondary">Annuler</button>
<button type="submit" class="button button-primary">Importer le modèle</button>
</div>
</form>
</section>
</div>
<div id="apply-week-template-period-modal" class="modal-backdrop" hidden>
<section class="modal-card week-template-period-modal-card" role="dialog" aria-modal="true" aria-labelledby="apply-week-template-period-title">
<div class="section-heading modal-heading">
<div>
<h2 id="apply-week-template-period-title">Appliquer une semaine type sur une période</h2>
<p class="section-subtitle">Les périodes de vacances déjà validées dans PTA sont prises en compte avant toute création de planning.</p>
</div>
<button id="close-apply-week-template-period-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="apply-week-template-period-form" class="admin-form">
<div class="week-template-period-grid">
<label class="field">
<span>Semaine type <strong>*</strong></span>
<select id="week-template-period-select" required>
<option value="">Chargement...</option>
</select>
</label>
<label class="field">
<span>Date de début <strong>*</strong></span>
<input id="week-template-period-start" type="date" required>
</label>
<label class="field">
<span>Date de fin <strong>*</strong></span>
<input id="week-template-period-end" type="date" required>
</label>
</div>
<fieldset class="entry-action-mode week-template-mode">
<legend>Périodes à appliquer</legend>
<label class="entry-action-option">
<input type="radio" name="week-template-period-filter" value="all" checked>
<span><strong>Toutes les semaines</strong><small>Répète la semaine type sur toutes les dates de la période, quelles soient scolaires ou pendant les vacances.</small></span>
</label>
<label class="entry-action-option">
<input type="radio" name="week-template-period-filter" value="school">
<span><strong>Uniquement les périodes scolaires</strong><small>Applique le modèle uniquement aux jours situés hors des vacances enregistrées dans PTA.</small></span>
</label>
<label class="entry-action-option">
<input type="radio" name="week-template-period-filter" value="extra">
<span><strong>Uniquement les périodes extrascolaires</strong><small>Applique le modèle uniquement aux jours compris dans les vacances scolaires enregistrées dans PTA.</small></span>
</label>
</fieldset>
<div class="week-template-period-rule-note">
<strong>Calendrier :</strong>
le caractère scolaire ou extrascolaire dune date est déterminé à partir des périodes de vacances validées dans PTA.
Le choix ci-dessus pilote lapplication du modèle ; il nest pas déduit du lieu daffectation.
</div>
<fieldset class="entry-action-mode week-template-mode">
<legend>Mode dapplication</legend>
<label class="entry-action-option">
<input type="radio" name="week-template-period-mode" value="merge" checked>
<span><strong>Compléter les plannings</strong><small>Conserve les affectations déjà existantes. Lapplication est bloquée si un chevauchement est détecté.</small></span>
</label>
<label class="entry-action-option">
<input type="radio" name="week-template-period-mode" value="replace">
<span><strong>Remplacer les jours concernés</strong><small>Supprime les affectations existantes uniquement sur les dates où le modèle doit réellement sappliquer, puis recrée le planning.</small></span>
</label>
</fieldset>
<div class="week-template-danger" id="week-template-period-replace-warning" hidden>
Les journées exclues par le filtre de calendrier ne seront pas effacées. Seules les dates sur lesquelles le modèle crée au moins un créneau seront remplacées.
</div>
<div class="week-template-period-preview-actions">
<button id="preview-week-template-period" type="button" class="button button-secondary">Prévisualiser lapplication</button>
</div>
<div id="week-template-period-preview" class="week-template-period-preview" hidden></div>
<div id="apply-week-template-period-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions">
<button id="cancel-apply-week-template-period" type="button" class="button button-secondary">Annuler</button>
<button id="confirm-apply-week-template-period" type="submit" class="button button-primary" disabled>Appliquer sur la période</button>
</div>
</form>
</section>
</div>

133
app/Views/partials/pta.php Normal file
View File

@@ -0,0 +1,133 @@
<section class="page-content pta-page">
<section class="card">
<div class="section-heading">
<div>
<h2>PTA annuel de lagent</h2>
<p class="section-subtitle">Synthèse de lannée civile, répartition des 1 607 heures proratisées et contrôles métier. Un même agent peut intervenir en périscolaire et en extrascolaire.</p>
</div>
</div>
<div class="selection-grid overview-filters">
<label class="field">
<span>Agent soumis au PTA</span>
<select id="pta-agent">
<option value="">Choisir un agent</option>
<?php foreach ($agents as $agent): ?>
<option value="<?= (int) $agent['id_agent'] ?>">
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' — ' . ($agent['poste_libelle'] ?? 'Poste non renseigné')) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Date de référence</span>
<input id="pta-reference-date" type="date" value="<?= htmlspecialchars(date('Y-m-d')) ?>">
</label>
<div class="overview-filter-actions">
<button id="load-pta" type="button" class="button button-primary filter-button">Afficher le PTA</button>
</div>
</div>
<div id="pta-message" class="message" role="status" aria-live="polite"></div>
</section>
<section id="pta-content" hidden>
<section class="card">
<div class="section-heading">
<div>
<h2 id="pta-agent-name">Synthèse</h2>
<p id="pta-agent-meta" class="section-subtitle"></p>
</div>
<div class="button-group">
<a id="pta-open-planning" class="button button-secondary" href="planning.php">Ouvrir le planning</a>
<a id="pta-open-agent-view" class="button button-secondary" href="agents.php">Vue mensuelle</a>
</div>
</div>
<div id="pta-summary-cards" class="summary-grid"></div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Répartition du temps</h2>
<p class="section-subtitle">Les congés annuels et les JNT restent visibles, même lorsquils ne décomptent pas le quota.</p>
</div>
</div>
<div class="table-scroll">
<table class="planning-table">
<thead><tr><th>Catégorie</th><th>Durée</th><th>Jours concernés</th></tr></thead>
<tbody id="pta-category-body"></tbody>
</table>
</div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Contrôles du PTA</h2>
<p class="section-subtitle">Les alertes signalent les incohérences à contrôler ; elles ne remplacent pas la validation RH ou réglementaire.</p>
</div>
</div>
<div id="pta-controls" class="control-list"></div>
</section>
<div class="admin-grid">
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Contraintes prioritaires</h2>
<p class="section-subtitle">Préconisation médicale ou temps partiel thérapeutique applicable sur une période.</p>
</div>
</div>
<form id="pta-constraint-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Type</span>
<select id="pta-constraint-type" required>
<option value="MEDICALE">Préconisation médicale</option>
<option value="TEMPS_PARTIEL_THERAPEUTIQUE">Temps partiel thérapeutique</option>
</select>
</label>
<label class="field"><span>Début</span><input id="pta-constraint-start" type="date" required></label>
<label class="field"><span>Fin</span><input id="pta-constraint-end" type="date"></label>
<label class="field"><span>Quotité temporaire (%)</span><input id="pta-constraint-rate" type="number" min="1" max="100" step="0.01"></label>
<label class="field"><span>Maximum par jour (h)</span><input id="pta-constraint-day-max" type="number" min="0.25" step="0.25"></label>
<label class="field"><span>Maximum par semaine (h)</span><input id="pta-constraint-week-max" type="number" min="0.25" step="0.25"></label>
<label class="check-field"><input id="pta-forbid-morning" type="checkbox"> <span>Interdit le matin</span></label>
<label class="check-field"><input id="pta-forbid-midday" type="checkbox"> <span>Interdit le midi</span></label>
<label class="check-field"><input id="pta-forbid-evening" type="checkbox"> <span>Interdit le soir</span></label>
<label class="field admin-field-wide"><span>Commentaire prioritaire</span><textarea id="pta-constraint-comment" rows="3" maxlength="1000" required></textarea></label>
</div>
<div class="admin-form-actions"><button class="button button-primary" type="submit">Enregistrer la contrainte</button></div>
</form>
<div id="pta-constraint-list" class="stack-list"></div>
</section>
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Préférences et interdictions de lieu</h2>
<p class="section-subtitle">Ces informations orientent la composition des équipes après les contraintes prioritaires.</p>
</div>
</div>
<form id="pta-wish-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Lieu</span>
<select id="pta-wish-structure" required>
<option value="">Choisir un lieu</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field"><span>Choix</span><select id="pta-wish-type"><option value="PREFERENCE">Préférence</option><option value="INTERDICTION">Interdiction</option></select></label>
<label class="field"><span>Priorité</span><input id="pta-wish-priority" type="number" min="1" max="5" value="1"></label>
<label class="field"><span>Distance domicile-lieu (km)</span><input id="pta-wish-distance" type="number" min="0" step="0.1"></label>
<label class="field admin-field-wide"><span>Commentaire</span><textarea id="pta-wish-comment" rows="3" maxlength="500"></textarea></label>
</div>
<div class="admin-form-actions"><button class="button button-primary" type="submit">Enregistrer le choix</button></div>
</form>
<div id="pta-wish-list" class="stack-list"></div>
</section>
</div>
</section>
</section>

View File

@@ -0,0 +1,35 @@
<section id="structure-view" class="app-view page-content">
<section class="card">
<div class="section-heading">
<div>
<h2><?= ($accessProfile['role'] ?? '') === 'AGENT' ? 'Planning de mon lieu daffectation' : 'Vue densemble dun lieu daffectation' ?></h2>
<p class="section-subtitle">Les agents rattachés par défaut à ce lieu et ceux qui y sont effectivement planifiés sur la semaine. Cette vue est en lecture seule.</p>
</div>
</div>
<div class="selection-grid overview-filters">
<label class="field">
<span>Lieu daffectation</span>
<select id="overview-structure">
<option value="">Choisir un lieu daffectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Semaine</span>
<input id="overview-week" type="week" value="<?= htmlspecialchars($currentWeek) ?>">
</label>
<div class="overview-filter-actions">
<button id="load-structure-overview" class="button button-primary filter-button" type="button">Afficher</button>
<button id="open-structure-pdf" class="button button-secondary filter-button button-pdf" type="button" disabled>Voir le PDF du lieu</button>
</div>
</div>
</section>
<section class="card overview-card">
<div id="structure-overview-empty" class="empty-state">Choisissez un lieu daffectation et une semaine.</div>
<div id="structure-overview-content" hidden></div>
</section>
</section>

View File

@@ -0,0 +1,54 @@
<section class="page-content team-page">
<section class="card">
<div class="section-heading">
<div>
<h2>Élaborer les équipes</h2>
<p class="section-subtitle">Équipes périscolaires, mercredis scolaires et périodes extrascolaires, avec contrôle de leffectif et de la qualification. Un même agent peut appartenir à plusieurs types déquipes au cours de lannée.</p>
</div>
</div>
<form id="team-create-form" class="admin-form">
<div class="admin-form-grid">
<label class="field"><span>Nom de léquipe</span><input id="team-label" type="text" maxlength="180" required placeholder="Ex. Vacances dhiver — Équipe Matusalème"></label>
<label class="field"><span>Type</span><select id="team-type" required><option value="MERCREDI_PERISCOLAIRE">Mercredi périscolaire</option><option value="EXTRASCOLAIRE">Extrascolaire</option><option value="PERISCOLAIRE_SEMAINE">Périscolaire semaine</option></select></label>
<label class="field"><span>Lieu</span><select id="team-structure" required><option value="">Choisir un lieu</option><?php foreach ($structures as $structure): ?><option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?></option><?php endforeach; ?></select></label>
<label class="field"><span>Date de début</span><input id="team-start" type="date" required></label>
<label class="field"><span>Date de fin</span><input id="team-end" type="date" required></label>
<label class="field"><span>Enfants de moins de 6 ans</span><input id="team-under-six" type="number" min="0" value="0"></label>
<label class="field"><span>Enfants de 6 ans et plus</span><input id="team-over-six" type="number" min="0" value="0"></label>
<label class="field"><span>Ratio moins de 6 ans (1 pour)</span><input id="team-ratio-under-six" type="number" min="1" value="8"></label>
<label class="field"><span>Ratio 6 ans et plus (1 pour)</span><input id="team-ratio-over-six" type="number" min="1" value="12"></label>
<label class="field"><span>Minimum dagents</span><input id="team-min-agents" type="number" min="1" value="1"></label>
<label class="field"><span>Diplômés minimum (%)</span><input id="team-qualified-rate" type="number" min="0" max="100" step="0.01" value="50"></label>
<label class="field admin-field-wide"><span>Commentaire</span><textarea id="team-comment" rows="3" maxlength="1000"></textarea></label>
</div>
<div class="admin-form-actions">
<div id="team-create-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Créer léquipe</button>
</div>
</form>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Équipes enregistrées</h2>
<p class="section-subtitle">Les seuils dencadrement et de qualification sont paramétrables pour chaque équipe avant validation.</p>
</div>
</div>
<div class="selection-grid overview-filters">
<label class="field"><span>Du</span><input id="team-filter-start" type="date"></label>
<label class="field"><span>Au</span><input id="team-filter-end" type="date"></label>
<div class="overview-filter-actions"><button id="load-teams" class="button button-secondary filter-button" type="button">Actualiser</button></div>
</div>
<div id="team-list-message" class="message" role="status" aria-live="polite"></div>
<div id="team-list" class="team-list"><div class="empty-state">Chargez les équipes pour commencer.</div></div>
</section>
<template id="team-agent-options">
<?php foreach ($agents as $agent): ?>
<option value="<?= (int) $agent['id_agent'] ?>">
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' — ' . ($agent['poste_libelle'] ?? 'Poste non renseigné') . (!empty($agent['est_diplome']) ? ' — diplômé' : ' — non diplômé')) ?>
</option>
<?php endforeach; ?>
</template>
</section>

View File

@@ -0,0 +1,144 @@
<?php
$currentYear = (int) date('Y');
$currentMonth = (int) date('n');
$schoolYearStart = $currentMonth >= 8 ? $currentYear : $currentYear - 1;
$defaultSchoolYear = $schoolYearStart . '-' . ($schoolYearStart + 1);
$defaultCalendarYear = $currentYear;
?>
<section id="vacation-view" class="app-view page-content">
<section class="card vacation-import-card">
<div class="section-heading">
<div>
<h2>Vacances scolaires</h2>
<p class="section-subtitle">Consultez toutes les vacances dune année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
</div>
</div>
<div class="vacation-api-note">
<strong>Principe :</strong> une journée comprise dans une période de vacances enregistrée est considérée comme <strong>extrascolaire</strong>. En dehors de ces périodes, elle est considérée comme <strong>périscolaire</strong>.
</div>
<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">
<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">
<option value="Lille"><option value="Limoges"><option value="Lyon"><option value="Montpellier">
<option value="Nancy-Metz"><option value="Nantes"><option value="Nice"><option value="Normandie">
<option value="Orléans-Tours"><option value="Paris"><option value="Poitiers"><option value="Reims">
<option value="Rennes"><option value="Strasbourg"><option value="Toulouse"><option value="Versailles">
</datalist>
</label>
<label class="field">
<span>Année civile</span>
<input id="vacation-calendar-year" type="number" min="2000" max="2100" value="<?= (int) $defaultCalendarYear ?>" placeholder="2026">
</label>
<div class="overview-filter-actions">
<button id="preview-vacations" type="button" class="button button-secondary filter-button">Consulter les dates proposées</button>
</div>
</div>
<div id="vacation-preview-message" class="message" role="status" aria-live="polite"></div>
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
<div class="section-heading compact-heading">
<div>
<h3>Dates proposées par lAPI</h3>
<p class="section-subtitle">Toutes les périodes de lanné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 lenregistrement.</p>
</div>
<label class="vacation-select-all">
<input id="vacation-select-all" type="checkbox">
Tout sélectionner
</label>
</div>
<div class="table-scroll">
<table class="planning-table vacation-table">
<thead>
<tr>
<th>Importer</th>
<th>Période</th>
<th>Début</th>
<th>Fin</th>
<th>Zone / académie</th>
</tr>
</thead>
<tbody id="vacation-preview-body"></tbody>
</table>
</div>
<div class="admin-form-actions">
<button id="save-selected-vacations" type="button" class="button button-primary">Enregistrer les périodes sélectionnées</button>
</div>
</div>
</section>
<section class="card vacation-manual-card">
<div class="section-heading">
<div>
<h2>Ajouter une période manuellement</h2>
<p class="section-subtitle">Utilisez ce formulaire pour corriger le calendrier ou ajouter une période qui napparaît pas dans lAPI.</p>
</div>
</div>
<form id="manual-vacation-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Libellé <strong>*</strong></span>
<input id="manual-vacation-label" type="text" maxlength="150" placeholder="Ex. Vacances de la Toussaint" required>
</label>
<label class="field">
<span>Année scolaire <strong>*</strong></span>
<input id="manual-vacation-school-year" type="text" maxlength="9" value="<?= htmlspecialchars($defaultSchoolYear) ?>" pattern="\d{4}-\d{4}" required>
</label>
<label class="field">
<span>Date de début <strong>*</strong></span>
<input id="manual-vacation-start" type="date" required>
</label>
<label class="field">
<span>Dernier jour de vacances <strong>*</strong></span>
<input id="manual-vacation-end" type="date" required>
</label>
<label class="field">
<span>Académie</span>
<input id="manual-vacation-academy" type="text" maxlength="100" value="Orléans-Tours">
</label>
<label class="field">
<span>Zone</span>
<input id="manual-vacation-zone" type="text" maxlength="50" placeholder="Ex. Zone B">
</label>
</div>
<div class="admin-form-actions">
<div id="manual-vacation-message" class="message" role="status" aria-live="polite"></div>
<button type="submit" class="button button-primary">Ajouter la période</button>
</div>
</form>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Périodes enregistrées</h2>
<p class="section-subtitle">Ces périodes sont celles réellement utilisées par PTA pour distinguer périscolaire et extrascolaire.</p>
</div>
<button id="refresh-vacations" type="button" class="button button-secondary">Actualiser</button>
</div>
<div id="vacation-saved-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table vacation-table">
<thead>
<tr>
<th>Période</th>
<th>Dates</th>
<th>Année scolaire</th>
<th>Académie / zone</th>
<th>Source</th>
<th>Action</th>
</tr>
</thead>
<tbody id="vacation-saved-body">
<tr><td colspan="6"><div class="empty-state">Chargement des périodes enregistrées…</div></td></tr>
</tbody>
</table>
</div>
</section>
</section>

17
app/autoload.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});

14
app/bootstrap.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use App\Models\SchemaModel;
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$pdo = require dirname(__DIR__) . '/db.php';
(new SchemaModel($pdo))->assertCurrent();
return $pdo;