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;
}
}