Files
pta/app/Controllers/AgentController.php
PTA 4201894e28 V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:53:04 +02:00

535 lines
24 KiB
PHP
Raw Blame History

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