pour prod
This commit is contained in:
529
app/Controllers/AgentController.php
Normal file
529
app/Controllers/AgentController.php
Normal 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 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')));
|
||||
$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' => '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,
|
||||
'formation_repartition_annuelle' => $trainingPlanned,
|
||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible de créer l’agent.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$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' => '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,
|
||||
'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 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;
|
||||
}
|
||||
}
|
||||
210
app/Controllers/AnnualOverviewController.php
Normal file
210
app/Controllers/AnnualOverviewController.php
Normal 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 n’est à 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 n’a é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 l’anné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 l’anné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('L’affectation #%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(
|
||||
'L’affectation #%d chevauche l’affectation #%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];
|
||||
}
|
||||
}
|
||||
233
app/Controllers/CoverageController.php
Normal file
233
app/Controllers/CoverageController.php
Normal 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 d’affectation invalide.']);
|
||||
}
|
||||
|
||||
$structure = $this->structureModel->findActive((int) $structureId);
|
||||
if ($structure === null) {
|
||||
JsonResponse::send(404, ['error' => 'Lieu d’affectation 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 d’affectation 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 d’agents 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 d’enregistrer 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;
|
||||
}
|
||||
}
|
||||
271
app/Controllers/PageController.php
Normal file
271
app/Controllers/PageController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
644
app/Controllers/PlanningController.php
Normal file
644
app/Controllers/PlanningController.php
Normal 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 d’affectation 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 d’affectation, 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 d’affectation 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 l’enregistrement 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 d’affectation 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 d’heure.']);
|
||||
}
|
||||
|
||||
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('L’affectation #%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(
|
||||
'L’affectation #%d chevauche l’affectation #%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 : l’agent 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];
|
||||
}
|
||||
}
|
||||
174
app/Controllers/PtaController.php
Normal file
174
app/Controllers/PtaController.php
Normal 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 l’agent.',
|
||||
'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 d’enregistrer 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 d’enregistrer la préférence.', 'details' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Controllers/RoleController.php
Normal file
59
app/Controllers/RoleController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
225
app/Controllers/StructureController.php
Normal file
225
app/Controllers/StructureController.php
Normal 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 d’affectation ou semaine invalide.']);
|
||||
}
|
||||
$this->access->requireStructureView((int) $structureId);
|
||||
|
||||
$structure = $this->structureModel->findActive((int) $structureId);
|
||||
if ($structure === null) {
|
||||
JsonResponse::send(404, ['error' => 'Lieu d’affectation 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 d’affectation ou semaine invalide.';
|
||||
return;
|
||||
}
|
||||
$this->access->requireStructureView((int) $structureId);
|
||||
|
||||
$structure = $this->structureModel->findActive((int) $structureId);
|
||||
if ($structure === null) {
|
||||
http_response_code(404);
|
||||
echo 'Lieu d’affectation 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 d’affectation 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 d’affectation 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 d’affectation.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Le lieu d’affectation « %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 d’affectation 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 d’affectation introuvable.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->structureModel->updateType((int) $structureId, $typeAffectation);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible de modifier le type du lieu d’affectation.',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
144
app/Controllers/TeamController.php
Normal file
144
app/Controllers/TeamController.php
Normal 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 ? 'L’agent 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' => 'L’agent 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.']);
|
||||
}
|
||||
}
|
||||
170
app/Controllers/VacationController.php
Normal file
170
app/Controllers/VacationController.php
Normal 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 l’API 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 n’a été trouvée pour l’année ' . $calendarYear . '.'
|
||||
: sprintf('%d période%s proposée%s pour toute l’année %d. Aucune donnée n’a é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;
|
||||
}
|
||||
}
|
||||
335
app/Controllers/WeekTemplateController.php
Normal file
335
app/Controllers/WeekTemplateController.php
Normal 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 d’enregistrer 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 d’import 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 d’importer 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 d’application 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 l’application 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 d’application 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 d’appliquer 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user