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;
|
||||
}
|
||||
}
|
||||
368
app/Core/Access.php
Normal file
368
app/Core/Access.php
Normal file
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Access
|
||||
{
|
||||
public const ROLE_AGENT = 'AGENT';
|
||||
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
|
||||
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
|
||||
|
||||
private const SESSION_KEY = 'pta_access';
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$role = $this->role();
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
return $this->agentId() !== null && $this->agentRecord() !== null;
|
||||
}
|
||||
if ($role === self::ROLE_RESPONSABLE) {
|
||||
return $this->structureId() !== null && $this->structureRecord() !== null;
|
||||
}
|
||||
return $role === self::ROLE_SERVICE;
|
||||
}
|
||||
|
||||
public function role(): ?string
|
||||
{
|
||||
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
|
||||
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
|
||||
? $role
|
||||
: null;
|
||||
}
|
||||
|
||||
public function agentId(): ?int
|
||||
{
|
||||
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
public function structureId(): ?int
|
||||
{
|
||||
if ($this->role() === self::ROLE_AGENT) {
|
||||
$agent = $this->agentRecord();
|
||||
return isset($agent['id_structure']) && $agent['id_structure'] !== null
|
||||
? (int) $agent['id_structure']
|
||||
: null;
|
||||
}
|
||||
|
||||
$value = filter_var($_SESSION[self::SESSION_KEY]['structure_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
|
||||
{
|
||||
$role = strtoupper(trim($role));
|
||||
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
|
||||
throw new \DomainException('Rôle invalide.');
|
||||
}
|
||||
|
||||
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
|
||||
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
if (!$agentId || !$this->activeAgentExists($agentId)) {
|
||||
throw new \DomainException('Sélectionnez un agent actif.');
|
||||
}
|
||||
$selection['agent_id'] = $agentId;
|
||||
} elseif ($role === self::ROLE_RESPONSABLE) {
|
||||
if (!$structureId || !$this->activeStructureExists($structureId)) {
|
||||
throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
|
||||
}
|
||||
$selection['structure_id'] = $structureId;
|
||||
}
|
||||
|
||||
$_SESSION[self::SESSION_KEY] = $selection;
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
unset($_SESSION[self::SESSION_KEY]);
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
public function profile(): array
|
||||
{
|
||||
$role = $this->role();
|
||||
$permissions = $this->permissions();
|
||||
$profile = [
|
||||
'role' => $role,
|
||||
'role_label' => $this->roleLabel(),
|
||||
'label' => $this->roleLabel(),
|
||||
'agent_id' => $this->agentId(),
|
||||
'structure_id' => $this->structureId(),
|
||||
'permissions' => $permissions,
|
||||
];
|
||||
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
$agent = $this->agentRecord();
|
||||
if ($agent !== null) {
|
||||
$profile['label'] = trim((string) $agent['prenom'] . ' ' . (string) $agent['nom']);
|
||||
$profile['matricule'] = $agent['matricule'];
|
||||
$profile['structure_name'] = $agent['structure_nom'];
|
||||
$profile['structure_id'] = $agent['id_structure'] !== null ? (int) $agent['id_structure'] : null;
|
||||
}
|
||||
} elseif ($role === self::ROLE_RESPONSABLE) {
|
||||
$structure = $this->structureRecord();
|
||||
if ($structure !== null) {
|
||||
$profile['label'] = (string) $structure['nom'];
|
||||
$profile['structure_name'] = (string) $structure['nom'];
|
||||
$profile['structure_code'] = (string) $structure['code'];
|
||||
}
|
||||
}
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
$role = $this->role();
|
||||
$service = $role === self::ROLE_SERVICE;
|
||||
$responsable = $role === self::ROLE_RESPONSABLE;
|
||||
|
||||
return [
|
||||
'read_only' => $role === self::ROLE_AGENT,
|
||||
'can_edit_draft' => $service || $responsable,
|
||||
'can_edit_cross_structure' => $service,
|
||||
'can_validate' => $service,
|
||||
'can_reopen' => $service || $responsable,
|
||||
'can_use_templates' => $service,
|
||||
'can_copy_full_week' => $service,
|
||||
'can_view_pending' => $service,
|
||||
'can_manage_coverage' => $service,
|
||||
'can_manage_agents' => $service,
|
||||
'can_manage_structures' => $service,
|
||||
'can_manage_vacations' => $service,
|
||||
'can_manage_teams' => $service,
|
||||
'can_manage_pta' => $service,
|
||||
'can_forecast_next_year' => $service,
|
||||
];
|
||||
}
|
||||
|
||||
public function allowedPages(): array
|
||||
{
|
||||
return match ($this->role()) {
|
||||
self::ROLE_AGENT => ['agents', 'structures'],
|
||||
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
|
||||
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public function canAccessPage(string $page): bool
|
||||
{
|
||||
return in_array($page, $this->allowedPages(), true);
|
||||
}
|
||||
|
||||
public function homeUrl(): string
|
||||
{
|
||||
return match ($this->role()) {
|
||||
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
|
||||
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
|
||||
self::ROLE_SERVICE => 'planning.php',
|
||||
default => 'role.php',
|
||||
};
|
||||
}
|
||||
|
||||
public function requirePage(string $page): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
header('Location: role.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
if (!$this->canAccessPage($page)) {
|
||||
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public function requireRoles(array $roles): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
|
||||
}
|
||||
if (!in_array($this->role(), $roles, true)) {
|
||||
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
|
||||
}
|
||||
}
|
||||
|
||||
public function requireService(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_SERVICE]);
|
||||
}
|
||||
|
||||
public function requireDraftEditor(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
}
|
||||
|
||||
public function requireStructureView(int $structureId): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
|
||||
public function requireStructureEdit(int $structureId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
|
||||
public function requireAgentView(int $agentId): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
if ($this->role() === self::ROLE_SERVICE) {
|
||||
return;
|
||||
}
|
||||
if ($this->role() === self::ROLE_AGENT && $this->agentId() === $agentId) {
|
||||
return;
|
||||
}
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
|
||||
return;
|
||||
}
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
|
||||
}
|
||||
|
||||
public function requireEntryEdit(int $entryId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
if ($this->role() === self::ROLE_SERVICE) {
|
||||
return;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT p.id_structure
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
WHERE c.id_creneau = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $entryId]);
|
||||
$structureId = $stmt->fetchColumn();
|
||||
if ($structureId === false) {
|
||||
JsonResponse::send(404, ['error' => 'Créneau introuvable.']);
|
||||
}
|
||||
$this->requireStructureEdit((int) $structureId);
|
||||
}
|
||||
|
||||
public function requirePlanningEdit(int $planningId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
if ($this->role() === self::ROLE_SERVICE) {
|
||||
return;
|
||||
}
|
||||
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $planningId]);
|
||||
$structureId = $stmt->fetchColumn();
|
||||
if ($structureId === false) {
|
||||
JsonResponse::send(404, ['error' => 'Planning introuvable.']);
|
||||
}
|
||||
$this->requireStructureEdit((int) $structureId);
|
||||
}
|
||||
|
||||
public function editableStructureId(): ?int
|
||||
{
|
||||
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
|
||||
}
|
||||
|
||||
private function roleLabel(): string
|
||||
{
|
||||
return match ($this->role()) {
|
||||
self::ROLE_AGENT => 'Agent',
|
||||
self::ROLE_RESPONSABLE => 'Responsable de structure',
|
||||
self::ROLE_SERVICE => 'Service Enfance',
|
||||
default => 'Rôle non sélectionné',
|
||||
};
|
||||
}
|
||||
|
||||
private function activeAgentExists(int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1');
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
private function activeStructureExists(int $structureId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
private function agentRecord(): ?array
|
||||
{
|
||||
$agentId = $this->agentId();
|
||||
if ($agentId === null) {
|
||||
return null;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
|
||||
s.nom AS structure_nom
|
||||
FROM agent a
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
private function structureRecord(): ?array
|
||||
{
|
||||
$structureId = $this->structureId();
|
||||
if ($structureId === null) {
|
||||
return null;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_structure, code, nom
|
||||
FROM structure
|
||||
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
private function responsibleCanViewAgent(int $agentId): bool
|
||||
{
|
||||
$structureId = $this->structureId();
|
||||
if ($structureId === null) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT 1
|
||||
FROM agent a
|
||||
WHERE a.id_agent = :agent_id
|
||||
AND a.actif = TRUE
|
||||
AND (
|
||||
a.id_structure = :structure_id_default
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM planning p
|
||||
WHERE p.id_agent = a.id_agent
|
||||
AND p.id_structure = :structure_id_planning
|
||||
)
|
||||
)
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'structure_id_default' => $structureId,
|
||||
'structure_id_planning' => $structureId,
|
||||
]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
}
|
||||
19
app/Core/Controller.php
Normal file
19
app/Core/Controller.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
protected function render(string $view, array $data = []): void
|
||||
{
|
||||
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
|
||||
if (!is_file($viewFile)) {
|
||||
throw new \RuntimeException('Vue introuvable : ' . $view);
|
||||
}
|
||||
|
||||
extract($data, EXTR_SKIP);
|
||||
require $viewFile;
|
||||
}
|
||||
}
|
||||
31
app/Core/Csrf.php
Normal file
31
app/Core/Csrf.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Csrf
|
||||
{
|
||||
public static function ensureToken(): string
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return (string) $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
public static function assertPayload(array $payload): void
|
||||
{
|
||||
$expected = self::ensureToken();
|
||||
$provided = (string) ($payload['csrf_token'] ?? '');
|
||||
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Core/JsonResponse.php
Normal file
51
app/Core/JsonResponse.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class JsonResponse
|
||||
{
|
||||
public static function bootstrap(): void
|
||||
{
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
if (!(error_reporting() & $severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
set_exception_handler(static function (\Throwable $exception): void {
|
||||
error_log(sprintf(
|
||||
'[PTA API] %s: %s in %s:%d',
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
$exception->getFile(),
|
||||
$exception->getLine()
|
||||
));
|
||||
|
||||
$payload = ['error' => 'Une erreur serveur est survenue.'];
|
||||
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
|
||||
$payload['details'] = $exception->getMessage();
|
||||
}
|
||||
|
||||
self::send(500, $payload);
|
||||
});
|
||||
}
|
||||
|
||||
public static function send(int $status, array $payload): void
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
27
app/Core/PdfResponse.php
Normal file
27
app/Core/PdfResponse.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class PdfResponse
|
||||
{
|
||||
public static function inline(string $content, string $filename): void
|
||||
{
|
||||
$safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf';
|
||||
if (!str_ends_with(strtolower($safeFilename), '.pdf')) {
|
||||
$safeFilename .= '.pdf';
|
||||
}
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . strlen($content));
|
||||
header('Cache-Control: private, max-age=0, must-revalidate');
|
||||
header('Pragma: public');
|
||||
}
|
||||
|
||||
echo $content;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
25
app/Core/Request.php
Normal file
25
app/Core/Request.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Request
|
||||
{
|
||||
public static function json(): array
|
||||
{
|
||||
$payload = json_decode((string) file_get_contents('php://input'), true);
|
||||
if (!is_array($payload)) {
|
||||
JsonResponse::send(400, ['error' => 'Corps JSON invalide.']);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public static function requireMethod(string $method): void
|
||||
{
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) {
|
||||
JsonResponse::send(405, ['error' => 'Méthode non autorisée.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
440
app/Models/AgentModel.php
Normal file
440
app/Models/AgentModel.php
Normal file
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class AgentModel extends BaseModel
|
||||
{
|
||||
private const AGENT_SELECT = <<<'SQL'
|
||||
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
|
||||
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
|
||||
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
|
||||
a.formation_repartition_annuelle, a.commentaire_pta,
|
||||
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
|
||||
po.famille AS poste_famille, po.heures_mercredi_minutes,
|
||||
po.heures_extrascolaire_minutes, po.autorise_preparation,
|
||||
po.peut_assurer_animation, po.preparation_lundi_si_100,
|
||||
a.id_structure,
|
||||
s.nom AS structure_nom,
|
||||
s.nom AS structure_principale_nom,
|
||||
s.type_affectation AS structure_type_affectation,
|
||||
s.type_affectation AS structure_principale_type_affectation
|
||||
FROM agent a
|
||||
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
SQL;
|
||||
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
self::AGENT_SELECT . " WHERE a.actif = TRUE ORDER BY a.nom, a.prenom"
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function allVisibleForStructure(int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
self::AGENT_SELECT . "
|
||||
WHERE a.actif = TRUE
|
||||
AND (
|
||||
a.id_structure = :structure_id_default
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM planning p
|
||||
WHERE p.id_agent = a.id_agent
|
||||
AND p.id_structure = :structure_id_planning
|
||||
)
|
||||
)
|
||||
ORDER BY a.nom, a.prenom"
|
||||
);
|
||||
$stmt->execute([
|
||||
'structure_id_default' => $structureId,
|
||||
'structure_id_planning' => $structureId,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function allEligibleForPta(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
self::AGENT_SELECT . "
|
||||
WHERE a.actif = TRUE
|
||||
AND a.type_contrat = 'PERMANENT'
|
||||
AND a.id_poste IS NOT NULL
|
||||
ORDER BY a.nom, a.prenom"
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function allPosts(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
"SELECT id_poste, code, libelle, famille, heures_mercredi_minutes,
|
||||
heures_extrascolaire_minutes, autorise_preparation,
|
||||
peut_assurer_animation, preparation_lundi_si_100
|
||||
FROM poste_agent
|
||||
WHERE actif = TRUE
|
||||
ORDER BY ordre_affichage, libelle"
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function listForLocation(int $selectedLocation = 0): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
|
||||
a.id_poste, po.libelle AS poste_libelle,
|
||||
s.nom AS structure_nom,
|
||||
s.type_affectation AS structure_type_affectation,
|
||||
CASE WHEN a.id_structure = :selected_location THEN 1 ELSE 0 END AS lieu_principal_selectionne
|
||||
FROM agent a
|
||||
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
WHERE a.actif = TRUE
|
||||
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
|
||||
);
|
||||
$stmt->execute(['selected_location' => $selectedLocation]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function findActive(int $agentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
self::AGENT_SELECT . " WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1"
|
||||
);
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function matriculeExists(string $matricule): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
|
||||
$stmt->execute(['matricule' => $matricule]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
public function createWithDefaults(array $data): array
|
||||
{
|
||||
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
|
||||
$contractEnd = !empty($data['date_fin_contrat'])
|
||||
? new DateTimeImmutable((string) $data['date_fin_contrat'])
|
||||
: null;
|
||||
$year = (int) $contractStart->format('Y');
|
||||
// Le quota PTA est suivi sur l'année civile, indépendamment de la date
|
||||
// anniversaire du contrat. La date de contrat reste une donnée RH.
|
||||
$periodStart = new DateTimeImmutable(sprintf('%04d-01-01', $year));
|
||||
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
|
||||
$quotaReference = QuotaModel::REFERENCE_MINUTES;
|
||||
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
|
||||
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840;
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$insertAgent = $this->pdo->prepare(
|
||||
'INSERT INTO agent (
|
||||
matricule, nom, prenom, email, telephone, adresse,
|
||||
est_diplome, diplome_libelle, date_debut_contrat,
|
||||
id_poste, type_contrat, date_fin_contrat,
|
||||
formation_repartition_annuelle, commentaire_pta,
|
||||
id_structure, actif
|
||||
) VALUES (
|
||||
:matricule, :nom, :prenom, :email, :telephone, :adresse,
|
||||
:est_diplome, :diplome_libelle, :date_debut_contrat,
|
||||
:id_poste, :type_contrat, :date_fin_contrat,
|
||||
:formation_repartition_annuelle, :commentaire_pta,
|
||||
:structure_id, TRUE
|
||||
)'
|
||||
);
|
||||
$insertAgent->execute([
|
||||
'matricule' => $data['matricule'],
|
||||
'nom' => $data['nom'],
|
||||
'prenom' => $data['prenom'],
|
||||
'email' => $data['email'],
|
||||
'telephone' => $data['telephone'],
|
||||
'adresse' => $data['adresse'],
|
||||
'est_diplome' => $data['est_diplome'] ? 1 : 0,
|
||||
'diplome_libelle' => $data['est_diplome'] ? $data['diplome_libelle'] : null,
|
||||
'date_debut_contrat' => $contractStart->format('Y-m-d'),
|
||||
'id_poste' => $data['id_poste'],
|
||||
'type_contrat' => $data['type_contrat'],
|
||||
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
|
||||
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0,
|
||||
'commentaire_pta' => $data['commentaire_pta'],
|
||||
'structure_id' => $data['structure_id'],
|
||||
]);
|
||||
$agentId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$history = $this->pdo->prepare(
|
||||
'INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
|
||||
VALUES (:agent_id, :structure_id, :date_debut, NULL, TRUE)'
|
||||
);
|
||||
$history->execute([
|
||||
'agent_id' => $agentId,
|
||||
'structure_id' => $data['structure_id'],
|
||||
'date_debut' => $contractStart->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$quota = $this->pdo->prepare(
|
||||
'INSERT INTO quota_agent_annuel (
|
||||
id_agent, annee, quotite_travail, quota_reference_minutes,
|
||||
quota_cible_minutes, commentaire
|
||||
) VALUES (
|
||||
:agent_id, :annee, :quotite, :quota_reference,
|
||||
:quota_cible, :commentaire
|
||||
)'
|
||||
);
|
||||
$quota->execute([
|
||||
'agent_id' => $agentId,
|
||||
'annee' => $year,
|
||||
'quotite' => $data['quotite_travail'],
|
||||
'quota_reference' => $quotaReference,
|
||||
'quota_cible' => $quotaTarget,
|
||||
'commentaire' => 'Quota PTA de référence - année civile',
|
||||
]);
|
||||
|
||||
$pta = $this->pdo->prepare(
|
||||
'INSERT INTO pta_annuel (
|
||||
id_agent, date_debut, date_fin, quotite_travail,
|
||||
quota_reference_minutes, quota_cible_minutes,
|
||||
enveloppe_formation_minutes, statut
|
||||
) VALUES (
|
||||
:agent_id, :date_debut, :date_fin, :quotite,
|
||||
:quota_reference, :quota_cible,
|
||||
:formation, \'BROUILLON\'
|
||||
)'
|
||||
);
|
||||
$pta->execute([
|
||||
'agent_id' => $agentId,
|
||||
'date_debut' => $periodStart->format('Y-m-d'),
|
||||
'date_fin' => $periodEnd->format('Y-m-d'),
|
||||
'quotite' => $data['quotite_travail'],
|
||||
'quota_reference' => $quotaReference,
|
||||
'quota_cible' => $quotaTarget,
|
||||
'formation' => $trainingEnvelope,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return [
|
||||
'id_agent' => $agentId,
|
||||
'annee_quota' => $year,
|
||||
'date_debut_contrat' => $contractStart->format('Y-m-d'),
|
||||
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
|
||||
'quota_cible_minutes' => $quotaTarget,
|
||||
'id_pta' => (int) $this->pdo->lastInsertId(),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateProfile(int $agentId, array $data): void
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$currentStmt = $this->pdo->prepare('SELECT id_structure FROM agent WHERE id_agent = :agent_id FOR UPDATE');
|
||||
$currentStmt->execute(['agent_id' => $agentId]);
|
||||
$currentStructure = $currentStmt->fetchColumn();
|
||||
if ($currentStructure === false) {
|
||||
throw new \RuntimeException('Agent introuvable.');
|
||||
}
|
||||
$currentStructureId = $currentStructure !== null ? (int) $currentStructure : null;
|
||||
$newStructureId = $data['structure_id'];
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE agent
|
||||
SET email = :email,
|
||||
telephone = :telephone,
|
||||
adresse = :adresse,
|
||||
est_diplome = :est_diplome,
|
||||
diplome_libelle = :diplome_libelle,
|
||||
date_debut_contrat = :date_debut_contrat,
|
||||
id_poste = :id_poste,
|
||||
type_contrat = :type_contrat,
|
||||
date_fin_contrat = :date_fin_contrat,
|
||||
formation_repartition_annuelle = :formation_repartition_annuelle,
|
||||
commentaire_pta = :commentaire_pta,
|
||||
id_structure = :structure_id
|
||||
WHERE id_agent = :agent_id'
|
||||
);
|
||||
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
|
||||
$update->bindValue(':email', $data['email']);
|
||||
$update->bindValue(':telephone', $data['telephone']);
|
||||
$update->bindValue(':adresse', $data['adresse']);
|
||||
$update->bindValue(':est_diplome', $data['est_diplome'] ? 1 : 0, PDO::PARAM_INT);
|
||||
$update->bindValue(':diplome_libelle', $data['est_diplome'] ? $data['diplome_libelle'] : null);
|
||||
$update->bindValue(':date_debut_contrat', $data['date_debut_contrat']);
|
||||
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
|
||||
$update->bindValue(':type_contrat', $data['type_contrat']);
|
||||
$update->bindValue(':date_fin_contrat', $data['date_fin_contrat']);
|
||||
$update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT);
|
||||
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
|
||||
if ($newStructureId === null) {
|
||||
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
|
||||
} else {
|
||||
$update->bindValue(':structure_id', $newStructureId, PDO::PARAM_INT);
|
||||
}
|
||||
$update->execute();
|
||||
|
||||
if ($currentStructureId !== $newStructureId) {
|
||||
$close = $this->pdo->prepare(
|
||||
"UPDATE agent_structure
|
||||
SET actif = FALSE,
|
||||
date_fin = CASE
|
||||
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
|
||||
ELSE CURRENT_DATE
|
||||
END
|
||||
WHERE id_agent = :agent_id AND actif = TRUE"
|
||||
);
|
||||
$close->execute(['agent_id' => $agentId]);
|
||||
|
||||
if ($newStructureId !== null) {
|
||||
$history = $this->pdo->prepare(
|
||||
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
|
||||
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
|
||||
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
|
||||
);
|
||||
$history->execute(['agent_id' => $agentId, 'structure_id' => $newStructureId]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateAssignment(int $agentId, ?int $structureId): void
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$update = $this->pdo->prepare('UPDATE agent SET id_structure = :structure_id WHERE id_agent = :agent_id');
|
||||
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
|
||||
if ($structureId === null) {
|
||||
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
|
||||
} else {
|
||||
$update->bindValue(':structure_id', $structureId, PDO::PARAM_INT);
|
||||
}
|
||||
$update->execute();
|
||||
|
||||
$close = $this->pdo->prepare(
|
||||
"UPDATE agent_structure
|
||||
SET actif = FALSE,
|
||||
date_fin = CASE
|
||||
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
|
||||
ELSE CURRENT_DATE
|
||||
END
|
||||
WHERE id_agent = :agent_id AND actif = TRUE"
|
||||
);
|
||||
$close->execute(['agent_id' => $agentId]);
|
||||
|
||||
if ($structureId !== null) {
|
||||
$history = $this->pdo->prepare(
|
||||
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
|
||||
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
|
||||
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
|
||||
);
|
||||
$history->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function entriesForWeek(int $agentId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_planning, p.statut,
|
||||
s2.id_structure, s2.nom AS structure_nom,
|
||||
s2.type_affectation AS structure_type_affectation,
|
||||
c.id_creneau, c.id_motif, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
|
||||
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
|
||||
FROM planning p
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND sem.annee = :annee
|
||||
AND sem.numero_semaine = :semaine
|
||||
ORDER BY c.date_jour, c.heure_debut, s2.nom"
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId, 'annee' => $year, 'semaine' => $week]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_planning, p.statut,
|
||||
s2.id_structure, s2.nom AS structure_nom,
|
||||
s2.type_affectation AS structure_type_affectation,
|
||||
c.id_creneau, c.id_motif, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
|
||||
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
|
||||
FROM planning p
|
||||
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||||
ORDER BY c.date_jour, c.heure_debut, s2.nom"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'date_debut' => $startDate,
|
||||
'date_fin' => $endDate,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT UPPER(m.code) AS motif_code,
|
||||
m.libelle AS motif_libelle,
|
||||
m.compte_dans_quota,
|
||||
COALESCE(SUM(TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))), 0) AS minutes_total,
|
||||
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_valides,
|
||||
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_brouillon
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||||
GROUP BY UPPER(m.code), m.libelle, m.compte_dans_quota
|
||||
ORDER BY m.compte_dans_quota DESC, m.libelle"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'date_debut' => $startDate,
|
||||
'date_fin' => $endDate,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function timeSummaryForYear(int $agentId, int $year): array
|
||||
{
|
||||
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
|
||||
}
|
||||
}
|
||||
342
app/Models/AnnualOverviewModel.php
Normal file
342
app/Models/AnnualOverviewModel.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
final class AnnualOverviewModel extends BaseModel
|
||||
{
|
||||
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
|
||||
|
||||
public function overview(int $agentId, int $year): array
|
||||
{
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
$agent = $agentModel->findActive($agentId);
|
||||
if ($agent === null) {
|
||||
throw new RuntimeException('Agent introuvable.');
|
||||
}
|
||||
|
||||
$start = sprintf('%04d-01-01', $year);
|
||||
$end = sprintf('%04d-12-31', $year);
|
||||
$entries = $agentModel->entriesForDateRange($agentId, $start, $end);
|
||||
$vacations = (new VacationModel($this->pdo))->periodsBetween($start, $end);
|
||||
$quotaPeriods = $this->quotaPeriodsForYear($agentId, $year, $agent);
|
||||
|
||||
$entriesByDate = [];
|
||||
foreach ($entries as $entry) {
|
||||
$entriesByDate[(string) $entry['date_jour']][] = $entry;
|
||||
}
|
||||
|
||||
$months = [];
|
||||
$yearTotals = array_fill_keys(self::CELL_KEYS, 0);
|
||||
$statusTotals = ['VALIDE' => 0, 'BROUILLON' => 0];
|
||||
$specialTotals = [];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
|
||||
$monthEnd = $monthStart->modify('last day of this month');
|
||||
$days = [];
|
||||
$monthTotals = array_fill_keys(self::CELL_KEYS, 0);
|
||||
|
||||
$period = new DatePeriod($monthStart, new DateInterval('P1D'), $monthEnd->modify('+1 day'));
|
||||
foreach ($period as $date) {
|
||||
$dateValue = $date->format('Y-m-d');
|
||||
$vacation = $this->vacationForDate($dateValue, $vacations);
|
||||
$day = $this->buildDay($date, $entriesByDate[$dateValue] ?? [], $vacation, $agent);
|
||||
$days[] = $day;
|
||||
|
||||
foreach (self::CELL_KEYS as $key) {
|
||||
$minutes = (int) $day['cells'][$key]['minutes'];
|
||||
$monthTotals[$key] += $minutes;
|
||||
$yearTotals[$key] += $minutes;
|
||||
}
|
||||
foreach ($day['status_minutes'] as $status => $minutes) {
|
||||
$statusTotals[$status] = ($statusTotals[$status] ?? 0) + $minutes;
|
||||
}
|
||||
foreach ($day['special_minutes'] as $code => $minutes) {
|
||||
$specialTotals[$code] = ($specialTotals[$code] ?? 0) + $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
$months[] = [
|
||||
'number' => $month,
|
||||
'name' => $this->monthName($month),
|
||||
'days' => $days,
|
||||
'totals' => $this->formatTotals($monthTotals),
|
||||
];
|
||||
}
|
||||
|
||||
$calendarMinutes = array_sum($yearTotals);
|
||||
|
||||
return [
|
||||
'agent' => $agent,
|
||||
'year' => $year,
|
||||
'generated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
'quota_periods' => $quotaPeriods,
|
||||
'calendar_totals' => [
|
||||
'minutes' => $calendarMinutes,
|
||||
'duration' => $this->formatMinutes($calendarMinutes),
|
||||
'cells' => $this->formatTotals($yearTotals),
|
||||
'validated' => $this->formatMinutes((int) ($statusTotals['VALIDE'] ?? 0)),
|
||||
'draft' => $this->formatMinutes((int) ($statusTotals['BROUILLON'] ?? 0)),
|
||||
'specials' => array_map(fn(int $minutes): string => $this->formatMinutes($minutes), $specialTotals),
|
||||
],
|
||||
'months' => $months,
|
||||
'legend' => [
|
||||
['code' => 'TRAVAIL', 'label' => 'Temps de travail', 'class' => 'annual-work'],
|
||||
['code' => 'PREPA', 'label' => 'Temps de préparation', 'class' => 'annual-preparation'],
|
||||
['code' => 'FOR', 'label' => 'Formation', 'class' => 'annual-training'],
|
||||
['code' => 'CA', 'label' => 'Congé annuel', 'class' => 'annual-leave'],
|
||||
['code' => 'ABS', 'label' => 'Absence', 'class' => 'annual-absence'],
|
||||
['code' => 'JNT', 'label' => 'Journée non travaillée', 'class' => 'annual-jnt'],
|
||||
['code' => 'JF', 'label' => 'Journée de fractionnement', 'class' => 'annual-fraction'],
|
||||
['code' => 'VAC', 'label' => 'Vacances scolaires', 'class' => 'annual-vacation'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
|
||||
{
|
||||
$cells = [];
|
||||
foreach (self::CELL_KEYS as $key) {
|
||||
$cells[$key] = [
|
||||
'minutes' => 0,
|
||||
'display' => '',
|
||||
'details' => [],
|
||||
'codes' => [],
|
||||
'class' => '',
|
||||
'structure_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$statusMinutes = ['VALIDE' => 0, 'BROUILLON' => 0];
|
||||
$specialMinutes = [];
|
||||
$hasDraft = false;
|
||||
$firstStructureId = null;
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$minutes = $this->minutesBetween((string) $entry['heure_debut'], (string) $entry['heure_fin']);
|
||||
if ($minutes <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = strtoupper((string) $entry['motif_code']);
|
||||
$cellKey = $this->cellForEntry($code, $vacation !== null);
|
||||
$shortCode = $this->shortCode($code);
|
||||
$status = strtoupper((string) $entry['statut']) === 'VALIDE' ? 'VALIDE' : 'BROUILLON';
|
||||
$statusMinutes[$status] += $minutes;
|
||||
$hasDraft = $hasDraft || $status === 'BROUILLON';
|
||||
$firstStructureId ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
|
||||
|
||||
$cells[$cellKey]['minutes'] += $minutes;
|
||||
$cells[$cellKey]['structure_id'] ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
|
||||
$cells[$cellKey]['details'][] = sprintf(
|
||||
'%s–%s · %s · %s%s',
|
||||
(string) $entry['heure_debut'],
|
||||
(string) $entry['heure_fin'],
|
||||
(string) $entry['motif_libelle'],
|
||||
(string) $entry['structure_nom'],
|
||||
$status === 'BROUILLON' ? ' · Brouillon' : ''
|
||||
);
|
||||
if ($shortCode !== null) {
|
||||
$cells[$cellKey]['codes'][$shortCode] = true;
|
||||
$specialMinutes[$code] = ($specialMinutes[$code] ?? 0) + $minutes;
|
||||
}
|
||||
$cells[$cellKey]['class'] = $this->strongestClass(
|
||||
(string) $cells[$cellKey]['class'],
|
||||
$this->classForCode($code, $cellKey)
|
||||
);
|
||||
}
|
||||
|
||||
foreach (self::CELL_KEYS as $key) {
|
||||
$codes = array_keys($cells[$key]['codes']);
|
||||
$cells[$key]['display'] = $this->cellDisplay((int) $cells[$key]['minutes'], $codes);
|
||||
$cells[$key]['title'] = implode("\n", $cells[$key]['details']);
|
||||
unset($cells[$key]['details'], $cells[$key]['codes']);
|
||||
}
|
||||
|
||||
$weekday = (int) $date->format('N');
|
||||
$week = (int) $date->format('W');
|
||||
$principalStructure = isset($agent['id_structure']) && $agent['id_structure'] !== null
|
||||
? (int) $agent['id_structure']
|
||||
: null;
|
||||
|
||||
return [
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'day' => (int) $date->format('j'),
|
||||
'weekday' => $weekday,
|
||||
'weekday_short' => $this->weekdayShort($weekday),
|
||||
'week' => $week,
|
||||
'week_year' => (int) $date->format('o'),
|
||||
'show_week' => $weekday === 1 || (int) $date->format('j') === 1,
|
||||
'is_weekend' => $weekday >= 6,
|
||||
'is_vacation' => $vacation !== null,
|
||||
'vacation_label' => $vacation['libelle'] ?? null,
|
||||
'has_entries' => $entries !== [],
|
||||
'has_draft' => $hasDraft,
|
||||
'structure_id' => $firstStructureId ?? $principalStructure,
|
||||
'cells' => $cells,
|
||||
'status_minutes' => $statusMinutes,
|
||||
'special_minutes' => $specialMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
|
||||
{
|
||||
$quotaModel = new QuotaModel($this->pdo);
|
||||
$contexts = [sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)];
|
||||
$contractStart = trim((string) ($agent['date_debut_contrat'] ?? ''));
|
||||
if ($contractStart !== '' && str_starts_with($contractStart, (string) $year)) {
|
||||
$contexts[] = $contractStart;
|
||||
}
|
||||
|
||||
$periods = [];
|
||||
foreach ($contexts as $context) {
|
||||
$quota = $quotaModel->forDateForApi($agentId, $context);
|
||||
$key = $quota['date_debut_periode'] . '|' . $quota['date_fin_periode'];
|
||||
$periods[$key] = $quota;
|
||||
}
|
||||
|
||||
uasort($periods, static fn(array $a, array $b): int => strcmp(
|
||||
(string) $a['date_debut_periode'],
|
||||
(string) $b['date_debut_periode']
|
||||
));
|
||||
|
||||
return array_values($periods);
|
||||
}
|
||||
|
||||
private function cellForEntry(string $code, bool $isVacation): string
|
||||
{
|
||||
return match ($code) {
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_LUNDI' => 'PP',
|
||||
'PREPARATION_EXTRASCOLAIRE' => 'PE',
|
||||
'EXTRASCOLAIRE' => 'AE',
|
||||
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', 'MERCREDI_PERISCOLAIRE' => 'AP',
|
||||
'JOUR_FRACTIONNEMENT' => 'AP',
|
||||
default => $isVacation ? 'AE' : 'AP',
|
||||
};
|
||||
}
|
||||
|
||||
private function shortCode(string $code): ?string
|
||||
{
|
||||
return match ($code) {
|
||||
'FORMATION' => 'FOR',
|
||||
'CONGE' => 'CA',
|
||||
'ABSENCE', 'MALADIE' => 'ABS',
|
||||
'AUTRE_ABSENCE' => 'A.ND',
|
||||
'JNT' => 'JNT',
|
||||
'JOUR_FRACTIONNEMENT' => 'JF',
|
||||
'MENAGE_FOND' => 'MF',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function classForCode(string $code, string $cellKey): string
|
||||
{
|
||||
return match ($code) {
|
||||
'FORMATION' => 'annual-training',
|
||||
'CONGE' => 'annual-leave',
|
||||
'ABSENCE', 'MALADIE' => 'annual-absence',
|
||||
'AUTRE_ABSENCE' => 'annual-other-absence',
|
||||
'JNT' => 'annual-jnt',
|
||||
'JOUR_FRACTIONNEMENT' => 'annual-fraction',
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'annual-preparation',
|
||||
'EXTRASCOLAIRE' => 'annual-extra',
|
||||
default => in_array($cellKey, ['PP', 'PE'], true) ? 'annual-preparation' : 'annual-work',
|
||||
};
|
||||
}
|
||||
|
||||
private function strongestClass(string $current, string $candidate): string
|
||||
{
|
||||
$priority = [
|
||||
'' => 0,
|
||||
'annual-work' => 1,
|
||||
'annual-extra' => 2,
|
||||
'annual-preparation' => 3,
|
||||
'annual-fraction' => 4,
|
||||
'annual-training' => 5,
|
||||
'annual-leave' => 6,
|
||||
'annual-other-absence' => 7,
|
||||
'annual-absence' => 8,
|
||||
'annual-jnt' => 9,
|
||||
];
|
||||
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
|
||||
}
|
||||
|
||||
private function cellDisplay(int $minutes, array $codes): string
|
||||
{
|
||||
if ($minutes <= 0) {
|
||||
return '';
|
||||
}
|
||||
if ($codes !== []) {
|
||||
return implode('/', $codes);
|
||||
}
|
||||
return $this->formatCompactMinutes($minutes);
|
||||
}
|
||||
|
||||
private function vacationForDate(string $date, array $vacations): ?array
|
||||
{
|
||||
foreach ($vacations as $vacation) {
|
||||
if ($date >= (string) $vacation['date_debut'] && $date <= (string) $vacation['date_fin']) {
|
||||
return $vacation;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function minutesBetween(string $start, string $end): int
|
||||
{
|
||||
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
|
||||
[$endHour, $endMinute] = array_map('intval', explode(':', $end));
|
||||
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
|
||||
}
|
||||
|
||||
private function formatTotals(array $totals): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($totals as $key => $minutes) {
|
||||
$formatted[$key] = [
|
||||
'minutes' => (int) $minutes,
|
||||
'duration' => $this->formatMinutes((int) $minutes),
|
||||
'compact' => $this->formatCompactMinutes((int) $minutes),
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$minutes = abs($minutes);
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
private function formatCompactMinutes(int $minutes): string
|
||||
{
|
||||
if ($minutes <= 0) {
|
||||
return '';
|
||||
}
|
||||
$hours = intdiv($minutes, 60);
|
||||
$mins = $minutes % 60;
|
||||
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
|
||||
}
|
||||
|
||||
private function monthName(int $month): string
|
||||
{
|
||||
return [
|
||||
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
|
||||
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
|
||||
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
|
||||
][$month];
|
||||
}
|
||||
|
||||
private function weekdayShort(int $weekday): string
|
||||
{
|
||||
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];
|
||||
}
|
||||
}
|
||||
14
app/Models/BaseModel.php
Normal file
14
app/Models/BaseModel.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use PDO;
|
||||
|
||||
abstract class BaseModel
|
||||
{
|
||||
public function __construct(protected PDO $pdo)
|
||||
{
|
||||
}
|
||||
}
|
||||
225
app/Models/CoverageModel.php
Normal file
225
app/Models/CoverageModel.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class CoverageModel extends BaseModel
|
||||
{
|
||||
private const DAY_LABELS = [
|
||||
1 => 'Lundi',
|
||||
2 => 'Mardi',
|
||||
3 => 'Mercredi',
|
||||
4 => 'Jeudi',
|
||||
5 => 'Vendredi',
|
||||
];
|
||||
|
||||
public function rulesForStructure(int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id_couverture, id_structure, jour_semaine,
|
||||
TIME_FORMAT(heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(heure_fin, '%H:%i') AS heure_fin,
|
||||
minimum_agents
|
||||
FROM structure_couverture_horaire
|
||||
WHERE id_structure = :structure_id
|
||||
ORDER BY jour_semaine, heure_debut, heure_fin"
|
||||
);
|
||||
$stmt->execute(['structure_id' => $structureId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function replaceRules(int $structureId, array $rules): void
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$delete = $this->pdo->prepare(
|
||||
'DELETE FROM structure_couverture_horaire WHERE id_structure = :structure_id'
|
||||
);
|
||||
$delete->execute(['structure_id' => $structureId]);
|
||||
|
||||
if ($rules !== []) {
|
||||
$insert = $this->pdo->prepare(
|
||||
'INSERT INTO structure_couverture_horaire
|
||||
(id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents)
|
||||
VALUES
|
||||
(:structure_id, :jour_semaine, :heure_debut, :heure_fin, :minimum_agents)'
|
||||
);
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$insert->execute([
|
||||
'structure_id' => $structureId,
|
||||
'jour_semaine' => (int) $rule['jour_semaine'],
|
||||
'heure_debut' => $rule['heure_debut'],
|
||||
'heure_fin' => $rule['heure_fin'],
|
||||
'minimum_agents' => (int) $rule['minimum_agents'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function reportForStructure(int $structureId, int $year, int $weekNumber): array
|
||||
{
|
||||
$rules = $this->rulesForStructure($structureId);
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
$weekEnd = $weekStart->modify('+4 days');
|
||||
|
||||
if ($rules === []) {
|
||||
return [
|
||||
'configured' => false,
|
||||
'rules' => [],
|
||||
'gaps' => [],
|
||||
'validation_warnings' => [],
|
||||
'summary' => [
|
||||
'gap_count' => 0,
|
||||
'gap_minutes' => 0,
|
||||
'validation_warning_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$entries = $this->workEntries(
|
||||
$structureId,
|
||||
$weekStart->format('Y-m-d'),
|
||||
$weekEnd->format('Y-m-d')
|
||||
);
|
||||
|
||||
$entriesByDate = [];
|
||||
foreach ($entries as $entry) {
|
||||
$entriesByDate[$entry['date_jour']][] = $entry;
|
||||
}
|
||||
|
||||
$gaps = [];
|
||||
$validationWarnings = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$dayNumber = (int) $rule['jour_semaine'];
|
||||
$date = $weekStart->modify('+' . ($dayNumber - 1) . ' days')->format('Y-m-d');
|
||||
$start = $this->timeToMinutes((string) $rule['heure_debut']);
|
||||
$end = $this->timeToMinutes((string) $rule['heure_fin']);
|
||||
$minimum = (int) $rule['minimum_agents'];
|
||||
|
||||
for ($slotStart = $start; $slotStart < $end; $slotStart += 15) {
|
||||
$slotEnd = min($slotStart + 15, $end);
|
||||
$plannedAgents = [];
|
||||
$validatedAgents = [];
|
||||
|
||||
foreach ($entriesByDate[$date] ?? [] as $entry) {
|
||||
$entryStart = $this->timeToMinutes((string) $entry['heure_debut']);
|
||||
$entryEnd = $this->timeToMinutes((string) $entry['heure_fin']);
|
||||
if ($entryStart >= $slotEnd || $entryEnd <= $slotStart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$agentId = (int) $entry['id_agent'];
|
||||
$plannedAgents[$agentId] = true;
|
||||
if ($entry['statut'] === 'VALIDE') {
|
||||
$validatedAgents[$agentId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$plannedCount = count($plannedAgents);
|
||||
$validatedCount = count($validatedAgents);
|
||||
$base = [
|
||||
'date' => $date,
|
||||
'jour_semaine' => $dayNumber,
|
||||
'jour_libelle' => self::DAY_LABELS[$dayNumber] ?? 'Jour',
|
||||
'heure_debut' => $this->minutesToTime($slotStart),
|
||||
'heure_fin' => $this->minutesToTime($slotEnd),
|
||||
'minimum_agents' => $minimum,
|
||||
'agents_planifies' => $plannedCount,
|
||||
'agents_valides' => $validatedCount,
|
||||
];
|
||||
|
||||
if ($plannedCount < $minimum) {
|
||||
$base['agents_manquants'] = $minimum - $plannedCount;
|
||||
$this->appendMergedInterval($gaps, $base, ['minimum_agents', 'agents_planifies', 'agents_manquants']);
|
||||
} elseif ($validatedCount < $minimum) {
|
||||
$base['validations_manquantes'] = $minimum - $validatedCount;
|
||||
$this->appendMergedInterval($validationWarnings, $base, ['minimum_agents', 'agents_valides', 'validations_manquantes']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$gapMinutes = 0;
|
||||
foreach ($gaps as $gap) {
|
||||
$gapMinutes += $this->timeToMinutes($gap['heure_fin']) - $this->timeToMinutes($gap['heure_debut']);
|
||||
}
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'rules' => $rules,
|
||||
'gaps' => $gaps,
|
||||
'validation_warnings' => $validationWarnings,
|
||||
'summary' => [
|
||||
'gap_count' => count($gaps),
|
||||
'gap_minutes' => $gapMinutes,
|
||||
'validation_warning_count' => count($validationWarnings),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function workEntries(int $structureId, string $dateStart, string $dateEnd): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_agent, p.statut, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
|
||||
FROM planning p
|
||||
INNER JOIN agent a ON a.id_agent = p.id_agent AND a.actif = TRUE
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.id_structure = :structure_id
|
||||
AND c.date_jour BETWEEN :date_start AND :date_end
|
||||
AND m.code = 'TRAVAIL'
|
||||
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
|
||||
);
|
||||
$stmt->execute([
|
||||
'structure_id' => $structureId,
|
||||
'date_start' => $dateStart,
|
||||
'date_end' => $dateEnd,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void
|
||||
{
|
||||
$lastIndex = count($target) - 1;
|
||||
if ($lastIndex >= 0) {
|
||||
$last = $target[$lastIndex];
|
||||
$same = $last['date'] === $slot['date'] && $last['heure_fin'] === $slot['heure_debut'];
|
||||
foreach ($comparisonKeys as $key) {
|
||||
$same = $same && (int) $last[$key] === (int) $slot[$key];
|
||||
}
|
||||
if ($same) {
|
||||
$target[$lastIndex]['heure_fin'] = $slot['heure_fin'];
|
||||
return;
|
||||
}
|
||||
}
|
||||
$target[] = $slot;
|
||||
}
|
||||
|
||||
private function timeToMinutes(string $time): int
|
||||
{
|
||||
[$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5)));
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
private function minutesToTime(int $minutes): string
|
||||
{
|
||||
return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
}
|
||||
26
app/Models/MotifModel.php
Normal file
26
app/Models/MotifModel.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
final class MotifModel extends BaseModel
|
||||
{
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
"SELECT id_motif, code, libelle, compte_dans_quota
|
||||
FROM motif_planning
|
||||
WHERE actif = TRUE
|
||||
ORDER BY ordre_affichage, libelle"
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function activeIds(): array
|
||||
{
|
||||
return array_map(
|
||||
'intval',
|
||||
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
|
||||
);
|
||||
}
|
||||
}
|
||||
986
app/Models/PlanningModel.php
Normal file
986
app/Models/PlanningModel.php
Normal file
@@ -0,0 +1,986 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
final class PlanningModel extends BaseModel
|
||||
{
|
||||
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_planning, p.statut, p.commentaire, s.date_debut, s.date_fin
|
||||
FROM planning p
|
||||
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
|
||||
WHERE p.id_structure = :structure_id
|
||||
AND p.id_agent = :agent_id
|
||||
AND s.annee = :annee
|
||||
AND s.numero_semaine = :semaine
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([
|
||||
'structure_id' => $structureId,
|
||||
'agent_id' => $agentId,
|
||||
'annee' => $year,
|
||||
'semaine' => $week,
|
||||
]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function entries(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c.id_creneau, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
|
||||
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
|
||||
m.compte_dans_quota
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE c.id_planning = :planning_id
|
||||
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_planning, p.statut,
|
||||
st.id_structure, st.code AS structure_code, st.nom AS structure_nom,
|
||||
st.type_affectation AS structure_type_affectation,
|
||||
c.id_creneau, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
|
||||
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
|
||||
m.compte_dans_quota
|
||||
FROM planning p
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
INNER JOIN structure st ON st.id_structure = p.id_structure
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND sem.annee = :annee
|
||||
AND sem.numero_semaine = :semaine
|
||||
AND p.id_structure <> :structure_id
|
||||
ORDER BY c.date_jour, c.heure_debut, st.nom"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'annee' => $year,
|
||||
'semaine' => $week,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int
|
||||
{
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
$weekEnd = $weekStart->modify('+6 days');
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$weekStmt = $this->pdo->prepare(
|
||||
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
|
||||
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
|
||||
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
|
||||
);
|
||||
$weekStmt->execute([
|
||||
'annee' => $year,
|
||||
'numero' => $weekNumber,
|
||||
'date_debut' => $weekStart->format('Y-m-d'),
|
||||
'date_fin' => $weekEnd->format('Y-m-d'),
|
||||
]);
|
||||
$weekId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
if ($weekId === 0) {
|
||||
$findWeek = $this->pdo->prepare(
|
||||
'SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero'
|
||||
);
|
||||
$findWeek->execute(['annee' => $year, 'numero' => $weekNumber]);
|
||||
$weekId = (int) $findWeek->fetchColumn();
|
||||
}
|
||||
|
||||
$planningStmt = $this->pdo->prepare(
|
||||
"SELECT id_planning, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id
|
||||
AND id_semaine = :semaine_id
|
||||
AND id_structure = :structure_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$planningStmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'semaine_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planning = $planningStmt->fetch();
|
||||
|
||||
if ($planning && $planning['statut'] === 'VALIDE') {
|
||||
throw new \DomainException('Ce planning est déjà validé et doit être rouvert avant modification.');
|
||||
}
|
||||
|
||||
if ($planning) {
|
||||
$planningId = (int) $planning['id_planning'];
|
||||
$delete = $this->pdo->prepare('DELETE FROM creneau_horaire WHERE id_planning = :planning_id');
|
||||
$delete->execute(['planning_id' => $planningId]);
|
||||
} else {
|
||||
$insertPlanning = $this->pdo->prepare(
|
||||
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
|
||||
VALUES (:agent_id, :semaine_id, :structure_id, 'BROUILLON')"
|
||||
);
|
||||
$insertPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'semaine_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planningId = (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
|
||||
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
|
||||
);
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$insertEntry->execute([
|
||||
'planning_id' => $planningId,
|
||||
'motif_id' => $entry['motif_id'],
|
||||
'date_jour' => $entry['date'],
|
||||
'heure_debut' => $entry['start'],
|
||||
'heure_fin' => $entry['end'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return $planningId;
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function pendingValidation(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
|
||||
p.date_creation, p.date_modification,
|
||||
a.matricule, a.nom AS agent_nom, a.prenom AS agent_prenom,
|
||||
st.nom AS structure_nom, st.type_affectation AS structure_type_affectation,
|
||||
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
|
||||
DATE_ADD(sem.date_debut, INTERVAL 4 DAY) AS date_fin_ouvrable,
|
||||
COUNT(c.id_creneau) AS nombre_creneaux,
|
||||
COALESCE(SUM(TIMESTAMPDIFF(
|
||||
MINUTE,
|
||||
CONCAT(c.date_jour, ' ', c.heure_debut),
|
||||
CONCAT(c.date_jour, ' ', c.heure_fin)
|
||||
)), 0) AS total_minutes,
|
||||
COALESCE(SUM(CASE WHEN m.compte_dans_quota = 1 THEN TIMESTAMPDIFF(
|
||||
MINUTE,
|
||||
CONCAT(c.date_jour, ' ', c.heure_debut),
|
||||
CONCAT(c.date_jour, ' ', c.heure_fin)
|
||||
) ELSE 0 END), 0) AS minutes_decomptees
|
||||
FROM planning p
|
||||
INNER JOIN agent a ON a.id_agent = p.id_agent
|
||||
INNER JOIN structure st ON st.id_structure = p.id_structure
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
LEFT JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.statut = 'BROUILLON'
|
||||
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
|
||||
p.date_creation, p.date_modification,
|
||||
a.matricule, a.nom, a.prenom,
|
||||
st.nom, st.type_affectation,
|
||||
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
|
||||
HAVING COUNT(c.id_creneau) > 0
|
||||
ORDER BY sem.date_debut ASC, a.nom ASC, a.prenom ASC, st.nom ASC"
|
||||
);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Plannings en brouillon de l'agent qui contiennent au moins un créneau
|
||||
* dans l'année civile demandée.
|
||||
*/
|
||||
public function pendingValidationForAgentYear(int $agentId, int $year): array
|
||||
{
|
||||
$start = sprintf('%04d-01-01', $year);
|
||||
$end = sprintf('%04d-12-31', $year);
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
|
||||
st.nom AS structure_nom,
|
||||
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
|
||||
COUNT(c.id_creneau) AS nombre_creneaux,
|
||||
COALESCE(SUM(TIMESTAMPDIFF(
|
||||
MINUTE,
|
||||
CONCAT(c.date_jour, ' ', c.heure_debut),
|
||||
CONCAT(c.date_jour, ' ', c.heure_fin)
|
||||
)), 0) AS total_minutes
|
||||
FROM planning p
|
||||
INNER JOIN structure st ON st.id_structure = p.id_structure
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND p.statut = 'BROUILLON'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM creneau_horaire cy
|
||||
WHERE cy.id_planning = p.id_planning
|
||||
AND cy.date_jour BETWEEN :date_debut AND :date_fin
|
||||
)
|
||||
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
|
||||
st.nom, sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
|
||||
HAVING COUNT(c.id_creneau) > 0
|
||||
ORDER BY sem.date_debut, st.nom"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'date_debut' => $start,
|
||||
'date_fin' => $end,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chevauchements avec n'importe quel autre planning de l'agent,
|
||||
* qu'il soit déjà validé ou encore en brouillon.
|
||||
*/
|
||||
public function externalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2,
|
||||
c1.date_jour, st.nom AS structure_nom, p2.statut AS autre_statut
|
||||
FROM planning p1
|
||||
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
|
||||
INNER JOIN planning p2
|
||||
ON p2.id_agent = p1.id_agent
|
||||
AND p2.id_planning <> p1.id_planning
|
||||
INNER JOIN structure st ON st.id_structure = p2.id_structure
|
||||
INNER JOIN creneau_horaire c2
|
||||
ON c2.id_planning = p2.id_planning
|
||||
AND c2.date_jour = c1.date_jour
|
||||
AND c1.heure_debut < c2.heure_fin
|
||||
AND c1.heure_fin > c2.heure_debut
|
||||
WHERE p1.id_planning = :planning_id"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function findDetailed(int $planningId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
|
||||
s.annee, s.numero_semaine, s.date_debut, s.date_fin
|
||||
FROM planning p
|
||||
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
|
||||
WHERE p.id_planning = :planning_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function countEntries(int $planningId): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id');
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function entriesOutsideWeek(int $planningId, string $start, string $end): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id_creneau, date_jour
|
||||
FROM creneau_horaire
|
||||
WHERE id_planning = :planning_id
|
||||
AND date_jour NOT BETWEEN :date_debut AND :date_fin"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId, 'date_debut' => $start, 'date_fin' => $end]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function internalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour
|
||||
FROM creneau_horaire c1
|
||||
INNER JOIN creneau_horaire c2
|
||||
ON c2.id_planning = c1.id_planning
|
||||
AND c2.date_jour = c1.date_jour
|
||||
AND c2.id_creneau > c1.id_creneau
|
||||
AND c1.heure_debut < c2.heure_fin
|
||||
AND c1.heure_fin > c2.heure_debut
|
||||
WHERE c1.id_planning = :planning_id"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function validatedExternalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour, st.nom AS structure_nom
|
||||
FROM planning p1
|
||||
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
|
||||
INNER JOIN planning p2
|
||||
ON p2.id_agent = p1.id_agent
|
||||
AND p2.id_planning <> p1.id_planning
|
||||
AND p2.statut = 'VALIDE'
|
||||
INNER JOIN structure st ON st.id_structure = p2.id_structure
|
||||
INNER JOIN creneau_horaire c2
|
||||
ON c2.id_planning = p2.id_planning
|
||||
AND c2.date_jour = c1.date_jour
|
||||
AND c1.heure_debut < c2.heure_fin
|
||||
AND c1.heure_fin > c2.heure_debut
|
||||
WHERE p1.id_planning = :planning_id"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function findEntryDetailed(int $entryId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
|
||||
p.id_agent, p.id_structure, p.id_semaine, p.statut,
|
||||
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
WHERE c.id_creneau = :entry_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute(['entry_id' => $entryId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie un créneau depuis la vue agent, y compris vers une autre semaine.
|
||||
* Le planning cible est créé si nécessaire et tout planning validé modifié
|
||||
* repasse automatiquement en brouillon.
|
||||
*/
|
||||
public function updateEntryFromAgentView(
|
||||
int $entryId,
|
||||
int $targetStructureId,
|
||||
int $motifId,
|
||||
string $date,
|
||||
string $start,
|
||||
string $end
|
||||
): array {
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$entryStmt = $this->pdo->prepare(
|
||||
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
|
||||
p.id_agent, p.id_structure, p.id_semaine, p.statut,
|
||||
sem.annee, sem.numero_semaine
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
WHERE c.id_creneau = :entry_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$entryStmt->execute(['entry_id' => $entryId]);
|
||||
$entry = $entryStmt->fetch();
|
||||
if (!$entry) {
|
||||
throw new \DomainException('Le créneau à modifier est introuvable.');
|
||||
}
|
||||
|
||||
$targetDate = new DateTimeImmutable($date);
|
||||
if ((int) $targetDate->format('N') > 5) {
|
||||
throw new \DomainException('Le créneau doit être positionné entre le lundi et le vendredi.');
|
||||
}
|
||||
|
||||
$conflictStmt = $this->pdo->prepare(
|
||||
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
INNER JOIN structure st ON st.id_structure = p.id_structure
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND c.id_creneau <> :entry_id
|
||||
AND c.date_jour = :date_jour
|
||||
AND c.heure_debut < :heure_fin
|
||||
AND c.heure_fin > :heure_debut
|
||||
LIMIT 1
|
||||
FOR UPDATE"
|
||||
);
|
||||
$conflictStmt->execute([
|
||||
'agent_id' => (int) $entry['id_agent'],
|
||||
'entry_id' => $entryId,
|
||||
'date_jour' => $date,
|
||||
'heure_debut' => $start,
|
||||
'heure_fin' => $end,
|
||||
]);
|
||||
$conflict = $conflictStmt->fetch();
|
||||
if ($conflict) {
|
||||
throw new \DomainException(sprintf(
|
||||
'Double affectation impossible : un autre créneau existe déjà sur le lieu « %s » de %s à %s (%s).',
|
||||
$conflict['structure_nom'],
|
||||
$conflict['heure_debut'],
|
||||
$conflict['heure_fin'],
|
||||
strtolower((string) $conflict['statut'])
|
||||
));
|
||||
}
|
||||
|
||||
$targetYear = (int) $targetDate->format('o');
|
||||
$targetWeekNumber = (int) $targetDate->format('W');
|
||||
$targetWeekStart = $targetDate->modify('-' . ((int) $targetDate->format('N') - 1) . ' days')->setTime(0, 0);
|
||||
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetWeekStart, $targetWeekStart->modify('+6 days'));
|
||||
|
||||
$sourcePlanningId = (int) $entry['id_planning'];
|
||||
$targetPlanning = $this->findOrCreatePlanning(
|
||||
(int) $entry['id_agent'],
|
||||
$targetWeekId,
|
||||
$targetStructureId
|
||||
);
|
||||
$targetPlanningId = (int) $targetPlanning['id_planning'];
|
||||
|
||||
$updateEntry = $this->pdo->prepare(
|
||||
"UPDATE creneau_horaire
|
||||
SET id_planning = :planning_id,
|
||||
id_motif = :motif_id,
|
||||
date_jour = :date_jour,
|
||||
heure_debut = :heure_debut,
|
||||
heure_fin = :heure_fin
|
||||
WHERE id_creneau = :entry_id"
|
||||
);
|
||||
$updateEntry->execute([
|
||||
'planning_id' => $targetPlanningId,
|
||||
'motif_id' => $motifId,
|
||||
'date_jour' => $date,
|
||||
'heure_debut' => $start,
|
||||
'heure_fin' => $end,
|
||||
'entry_id' => $entryId,
|
||||
]);
|
||||
|
||||
$reopenedPlanningIds = [];
|
||||
if ((string) $entry['statut'] === 'VALIDE') {
|
||||
$reopenedPlanningIds[] = $sourcePlanningId;
|
||||
}
|
||||
if ((string) $targetPlanning['statut'] === 'VALIDE') {
|
||||
$reopenedPlanningIds[] = $targetPlanningId;
|
||||
}
|
||||
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return [
|
||||
'entry_id' => $entryId,
|
||||
'agent_id' => (int) $entry['id_agent'],
|
||||
'year' => $targetYear,
|
||||
'week' => $targetWeekNumber,
|
||||
'source_planning_id' => $sourcePlanningId,
|
||||
'target_planning_id' => $targetPlanningId,
|
||||
'reopened_planning_ids' => $reopenedPlanningIds,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un créneau depuis la vue par agent.
|
||||
* Si le planning était validé, il repasse en brouillon afin de signaler
|
||||
* que son contenu a été modifié. Le planning est conservé même s'il ne
|
||||
* contient plus aucun créneau.
|
||||
*/
|
||||
public function deleteEntryFromAgentView(int $entryId): array
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$entryStmt = $this->pdo->prepare(
|
||||
"SELECT c.id_creneau, c.id_planning, c.date_jour,
|
||||
p.id_agent, p.statut,
|
||||
sem.annee, sem.numero_semaine
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
WHERE c.id_creneau = :entry_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$entryStmt->execute(['entry_id' => $entryId]);
|
||||
$entry = $entryStmt->fetch();
|
||||
|
||||
if (!$entry) {
|
||||
throw new \DomainException('Le créneau à supprimer est introuvable.');
|
||||
}
|
||||
|
||||
$deleteStmt = $this->pdo->prepare(
|
||||
'DELETE FROM creneau_horaire WHERE id_creneau = :entry_id'
|
||||
);
|
||||
$deleteStmt->execute(['entry_id' => $entryId]);
|
||||
|
||||
if ($deleteStmt->rowCount() !== 1) {
|
||||
throw new \DomainException('Le créneau a déjà été supprimé ou modifié. Rechargez la page.');
|
||||
}
|
||||
|
||||
$reopened = (string) $entry['statut'] === 'VALIDE';
|
||||
if ($reopened) {
|
||||
$reopenStmt = $this->pdo->prepare(
|
||||
"UPDATE planning
|
||||
SET statut = 'BROUILLON'
|
||||
WHERE id_planning = :planning_id"
|
||||
);
|
||||
$reopenStmt->execute(['planning_id' => (int) $entry['id_planning']]);
|
||||
}
|
||||
|
||||
$countStmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id'
|
||||
);
|
||||
$countStmt->execute(['planning_id' => (int) $entry['id_planning']]);
|
||||
$remainingEntries = (int) $countStmt->fetchColumn();
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return [
|
||||
'entry_id' => $entryId,
|
||||
'planning_id' => (int) $entry['id_planning'],
|
||||
'agent_id' => (int) $entry['id_agent'],
|
||||
'year' => (int) $entry['annee'],
|
||||
'week' => (int) $entry['numero_semaine'],
|
||||
'date' => (string) $entry['date_jour'],
|
||||
'reopened' => $reopened,
|
||||
'remaining_entries' => $remainingEntries,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déplace ou duplique un créneau vers n'importe quel jour ouvré.
|
||||
* Le lieu, le motif et les horaires sont conservés, y compris lors d'un
|
||||
* changement de semaine.
|
||||
*/
|
||||
public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$entryStmt = $this->pdo->prepare(
|
||||
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
|
||||
p.id_agent, p.id_structure, p.id_semaine, p.statut,
|
||||
sem.annee, sem.numero_semaine
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
WHERE c.id_creneau = :entry_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$entryStmt->execute(['entry_id' => $entryId]);
|
||||
$entry = $entryStmt->fetch();
|
||||
if (!$entry) {
|
||||
throw new \DomainException('Le créneau à déplacer est introuvable.');
|
||||
}
|
||||
|
||||
$targetDateObject = new DateTimeImmutable($targetDate);
|
||||
if ((int) $targetDateObject->format('N') > 5) {
|
||||
throw new \DomainException('Le jour cible doit être compris entre le lundi et le vendredi.');
|
||||
}
|
||||
|
||||
if (!$duplicate && $targetDate === (string) $entry['date_jour']) {
|
||||
$this->pdo->commit();
|
||||
return [
|
||||
'entry_id' => $entryId,
|
||||
'created_entry_id' => null,
|
||||
'agent_id' => (int) $entry['id_agent'],
|
||||
'year' => (int) $entry['annee'],
|
||||
'week' => (int) $entry['numero_semaine'],
|
||||
'planning_id' => (int) $entry['id_planning'],
|
||||
'target_planning_id' => (int) $entry['id_planning'],
|
||||
'reopened_planning_ids' => [],
|
||||
'action' => 'noop',
|
||||
];
|
||||
}
|
||||
|
||||
$excludeEntryId = $duplicate ? 0 : $entryId;
|
||||
$conflictStmt = $this->pdo->prepare(
|
||||
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
|
||||
FROM creneau_horaire c
|
||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||
INNER JOIN structure st ON st.id_structure = p.id_structure
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND (:exclude_entry_id = 0 OR c.id_creneau <> :exclude_entry_id_compare)
|
||||
AND c.date_jour = :date_jour
|
||||
AND c.heure_debut < :heure_fin
|
||||
AND c.heure_fin > :heure_debut
|
||||
LIMIT 1
|
||||
FOR UPDATE"
|
||||
);
|
||||
$conflictStmt->execute([
|
||||
'agent_id' => (int) $entry['id_agent'],
|
||||
'exclude_entry_id' => $excludeEntryId,
|
||||
'exclude_entry_id_compare' => $excludeEntryId,
|
||||
'date_jour' => $targetDate,
|
||||
'heure_fin' => $entry['heure_fin'],
|
||||
'heure_debut' => $entry['heure_debut'],
|
||||
]);
|
||||
$conflict = $conflictStmt->fetch();
|
||||
if ($conflict) {
|
||||
throw new \DomainException(sprintf(
|
||||
'Action impossible : l’agent possède déjà un créneau sur le lieu « %s » de %s à %s (%s).',
|
||||
$conflict['structure_nom'],
|
||||
$conflict['heure_debut'],
|
||||
$conflict['heure_fin'],
|
||||
strtolower((string) $conflict['statut'])
|
||||
));
|
||||
}
|
||||
|
||||
$targetYear = (int) $targetDateObject->format('o');
|
||||
$targetWeekNumber = (int) $targetDateObject->format('W');
|
||||
$targetMonday = $targetDateObject->modify('-' . ((int) $targetDateObject->format('N') - 1) . ' days')->setTime(0, 0);
|
||||
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
|
||||
$targetPlanning = $this->findOrCreatePlanning(
|
||||
(int) $entry['id_agent'],
|
||||
$targetWeekId,
|
||||
(int) $entry['id_structure']
|
||||
);
|
||||
$targetPlanningId = (int) $targetPlanning['id_planning'];
|
||||
$sourcePlanningId = (int) $entry['id_planning'];
|
||||
|
||||
$createdEntryId = null;
|
||||
if ($duplicate) {
|
||||
$insertStmt = $this->pdo->prepare(
|
||||
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
|
||||
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
|
||||
);
|
||||
$insertStmt->execute([
|
||||
'planning_id' => $targetPlanningId,
|
||||
'motif_id' => (int) $entry['id_motif'],
|
||||
'date_jour' => $targetDate,
|
||||
'heure_debut' => $entry['heure_debut'],
|
||||
'heure_fin' => $entry['heure_fin'],
|
||||
]);
|
||||
$createdEntryId = (int) $this->pdo->lastInsertId();
|
||||
} else {
|
||||
$moveStmt = $this->pdo->prepare(
|
||||
"UPDATE creneau_horaire
|
||||
SET id_planning = :target_planning_id,
|
||||
date_jour = :date_jour
|
||||
WHERE id_creneau = :entry_id"
|
||||
);
|
||||
$moveStmt->execute([
|
||||
'target_planning_id' => $targetPlanningId,
|
||||
'date_jour' => $targetDate,
|
||||
'entry_id' => $entryId,
|
||||
]);
|
||||
}
|
||||
|
||||
$reopenedPlanningIds = [];
|
||||
if (!$duplicate && (string) $entry['statut'] === 'VALIDE') {
|
||||
$reopenedPlanningIds[] = $sourcePlanningId;
|
||||
}
|
||||
if ((string) $targetPlanning['statut'] === 'VALIDE') {
|
||||
$reopenedPlanningIds[] = $targetPlanningId;
|
||||
}
|
||||
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return [
|
||||
'entry_id' => $entryId,
|
||||
'created_entry_id' => $createdEntryId,
|
||||
'agent_id' => (int) $entry['id_agent'],
|
||||
'year' => $targetYear,
|
||||
'week' => $targetWeekNumber,
|
||||
'planning_id' => $sourcePlanningId,
|
||||
'target_planning_id' => $targetPlanningId,
|
||||
'reopened_planning_ids' => $reopenedPlanningIds,
|
||||
'action' => $duplicate ? 'duplicate' : 'move',
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function copyAgentWeek(
|
||||
int $agentId,
|
||||
int $sourceYear,
|
||||
int $sourceWeekNumber,
|
||||
int $targetYear,
|
||||
int $targetWeekNumber,
|
||||
string $mode
|
||||
): array {
|
||||
if (!in_array($mode, ['merge', 'replace'], true)) {
|
||||
throw new \DomainException('Mode de duplication invalide.');
|
||||
}
|
||||
if ($sourceYear === $targetYear && $sourceWeekNumber === $targetWeekNumber) {
|
||||
throw new \DomainException('La semaine cible doit être différente de la semaine source.');
|
||||
}
|
||||
|
||||
$sourceStmt = $this->pdo->prepare(
|
||||
"SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine,
|
||||
p.id_structure, c.id_motif,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin
|
||||
FROM planning p
|
||||
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND sem.annee = :annee
|
||||
AND sem.numero_semaine = :semaine
|
||||
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
|
||||
ORDER BY c.date_jour, c.heure_debut"
|
||||
);
|
||||
$sourceStmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'annee' => $sourceYear,
|
||||
'semaine' => $sourceWeekNumber,
|
||||
]);
|
||||
$sourceEntries = $sourceStmt->fetchAll();
|
||||
if (!$sourceEntries) {
|
||||
throw new \DomainException('La semaine source ne contient aucun créneau à dupliquer.');
|
||||
}
|
||||
|
||||
$targetMonday = (new DateTimeImmutable())->setISODate($targetYear, $targetWeekNumber, 1)->setTime(0, 0);
|
||||
$targetEntries = array_map(static function (array $entry) use ($targetMonday): array {
|
||||
return [
|
||||
'date_jour' => $targetMonday->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d'),
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
'heure_debut' => (string) $entry['heure_debut'],
|
||||
'heure_fin' => (string) $entry['heure_fin'],
|
||||
];
|
||||
}, $sourceEntries);
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
|
||||
|
||||
$existingPlanningStmt = $this->pdo->prepare(
|
||||
"SELECT id_planning, id_structure, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$existingPlanningStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
|
||||
$existingPlannings = $existingPlanningStmt->fetchAll();
|
||||
|
||||
$reopened = [];
|
||||
if ($mode === 'replace') {
|
||||
if ($existingPlannings) {
|
||||
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
|
||||
foreach ($existingPlannings as $planning) {
|
||||
if ((string) $planning['statut'] === 'VALIDE') {
|
||||
$reopened[] = (int) $planning['id_planning'];
|
||||
}
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
|
||||
$delete->execute($ids);
|
||||
}
|
||||
} else {
|
||||
$existingEntriesStmt = $this->pdo->prepare(
|
||||
"SELECT c.date_jour, c.heure_debut, c.heure_fin, st.nom AS structure_nom
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN structure st ON st.id_structure = p.id_structure
|
||||
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$existingEntriesStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
|
||||
$existingEntries = $existingEntriesStmt->fetchAll();
|
||||
foreach ($targetEntries as $candidate) {
|
||||
foreach ($existingEntries as $existing) {
|
||||
if ($candidate['date_jour'] === $existing['date_jour']
|
||||
&& $candidate['heure_debut'] < $existing['heure_fin']
|
||||
&& $candidate['heure_fin'] > $existing['heure_debut']) {
|
||||
throw new \DomainException(sprintf(
|
||||
'Duplication impossible : un créneau existe déjà le %s de %s à %s sur « %s ».',
|
||||
$candidate['date_jour'],
|
||||
substr((string) $existing['heure_debut'], 0, 5),
|
||||
substr((string) $existing['heure_fin'], 0, 5),
|
||||
$existing['structure_nom']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$planningCache = [];
|
||||
foreach ($existingPlannings as $planning) {
|
||||
$planningCache[(int) $planning['id_structure']] = [
|
||||
'id_planning' => (int) $planning['id_planning'],
|
||||
'statut' => (string) $planning['statut'],
|
||||
];
|
||||
}
|
||||
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
|
||||
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
|
||||
);
|
||||
|
||||
foreach ($targetEntries as $candidate) {
|
||||
$structureId = (int) $candidate['id_structure'];
|
||||
if (!isset($planningCache[$structureId])) {
|
||||
$planningCache[$structureId] = $this->findOrCreatePlanning($agentId, $targetWeekId, $structureId);
|
||||
}
|
||||
$planning = $planningCache[$structureId];
|
||||
if ((string) $planning['statut'] === 'VALIDE') {
|
||||
$reopened[] = (int) $planning['id_planning'];
|
||||
}
|
||||
$insertEntry->execute([
|
||||
'planning_id' => (int) $planning['id_planning'],
|
||||
'motif_id' => (int) $candidate['id_motif'],
|
||||
'date_jour' => $candidate['date_jour'],
|
||||
'heure_debut' => $candidate['heure_debut'],
|
||||
'heure_fin' => $candidate['heure_fin'],
|
||||
]);
|
||||
}
|
||||
|
||||
$reopened = $this->reopenPlanningIds($reopened);
|
||||
$this->pdo->commit();
|
||||
|
||||
return [
|
||||
'agent_id' => $agentId,
|
||||
'year' => $targetYear,
|
||||
'week' => $targetWeekNumber,
|
||||
'copied_entries' => count($targetEntries),
|
||||
'reopened_planning_ids' => $reopened,
|
||||
'mode' => $mode,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
|
||||
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
|
||||
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
|
||||
);
|
||||
$stmt->execute([
|
||||
'annee' => $year,
|
||||
'numero' => $weekNumber,
|
||||
'date_debut' => $weekStart->format('Y-m-d'),
|
||||
'date_fin' => $weekEnd->format('Y-m-d'),
|
||||
]);
|
||||
$weekId = (int) $this->pdo->lastInsertId();
|
||||
if ($weekId > 0) {
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
$find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero');
|
||||
$find->execute(['annee' => $year, 'numero' => $weekNumber]);
|
||||
$weekId = (int) $find->fetchColumn();
|
||||
if ($weekId <= 0) {
|
||||
throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.');
|
||||
}
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
/** @return array{id_planning:int,statut:string} */
|
||||
private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id_planning, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planning = $stmt->fetch();
|
||||
if ($planning) {
|
||||
return ['id_planning' => (int) $planning['id_planning'], 'statut' => (string) $planning['statut']];
|
||||
}
|
||||
|
||||
$insert = $this->pdo->prepare(
|
||||
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
|
||||
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
|
||||
);
|
||||
$insert->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON'];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function reopenPlanningIds(array $planningIds): array
|
||||
{
|
||||
$planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds))));
|
||||
if (!$planningIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($planningIds);
|
||||
return $planningIds;
|
||||
}
|
||||
|
||||
|
||||
/** @param int[] $planningIds */
|
||||
public function markValidatedMany(array $planningIds): int
|
||||
{
|
||||
$planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0)));
|
||||
if ($planningIds === []) {
|
||||
return 0;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
"UPDATE planning
|
||||
SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP
|
||||
WHERE statut = 'BROUILLON' AND id_planning IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($planningIds);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function markValidated(int $planningId): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP WHERE id_planning = :planning_id"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
}
|
||||
|
||||
public function reopen(int $planningId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"UPDATE planning
|
||||
SET statut = 'BROUILLON', date_validation = NULL
|
||||
WHERE id_planning = :planning_id AND statut = 'VALIDE'"
|
||||
);
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
return $stmt->rowCount() === 1;
|
||||
}
|
||||
}
|
||||
642
app/Models/PtaModel.php
Normal file
642
app/Models/PtaModel.php
Normal file
@@ -0,0 +1,642 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class PtaModel extends BaseModel
|
||||
{
|
||||
private const WORK_CODES = [
|
||||
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR',
|
||||
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
|
||||
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
|
||||
];
|
||||
|
||||
private const PREPARATION_CODES = [
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
||||
];
|
||||
|
||||
public function summary(int $agentId, string $contextDate): array
|
||||
{
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
$agent = $agentModel->findActive($agentId);
|
||||
if ($agent === null) {
|
||||
throw new RuntimeException('Agent introuvable.');
|
||||
}
|
||||
|
||||
$quota = (new QuotaModel($this->pdo))->forDateForApi($agentId, $contextDate);
|
||||
$pta = $this->ensurePta($agent, $quota);
|
||||
$entries = $agentModel->entriesForDateRange(
|
||||
$agentId,
|
||||
(string) $quota['date_debut_periode'],
|
||||
(string) $quota['date_fin_periode']
|
||||
);
|
||||
$vacations = (new VacationModel($this->pdo))->periodsBetween(
|
||||
(string) $quota['date_debut_periode'],
|
||||
(string) $quota['date_fin_periode']
|
||||
);
|
||||
|
||||
$analysis = $this->analyseEntries($agent, $entries, $vacations, $quota);
|
||||
$constraints = $this->constraintsForAgent(
|
||||
$agentId,
|
||||
(string) $quota['date_debut_periode'],
|
||||
(string) $quota['date_fin_periode']
|
||||
);
|
||||
$wishes = $this->wishesForAgent($agentId);
|
||||
$controls = array_merge(
|
||||
$this->eligibilityControls($agent),
|
||||
$analysis['controls'],
|
||||
$this->constraintControls($analysis, $constraints),
|
||||
$this->deepCleaningControls($agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode'])
|
||||
);
|
||||
|
||||
usort($controls, static function (array $a, array $b): int {
|
||||
$order = ['ERREUR' => 0, 'ALERTE' => 1, 'INFO' => 2];
|
||||
return ($order[$a['niveau']] ?? 9) <=> ($order[$b['niveau']] ?? 9);
|
||||
});
|
||||
|
||||
return [
|
||||
'agent' => $agent,
|
||||
'pta' => $pta,
|
||||
'quota' => $quota,
|
||||
'categories' => $analysis['categories'],
|
||||
'counters' => $analysis['counters'],
|
||||
'controls' => $controls,
|
||||
'constraints' => $constraints,
|
||||
'wishes' => $wishes,
|
||||
'vacations' => $vacations,
|
||||
];
|
||||
}
|
||||
|
||||
public function saveConstraint(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO agent_contrainte (
|
||||
id_agent, type_contrainte, date_debut, date_fin,
|
||||
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
|
||||
interdit_matin, interdit_midi, interdit_soir, commentaire, actif
|
||||
) VALUES (
|
||||
:id_agent, :type_contrainte, :date_debut, :date_fin,
|
||||
:quotite_temporaire, :maximum_minutes_jour, :maximum_minutes_semaine,
|
||||
:interdit_matin, :interdit_midi, :interdit_soir, :commentaire, TRUE
|
||||
)"
|
||||
);
|
||||
$stmt->execute($data);
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function deleteConstraint(int $constraintId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM agent_contrainte WHERE id_contrainte = :id AND id_agent = :agent_id'
|
||||
);
|
||||
$stmt->execute(['id' => $constraintId, 'agent_id' => $agentId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
public function saveWish(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO agent_structure_souhait (
|
||||
id_agent, id_structure, type_souhait, priorite, distance_km, commentaire, actif
|
||||
) VALUES (
|
||||
:id_agent, :id_structure, :type_souhait, :priorite, :distance_km, :commentaire, TRUE
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
type_souhait = VALUES(type_souhait),
|
||||
priorite = VALUES(priorite),
|
||||
distance_km = VALUES(distance_km),
|
||||
commentaire = VALUES(commentaire),
|
||||
actif = TRUE"
|
||||
);
|
||||
$stmt->execute($data);
|
||||
return (int) ($this->pdo->lastInsertId() ?: 0);
|
||||
}
|
||||
|
||||
public function deleteWish(int $wishId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM agent_structure_souhait WHERE id_souhait = :id AND id_agent = :agent_id'
|
||||
);
|
||||
$stmt->execute(['id' => $wishId, 'agent_id' => $agentId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
private function ensurePta(array $agent, array $quota): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM pta_annuel
|
||||
WHERE id_agent = :agent_id
|
||||
AND date_debut = :date_debut
|
||||
AND date_fin = :date_fin
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agent['id_agent'],
|
||||
'date_debut' => $quota['date_debut_periode'],
|
||||
'date_fin' => $quota['date_fin_periode'],
|
||||
]);
|
||||
$pta = $stmt->fetch();
|
||||
if ($pta) {
|
||||
return $pta;
|
||||
}
|
||||
|
||||
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
||||
$insert = $this->pdo->prepare(
|
||||
"INSERT INTO pta_annuel (
|
||||
id_agent, date_debut, date_fin, quotite_travail,
|
||||
quota_reference_minutes, quota_cible_minutes,
|
||||
enveloppe_formation_minutes, statut
|
||||
) VALUES (
|
||||
:agent_id, :date_debut, :date_fin, :quotite,
|
||||
:quota_reference, :quota_cible, :formation, 'BROUILLON'
|
||||
)"
|
||||
);
|
||||
$insert->execute([
|
||||
'agent_id' => $agent['id_agent'],
|
||||
'date_debut' => $quota['date_debut_periode'],
|
||||
'date_fin' => $quota['date_fin_periode'],
|
||||
'quotite' => $quota['quotite_travail'],
|
||||
'quota_reference' => $quota['quota_reference_minutes'],
|
||||
'quota_cible' => $quota['quota_cible_minutes'],
|
||||
'formation' => $training,
|
||||
]);
|
||||
|
||||
$stmt->execute([
|
||||
'agent_id' => $agent['id_agent'],
|
||||
'date_debut' => $quota['date_debut_periode'],
|
||||
'date_fin' => $quota['date_fin_periode'],
|
||||
]);
|
||||
return $stmt->fetch() ?: [];
|
||||
}
|
||||
|
||||
private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array
|
||||
{
|
||||
$categories = [
|
||||
'PERISCOLAIRE' => 0,
|
||||
'MERCREDI_PERISCOLAIRE' => 0,
|
||||
'EXTRASCOLAIRE' => 0,
|
||||
'FORMATION' => 0,
|
||||
'CONGE' => 0,
|
||||
'JOUR_FRACTIONNEMENT' => 0,
|
||||
'PREPARATION' => 0,
|
||||
'MENAGE_FOND' => 0,
|
||||
'ABSENCE' => 0,
|
||||
'NON_DECOMPTE' => 0,
|
||||
];
|
||||
$daysByCategory = [];
|
||||
$daily = [];
|
||||
$weekly = [];
|
||||
$controls = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$date = (string) $entry['date_jour'];
|
||||
$start = (string) $entry['heure_debut'];
|
||||
$end = (string) $entry['heure_fin'];
|
||||
$minutes = $this->minutesBetween($start, $end);
|
||||
$code = strtoupper((string) $entry['motif_code']);
|
||||
$isVacation = $this->vacationForDate($date, $vacations) !== null;
|
||||
$weekday = (int) (new DateTimeImmutable($date))->format('N');
|
||||
$category = $this->categoryForEntry($code, $isVacation, $weekday, (bool) $entry['compte_dans_quota']);
|
||||
|
||||
$categories[$category] = ($categories[$category] ?? 0) + $minutes;
|
||||
$daysByCategory[$category][$date] = true;
|
||||
|
||||
$daily[$date] ??= ['minutes' => 0, 'starts' => [], 'ends' => [], 'entries' => []];
|
||||
$daily[$date]['minutes'] += $minutes;
|
||||
$daily[$date]['starts'][] = $start;
|
||||
$daily[$date]['ends'][] = $end;
|
||||
$daily[$date]['entries'][] = [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'minutes' => $minutes,
|
||||
'code' => $code,
|
||||
'category' => $category,
|
||||
];
|
||||
|
||||
$monday = (new DateTimeImmutable($date))->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
|
||||
$weekly[$monday] = ($weekly[$monday] ?? 0) + $minutes;
|
||||
}
|
||||
|
||||
foreach ($daily as $date => &$day) {
|
||||
sort($day['starts']);
|
||||
sort($day['ends']);
|
||||
usort($day['entries'], static fn(array $a, array $b): int => strcmp($a['start'], $b['start']));
|
||||
$first = min($day['starts']);
|
||||
$last = max($day['ends']);
|
||||
$day['amplitude_minutes'] = $this->minutesBetween($first, $last);
|
||||
$day['first'] = $first;
|
||||
$day['last'] = $last;
|
||||
}
|
||||
unset($day);
|
||||
|
||||
foreach ($daily as $date => $day) {
|
||||
if ($day['minutes'] > 600) {
|
||||
$controls[] = $this->control('ERREUR', 'DUREE_QUOTIDIENNE', sprintf(
|
||||
'%s : %s planifiées, au-delà de 10 h de travail quotidien.',
|
||||
$this->formatDate($date), $this->formatMinutes($day['minutes'])
|
||||
));
|
||||
}
|
||||
if ($day['amplitude_minutes'] > 720) {
|
||||
$controls[] = $this->control('ERREUR', 'AMPLITUDE_QUOTIDIENNE', sprintf(
|
||||
'%s : amplitude de %s, au-delà de 12 h.',
|
||||
$this->formatDate($date), $this->formatMinutes($day['amplitude_minutes'])
|
||||
));
|
||||
}
|
||||
|
||||
$continuous = 0;
|
||||
$previousEnd = null;
|
||||
foreach ($day['entries'] as $entry) {
|
||||
if ($previousEnd === null || $this->minutesBetween($previousEnd, $entry['start']) >= 20) {
|
||||
$continuous = $entry['minutes'];
|
||||
} else {
|
||||
$continuous += max(0, $this->minutesBetween(max($previousEnd, $entry['start']), $entry['end']));
|
||||
}
|
||||
$previousEnd = $previousEnd === null || $entry['end'] > $previousEnd ? $entry['end'] : $previousEnd;
|
||||
if ($continuous > 360) {
|
||||
$controls[] = $this->control('ERREUR', 'PAUSE_OBLIGATOIRE', sprintf(
|
||||
'%s : plus de 6 h consécutives sans coupure d’au moins 20 minutes.',
|
||||
$this->formatDate($date)
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dates = array_keys($daily);
|
||||
sort($dates);
|
||||
for ($i = 1, $count = count($dates); $i < $count; $i++) {
|
||||
$previousDate = $dates[$i - 1];
|
||||
$currentDate = $dates[$i];
|
||||
$previousEnd = new DateTimeImmutable($previousDate . ' ' . $daily[$previousDate]['last']);
|
||||
$currentStart = new DateTimeImmutable($currentDate . ' ' . $daily[$currentDate]['first']);
|
||||
$restMinutes = (int) round(($currentStart->getTimestamp() - $previousEnd->getTimestamp()) / 60);
|
||||
if ($restMinutes < 660) {
|
||||
$controls[] = $this->control('ERREUR', 'REPOS_QUOTIDIEN', sprintf(
|
||||
'Repos insuffisant entre le %s et le %s : %s au lieu de 11 h minimum.',
|
||||
$this->formatDate($previousDate), $this->formatDate($currentDate), $this->formatMinutes($restMinutes)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($weekly as $monday => $minutes) {
|
||||
if ($minutes > 2880) {
|
||||
$controls[] = $this->control('ERREUR', 'DUREE_HEBDOMADAIRE', sprintf(
|
||||
'Semaine du %s : %s planifiées, au-delà de 48 h.',
|
||||
$this->formatDate($monday), $this->formatMinutes($minutes)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Contrôle de la moyenne maximale de 44 h sur 12 semaines consécutives.
|
||||
$periodStart = new DateTimeImmutable((string) $quota['date_debut_periode']);
|
||||
$periodEnd = new DateTimeImmutable((string) $quota['date_fin_periode']);
|
||||
$firstMonday = $periodStart->modify('-' . ((int) $periodStart->format('N') - 1) . ' days');
|
||||
$lastMonday = $periodEnd->modify('-' . ((int) $periodEnd->format('N') - 1) . ' days');
|
||||
$weekSeries = [];
|
||||
for ($monday = $firstMonday; $monday <= $lastMonday; $monday = $monday->modify('+7 days')) {
|
||||
$key = $monday->format('Y-m-d');
|
||||
$weekSeries[] = ['date' => $key, 'minutes' => (int) ($weekly[$key] ?? 0)];
|
||||
}
|
||||
for ($index = 0, $totalWeeks = count($weekSeries); $index + 11 < $totalWeeks; $index++) {
|
||||
$window = array_slice($weekSeries, $index, 12);
|
||||
$average = (int) round(array_sum(array_column($window, 'minutes')) / 12);
|
||||
if ($average > 2640) {
|
||||
$controls[] = $this->control('ERREUR', 'MOYENNE_12_SEMAINES', sprintf(
|
||||
'Du %s au %s : moyenne de %s par semaine sur 12 semaines, au-delà de 44 h.',
|
||||
$this->formatDate($window[0]['date']),
|
||||
$this->formatDate((new DateTimeImmutable($window[11]['date']))->modify('+6 days')->format('Y-m-d')),
|
||||
$this->formatMinutes($average)
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$expectedWednesday = (int) ($agent['heures_mercredi_minutes'] ?? 0);
|
||||
$expectedExtra = (int) ($agent['heures_extrascolaire_minutes'] ?? 0);
|
||||
$vacationWeeks = [];
|
||||
foreach ($daily as $date => $day) {
|
||||
$dateObject = new DateTimeImmutable($date);
|
||||
$weekday = (int) $dateObject->format('N');
|
||||
$vacation = $this->vacationForDate($date, $vacations);
|
||||
$workingMinutes = 0;
|
||||
foreach ($day['entries'] as $entry) {
|
||||
if (in_array($entry['code'], self::WORK_CODES, true)) {
|
||||
$workingMinutes += $entry['minutes'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($weekday === 3 && $vacation === null && $workingMinutes > 0 && $expectedWednesday > 0 && $workingMinutes !== $expectedWednesday) {
|
||||
$controls[] = $this->control('ALERTE', 'MERCREDI_DUREE', sprintf(
|
||||
'%s : %s de travail le mercredi scolaire, contre %s attendues pour le poste.',
|
||||
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedWednesday)
|
||||
));
|
||||
}
|
||||
if ($vacation !== null && $workingMinutes > 0 && $expectedExtra > 0 && $workingMinutes !== $expectedExtra) {
|
||||
$controls[] = $this->control('ALERTE', 'EXTRA_DUREE', sprintf(
|
||||
'%s : %s de travail extrascolaire, contre %s attendues pour le poste.',
|
||||
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedExtra)
|
||||
));
|
||||
}
|
||||
if ($vacation !== null && $workingMinutes > 0) {
|
||||
$weekKey = $dateObject->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
|
||||
$vacationWeeks[$weekKey][$date] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (($agent['poste_famille'] ?? null) === 'ANIMATION') {
|
||||
foreach ($vacationWeeks as $weekStart => $workedDays) {
|
||||
$count = count($workedDays);
|
||||
if ($count > 0 && $count < 5) {
|
||||
$controls[] = $this->control('ALERTE', 'VACANCES_SEMAINE_INCOMPLETE', sprintf(
|
||||
'Semaine de vacances du %s : seulement %d jour(s) travaillé(s). La préférence métier est une semaine complète.',
|
||||
$this->formatDate($weekStart), $count
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (($agent['poste_famille'] ?? null) === 'RESTAURATION_ENTRETIEN') {
|
||||
foreach (self::PREPARATION_CODES as $code) {
|
||||
$hasPreparation = false;
|
||||
foreach ($entries as $entry) {
|
||||
if (strtoupper((string) $entry['motif_code']) === $code) {
|
||||
$hasPreparation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($hasPreparation) {
|
||||
$controls[] = $this->control('ERREUR', 'PREPARATION_NON_AUTORISEE',
|
||||
'Un agent de restauration collective et d’entretien ne doit pas recevoir de temps de préparation.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$formationMinutes = $categories['FORMATION'];
|
||||
$formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
||||
if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
|
||||
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
|
||||
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
|
||||
$this->formatMinutes($formationMinutes)
|
||||
));
|
||||
}
|
||||
if ($formationTarget > 0 && $formationMinutes < $formationTarget) {
|
||||
$controls[] = $this->control('INFO', 'FORMATION_RESTANTE', sprintf(
|
||||
'Il reste %s dans l’enveloppe de formation CNFPT de 14 h.',
|
||||
$this->formatMinutes($formationTarget - $formationMinutes)
|
||||
));
|
||||
}
|
||||
|
||||
$caDays = count($daysByCategory['CONGE'] ?? []);
|
||||
$jfDays = count($daysByCategory['JOUR_FRACTIONNEMENT'] ?? []);
|
||||
if ($caDays !== 25) {
|
||||
$controls[] = $this->control('ALERTE', 'CONGES_ANNUELS', sprintf(
|
||||
'%d jour(s) de congé annuel positionné(s) sur un objectif métier de 25 jours.',
|
||||
$caDays
|
||||
));
|
||||
}
|
||||
if ($jfDays !== 2) {
|
||||
$controls[] = $this->control('ALERTE', 'JOURS_FRACTIONNEMENT', sprintf(
|
||||
'%d journée(s) de fractionnement positionnée(s) sur un objectif de 2.',
|
||||
$jfDays
|
||||
));
|
||||
}
|
||||
|
||||
if ($controls === []) {
|
||||
$controls[] = $this->control('INFO', 'OK', 'Aucune anomalie détectée sur le PTA de cette période.');
|
||||
}
|
||||
|
||||
$formattedCategories = [];
|
||||
foreach ($categories as $code => $minutes) {
|
||||
$formattedCategories[] = [
|
||||
'code' => $code,
|
||||
'minutes' => $minutes,
|
||||
'libelle' => $this->categoryLabel($code),
|
||||
'duree' => $this->formatMinutes($minutes),
|
||||
'jours' => count($daysByCategory[$code] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'categories' => $formattedCategories,
|
||||
'counters' => [
|
||||
'conges_annuels_jours' => $caDays,
|
||||
'conges_annuels_cible' => 25,
|
||||
'jours_fractionnement' => $jfDays,
|
||||
'jours_fractionnement_cible' => 2,
|
||||
'formation_minutes' => $formationMinutes,
|
||||
'formation_cible_minutes' => $formationTarget,
|
||||
'formation_duree' => $this->formatMinutes($formationMinutes),
|
||||
'formation_cible_duree' => $this->formatMinutes($formationTarget),
|
||||
'quota_cible' => $quota['quota_cible'],
|
||||
'heures_affectees' => $quota['affectees'],
|
||||
'heures_restantes' => $quota['restantes'],
|
||||
],
|
||||
'controls' => $controls,
|
||||
'daily' => $daily,
|
||||
'weekly' => $weekly,
|
||||
];
|
||||
}
|
||||
|
||||
private function eligibilityControls(array $agent): array
|
||||
{
|
||||
$controls = [];
|
||||
if (($agent['type_contrat'] ?? '') !== 'PERMANENT') {
|
||||
$controls[] = $this->control('ERREUR', 'CONTRAT_NON_PERMANENT',
|
||||
'Le dispositif PTA est réservé ici aux agents en contrat permanent.');
|
||||
}
|
||||
if (empty($agent['id_poste'])) {
|
||||
$controls[] = $this->control('ERREUR', 'POSTE_MANQUANT',
|
||||
'Le poste de l’agent doit être renseigné pour appliquer les règles du PTA.');
|
||||
}
|
||||
return $controls;
|
||||
}
|
||||
|
||||
private function constraintControls(array $analysis, array $constraints): array
|
||||
{
|
||||
$controls = [];
|
||||
foreach ($constraints as $constraint) {
|
||||
$label = $constraint['type_contrainte'] === 'TEMPS_PARTIEL_THERAPEUTIQUE'
|
||||
? 'Temps partiel thérapeutique'
|
||||
: 'Préconisation médicale';
|
||||
$controls[] = $this->control('ALERTE', 'CONTRAINTE_PRIORITAIRE', sprintf(
|
||||
'%s du %s au %s : %s',
|
||||
$label,
|
||||
$this->formatDate((string) $constraint['date_debut']),
|
||||
$constraint['date_fin'] ? $this->formatDate((string) $constraint['date_fin']) : 'sans date de fin',
|
||||
(string) $constraint['commentaire']
|
||||
));
|
||||
|
||||
foreach ($analysis['daily'] as $date => $day) {
|
||||
if ($date < $constraint['date_debut'] || ($constraint['date_fin'] && $date > $constraint['date_fin'])) {
|
||||
continue;
|
||||
}
|
||||
if ($constraint['maximum_minutes_jour'] !== null
|
||||
&& $day['minutes'] > (int) $constraint['maximum_minutes_jour']) {
|
||||
$controls[] = $this->control('ERREUR', 'CONTRAINTE_MAX_JOUR', sprintf(
|
||||
'%s : %s planifiées alors que la contrainte limite la journée à %s.',
|
||||
$this->formatDate($date), $this->formatMinutes($day['minutes']),
|
||||
$this->formatMinutes((int) $constraint['maximum_minutes_jour'])
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return $controls;
|
||||
}
|
||||
|
||||
private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c.date_jour, p.id_structure, s.nom AS structure_nom,
|
||||
COUNT(DISTINCT p.id_agent) AS agents,
|
||||
SUM(CASE WHEN p.id_agent = :agent_id THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END) AS minutes_agent
|
||||
FROM creneau_horaire c
|
||||
JOIN planning p ON p.id_planning = c.id_planning
|
||||
JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
JOIN structure s ON s.id_structure = p.id_structure
|
||||
WHERE m.code = 'MENAGE_FOND'
|
||||
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM planning px
|
||||
JOIN creneau_horaire cx ON cx.id_planning = px.id_planning
|
||||
JOIN motif_planning mx ON mx.id_motif = cx.id_motif
|
||||
WHERE px.id_agent = :agent_exists
|
||||
AND px.id_structure = p.id_structure
|
||||
AND cx.date_jour = c.date_jour
|
||||
AND mx.code = 'MENAGE_FOND'
|
||||
)
|
||||
GROUP BY c.date_jour, p.id_structure, s.nom"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'agent_exists' => $agentId,
|
||||
'date_debut' => $startDate,
|
||||
'date_fin' => $endDate,
|
||||
]);
|
||||
$controls = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
if ((int) $row['agents'] < 2) {
|
||||
$controls[] = $this->control('ERREUR', 'MENAGE_EQUIPE', sprintf(
|
||||
'%s — %s : le ménage de fond ne compte que %d agent(s), alors que 2 sont requis.',
|
||||
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], (int) $row['agents']
|
||||
));
|
||||
}
|
||||
if ((int) $row['minutes_agent'] !== 420) {
|
||||
$controls[] = $this->control('ALERTE', 'MENAGE_DUREE', sprintf(
|
||||
'%s — %s : %s de ménage de fond pour l’agent, au lieu de 7 h.',
|
||||
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'],
|
||||
$this->formatMinutes((int) $row['minutes_agent'])
|
||||
));
|
||||
}
|
||||
}
|
||||
return $controls;
|
||||
}
|
||||
|
||||
private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id_contrainte, type_contrainte, date_debut, date_fin,
|
||||
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
|
||||
interdit_matin, interdit_midi, interdit_soir, commentaire
|
||||
FROM agent_contrainte
|
||||
WHERE id_agent = :agent_id
|
||||
AND actif = TRUE
|
||||
AND date_debut <= :date_fin
|
||||
AND COALESCE(date_fin, '9999-12-31') >= :date_debut
|
||||
ORDER BY date_debut DESC"
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function wishesForAgent(int $agentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT sw.id_souhait, sw.id_structure, s.nom AS structure_nom,
|
||||
sw.type_souhait, sw.priorite, sw.distance_km, sw.commentaire
|
||||
FROM agent_structure_souhait sw
|
||||
JOIN structure s ON s.id_structure = sw.id_structure
|
||||
WHERE sw.id_agent = :agent_id AND sw.actif = TRUE
|
||||
ORDER BY FIELD(sw.type_souhait, 'INTERDICTION', 'PREFERENCE'), sw.priorite DESC, s.nom"
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string
|
||||
{
|
||||
return match ($code) {
|
||||
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR' => 'PERISCOLAIRE',
|
||||
'MERCREDI_PERISCOLAIRE' => 'MERCREDI_PERISCOLAIRE',
|
||||
'EXTRASCOLAIRE' => 'EXTRASCOLAIRE',
|
||||
'FORMATION' => 'FORMATION',
|
||||
'CONGE' => 'CONGE',
|
||||
'JOUR_FRACTIONNEMENT' => 'JOUR_FRACTIONNEMENT',
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'PREPARATION',
|
||||
'MENAGE_FOND' => 'MENAGE_FOND',
|
||||
'ABSENCE' => 'ABSENCE',
|
||||
'AUTRE_ABSENCE', 'JNT' => 'NON_DECOMPTE',
|
||||
'TRAVAIL' => $isVacation ? 'EXTRASCOLAIRE' : ($weekday === 3 ? 'MERCREDI_PERISCOLAIRE' : 'PERISCOLAIRE'),
|
||||
default => $counts ? 'PERISCOLAIRE' : 'NON_DECOMPTE',
|
||||
};
|
||||
}
|
||||
|
||||
private function categoryLabel(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
'PERISCOLAIRE' => 'Temps périscolaire lundi, mardi, jeudi et vendredi',
|
||||
'MERCREDI_PERISCOLAIRE' => 'Mercredis périscolaires',
|
||||
'EXTRASCOLAIRE' => 'Temps extrascolaire',
|
||||
'FORMATION' => 'Formation',
|
||||
'CONGE' => 'Congés annuels non décomptés',
|
||||
'JOUR_FRACTIONNEMENT' => 'Journées de fractionnement',
|
||||
'PREPARATION' => 'Temps de préparation',
|
||||
'MENAGE_FOND' => 'Ménages de fond',
|
||||
'ABSENCE' => 'Absences décomptées',
|
||||
'NON_DECOMPTE' => 'JNT et absences non décomptées',
|
||||
default => $code,
|
||||
};
|
||||
}
|
||||
|
||||
private function vacationForDate(string $date, array $vacations): ?array
|
||||
{
|
||||
foreach ($vacations as $vacation) {
|
||||
if ($date >= $vacation['date_debut'] && $date <= $vacation['date_fin']) {
|
||||
return $vacation;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function minutesBetween(string $start, string $end): int
|
||||
{
|
||||
[$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5)));
|
||||
[$eh, $em] = array_map('intval', explode(':', substr($end, 0, 5)));
|
||||
return ($eh * 60 + $em) - ($sh * 60 + $sm);
|
||||
}
|
||||
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$minutes = abs($minutes);
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
private function formatDate(string $date): string
|
||||
{
|
||||
return (new DateTimeImmutable($date))->format('d/m/Y');
|
||||
}
|
||||
|
||||
private function control(string $level, string $code, string $message): array
|
||||
{
|
||||
return ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
}
|
||||
}
|
||||
209
app/Models/QuotaModel.php
Normal file
209
app/Models/QuotaModel.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Suivi du quota PTA sur l'année CIVILE.
|
||||
*
|
||||
* Le PTA est planifié du 1er janvier au 31 décembre de l'année concernée.
|
||||
* La date de début du contrat reste une information RH utile, mais elle ne sert
|
||||
* plus d'ancre pour décaler la période annuelle du quota.
|
||||
*/
|
||||
final class QuotaModel extends BaseModel
|
||||
{
|
||||
public const REFERENCE_MINUTES = 1607 * 60;
|
||||
|
||||
public function createForAgent(int $agentId, int $year, float $rate): void
|
||||
{
|
||||
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO quota_agent_annuel (
|
||||
id_agent, annee, quotite_travail, quota_reference_minutes, quota_cible_minutes, commentaire
|
||||
) VALUES (
|
||||
:agent_id, :annee, :quotite, :quota_reference, :quota_cible, :commentaire
|
||||
)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'annee' => $year,
|
||||
'quotite' => $rate,
|
||||
'quota_reference' => self::REFERENCE_MINUTES,
|
||||
'quota_cible' => $target,
|
||||
'commentaire' => 'Quota PTA de référence pour l’année civile',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le quota de l'année civile contenant la date fournie.
|
||||
*
|
||||
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
|
||||
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
|
||||
*/
|
||||
public function forDate(int $agentId, string $contextDate): array
|
||||
{
|
||||
$period = $this->referencePeriod($agentId, $contextDate);
|
||||
$quota = $this->quotaProfile($agentId, (int) $period['annee_periode']);
|
||||
|
||||
$usedStmt = $this->pdo->prepare(
|
||||
"SELECT
|
||||
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN
|
||||
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
|
||||
ELSE 0 END), 0) AS minutes_valides,
|
||||
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN
|
||||
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
|
||||
ELSE 0 END), 0) AS minutes_brouillon
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||||
AND m.compte_dans_quota = TRUE"
|
||||
);
|
||||
$usedStmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'date_debut' => $period['date_debut_periode'],
|
||||
'date_fin' => $period['date_fin_periode'],
|
||||
]);
|
||||
$used = $usedStmt->fetch() ?: ['minutes_valides' => 0, 'minutes_brouillon' => 0];
|
||||
|
||||
$validated = (int) $used['minutes_valides'];
|
||||
$draft = (int) $used['minutes_brouillon'];
|
||||
$target = (int) $quota['quota_cible_minutes'];
|
||||
$allocated = $validated + $draft;
|
||||
|
||||
return array_merge($period, [
|
||||
'annee' => (int) $period['annee_periode'],
|
||||
'quotite_travail' => (float) $quota['quotite_travail'],
|
||||
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
|
||||
'quota_cible_minutes' => $target,
|
||||
'quota_source_annee' => (int) $quota['quota_source_annee'],
|
||||
'minutes_valides' => $validated,
|
||||
'minutes_brouillon' => $draft,
|
||||
'minutes_affectees' => $allocated,
|
||||
'minutes_restantes' => $target - $allocated,
|
||||
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 2) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function forDateForApi(int $agentId, string $contextDate): array
|
||||
{
|
||||
return $this->enrich($this->forDate($agentId, $contextDate));
|
||||
}
|
||||
|
||||
public function annual(int $agentId, int $year): array
|
||||
{
|
||||
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
|
||||
}
|
||||
|
||||
public function annualForApi(int $agentId, int $year): array
|
||||
{
|
||||
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
|
||||
}
|
||||
|
||||
/**
|
||||
* Période de référence PTA : toujours l'année civile.
|
||||
* La date de contrat est retournée uniquement à titre informatif.
|
||||
*/
|
||||
public function referencePeriod(int $agentId, string $contextDate): array
|
||||
{
|
||||
$context = DateTimeImmutable::createFromFormat('!Y-m-d', $contextDate);
|
||||
if (!$context || $context->format('Y-m-d') !== $contextDate) {
|
||||
throw new InvalidArgumentException('Date de référence du quota invalide.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT date_debut_contrat FROM agent WHERE id_agent = :agent_id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId]);
|
||||
$contractStartValue = $stmt->fetchColumn();
|
||||
|
||||
$year = (int) $context->format('Y');
|
||||
$start = new DateTimeImmutable(sprintf('%04d-01-01', $year));
|
||||
$end = new DateTimeImmutable(sprintf('%04d-12-31', $year));
|
||||
|
||||
return [
|
||||
'date_reference' => $context->format('Y-m-d'),
|
||||
'date_debut_contrat' => $contractStartValue ? (string) $contractStartValue : null,
|
||||
'date_debut_periode' => $start->format('Y-m-d'),
|
||||
'date_fin_periode' => $end->format('Y-m-d'),
|
||||
'annee_periode' => $year,
|
||||
'periode_libelle' => sprintf('année civile %d · du %s au %s', $year, $start->format('d/m/Y'), $end->format('d/m/Y')),
|
||||
'periode_contractuelle' => false,
|
||||
'periode_civile' => true,
|
||||
// Conservé pour compatibilité avec les anciens écrans : la date de
|
||||
// contrat n'est plus requise pour calculer la période du quota.
|
||||
'date_contrat_manquante' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function enrich(array $quota): array
|
||||
{
|
||||
return array_merge($quota, [
|
||||
'quota_cible' => $this->formatMinutes((int) $quota['quota_cible_minutes']),
|
||||
'valides' => $this->formatMinutes((int) $quota['minutes_valides']),
|
||||
'brouillons' => $this->formatMinutes((int) $quota['minutes_brouillon']),
|
||||
'affectees' => $this->formatMinutes((int) $quota['minutes_affectees']),
|
||||
'restantes' => $this->formatMinutes((int) $quota['minutes_restantes']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function formatMinutes(int $minutes): array
|
||||
{
|
||||
$sign = $minutes < 0 ? -1 : 1;
|
||||
$absolute = abs($minutes);
|
||||
$hours = intdiv($absolute, 60);
|
||||
$mins = $absolute % 60;
|
||||
|
||||
return [
|
||||
'minutes' => $minutes,
|
||||
'heures_decimales' => round($minutes / 60, 2),
|
||||
'libelle' => ($sign < 0 ? '-' : '') . $hours . ' h ' . str_pad((string) $mins, 2, '0', STR_PAD_LEFT),
|
||||
];
|
||||
}
|
||||
|
||||
private function quotaProfile(int $agentId, int $year): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT annee, quotite_travail, quota_reference_minutes, quota_cible_minutes
|
||||
FROM quota_agent_annuel
|
||||
WHERE id_agent = :agent_id
|
||||
ORDER BY CASE
|
||||
WHEN annee = :year_exact THEN 0
|
||||
WHEN annee < :year_past THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
CASE WHEN annee <= :year_latest THEN annee END DESC,
|
||||
CASE WHEN annee > :year_future THEN annee END ASC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'year_exact' => $year,
|
||||
'year_past' => $year,
|
||||
'year_latest' => $year,
|
||||
'year_future' => $year,
|
||||
]);
|
||||
$quota = $stmt->fetch();
|
||||
|
||||
if (!$quota) {
|
||||
return [
|
||||
'quotite_travail' => 100.00,
|
||||
'quota_reference_minutes' => self::REFERENCE_MINUTES,
|
||||
'quota_cible_minutes' => self::REFERENCE_MINUTES,
|
||||
'quota_source_annee' => $year,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'quotite_travail' => (float) $quota['quotite_travail'],
|
||||
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
|
||||
'quota_cible_minutes' => (int) $quota['quota_cible_minutes'],
|
||||
'quota_source_annee' => (int) $quota['annee'],
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Models/SchemaModel.php
Normal file
82
app/Models/SchemaModel.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
final class SchemaModel extends BaseModel
|
||||
{
|
||||
public function assertCurrent(): void
|
||||
{
|
||||
$requiredTables = [
|
||||
'agent',
|
||||
'structure',
|
||||
'agent_structure',
|
||||
'motif_planning',
|
||||
'semaine',
|
||||
'planning',
|
||||
'creneau_horaire',
|
||||
'quota_agent_annuel',
|
||||
'modele_semaine_agent',
|
||||
'modele_semaine_creneau',
|
||||
'structure_couverture_horaire',
|
||||
'periode_vacance',
|
||||
'poste_agent',
|
||||
'pta_annuel',
|
||||
'agent_structure_souhait',
|
||||
'agent_contrainte',
|
||||
'equipe_pta',
|
||||
'equipe_pta_membre',
|
||||
'structure_creneau_reference',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT TABLE_NAME
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($requiredTables);
|
||||
$existing = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||
$missingTables = array_values(array_diff($requiredTables, $existing));
|
||||
|
||||
if ($missingTables) {
|
||||
throw new \RuntimeException(
|
||||
'Base PTA incomplète : table(s) manquante(s) : '
|
||||
. implode(', ', $missingTables)
|
||||
. '. Exécutez database/install_complet.sql ou les migrations nécessaires.'
|
||||
);
|
||||
}
|
||||
|
||||
$requiredColumns = [
|
||||
'creneau_horaire' => ['id_motif'],
|
||||
'planning' => ['date_validation'],
|
||||
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'commentaire_pta'],
|
||||
'structure' => ['type_affectation'],
|
||||
];
|
||||
|
||||
foreach ($requiredColumns as $table => $columns) {
|
||||
$columnPlaceholders = implode(',', array_fill(0, count($columns), '?'));
|
||||
$params = array_merge([$table], $columns);
|
||||
$columnStmt = $this->pdo->prepare(
|
||||
"SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME IN ($columnPlaceholders)"
|
||||
);
|
||||
$columnStmt->execute($params);
|
||||
$existingColumns = $columnStmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||
$missingColumns = array_values(array_diff($columns, $existingColumns));
|
||||
|
||||
if ($missingColumns) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Base PTA incomplète : colonne(s) manquante(s) dans %s : %s.',
|
||||
$table,
|
||||
implode(', ', $missingColumns)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Models/StructureModel.php
Normal file
114
app/Models/StructureModel.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
final class StructureModel extends BaseModel
|
||||
{
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE actif = TRUE ORDER BY nom'
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function findActive(int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $id]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function codeExists(string $code): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1');
|
||||
$stmt->execute(['code' => $code]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
public function create(string $code, string $name, ?string $address, string $typeAffectation): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO structure (code, nom, adresse, type_affectation, actif)
|
||||
VALUES (:code, :nom, :adresse, :type_affectation, TRUE)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'code' => $code,
|
||||
'nom' => $name,
|
||||
'adresse' => $address,
|
||||
'type_affectation' => $typeAffectation,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id_structure' => (int) $this->pdo->lastInsertId(),
|
||||
'code' => $code,
|
||||
'nom' => $name,
|
||||
'adresse' => $address,
|
||||
'type_affectation' => $typeAffectation,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateType(int $structureId, string $typeAffectation): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE structure SET type_affectation = :type_affectation WHERE id_structure = :id_structure'
|
||||
);
|
||||
$stmt->execute([
|
||||
'type_affectation' => $typeAffectation,
|
||||
'id_structure' => $structureId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function agentsForOverview(int $structureId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT DISTINCT a.id_agent, a.matricule, a.nom, a.prenom,
|
||||
a.id_structure AS id_structure_principale
|
||||
FROM agent a
|
||||
LEFT JOIN planning p
|
||||
ON p.id_agent = a.id_agent
|
||||
AND p.id_structure = :structure_id_planning
|
||||
LEFT JOIN semaine sem
|
||||
ON sem.id_semaine = p.id_semaine
|
||||
AND sem.annee = :annee
|
||||
AND sem.numero_semaine = :semaine
|
||||
WHERE a.actif = TRUE
|
||||
AND (a.id_structure = :structure_id_default OR sem.id_semaine IS NOT NULL)
|
||||
ORDER BY a.nom, a.prenom"
|
||||
);
|
||||
$stmt->execute([
|
||||
'structure_id_planning' => $structureId,
|
||||
'structure_id_default' => $structureId,
|
||||
'annee' => $year,
|
||||
'semaine' => $week,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function entriesForOverview(int $structureId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT p.id_agent, p.statut, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
|
||||
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
|
||||
FROM planning p
|
||||
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE p.id_structure = :structure_id
|
||||
AND s.annee = :annee
|
||||
AND s.numero_semaine = :semaine
|
||||
ORDER BY p.id_agent, c.date_jour, c.heure_debut"
|
||||
);
|
||||
$stmt->execute([
|
||||
'structure_id' => $structureId,
|
||||
'annee' => $year,
|
||||
'semaine' => $week,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
}
|
||||
236
app/Models/TeamModel.php
Normal file
236
app/Models/TeamModel.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Throwable;
|
||||
|
||||
final class TeamModel extends BaseModel
|
||||
{
|
||||
public function all(?string $startDate = null, ?string $endDate = null): array
|
||||
{
|
||||
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom,
|
||||
e.type_equipe, e.libelle, e.date_debut, e.date_fin,
|
||||
e.nombre_enfants_moins_6, e.nombre_enfants_6_plus,
|
||||
e.ratio_moins_6, e.ratio_6_plus, e.minimum_agents,
|
||||
e.pourcentage_diplomes_min, e.statut, e.commentaire,
|
||||
GREATEST(e.minimum_agents,
|
||||
CEIL(e.nombre_enfants_moins_6 / e.ratio_moins_6)
|
||||
+ CEIL(e.nombre_enfants_6_plus / e.ratio_6_plus)
|
||||
) AS agents_requis,
|
||||
COUNT(em.id_agent) AS agents_affectes,
|
||||
SUM(CASE WHEN a.est_diplome = TRUE THEN 1 ELSE 0 END) AS agents_diplomes,
|
||||
SUM(CASE WHEN em.fonction_equipe = 'RESPONSABLE' THEN 1 ELSE 0 END) AS responsables
|
||||
FROM equipe_pta e
|
||||
JOIN structure s ON s.id_structure = e.id_structure
|
||||
LEFT JOIN equipe_pta_membre em ON em.id_equipe = e.id_equipe
|
||||
LEFT JOIN agent a ON a.id_agent = em.id_agent";
|
||||
$params = [];
|
||||
$conditions = [];
|
||||
if ($startDate !== null) {
|
||||
$conditions[] = 'e.date_fin >= :date_debut';
|
||||
$params['date_debut'] = $startDate;
|
||||
}
|
||||
if ($endDate !== null) {
|
||||
$conditions[] = 'e.date_debut <= :date_fin';
|
||||
$params['date_fin'] = $endDate;
|
||||
}
|
||||
if ($conditions !== []) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $conditions);
|
||||
}
|
||||
$sql .= " GROUP BY e.id_equipe, e.id_structure, s.nom, e.type_equipe, e.libelle,
|
||||
e.date_debut, e.date_fin, e.nombre_enfants_moins_6,
|
||||
e.nombre_enfants_6_plus, e.ratio_moins_6, e.ratio_6_plus,
|
||||
e.minimum_agents, e.pourcentage_diplomes_min, e.statut,
|
||||
e.commentaire
|
||||
ORDER BY e.date_debut, s.nom, e.libelle";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$teams = $stmt->fetchAll();
|
||||
foreach ($teams as &$team) {
|
||||
$team['members'] = $this->members((int) $team['id_equipe']);
|
||||
$team['controls'] = $this->controls($team, $team['members']);
|
||||
}
|
||||
unset($team);
|
||||
return $teams;
|
||||
}
|
||||
|
||||
public function find(int $teamId): ?array
|
||||
{
|
||||
foreach ($this->all() as $team) {
|
||||
if ((int) $team['id_equipe'] === $teamId) {
|
||||
return $team;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function create(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO equipe_pta (
|
||||
id_structure, type_equipe, libelle, date_debut, date_fin,
|
||||
nombre_enfants_moins_6, nombre_enfants_6_plus,
|
||||
ratio_moins_6, ratio_6_plus, minimum_agents,
|
||||
pourcentage_diplomes_min, statut, commentaire
|
||||
) VALUES (
|
||||
:id_structure, :type_equipe, :libelle, :date_debut, :date_fin,
|
||||
:nombre_enfants_moins_6, :nombre_enfants_6_plus,
|
||||
:ratio_moins_6, :ratio_6_plus, :minimum_agents,
|
||||
:pourcentage_diplomes_min, 'BROUILLON', :commentaire
|
||||
)"
|
||||
);
|
||||
$stmt->execute($data);
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void
|
||||
{
|
||||
$team = $this->teamDates($teamId);
|
||||
if ($team === null) {
|
||||
throw new \RuntimeException('Équipe introuvable.');
|
||||
}
|
||||
|
||||
$forbidden = $this->pdo->prepare(
|
||||
"SELECT 1
|
||||
FROM agent_structure_souhait
|
||||
WHERE id_agent = :agent_id
|
||||
AND id_structure = :structure_id
|
||||
AND type_souhait = 'INTERDICTION'
|
||||
AND actif = TRUE
|
||||
LIMIT 1"
|
||||
);
|
||||
$forbidden->execute(['agent_id' => $agentId, 'structure_id' => $team['id_structure']]);
|
||||
if ($forbidden->fetchColumn() !== false) {
|
||||
throw new \RuntimeException('Cet agent a une interdiction active pour ce lieu.');
|
||||
}
|
||||
|
||||
// Les contraintes médicales et thérapeutiques restent prioritaires, mais
|
||||
// l’affectation n’est pas bloquée automatiquement : elles sont signalées
|
||||
// dans les contrôles de l’équipe pour validation humaine.
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO equipe_pta_membre (id_equipe, id_agent, fonction_equipe, commentaire)
|
||||
VALUES (:id_equipe, :id_agent, :fonction_equipe, :commentaire)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
fonction_equipe = VALUES(fonction_equipe),
|
||||
commentaire = VALUES(commentaire)"
|
||||
);
|
||||
$stmt->execute([
|
||||
'id_equipe' => $teamId,
|
||||
'id_agent' => $agentId,
|
||||
'fonction_equipe' => $function,
|
||||
'commentaire' => $comment,
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeMember(int $teamId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM equipe_pta_membre WHERE id_equipe = :team_id AND id_agent = :agent_id'
|
||||
);
|
||||
$stmt->execute(['team_id' => $teamId, 'agent_id' => $agentId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
public function setStatus(int $teamId, string $status): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id');
|
||||
$stmt->execute(['statut' => $status, 'id' => $teamId]);
|
||||
}
|
||||
|
||||
private function members(int $teamId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT em.id_agent, em.fonction_equipe, em.commentaire,
|
||||
a.matricule, a.nom, a.prenom, a.est_diplome,
|
||||
a.diplome_libelle, a.adresse, a.id_structure,
|
||||
po.code AS poste_code, po.libelle AS poste_libelle,
|
||||
s.nom AS structure_principale_nom,
|
||||
sw.type_souhait, sw.priorite, sw.distance_km,
|
||||
(SELECT COUNT(*)
|
||||
FROM agent_contrainte ac
|
||||
WHERE ac.id_agent = a.id_agent
|
||||
AND ac.actif = TRUE
|
||||
AND ac.date_debut <= e.date_fin
|
||||
AND COALESCE(ac.date_fin, '9999-12-31') >= e.date_debut) AS contraintes_actives
|
||||
FROM equipe_pta_membre em
|
||||
JOIN agent a ON a.id_agent = em.id_agent
|
||||
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
LEFT JOIN equipe_pta e ON e.id_equipe = em.id_equipe
|
||||
LEFT JOIN agent_structure_souhait sw
|
||||
ON sw.id_agent = a.id_agent
|
||||
AND sw.id_structure = e.id_structure
|
||||
AND sw.actif = TRUE
|
||||
WHERE em.id_equipe = :team_id
|
||||
ORDER BY FIELD(em.fonction_equipe, 'RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN'), a.nom, a.prenom"
|
||||
);
|
||||
$stmt->execute(['team_id' => $teamId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function controls(array $team, array $members): array
|
||||
{
|
||||
$controls = [];
|
||||
$required = (int) $team['agents_requis'];
|
||||
$assigned = (int) $team['agents_affectes'];
|
||||
if ($assigned < $required) {
|
||||
$controls[] = [
|
||||
'niveau' => 'ERREUR',
|
||||
'code' => 'ENCADREMENT_INSUFFISANT',
|
||||
'message' => sprintf('%d agent(s) affecté(s) pour %d requis.', $assigned, $required),
|
||||
];
|
||||
}
|
||||
|
||||
$qualified = (int) $team['agents_diplomes'];
|
||||
$qualifiedRate = $assigned > 0 ? round(($qualified / $assigned) * 100, 2) : 0;
|
||||
if ($qualifiedRate < (float) $team['pourcentage_diplomes_min']) {
|
||||
$controls[] = [
|
||||
'niveau' => 'ERREUR',
|
||||
'code' => 'QUALIFICATION_INSUFFISANTE',
|
||||
'message' => sprintf('%.2f %% d’agents diplômés pour un minimum paramétré à %.2f %%.', $qualifiedRate, (float) $team['pourcentage_diplomes_min']),
|
||||
];
|
||||
}
|
||||
|
||||
if ((int) $team['responsables'] < 1) {
|
||||
$controls[] = [
|
||||
'niveau' => 'ALERTE',
|
||||
'code' => 'RESPONSABLE_MANQUANT',
|
||||
'message' => 'Aucun responsable n’est identifié dans l’équipe.',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($members as $member) {
|
||||
if ((int) ($member['contraintes_actives'] ?? 0) > 0) {
|
||||
$controls[] = [
|
||||
'niveau' => 'ALERTE',
|
||||
'code' => 'CONTRAINTE_PRIORITAIRE',
|
||||
'message' => sprintf('%s %s possède une préconisation médicale ou un temps partiel thérapeutique actif sur la période.', $member['prenom'], $member['nom']),
|
||||
];
|
||||
}
|
||||
if (($member['type_souhait'] ?? null) === 'PREFERENCE') {
|
||||
$controls[] = [
|
||||
'niveau' => 'INFO',
|
||||
'code' => 'PREFERENCE_RESPECTEE',
|
||||
'message' => sprintf('%s %s a exprimé une préférence pour ce lieu.', $member['prenom'], $member['nom']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($controls === []) {
|
||||
$controls[] = ['niveau' => 'INFO', 'code' => 'OK', 'message' => 'Équipe conforme aux paramètres enregistrés.'];
|
||||
}
|
||||
return $controls;
|
||||
}
|
||||
|
||||
private function teamDates(int $teamId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_structure, date_debut, date_fin FROM equipe_pta WHERE id_equipe = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $teamId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
131
app/Models/VacationModel.php
Normal file
131
app/Models/VacationModel.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PDO;
|
||||
|
||||
final class VacationModel extends BaseModel
|
||||
{
|
||||
public function all(?string $schoolYear = null): array
|
||||
{
|
||||
$sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire,
|
||||
academie, zone, source, date_creation
|
||||
FROM periode_vacance
|
||||
WHERE actif = TRUE";
|
||||
$params = [];
|
||||
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$sql .= ' AND annee_scolaire = :annee_scolaire';
|
||||
$params['annee_scolaire'] = $schoolYear;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY date_debut, libelle';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function saveMany(array $periods): int
|
||||
{
|
||||
if ($periods === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO periode_vacance
|
||||
(libelle, date_debut, date_fin, annee_scolaire, academie, zone, source, actif)
|
||||
VALUES
|
||||
(:libelle, :date_debut, :date_fin, :annee_scolaire, :academie, :zone, :source, TRUE)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
libelle = VALUES(libelle),
|
||||
zone = VALUES(zone),
|
||||
source = VALUES(source),
|
||||
actif = TRUE";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($periods as $period) {
|
||||
$stmt->execute([
|
||||
'libelle' => $period['libelle'],
|
||||
'date_debut' => $period['date_debut'],
|
||||
'date_fin' => $period['date_fin'],
|
||||
'annee_scolaire' => $period['annee_scolaire'],
|
||||
'academie' => $period['academie'],
|
||||
'zone' => $period['zone'],
|
||||
'source' => $period['source'],
|
||||
]);
|
||||
}
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return count($periods);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('DELETE FROM periode_vacance WHERE id_periode = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
public function periodsBetween(string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire, academie, zone, source
|
||||
FROM periode_vacance
|
||||
WHERE actif = TRUE
|
||||
AND date_debut <= :date_fin
|
||||
AND date_fin >= :date_debut
|
||||
ORDER BY date_debut"
|
||||
);
|
||||
$stmt->execute([
|
||||
'date_debut' => $startDate,
|
||||
'date_fin' => $endDate,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le type de période pour les cinq jours ouvrés d'une semaine ISO.
|
||||
* Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE,
|
||||
* sinon elle est PERISCOLAIRE.
|
||||
*/
|
||||
public function typesForIsoWeek(int $year, int $week): array
|
||||
{
|
||||
$monday = (new DateTimeImmutable())->setISODate($year, $week, 1)->setTime(0, 0);
|
||||
$friday = $monday->modify('+4 days');
|
||||
$periods = $this->periodsBetween($monday->format('Y-m-d'), $friday->format('Y-m-d'));
|
||||
$days = [];
|
||||
|
||||
for ($offset = 0; $offset < 5; $offset++) {
|
||||
$date = $monday->modify('+' . $offset . ' days')->format('Y-m-d');
|
||||
$matchingPeriod = null;
|
||||
foreach ($periods as $period) {
|
||||
if ($date >= $period['date_debut'] && $date <= $period['date_fin']) {
|
||||
$matchingPeriod = $period;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$days[] = [
|
||||
'date' => $date,
|
||||
'type_affectation' => $matchingPeriod ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE',
|
||||
'vacances' => $matchingPeriod ? [
|
||||
'id_periode' => (int) $matchingPeriod['id_periode'],
|
||||
'libelle' => (string) $matchingPeriod['libelle'],
|
||||
'annee_scolaire' => (string) $matchingPeriod['annee_scolaire'],
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
}
|
||||
861
app/Models/WeekTemplateModel.php
Normal file
861
app/Models/WeekTemplateModel.php
Normal file
@@ -0,0 +1,861 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DomainException;
|
||||
use Throwable;
|
||||
|
||||
final class WeekTemplateModel extends BaseModel
|
||||
{
|
||||
public function listForAgent(int $agentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT m.id_modele, m.nom, m.date_creation, m.date_modification,
|
||||
COUNT(c.id_modele_creneau) AS nombre_creneaux,
|
||||
COUNT(DISTINCT c.id_structure) AS nombre_lieux
|
||||
FROM modele_semaine_agent m
|
||||
LEFT JOIN modele_semaine_creneau c ON c.id_modele = m.id_modele
|
||||
WHERE m.id_agent = :agent_id
|
||||
GROUP BY m.id_modele, m.nom, m.date_creation, m.date_modification
|
||||
ORDER BY m.nom"
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function findForAgent(int $templateId, int $agentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id_modele, id_agent, nom, date_creation, date_modification
|
||||
FROM modele_semaine_agent
|
||||
WHERE id_modele = :template_id AND id_agent = :agent_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function entries(int $templateId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c.id_modele_creneau, c.jour_semaine, c.id_structure, c.id_motif,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
|
||||
s.nom AS structure_nom,
|
||||
s.type_affectation AS structure_type_affectation,
|
||||
s.actif AS structure_actif,
|
||||
m.libelle AS motif_libelle,
|
||||
m.compte_dans_quota,
|
||||
m.actif AS motif_actif
|
||||
FROM modele_semaine_creneau c
|
||||
INNER JOIN structure s ON s.id_structure = c.id_structure
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE c.id_modele = :template_id
|
||||
ORDER BY c.jour_semaine, c.heure_debut, c.heure_fin, s.nom"
|
||||
);
|
||||
$stmt->execute(['template_id' => $templateId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || strlen($name) > 240) {
|
||||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||||
}
|
||||
|
||||
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
|
||||
if (!$sourceEntries) {
|
||||
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$existingStmt = $this->pdo->prepare(
|
||||
'SELECT id_modele FROM modele_semaine_agent WHERE id_agent = :agent_id AND nom = :nom FOR UPDATE'
|
||||
);
|
||||
$existingStmt->execute(['agent_id' => $agentId, 'nom' => $name]);
|
||||
$existingId = (int) ($existingStmt->fetchColumn() ?: 0);
|
||||
|
||||
if ($existingId > 0 && !$replaceExisting) {
|
||||
throw new DomainException('Une semaine type porte déjà ce nom pour cet agent.');
|
||||
}
|
||||
|
||||
if ($existingId > 0) {
|
||||
$templateId = $existingId;
|
||||
$delete = $this->pdo->prepare('DELETE FROM modele_semaine_creneau WHERE id_modele = :template_id');
|
||||
$delete->execute(['template_id' => $templateId]);
|
||||
$touch = $this->pdo->prepare('UPDATE modele_semaine_agent SET date_modification = CURRENT_TIMESTAMP WHERE id_modele = :template_id');
|
||||
$touch->execute(['template_id' => $templateId]);
|
||||
} else {
|
||||
$insertTemplate = $this->pdo->prepare(
|
||||
'INSERT INTO modele_semaine_agent (id_agent, nom) VALUES (:agent_id, :nom)'
|
||||
);
|
||||
$insertTemplate->execute(['agent_id' => $agentId, 'nom' => $name]);
|
||||
$templateId = (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO modele_semaine_creneau
|
||||
(id_modele, jour_semaine, id_structure, id_motif, heure_debut, heure_fin)
|
||||
VALUES
|
||||
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
|
||||
);
|
||||
|
||||
foreach ($sourceEntries as $entry) {
|
||||
$insertEntry->execute([
|
||||
'template_id' => $templateId,
|
||||
'weekday' => (int) $entry['jour_semaine'],
|
||||
'structure_id' => (int) $entry['id_structure'],
|
||||
'motif_id' => (int) $entry['id_motif'],
|
||||
'start' => $entry['heure_debut'],
|
||||
'end' => $entry['heure_fin'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return [
|
||||
'id_modele' => $templateId,
|
||||
'nom' => $name,
|
||||
'nombre_creneaux' => count($sourceEntries),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function applyToWeek(int $templateId, int $agentId, int $year, int $weekNumber, string $mode): array
|
||||
{
|
||||
if (!in_array($mode, ['merge', 'replace'], true)) {
|
||||
throw new DomainException('Mode d’import invalide.');
|
||||
}
|
||||
|
||||
$template = $this->findForAgent($templateId, $agentId);
|
||||
if ($template === null) {
|
||||
throw new DomainException('Semaine type introuvable pour cet agent.');
|
||||
}
|
||||
|
||||
$templateEntries = $this->entries($templateId);
|
||||
if (!$templateEntries) {
|
||||
throw new DomainException('Cette semaine type ne contient aucun créneau.');
|
||||
}
|
||||
foreach ($templateEntries as $entry) {
|
||||
if (!(bool) $entry['structure_actif']) {
|
||||
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
|
||||
}
|
||||
if (!(bool) $entry['motif_actif']) {
|
||||
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
|
||||
}
|
||||
}
|
||||
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
$weekEnd = $weekStart->modify('+6 days');
|
||||
|
||||
$targetEntries = array_map(static function (array $entry) use ($weekStart): array {
|
||||
$date = $weekStart->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d');
|
||||
return [
|
||||
'date' => $date,
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
'heure_debut' => $entry['heure_debut'],
|
||||
'heure_fin' => $entry['heure_fin'],
|
||||
'structure_nom' => $entry['structure_nom'],
|
||||
];
|
||||
}, $templateEntries);
|
||||
|
||||
$this->assertTemplateNoOverlap($targetEntries);
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
$weekId = $this->ensureWeek($year, $weekNumber, $weekStart, $weekEnd);
|
||||
|
||||
$existingPlanningsStmt = $this->pdo->prepare(
|
||||
"SELECT id_planning, id_structure, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$existingPlanningsStmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
|
||||
$existingPlannings = $existingPlanningsStmt->fetchAll();
|
||||
|
||||
$reopened = [];
|
||||
if ($mode === 'replace') {
|
||||
foreach ($existingPlannings as $planning) {
|
||||
if ($planning['statut'] === 'VALIDE') {
|
||||
$reopened[] = (int) $planning['id_planning'];
|
||||
}
|
||||
}
|
||||
if ($existingPlannings) {
|
||||
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
|
||||
$delete->execute($ids);
|
||||
$reopen = $this->pdo->prepare("UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)");
|
||||
$reopen->execute($ids);
|
||||
}
|
||||
} else {
|
||||
$this->assertNoExistingOverlap($agentId, $weekId, $targetEntries);
|
||||
}
|
||||
|
||||
$planningByStructure = [];
|
||||
foreach ($existingPlannings as $planning) {
|
||||
$planningByStructure[(int) $planning['id_structure']] = [
|
||||
'id_planning' => (int) $planning['id_planning'],
|
||||
'statut' => (string) $planning['statut'],
|
||||
];
|
||||
}
|
||||
|
||||
$selectPlanning = $this->pdo->prepare(
|
||||
"SELECT id_planning, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$insertPlanning = $this->pdo->prepare(
|
||||
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
|
||||
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
|
||||
);
|
||||
$reopenPlanning = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
|
||||
);
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
|
||||
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
|
||||
);
|
||||
|
||||
$inserted = 0;
|
||||
foreach ($targetEntries as $entry) {
|
||||
$structureId = (int) $entry['id_structure'];
|
||||
if (!isset($planningByStructure[$structureId])) {
|
||||
$selectPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planning = $selectPlanning->fetch();
|
||||
if ($planning) {
|
||||
$planningByStructure[$structureId] = [
|
||||
'id_planning' => (int) $planning['id_planning'],
|
||||
'statut' => (string) $planning['statut'],
|
||||
];
|
||||
} else {
|
||||
$insertPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planningByStructure[$structureId] = [
|
||||
'id_planning' => (int) $this->pdo->lastInsertId(),
|
||||
'statut' => 'BROUILLON',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$planningInfo = &$planningByStructure[$structureId];
|
||||
if ($planningInfo['statut'] === 'VALIDE') {
|
||||
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
|
||||
$reopened[] = $planningInfo['id_planning'];
|
||||
$planningInfo['statut'] = 'BROUILLON';
|
||||
}
|
||||
|
||||
$insertEntry->execute([
|
||||
'planning_id' => $planningInfo['id_planning'],
|
||||
'motif_id' => $entry['id_motif'],
|
||||
'date_jour' => $entry['date'],
|
||||
'start' => $entry['heure_debut'],
|
||||
'end' => $entry['heure_fin'],
|
||||
]);
|
||||
$inserted++;
|
||||
unset($planningInfo);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return [
|
||||
'template' => $template,
|
||||
'inserted' => $inserted,
|
||||
'mode' => $mode,
|
||||
'reopened_planning_ids' => array_values(array_unique($reopened)),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function previewPeriod(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $mode,
|
||||
string $periodFilter = 'all'
|
||||
): array {
|
||||
return $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, true);
|
||||
}
|
||||
|
||||
public function applyToPeriod(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $mode,
|
||||
string $periodFilter = 'all'
|
||||
): array {
|
||||
$plan = $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, false);
|
||||
$targetEntries = $plan['target_entries'];
|
||||
|
||||
if ($targetEntries === []) {
|
||||
throw new DomainException(
|
||||
'Aucun créneau de cette semaine type ne correspond au filtre de calendrier sélectionné sur cette période. Vérifiez les dates et les vacances enregistrées dans PTA.'
|
||||
);
|
||||
}
|
||||
|
||||
$targetDates = array_values(array_unique(array_column($targetEntries, 'date')));
|
||||
$targetDateLookup = array_fill_keys($targetDates, true);
|
||||
$reopened = [];
|
||||
$deleted = 0;
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate, true);
|
||||
if ($mode === 'merge') {
|
||||
$conflicts = $this->findConflicts($targetEntries, $existing);
|
||||
if ($conflicts !== []) {
|
||||
$first = $conflicts[0];
|
||||
throw new DomainException(sprintf(
|
||||
'Application impossible en mode « Compléter » : %d conflit%s détecté%s. Premier conflit le %s entre %s-%s et un créneau existant %s-%s à « %s ». Utilisez l’aperçu puis le mode « Remplacer les jours concernés » ou corrigez le planning existant.',
|
||||
count($conflicts),
|
||||
count($conflicts) > 1 ? 's' : '',
|
||||
count($conflicts) > 1 ? 's' : '',
|
||||
$first['date'],
|
||||
substr($first['template_start'], 0, 5),
|
||||
substr($first['template_end'], 0, 5),
|
||||
substr($first['existing_start'], 0, 5),
|
||||
substr($first['existing_end'], 0, 5),
|
||||
$first['existing_structure']
|
||||
));
|
||||
}
|
||||
} else {
|
||||
$entriesToDelete = array_values(array_filter(
|
||||
$existing,
|
||||
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
|
||||
));
|
||||
|
||||
if ($entriesToDelete !== []) {
|
||||
$entryIds = array_map(static fn(array $entry): int => (int) $entry['id_creneau'], $entriesToDelete);
|
||||
$planningIds = [];
|
||||
foreach ($entriesToDelete as $entry) {
|
||||
if ((string) $entry['statut'] === 'VALIDE') {
|
||||
$planningIds[(int) $entry['id_planning']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($entryIds), '?'));
|
||||
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_creneau IN ($placeholders)");
|
||||
$delete->execute($entryIds);
|
||||
$deleted = count($entryIds);
|
||||
|
||||
if ($planningIds !== []) {
|
||||
$ids = array_keys($planningIds);
|
||||
$planningPlaceholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$reopen = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($planningPlaceholders)"
|
||||
);
|
||||
$reopen->execute($ids);
|
||||
$reopened = array_merge($reopened, $ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$weekIds = [];
|
||||
$planningCache = [];
|
||||
$selectPlanning = $this->pdo->prepare(
|
||||
"SELECT id_planning, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$insertPlanning = $this->pdo->prepare(
|
||||
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
|
||||
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
|
||||
);
|
||||
$reopenPlanning = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
|
||||
);
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
|
||||
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
|
||||
);
|
||||
|
||||
$inserted = 0;
|
||||
foreach ($targetEntries as $entry) {
|
||||
$date = new DateTimeImmutable($entry['date']);
|
||||
$isoYear = (int) $date->format('o');
|
||||
$weekNumber = (int) $date->format('W');
|
||||
$weekKey = sprintf('%04d-W%02d', $isoYear, $weekNumber);
|
||||
|
||||
if (!isset($weekIds[$weekKey])) {
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($isoYear, $weekNumber, 1)->setTime(0, 0);
|
||||
$weekIds[$weekKey] = $this->ensureWeek(
|
||||
$isoYear,
|
||||
$weekNumber,
|
||||
$weekStart,
|
||||
$weekStart->modify('+6 days')
|
||||
);
|
||||
}
|
||||
|
||||
$weekId = $weekIds[$weekKey];
|
||||
$structureId = (int) $entry['id_structure'];
|
||||
$planningKey = $weekId . ':' . $structureId;
|
||||
|
||||
if (!isset($planningCache[$planningKey])) {
|
||||
$selectPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planning = $selectPlanning->fetch();
|
||||
if ($planning) {
|
||||
$planningCache[$planningKey] = [
|
||||
'id_planning' => (int) $planning['id_planning'],
|
||||
'statut' => (string) $planning['statut'],
|
||||
];
|
||||
} else {
|
||||
$insertPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planningCache[$planningKey] = [
|
||||
'id_planning' => (int) $this->pdo->lastInsertId(),
|
||||
'statut' => 'BROUILLON',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$planningInfo = &$planningCache[$planningKey];
|
||||
if ($planningInfo['statut'] === 'VALIDE') {
|
||||
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
|
||||
$reopened[] = $planningInfo['id_planning'];
|
||||
$planningInfo['statut'] = 'BROUILLON';
|
||||
}
|
||||
|
||||
$insertEntry->execute([
|
||||
'planning_id' => $planningInfo['id_planning'],
|
||||
'motif_id' => (int) $entry['id_motif'],
|
||||
'date_jour' => $entry['date'],
|
||||
'start' => $entry['heure_debut'],
|
||||
'end' => $entry['heure_fin'],
|
||||
]);
|
||||
$inserted++;
|
||||
unset($planningInfo);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return [
|
||||
'template' => $plan['template'],
|
||||
'inserted' => $inserted,
|
||||
'deleted' => $deleted,
|
||||
'mode' => $mode,
|
||||
'period_filter' => $plan['period_filter'],
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'weeks_touched' => count($weekIds),
|
||||
'days_touched' => count($targetDates),
|
||||
'skipped_count' => $plan['summary']['skipped_count'],
|
||||
'skipped_vacation_count' => $plan['summary']['skipped_vacation_count'],
|
||||
'skipped_school_count' => $plan['summary']['skipped_school_count'],
|
||||
'reopened_planning_ids' => array_values(array_unique(array_map('intval', $reopened))),
|
||||
'years' => $plan['summary']['years'],
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(int $templateId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM modele_semaine_agent WHERE id_modele = :template_id AND id_agent = :agent_id'
|
||||
);
|
||||
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
|
||||
return $stmt->rowCount() === 1;
|
||||
}
|
||||
|
||||
private function sourceWeekEntries(int $agentId, int $year, int $weekNumber): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine,
|
||||
p.id_structure, c.id_motif,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin
|
||||
FROM planning p
|
||||
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND s.annee = :annee
|
||||
AND s.numero_semaine = :week_number
|
||||
AND c.date_jour BETWEEN s.date_debut AND s.date_fin
|
||||
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
|
||||
ORDER BY c.date_jour, c.heure_debut, p.id_structure"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'annee' => $year,
|
||||
'week_number' => $weekNumber,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function buildPeriodPlan(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $mode,
|
||||
string $periodFilter,
|
||||
bool $includeExistingAnalysis
|
||||
): array {
|
||||
if (!in_array($mode, ['merge', 'replace'], true)) {
|
||||
throw new DomainException('Mode d’application invalide.');
|
||||
}
|
||||
if (!in_array($periodFilter, ['all', 'school', 'extra'], true)) {
|
||||
throw new DomainException('Filtre de calendrier invalide.');
|
||||
}
|
||||
|
||||
$start = $this->parseStrictDate($startDate, 'date de début');
|
||||
$end = $this->parseStrictDate($endDate, 'date de fin');
|
||||
if ($end < $start) {
|
||||
throw new DomainException('La date de fin doit être postérieure ou égale à la date de début.');
|
||||
}
|
||||
if ((int) $start->diff($end)->days > 730) {
|
||||
throw new DomainException('La période ne peut pas dépasser deux ans.');
|
||||
}
|
||||
|
||||
$template = $this->findForAgent($templateId, $agentId);
|
||||
if ($template === null) {
|
||||
throw new DomainException('Semaine type introuvable pour cet agent.');
|
||||
}
|
||||
|
||||
$templateEntries = $this->entries($templateId);
|
||||
if ($templateEntries === []) {
|
||||
throw new DomainException('Cette semaine type ne contient aucun créneau.');
|
||||
}
|
||||
foreach ($templateEntries as $entry) {
|
||||
if (!(bool) $entry['structure_actif']) {
|
||||
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
|
||||
}
|
||||
if (!(bool) $entry['motif_actif']) {
|
||||
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
|
||||
}
|
||||
}
|
||||
|
||||
$entriesByWeekday = [];
|
||||
foreach ($templateEntries as $entry) {
|
||||
$entriesByWeekday[(int) $entry['jour_semaine']][] = $entry;
|
||||
}
|
||||
|
||||
$vacationPeriods = (new VacationModel($this->pdo))->periodsBetween($startDate, $endDate);
|
||||
$targetEntries = [];
|
||||
$skippedEntries = [];
|
||||
$days = [];
|
||||
$weeks = [];
|
||||
$years = [];
|
||||
|
||||
for ($date = $start; $date <= $end; $date = $date->modify('+1 day')) {
|
||||
$weekday = (int) $date->format('N');
|
||||
if ($weekday > 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateValue = $date->format('Y-m-d');
|
||||
$vacation = $this->vacationForDate($dateValue, $vacationPeriods);
|
||||
$periodType = $vacation ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE';
|
||||
$dayEntries = $entriesByWeekday[$weekday] ?? [];
|
||||
$eligibleCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($dayEntries as $entry) {
|
||||
$dateIsEligible = match ($periodFilter) {
|
||||
'school' => $periodType === 'PERISCOLAIRE',
|
||||
'extra' => $periodType === 'EXTRASCOLAIRE',
|
||||
default => true,
|
||||
};
|
||||
|
||||
if (!$dateIsEligible) {
|
||||
$skippedCount++;
|
||||
$skippedEntries[] = [
|
||||
'date' => $dateValue,
|
||||
'reason' => $periodType === 'EXTRASCOLAIRE' ? 'VACANCES' : 'HORS_VACANCES',
|
||||
'period_type' => $periodType,
|
||||
'vacation_label' => $vacation['libelle'] ?? null,
|
||||
'structure_name' => (string) $entry['structure_nom'],
|
||||
'start' => (string) $entry['heure_debut'],
|
||||
'end' => (string) $entry['heure_fin'],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$eligibleCount++;
|
||||
$targetEntries[] = [
|
||||
'date' => $dateValue,
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
'heure_debut' => (string) $entry['heure_debut'],
|
||||
'heure_fin' => (string) $entry['heure_fin'],
|
||||
'structure_nom' => (string) $entry['structure_nom'],
|
||||
'motif_libelle' => (string) $entry['motif_libelle'],
|
||||
'period_type' => $periodType,
|
||||
'vacation_label' => $vacation['libelle'] ?? null,
|
||||
];
|
||||
$weeks[$date->format('o-W')] = true;
|
||||
$years[(int) $date->format('Y')] = true;
|
||||
}
|
||||
|
||||
if ($dayEntries !== []) {
|
||||
$days[$dateValue] = [
|
||||
'date' => $dateValue,
|
||||
'weekday' => $weekday,
|
||||
'period_type' => $periodType,
|
||||
'vacation_label' => $vacation['libelle'] ?? null,
|
||||
'template_entry_count' => count($dayEntries),
|
||||
'eligible_count' => $eligibleCount,
|
||||
'skipped_count' => $skippedCount,
|
||||
'conflict_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertTemplateNoOverlap($targetEntries);
|
||||
|
||||
$existing = [];
|
||||
$conflicts = [];
|
||||
$existingOnTargetDates = [];
|
||||
if ($includeExistingAnalysis && $targetEntries !== []) {
|
||||
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate);
|
||||
$conflicts = $this->findConflicts($targetEntries, $existing);
|
||||
$targetDateLookup = array_fill_keys(array_unique(array_column($targetEntries, 'date')), true);
|
||||
$existingOnTargetDates = array_values(array_filter(
|
||||
$existing,
|
||||
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
|
||||
));
|
||||
|
||||
foreach ($conflicts as $conflict) {
|
||||
if (isset($days[$conflict['date']])) {
|
||||
$days[$conflict['date']]['conflict_count']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$skippedVacationCount = count(array_filter(
|
||||
$skippedEntries,
|
||||
static fn(array $entry): bool => $entry['reason'] === 'VACANCES'
|
||||
));
|
||||
$skippedSchoolCount = count($skippedEntries) - $skippedVacationCount;
|
||||
|
||||
return [
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
],
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'mode' => $mode,
|
||||
'period_filter' => $periodFilter,
|
||||
'target_entries' => $targetEntries,
|
||||
'skipped_entries' => $skippedEntries,
|
||||
'conflicts' => $conflicts,
|
||||
'days' => array_values($days),
|
||||
'vacation_periods' => array_map(static fn(array $period): array => [
|
||||
'id' => (int) $period['id_periode'],
|
||||
'label' => (string) $period['libelle'],
|
||||
'start_date' => (string) $period['date_debut'],
|
||||
'end_date' => (string) $period['date_fin'],
|
||||
'school_year' => (string) $period['annee_scolaire'],
|
||||
], $vacationPeriods),
|
||||
'summary' => [
|
||||
'entry_count' => count($targetEntries),
|
||||
'target_day_count' => count(array_unique(array_column($targetEntries, 'date'))),
|
||||
'week_count' => count($weeks),
|
||||
'skipped_count' => count($skippedEntries),
|
||||
'skipped_vacation_count' => $skippedVacationCount,
|
||||
'skipped_school_count' => $skippedSchoolCount,
|
||||
'conflict_count' => count($conflicts),
|
||||
'existing_entry_count_on_target_dates' => count($existingOnTargetDates),
|
||||
'years' => array_values(array_map('intval', array_keys($years))),
|
||||
'vacation_period_count' => count($vacationPeriods),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseStrictDate(string $value, string $label): DateTimeImmutable
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
$hasErrors = is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0);
|
||||
if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) {
|
||||
throw new DomainException(sprintf('La %s est invalide.', $label));
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
private function vacationForDate(string $date, array $periods): ?array
|
||||
{
|
||||
foreach ($periods as $period) {
|
||||
if ($date >= $period['date_debut'] && $date <= $period['date_fin']) {
|
||||
return $period;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function existingEntriesBetween(int $agentId, string $startDate, string $endDate, bool $forUpdate = false): array
|
||||
{
|
||||
$sql = "SELECT c.id_creneau, c.id_planning, p.statut, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
|
||||
s.nom AS structure_nom
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN structure s ON s.id_structure = p.id_structure
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND c.date_jour BETWEEN :start_date AND :end_date
|
||||
ORDER BY c.date_jour, c.heure_debut";
|
||||
if ($forUpdate) {
|
||||
$sql .= ' FOR UPDATE';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function findConflicts(array $targetEntries, array $existingEntries): array
|
||||
{
|
||||
$conflicts = [];
|
||||
foreach ($targetEntries as $target) {
|
||||
foreach ($existingEntries as $existing) {
|
||||
if ($target['date'] !== $existing['date_jour']) {
|
||||
continue;
|
||||
}
|
||||
if ($target['heure_debut'] < $existing['heure_fin'] && $target['heure_fin'] > $existing['heure_debut']) {
|
||||
$conflicts[] = [
|
||||
'date' => $target['date'],
|
||||
'template_start' => $target['heure_debut'],
|
||||
'template_end' => $target['heure_fin'],
|
||||
'template_structure' => $target['structure_nom'],
|
||||
'existing_entry_id' => (int) $existing['id_creneau'],
|
||||
'existing_start' => $existing['heure_debut'],
|
||||
'existing_end' => $existing['heure_fin'],
|
||||
'existing_structure' => $existing['structure_nom'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
|
||||
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
|
||||
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
|
||||
);
|
||||
$stmt->execute([
|
||||
'annee' => $year,
|
||||
'numero' => $weekNumber,
|
||||
'date_debut' => $weekStart->format('Y-m-d'),
|
||||
'date_fin' => $weekEnd->format('Y-m-d'),
|
||||
]);
|
||||
$weekId = (int) $this->pdo->lastInsertId();
|
||||
if ($weekId > 0) {
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
$find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero');
|
||||
$find->execute(['annee' => $year, 'numero' => $weekNumber]);
|
||||
$weekId = (int) $find->fetchColumn();
|
||||
if ($weekId <= 0) {
|
||||
throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.');
|
||||
}
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
private function assertTemplateNoOverlap(array $entries): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$first = $entries[$i];
|
||||
$second = $entries[$j];
|
||||
if ($first['date'] === $second['date']
|
||||
&& $first['heure_debut'] < $second['heure_fin']
|
||||
&& $first['heure_fin'] > $second['heure_debut']) {
|
||||
throw new DomainException(sprintf(
|
||||
'La semaine type contient un chevauchement le %s entre %s-%s (%s) et %s-%s (%s).',
|
||||
$first['date'],
|
||||
substr($first['heure_debut'], 0, 5),
|
||||
substr($first['heure_fin'], 0, 5),
|
||||
$first['structure_nom'],
|
||||
substr($second['heure_debut'], 0, 5),
|
||||
substr($second['heure_fin'], 0, 5),
|
||||
$second['structure_nom']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNoExistingOverlap(int $agentId, int $weekId, array $entries): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
|
||||
s.nom AS structure_nom
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN structure s ON s.id_structure = p.id_structure
|
||||
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
|
||||
ORDER BY c.date_jour, c.heure_debut"
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
|
||||
$existing = $stmt->fetchAll();
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
foreach ($existing as $current) {
|
||||
if ($entry['date'] === $current['date_jour']
|
||||
&& $entry['heure_debut'] < $current['heure_fin']
|
||||
&& $entry['heure_fin'] > $current['heure_debut']) {
|
||||
throw new DomainException(sprintf(
|
||||
'Import impossible en mode « compléter » : le modèle %s-%s chevauche un créneau existant %s-%s à « %s » le %s.',
|
||||
substr($entry['heure_debut'], 0, 5),
|
||||
substr($entry['heure_fin'], 0, 5),
|
||||
substr($current['heure_debut'], 0, 5),
|
||||
substr($current['heure_fin'], 0, 5),
|
||||
$current['structure_nom'],
|
||||
$entry['date']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
208
app/Services/AgentHoursContractPdfService.php
Normal file
208
app/Services/AgentHoursContractPdfService.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Génère le document annuel de validation du quota horaire d'un agent.
|
||||
* Le PDF est autonome et ne nécessite aucune dépendance Composer.
|
||||
*/
|
||||
final class AgentHoursContractPdfService
|
||||
{
|
||||
private const PAGE_WIDTH = 595.28;
|
||||
private const PAGE_HEIGHT = 841.89;
|
||||
private const MARGIN = 42.0;
|
||||
|
||||
public function annualContract(array $agent, array $quota): string
|
||||
{
|
||||
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
|
||||
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
|
||||
|
||||
$this->drawHeader($pdf, $agent, $periodLabel, $generatedAt);
|
||||
$this->drawAgentIdentity($pdf, $agent);
|
||||
$this->drawQuotaSummary($pdf, $quota);
|
||||
$this->drawValidationClause($pdf, $periodLabel, $generatedAt);
|
||||
$this->drawSignatures($pdf);
|
||||
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
813,
|
||||
sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')),
|
||||
7
|
||||
);
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true);
|
||||
$pdf->text(self::MARGIN, 71, 'Validation du quota annuel de travail', 12, true);
|
||||
$pdf->text(self::MARGIN, 92, 'Période de référence : ' . $periodLabel, 10);
|
||||
$pdf->text(self::MARGIN + 315, 92, 'Édité le : ' . $generatedAt->format('d/m/Y'), 10);
|
||||
$pdf->line(self::MARGIN, 108, self::PAGE_WIDTH - self::MARGIN, 108, 0.35);
|
||||
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
130,
|
||||
sprintf('%s %s', (string) ($agent['prenom'] ?? ''), (string) ($agent['nom'] ?? '')),
|
||||
14,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private function drawAgentIdentity(SimplePdf $pdf, array $agent): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 150.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$h = 140.0;
|
||||
|
||||
$pdf->fillRect($x, $y, $w, 28, 0.93);
|
||||
$pdf->rect($x, $y, $w, $h, 0.65);
|
||||
$pdf->text($x + 12, $y + 19, 'Informations de l\'agent', 11, true);
|
||||
|
||||
$leftX = $x + 14;
|
||||
$rightX = $x + 278;
|
||||
$row1 = $y + 52;
|
||||
$row2 = $y + 78;
|
||||
$row3 = $y + 102;
|
||||
$row4 = $y + 128;
|
||||
|
||||
$qualification = !empty($agent['est_diplome'])
|
||||
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' - ' . $agent['diplome_libelle'] : '')
|
||||
: 'Non diplômé';
|
||||
|
||||
$pdf->text($leftX, $row1, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9);
|
||||
$pdf->text($rightX, $row1, 'E-mail : ' . (($agent['email'] ?? '') ?: 'Non renseigné'), 9);
|
||||
$pdf->text($leftX, $row2, 'Téléphone : ' . (($agent['telephone'] ?? '') ?: 'Non renseigné'), 9);
|
||||
$pdf->text($rightX, $row2, 'Qualification : ' . $qualification, 9);
|
||||
$pdf->text($leftX, $row3, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9);
|
||||
$pdf->text($rightX, $row3, 'Début du contrat : ' . (!empty($agent['date_debut_contrat']) ? (new DateTimeImmutable((string) $agent['date_debut_contrat']))->format('d/m/Y') : 'Non renseigné'), 9);
|
||||
$address = (string) (($agent['adresse'] ?? '') ?: 'Non renseignée');
|
||||
$addressLines = $pdf->wrap('Adresse : ' . $address, $w - 28, 8.5);
|
||||
$pdf->text($leftX, $row4, (string) ($addressLines[0] ?? 'Adresse : Non renseignée'), 8.5);
|
||||
$pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5);
|
||||
}
|
||||
|
||||
private function drawQuotaSummary(SimplePdf $pdf, array $quota): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 318.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$headerH = 30.0;
|
||||
$rowH = 31.0;
|
||||
|
||||
$rows = [
|
||||
['Quota annuel de référence à 100 %', $this->minutesLabel((int) ($quota['quota_reference_minutes'] ?? 0))],
|
||||
['Quotité de travail applicable', $this->percentLabel((float) ($quota['quotite_travail'] ?? 100))],
|
||||
['Quota annuel cible de l\'agent', (string) ($quota['quota_cible']['libelle'] ?? $this->minutesLabel((int) ($quota['quota_cible_minutes'] ?? 0)))],
|
||||
['Heures validées dans PTA à la date d\'édition', (string) ($quota['valides']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_valides'] ?? 0)))],
|
||||
['Heures encore en brouillon', (string) ($quota['brouillons']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_brouillon'] ?? 0)))],
|
||||
['Solde restant à planifier / affecter', (string) ($quota['restantes']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_restantes'] ?? 0)))],
|
||||
];
|
||||
|
||||
$pdf->fillRect($x, $y, $w, $headerH, 0.90);
|
||||
$pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.60);
|
||||
$pdf->text($x + 12, $y + 20, 'Synthèse du quota horaire', 11, true);
|
||||
|
||||
$labelWidth = 360.0;
|
||||
$valueWidth = $w - $labelWidth;
|
||||
$cursorY = $y + $headerH;
|
||||
|
||||
foreach ($rows as $index => [$label, $value]) {
|
||||
if ($index % 2 === 1) {
|
||||
$pdf->fillRect($x, $cursorY, $w, $rowH, 0.975);
|
||||
}
|
||||
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.82);
|
||||
$pdf->line($x + $labelWidth, $cursorY, $x + $labelWidth, $cursorY + $rowH, 0.82);
|
||||
$pdf->text($x + 12, $cursorY + 20, (string) $label, 8.6, $index === 2);
|
||||
$pdf->text($x + $labelWidth + 12, $cursorY + 20, (string) $value, 9.2, $index === 2);
|
||||
$cursorY += $rowH;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 557.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
|
||||
$pdf->text($x, $y, 'Validation', 11, true);
|
||||
|
||||
$paragraphs = [
|
||||
sprintf(
|
||||
'Le présent document fixe et récapitule le quota annuel de travail applicable à l\'agent pour la période %s. La signature de l\'agent et la contre-signature du responsable valent validation du quota annuel indiqué ci-dessus.',
|
||||
$periodLabel
|
||||
),
|
||||
'Les heures validées et les heures en brouillon constituent un état de suivi à la date d\'édition. Toute modification ultérieure du planning doit faire l\'objet d\'une nouvelle validation dans PTA lorsque cela est nécessaire.',
|
||||
];
|
||||
|
||||
$cursorY = $y + 22;
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
foreach ($pdf->wrap($paragraph, $w, 9) as $line) {
|
||||
$pdf->text($x, $cursorY, $line, 9);
|
||||
$cursorY += 13;
|
||||
}
|
||||
$cursorY += 8;
|
||||
}
|
||||
|
||||
$pdf->text($x, 638, 'Fait à : ____________________________________', 9);
|
||||
$pdf->text($x + 286, 638, 'Le : ____ / ____ / ________', 9);
|
||||
$pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8);
|
||||
}
|
||||
|
||||
private function drawSignatures(SimplePdf $pdf): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 680.0;
|
||||
$gap = 18.0;
|
||||
$w = (self::PAGE_WIDTH - (self::MARGIN * 2) - $gap) / 2;
|
||||
$h = 120.0;
|
||||
|
||||
$pdf->rect($x, $y, $w, $h, 0.55);
|
||||
$pdf->rect($x + $w + $gap, $y, $w, $h, 0.55);
|
||||
|
||||
$pdf->fillRect($x, $y, $w, 28, 0.94);
|
||||
$pdf->fillRect($x + $w + $gap, $y, $w, 28, 0.94);
|
||||
|
||||
$pdf->text($x + 10, $y + 19, 'Signature de l\'agent', 10, true);
|
||||
$pdf->text($x + $w + $gap + 10, $y + 19, 'Contre-signature du responsable', 10, true);
|
||||
|
||||
$pdf->text($x + 10, $y + 48, 'Nom : ______________________________', 8.5);
|
||||
$pdf->text($x + 10, $y + 68, 'Mention « Lu et approuvé » :', 8.5);
|
||||
$pdf->text($x + 10, $y + 101, 'Signature :', 8.5);
|
||||
|
||||
$rightX = $x + $w + $gap + 10;
|
||||
$pdf->text($rightX, $y + 48, 'Nom / qualité : _____________________', 8.5);
|
||||
$pdf->text($rightX, $y + 68, 'Mention « Bon pour validation » :', 8.5);
|
||||
$pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5);
|
||||
}
|
||||
|
||||
private function typeLabel(string $type): string
|
||||
{
|
||||
return match (strtoupper($type)) {
|
||||
'EXTRASCOLAIRE' => 'Extrascolaire',
|
||||
'PERISCOLAIRE' => 'Périscolaire',
|
||||
default => 'Non renseigné',
|
||||
};
|
||||
}
|
||||
|
||||
private function percentLabel(float $rate): string
|
||||
{
|
||||
$formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ',');
|
||||
return $formatted . ' %';
|
||||
}
|
||||
|
||||
private function minutesLabel(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$absolute = abs($minutes);
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
|
||||
}
|
||||
}
|
||||
184
app/Services/AgentTimeSummaryPdfService.php
Normal file
184
app/Services/AgentTimeSummaryPdfService.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif.
|
||||
*/
|
||||
final class AgentTimeSummaryPdfService
|
||||
{
|
||||
private const PAGE_WIDTH = 595.28;
|
||||
private const PAGE_HEIGHT = 841.89;
|
||||
private const MARGIN = 42.0;
|
||||
|
||||
public function annualSummary(array $agent, array $quota, array $rows): string
|
||||
{
|
||||
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
|
||||
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
|
||||
$totals = $this->normalizeTotals($rows);
|
||||
|
||||
$this->drawHeader($pdf, $agent, $periodLabel, $generatedAt);
|
||||
$this->drawIdentity($pdf, $agent);
|
||||
$this->drawBreakdown($pdf, $totals);
|
||||
$this->drawResult($pdf, $quota, $totals);
|
||||
$this->drawFooter($pdf, $agent, $generatedAt);
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
|
||||
$pdf->text(self::MARGIN, 58, 'Compte-rendu du temps de l\'agent', 17, true);
|
||||
$pdf->text(self::MARGIN, 79, 'Synthèse des heures planifiées et décomptées pour la période ' . $periodLabel, 9.5);
|
||||
$pdf->text(self::PAGE_WIDTH - 190, 34, 'Édité le ' . $generatedAt->format('d/m/Y'), 8);
|
||||
$pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72);
|
||||
}
|
||||
|
||||
private function drawIdentity(SimplePdf $pdf, array $agent): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 118.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$h = 90.0;
|
||||
|
||||
$pdf->fillRect($x, $y, $w, 28, 0.93);
|
||||
$pdf->rect($x, $y, $w, $h, 0.68);
|
||||
$pdf->text($x + 12, $y + 19, 'Agent', 10.5, true);
|
||||
$pdf->text($x + 12, $y + 50, 'Nom : ' . (($agent['nom'] ?? '') ?: '-'), 9.2);
|
||||
$pdf->text($x + 270, $y + 50, 'Prénom : ' . (($agent['prenom'] ?? '') ?: '-'), 9.2);
|
||||
$pdf->text($x + 12, $y + 73, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9.2);
|
||||
$pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2);
|
||||
}
|
||||
|
||||
private function drawBreakdown(SimplePdf $pdf, array $totals): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 232.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$headerH = 31.0;
|
||||
$rowH = 43.0;
|
||||
|
||||
$rows = [
|
||||
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']],
|
||||
['MALADIE', 'Absence', $totals['MALADIE']],
|
||||
['FORMATION', 'Formation', $totals['FORMATION']],
|
||||
['CONGE', 'Congé', $totals['CONGE']],
|
||||
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']],
|
||||
];
|
||||
|
||||
$pdf->fillRect($x, $y, $w, $headerH, 0.92);
|
||||
$pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.65);
|
||||
$pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
|
||||
|
||||
$cursorY = $y + $headerH;
|
||||
foreach ($rows as [$code, $label, $minutes]) {
|
||||
$palette = $this->palette($code);
|
||||
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
|
||||
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
|
||||
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
|
||||
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true);
|
||||
$cursorY += $rowH;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
|
||||
{
|
||||
$counted = $totals['TRAVAIL'] + $totals['MALADIE'] + $totals['FORMATION'] + $totals['CONGE'] + $totals['OTHER_COUNTED'];
|
||||
$target = (int) ($quota['quota_cible_minutes'] ?? 0);
|
||||
$remaining = $target - $counted;
|
||||
$validated = (int) ($quota['minutes_valides'] ?? 0);
|
||||
$draft = (int) ($quota['minutes_brouillon'] ?? 0);
|
||||
|
||||
$x = self::MARGIN;
|
||||
$y = 507.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
|
||||
$pdf->line($x, $y, $x + $w, $y, 0.55);
|
||||
$pdf->text($x, $y + 32, 'Résultat - heures décomptées', 12, true);
|
||||
$pdf->text($x + $w - 150, $y + 32, $this->minutesLabel($counted), 13, true);
|
||||
$pdf->text($x, $y + 55, sprintf('Dont %s validées et %s en brouillon.', $this->minutesLabel($validated), $this->minutesLabel($draft)), 8.5);
|
||||
|
||||
$boxY = $y + 82;
|
||||
$boxH = 126.0;
|
||||
$pdf->fillRect($x, $boxY, $w, $boxH, 0.965);
|
||||
$pdf->rect($x, $boxY, $w, $boxH, 0.65);
|
||||
$pdf->text($x + 14, $boxY + 27, 'Quota à effectuer sur la période', 10, true);
|
||||
$pdf->text($x + $w - 160, $boxY + 27, $this->minutesLabel($target), 11, true);
|
||||
$pdf->line($x + 12, $boxY + 43, $x + $w - 12, $boxY + 43, 0.85);
|
||||
$pdf->text($x + 14, $boxY + 72, 'Heures décomptées à ce jour', 10, true);
|
||||
$pdf->text($x + $w - 160, $boxY + 72, $this->minutesLabel($counted), 11, true);
|
||||
$pdf->line($x + 12, $boxY + 88, $x + $w - 12, $boxY + 88, 0.85);
|
||||
$pdf->text($x + 14, $boxY + 114, $remaining >= 0 ? 'Heures restant à effectuer' : 'Dépassement du quota', 10.5, true);
|
||||
$pdf->textColor($x + $w - 160, $boxY + 114, $this->minutesLabel(abs($remaining)), $remaining >= 0 ? '#1D4ED8' : '#B91C1C', 12, true);
|
||||
|
||||
$noteY = $boxY + $boxH + 27;
|
||||
foreach ($pdf->wrap('Le résultat ci-dessus additionne uniquement les motifs qui décomptent le quota annuel. Les absences non décomptées apparaissent dans le détail mais ne réduisent pas le nombre d’heures restant à effectuer.', $w, 8.5) as $line) {
|
||||
$pdf->text($x, $noteY, $line, 8.5);
|
||||
$noteY += 12;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
812,
|
||||
sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeTotals(array $rows): array
|
||||
{
|
||||
$totals = [
|
||||
'TRAVAIL' => 0,
|
||||
'MALADIE' => 0,
|
||||
'FORMATION' => 0,
|
||||
'CONGE' => 0,
|
||||
'AUTRE' => 0,
|
||||
'OTHER_COUNTED' => 0,
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE'));
|
||||
$minutes = (int) ($row['minutes_total'] ?? 0);
|
||||
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') {
|
||||
$totals[$code] += $minutes;
|
||||
continue;
|
||||
}
|
||||
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
|
||||
$totals['OTHER_COUNTED'] += $minutes;
|
||||
} else {
|
||||
$totals['AUTRE'] += $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/** @return array{accent:string} */
|
||||
private function palette(string $code): array
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'TRAVAIL' => ['accent' => '#2563EB'],
|
||||
'FORMATION' => ['accent' => '#7C3AED'],
|
||||
'CONGE' => ['accent' => '#059669'],
|
||||
'MALADIE' => ['accent' => '#DC2626'],
|
||||
default => ['accent' => '#64748B'],
|
||||
};
|
||||
}
|
||||
|
||||
private function minutesLabel(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$absolute = abs($minutes);
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
|
||||
}
|
||||
}
|
||||
426
app/Services/PlanningPdfService.php
Normal file
426
app/Services/PlanningPdfService.php
Normal file
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class PlanningPdfService
|
||||
{
|
||||
private const MARGIN = 28.0;
|
||||
private const WEEK_DAYS = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi'];
|
||||
|
||||
/**
|
||||
* Compatibilité avec l'ancien export d'une seule semaine.
|
||||
*/
|
||||
public function agentWeek(array $agent, array $entries, array $week, array $quota): string
|
||||
{
|
||||
return $this->agentWeeks($agent, $entries, [$week], $quota);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un planning compact de 1 à 7 semaines. Avec 7 semaines, le rendu
|
||||
* tient volontairement sur deux pages A4 paysage maximum (4 + 3 semaines).
|
||||
*/
|
||||
public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string
|
||||
{
|
||||
$weeks = array_values(array_slice($weeks, 0, 7));
|
||||
if ($weeks === []) {
|
||||
throw new \InvalidArgumentException('Au moins une semaine est nécessaire pour générer le planning.');
|
||||
}
|
||||
|
||||
$pdf = new SimplePdf();
|
||||
$entriesByDate = $this->entriesByDate($entries);
|
||||
$chunks = array_chunk($weeks, 4);
|
||||
|
||||
foreach ($chunks as $pageIndex => $pageWeeks) {
|
||||
if ($pageIndex > 0) {
|
||||
$pdf->addPage();
|
||||
}
|
||||
|
||||
$this->drawAgentMultiWeekHeader($pdf, $agent, $weeks, $quota, $pageIndex + 1, count($chunks));
|
||||
|
||||
$sectionY = 96.0;
|
||||
foreach ($pageWeeks as $week) {
|
||||
$this->drawCompactWeek($pdf, $week, $entriesByDate, $sectionY);
|
||||
$sectionY += 112.0;
|
||||
}
|
||||
|
||||
$this->drawLegend($pdf, self::MARGIN, 563);
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
584,
|
||||
sprintf('Document genere depuis PTA - %d semaine%s affichee%s.', count($weeks), count($weeks) > 1 ? 's' : '', count($weeks) > 1 ? 's' : ''),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
public function structureWeek(array $structure, array $agents, array $week): string
|
||||
{
|
||||
$pdf = new SimplePdf();
|
||||
$this->drawDocumentHeader(
|
||||
$pdf,
|
||||
'Planning hebdomadaire du lieu d\'affectation',
|
||||
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
|
||||
$this->weekLabel($week)
|
||||
);
|
||||
|
||||
$weekDays = $this->weekDays($week);
|
||||
$margin = self::MARGIN;
|
||||
$tableY = 100.0;
|
||||
$agentWidth = 126.0;
|
||||
$quotaWidth = 84.0;
|
||||
$usableWidth = $pdf->width() - ($margin * 2);
|
||||
$dayWidth = ($usableWidth - $agentWidth - $quotaWidth) / 5;
|
||||
$headerHeight = 38.0;
|
||||
$bottomLimit = 548.0;
|
||||
|
||||
$drawHeader = function () use ($pdf, $weekDays, $margin, $tableY, $agentWidth, $quotaWidth, $dayWidth, $headerHeight): void {
|
||||
$x = $margin;
|
||||
$headers = [['Agent', $agentWidth]];
|
||||
foreach ($weekDays as $day) {
|
||||
$headers[] = [$day['label'] . ' ' . $day['display'], $dayWidth];
|
||||
}
|
||||
$headers[] = ['Quota restant', $quotaWidth];
|
||||
|
||||
foreach ($headers as [$label, $width]) {
|
||||
$pdf->fillRect($x, $tableY, $width, $headerHeight, 0.92);
|
||||
$pdf->rect($x, $tableY, $width, $headerHeight, 0.72);
|
||||
$lines = $pdf->wrap((string) $label, $width - 10, 8);
|
||||
$lineY = $tableY + 15;
|
||||
foreach (array_slice($lines, 0, 2) as $line) {
|
||||
$pdf->text($x + 5, $lineY, $line, 8, true);
|
||||
$lineY += 10;
|
||||
}
|
||||
$x += $width;
|
||||
}
|
||||
};
|
||||
|
||||
$drawHeader();
|
||||
$y = $tableY + $headerHeight;
|
||||
|
||||
foreach ($agents as $agent) {
|
||||
$entriesByDate = $this->entriesByDate($agent['entries'] ?? []);
|
||||
$dayBlocks = [];
|
||||
$maxCellHeight = 34.0;
|
||||
|
||||
foreach ($weekDays as $day) {
|
||||
$blocks = [];
|
||||
foreach ($entriesByDate[$day['date']] ?? [] as $entry) {
|
||||
$labelLines = $this->structureEntryLines($pdf, $entry, $dayWidth - 17);
|
||||
$blockHeight = max(16.0, 6.0 + (count($labelLines) * 8.0));
|
||||
$blocks[] = [
|
||||
'entry' => $entry,
|
||||
'lines' => $labelLines,
|
||||
'height' => $blockHeight,
|
||||
];
|
||||
}
|
||||
$dayBlocks[] = $blocks;
|
||||
|
||||
$cellHeight = 8.0;
|
||||
foreach ($blocks as $block) {
|
||||
$cellHeight += $block['height'] + 4.0;
|
||||
}
|
||||
$maxCellHeight = max($maxCellHeight, $cellHeight);
|
||||
}
|
||||
|
||||
$agentLines = $pdf->wrap(
|
||||
sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']),
|
||||
$agentWidth - 10,
|
||||
7.5
|
||||
);
|
||||
$rowHeight = max(44.0, $maxCellHeight, 14.0 + (count($agentLines) * 9.0));
|
||||
|
||||
if ($y + $rowHeight > $bottomLimit) {
|
||||
$pdf->addPage();
|
||||
$this->drawDocumentHeader(
|
||||
$pdf,
|
||||
'Planning hebdomadaire du lieu d\'affectation - suite',
|
||||
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
|
||||
$this->weekLabel($week)
|
||||
);
|
||||
$drawHeader();
|
||||
$y = $tableY + $headerHeight;
|
||||
}
|
||||
|
||||
$x = $margin;
|
||||
$widths = [$agentWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $quotaWidth];
|
||||
foreach ($widths as $width) {
|
||||
$pdf->rect($x, $y, $width, $rowHeight, 0.80);
|
||||
$x += $width;
|
||||
}
|
||||
|
||||
$lineY = $y + 13;
|
||||
foreach ($agentLines as $index => $line) {
|
||||
$pdf->text($margin + 5, $lineY, $line, 7.5, $index === 0);
|
||||
$lineY += 9;
|
||||
}
|
||||
|
||||
foreach ($dayBlocks as $dayIndex => $blocks) {
|
||||
$cellX = $margin + $agentWidth + ($dayIndex * $dayWidth);
|
||||
if ($blocks === []) {
|
||||
$pdf->text($cellX + 6, $y + 18, '-', 7.2);
|
||||
continue;
|
||||
}
|
||||
|
||||
$blockY = $y + 5;
|
||||
foreach ($blocks as $block) {
|
||||
$entry = $block['entry'];
|
||||
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
|
||||
$blockHeight = (float) $block['height'];
|
||||
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
|
||||
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
|
||||
|
||||
$textY = $blockY + 10;
|
||||
foreach ($block['lines'] as $lineIndex => $line) {
|
||||
$isFirst = $lineIndex === 0;
|
||||
$pdf->text($cellX + 10, $textY, $line, 7.0, $isFirst);
|
||||
$textY += 8;
|
||||
}
|
||||
$blockY += $blockHeight + 4;
|
||||
}
|
||||
}
|
||||
|
||||
$quotaText = (string) ($agent['quota']['restantes']['libelle'] ?? '-');
|
||||
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 18, $quotaText, 8, true);
|
||||
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 31, (($agent['lieu_principal'] ?? false) ? 'Principal' : 'Ponctuel'), 7);
|
||||
$y += $rowHeight;
|
||||
}
|
||||
|
||||
if ($agents === []) {
|
||||
$pdf->text($margin, $y + 24, 'Aucun agent rattache ou planifie sur ce lieu pour cette semaine.', 10);
|
||||
}
|
||||
|
||||
$this->drawLegend($pdf, self::MARGIN, 562);
|
||||
$pdf->text(self::MARGIN, 584, 'Document genere depuis PTA - Planning du lundi au vendredi.', 7);
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
private function drawAgentMultiWeekHeader(
|
||||
SimplePdf $pdf,
|
||||
array $agent,
|
||||
array $weeks,
|
||||
array $quota,
|
||||
int $page,
|
||||
int $pageCount
|
||||
): void {
|
||||
$first = $weeks[0];
|
||||
$last = $weeks[count($weeks) - 1];
|
||||
$periodStart = (new DateTimeImmutable((string) $first['date_debut']))->format('d/m/Y');
|
||||
$periodEnd = (new DateTimeImmutable((string) $last['date_debut']))->modify('+4 days')->format('d/m/Y');
|
||||
$principalLocation = (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigne');
|
||||
$principalType = $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? ''));
|
||||
|
||||
$pdf->text(self::MARGIN, 25, 'PTA', 9, true);
|
||||
$pdf->text(self::MARGIN, 44, 'Planning de l\'agent - vue multi-semaines', 16, true);
|
||||
$pdf->text(self::MARGIN, 60, sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']), 9.5, true);
|
||||
$pdf->text(self::MARGIN, 74, 'Lieu principal : ' . $principalLocation . ' - ' . $principalType, 8);
|
||||
$pdf->text($pdf->width() - 260, 44, sprintf('Du %s au %s', $periodStart, $periodEnd), 9.5, true);
|
||||
$pdf->text($pdf->width() - 260, 60, 'Quota restant : ' . ($quota['restantes']['libelle'] ?? '-'), 8.5, true);
|
||||
if ($pageCount > 1) {
|
||||
$pdf->text($pdf->width() - 260, 74, sprintf('Page %d / %d', $page, $pageCount), 8);
|
||||
}
|
||||
$pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72);
|
||||
}
|
||||
|
||||
/** @param array<string, array<int, array>> $entriesByDate */
|
||||
private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void
|
||||
{
|
||||
$weekDays = $this->weekDays($week);
|
||||
$usableWidth = $pdf->width() - (self::MARGIN * 2);
|
||||
$colWidth = $usableWidth / 5;
|
||||
$weekTitleHeight = 16.0;
|
||||
$dayHeaderHeight = 16.0;
|
||||
$bodyHeight = 77.0;
|
||||
|
||||
$pdf->fillRect(self::MARGIN, $y, $usableWidth, $weekTitleHeight, 0.965);
|
||||
$pdf->rect(self::MARGIN, $y, $usableWidth, $weekTitleHeight + $dayHeaderHeight + $bodyHeight, 0.78);
|
||||
$pdf->text(self::MARGIN + 6, $y + 11, $this->weekLabel($week), 8.5, true);
|
||||
|
||||
$dayHeaderY = $y + $weekTitleHeight;
|
||||
$bodyY = $dayHeaderY + $dayHeaderHeight;
|
||||
|
||||
foreach ($weekDays as $index => $day) {
|
||||
$colX = self::MARGIN + ($index * $colWidth);
|
||||
$pdf->fillRect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight, 0.92);
|
||||
$pdf->rect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight + $bodyHeight, 0.82);
|
||||
$pdf->text($colX + 5, $dayHeaderY + 11, $this->shortDayLabel($day['label']) . ' ' . substr($day['display'], 0, 5), 7.2, true);
|
||||
|
||||
$dayEntries = $entriesByDate[$day['date']] ?? [];
|
||||
if ($dayEntries === []) {
|
||||
$pdf->text($colX + 6, $bodyY + 17, 'Aucune affectation', 6.5);
|
||||
continue;
|
||||
}
|
||||
|
||||
$cursorY = $bodyY + 4;
|
||||
$rendered = 0;
|
||||
foreach ($dayEntries as $entryIndex => $entry) {
|
||||
$lines = $this->compactAgentEntryLines($pdf, $entry, $colWidth - 18);
|
||||
$cardHeight = max(18.0, 5.0 + (count($lines) * 7.2));
|
||||
if ($cursorY + $cardHeight > $bodyY + $bodyHeight - 4) {
|
||||
$remaining = count($dayEntries) - $rendered;
|
||||
if ($remaining > 0) {
|
||||
$pdf->text($colX + 7, $bodyY + $bodyHeight - 7, '+' . $remaining . ' autre(s) affectation(s)', 6.2, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
|
||||
$pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']);
|
||||
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
|
||||
|
||||
$lineY = $cursorY + 9;
|
||||
foreach ($lines as $lineIndex => $line) {
|
||||
$bold = $lineIndex === 0;
|
||||
$pdf->text($colX + 10, $lineY, $line, 6.5, $bold);
|
||||
$lineY += 7.2;
|
||||
}
|
||||
|
||||
$cursorY += $cardHeight + 3.0;
|
||||
$rendered++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array
|
||||
{
|
||||
$lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])];
|
||||
$motifLabel = $this->motifDisplayLabel($entry);
|
||||
if ($motifLabel !== '') {
|
||||
foreach ($pdf->wrap($motifLabel, $width, 6.3) as $line) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$location = (string) ($entry['structure_nom'] ?? 'Lieu non renseigne');
|
||||
foreach ($pdf->wrap('Lieu : ' . $location, $width, 6.1) as $line) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
if (($entry['statut'] ?? 'VALIDE') === 'BROUILLON') {
|
||||
$lines[] = 'Brouillon';
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array
|
||||
{
|
||||
$first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']);
|
||||
$motif = $this->motifDisplayLabel($entry);
|
||||
if ($motif !== '') {
|
||||
$first .= ' ' . $motif;
|
||||
}
|
||||
return $pdf->wrap($first, $width, 7.0);
|
||||
}
|
||||
|
||||
private function motifDisplayLabel(array $entry): string
|
||||
{
|
||||
return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL'
|
||||
? ''
|
||||
: (string) ($entry['motif_libelle'] ?? 'Affectation');
|
||||
}
|
||||
|
||||
/** @return array{accent:string,background:string,label:string} */
|
||||
private function motifPalette(string $code): array
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
|
||||
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
|
||||
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
|
||||
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
|
||||
default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'],
|
||||
};
|
||||
}
|
||||
|
||||
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
|
||||
{
|
||||
$items = [
|
||||
['TRAVAIL', 'Travail'],
|
||||
['FORMATION', 'Formation'],
|
||||
['CONGE', 'Conge'],
|
||||
['MALADIE', 'Absence'],
|
||||
['AUTRE', 'Autre absence'],
|
||||
];
|
||||
|
||||
foreach ($items as [$code, $label]) {
|
||||
$palette = $this->motifPalette($code);
|
||||
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
|
||||
$pdf->text($x + 12, $y, $label, 6.5);
|
||||
$x += 76;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, array>> */
|
||||
private function entriesByDate(array $entries): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($entries as $entry) {
|
||||
$grouped[(string) $entry['date_jour']][] = $entry;
|
||||
}
|
||||
foreach ($grouped as &$dayEntries) {
|
||||
usort($dayEntries, static fn (array $a, array $b): int => strcmp((string) $a['heure_debut'], (string) $b['heure_debut']));
|
||||
}
|
||||
unset($dayEntries);
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
|
||||
$pdf->text(self::MARGIN, 55, $title, 18, true);
|
||||
$pdf->text(self::MARGIN, 72, $subtitle, 10);
|
||||
$pdf->text($pdf->width() - 250, 55, $weekLabel, 10, true);
|
||||
$pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72);
|
||||
}
|
||||
|
||||
/** @return array<int, array{date:string,label:string,display:string}> */
|
||||
private function weekDays(array $week): array
|
||||
{
|
||||
$start = new DateTimeImmutable((string) $week['date_debut']);
|
||||
$days = [];
|
||||
for ($dayNumber = 1; $dayNumber <= 5; $dayNumber++) {
|
||||
$date = $start->modify('+' . ($dayNumber - 1) . ' days');
|
||||
$days[] = [
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'label' => self::WEEK_DAYS[$dayNumber],
|
||||
'display' => $date->format('d/m/Y'),
|
||||
];
|
||||
}
|
||||
return $days;
|
||||
}
|
||||
|
||||
private function weekLabel(array $week): string
|
||||
{
|
||||
$start = new DateTimeImmutable((string) $week['date_debut']);
|
||||
$end = $start->modify('+4 days');
|
||||
return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y'));
|
||||
}
|
||||
|
||||
private function shortDayLabel(string $label): string
|
||||
{
|
||||
return match ($label) {
|
||||
'Lundi' => 'Lun.',
|
||||
'Mardi' => 'Mar.',
|
||||
'Mercredi' => 'Mer.',
|
||||
'Jeudi' => 'Jeu.',
|
||||
'Vendredi' => 'Ven.',
|
||||
default => $label,
|
||||
};
|
||||
}
|
||||
|
||||
private function typeLabel(string $type): string
|
||||
{
|
||||
return match (strtoupper($type)) {
|
||||
'EXTRASCOLAIRE' => 'Extrascolaire',
|
||||
'PERISCOLAIRE' => 'Periscolaire',
|
||||
default => 'Non renseigne',
|
||||
};
|
||||
}
|
||||
}
|
||||
484
app/Services/SchoolHolidayApiService.php
Normal file
484
app/Services/SchoolHolidayApiService.php
Normal file
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class SchoolHolidayApiService
|
||||
{
|
||||
private const DATASET = 'fr-en-calendrier-scolaire';
|
||||
|
||||
/**
|
||||
* data.gouv.fr publie actuellement v2.0 comme URL de base officielle.
|
||||
* v2.1 et l'ancienne API 1.0 restent des solutions de repli afin que
|
||||
* la page continue à fonctionner lors d'une évolution du portail.
|
||||
*/
|
||||
private const EXPLORE_ENDPOINTS = [
|
||||
'https://data.education.gouv.fr/api/explore/v2.0/catalog/datasets/' . self::DATASET . '/records',
|
||||
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/' . self::DATASET . '/records',
|
||||
];
|
||||
|
||||
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
|
||||
|
||||
private const ACADEMY_ZONES = [
|
||||
'aix-marseille' => 'Zone B',
|
||||
'amiens' => 'Zone B',
|
||||
'besancon' => 'Zone A',
|
||||
'bordeaux' => 'Zone A',
|
||||
'clermont-ferrand' => 'Zone A',
|
||||
'creteil' => 'Zone C',
|
||||
'dijon' => 'Zone A',
|
||||
'grenoble' => 'Zone A',
|
||||
'lille' => 'Zone B',
|
||||
'limoges' => 'Zone A',
|
||||
'lyon' => 'Zone A',
|
||||
'montpellier' => 'Zone C',
|
||||
'nancy-metz' => 'Zone B',
|
||||
'nantes' => 'Zone B',
|
||||
'nice' => 'Zone B',
|
||||
'normandie' => 'Zone B',
|
||||
'orleans-tours' => 'Zone B',
|
||||
'paris' => 'Zone C',
|
||||
'poitiers' => 'Zone A',
|
||||
'reims' => 'Zone B',
|
||||
'rennes' => 'Zone B',
|
||||
'strasbourg' => 'Zone B',
|
||||
'toulouse' => 'Zone C',
|
||||
'versailles' => 'Zone C',
|
||||
];
|
||||
|
||||
/**
|
||||
* Compatibilité avec le code existant : retourne uniquement les périodes.
|
||||
*/
|
||||
public function previewCalendarYear(string $academy, int $calendarYear): array
|
||||
{
|
||||
return $this->previewCalendarYearDetailed($academy, $calendarYear)['periods'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne toutes les périodes recouvrant une année civile, accompagnées
|
||||
* d'avertissements non bloquants. Une année civile chevauche deux années
|
||||
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus
|
||||
* empêcher l'affichage des dates déjà disponibles.
|
||||
*
|
||||
* @return array{periods: array, warnings: array, school_years: array}
|
||||
*/
|
||||
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
|
||||
{
|
||||
if ($calendarYear < 2000 || $calendarYear > 2100) {
|
||||
throw new RuntimeException('Année civile invalide.');
|
||||
}
|
||||
|
||||
$academy = trim($academy);
|
||||
if ($academy === '') {
|
||||
throw new RuntimeException('Académie manquante.');
|
||||
}
|
||||
|
||||
$schoolYears = [
|
||||
($calendarYear - 1) . '-' . $calendarYear,
|
||||
$calendarYear . '-' . ($calendarYear + 1),
|
||||
];
|
||||
|
||||
$all = [];
|
||||
$warnings = [];
|
||||
$successfulQueries = 0;
|
||||
|
||||
foreach ($schoolYears as $schoolYear) {
|
||||
try {
|
||||
$periods = $this->previewSchoolYear($academy, $schoolYear);
|
||||
$successfulQueries++;
|
||||
|
||||
if ($periods === []) {
|
||||
$warnings[] = sprintf(
|
||||
'Aucune date publiée pour l’académie %s et l’année scolaire %s.',
|
||||
$academy,
|
||||
$schoolYear
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($periods as $period) {
|
||||
$key = $period['libelle'] . '|' . $period['date_debut'] . '|' . $period['date_fin'];
|
||||
$all[$key] = $period;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$warnings[] = sprintf(
|
||||
'Les dates de l’année scolaire %s n’ont pas pu être chargées : %s',
|
||||
$schoolYear,
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($successfulQueries === 0) {
|
||||
throw new RuntimeException(implode(' ', $warnings));
|
||||
}
|
||||
|
||||
$yearStart = sprintf('%04d-01-01', $calendarYear);
|
||||
$yearEnd = sprintf('%04d-12-31', $calendarYear);
|
||||
|
||||
$periods = array_values(array_filter(
|
||||
$all,
|
||||
static fn(array $period): bool => $period['date_debut'] <= $yearEnd && $period['date_fin'] >= $yearStart
|
||||
));
|
||||
|
||||
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
|
||||
|
||||
return [
|
||||
'periods' => $periods,
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
'school_years' => $schoolYears,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilité avec l'ancienne API interne V5.17.
|
||||
*/
|
||||
public function preview(string $academy, string $schoolYear): array
|
||||
{
|
||||
return $this->previewSchoolYear($academy, $schoolYear);
|
||||
}
|
||||
|
||||
private function previewSchoolYear(string $academy, string $schoolYear): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// 1. Recherche par académie. On essaie plusieurs graphies afin de ne
|
||||
// pas rendre la recherche dépendante des accents ou du type de tiret.
|
||||
foreach ($this->academyVariants($academy) as $academyVariant) {
|
||||
try {
|
||||
$records = $this->fetchExploreRecords([
|
||||
'limit' => 100,
|
||||
'lang' => 'fr',
|
||||
'timezone' => 'Europe/Paris',
|
||||
'order_by' => 'start_date',
|
||||
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
|
||||
'refine' => 'location:' . $this->quoteWhereValue($academyVariant),
|
||||
]);
|
||||
|
||||
$periods = $this->normalize($records, $academy, $schoolYear, false);
|
||||
if ($periods !== []) {
|
||||
return $periods;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Repli par zone. Il est utile lorsque le portail fournit une
|
||||
// période commune à toute la zone, sans ligne spécifique à l'académie.
|
||||
$zone = $this->zoneForAcademy($academy);
|
||||
if ($zone !== null) {
|
||||
try {
|
||||
$records = $this->fetchExploreRecords([
|
||||
'limit' => 100,
|
||||
'lang' => 'fr',
|
||||
'timezone' => 'Europe/Paris',
|
||||
'order_by' => 'start_date',
|
||||
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
|
||||
'refine' => 'zones:' . $this->quoteWhereValue($zone),
|
||||
]);
|
||||
|
||||
$periods = $this->normalize($records, $academy, $schoolYear, true);
|
||||
if ($periods !== []) {
|
||||
return $periods;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dernier repli sur l'ancienne API Opendatasoft. Elle utilise un
|
||||
// format JSON différent, normalisé dans fetchLegacyRecords().
|
||||
foreach ($this->academyVariants($academy) as $academyVariant) {
|
||||
try {
|
||||
$records = $this->fetchLegacyRecords($academyVariant, $schoolYear);
|
||||
$periods = $this->normalize($records, $academy, $schoolYear, false);
|
||||
if ($periods !== []) {
|
||||
return $periods;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(end($errors) ?: 'Erreur inconnue de l’API du calendrier scolaire.');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function fetchExploreRecords(array $parameters): array
|
||||
{
|
||||
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||||
$lastError = null;
|
||||
|
||||
foreach (self::EXPLORE_ENDPOINTS as $endpoint) {
|
||||
try {
|
||||
$payload = $this->fetchJson($endpoint . '?' . $query);
|
||||
$records = $payload['results'] ?? null;
|
||||
if (!is_array($records)) {
|
||||
throw new RuntimeException('Réponse inattendue de l’API Explore.');
|
||||
}
|
||||
return $records;
|
||||
} catch (RuntimeException $e) {
|
||||
$lastError = $e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
|
||||
}
|
||||
|
||||
private function fetchLegacyRecords(string $academy, string $schoolYear): array
|
||||
{
|
||||
$query = http_build_query([
|
||||
'dataset' => self::DATASET,
|
||||
'rows' => 100,
|
||||
'sort' => 'start_date',
|
||||
'refine.location' => $academy,
|
||||
'refine.annee_scolaire' => $schoolYear,
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
$payload = $this->fetchJson(self::LEGACY_ENDPOINT . '?' . $query);
|
||||
$records = $payload['records'] ?? null;
|
||||
if (!is_array($records)) {
|
||||
throw new RuntimeException('Réponse inattendue de l’ancienne API du calendrier scolaire.');
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn(mixed $record): ?array => is_array($record) && is_array($record['fields'] ?? null)
|
||||
? $record['fields']
|
||||
: null,
|
||||
$records
|
||||
)));
|
||||
}
|
||||
|
||||
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
|
||||
{
|
||||
$periods = [];
|
||||
$requestedAcademy = $this->normalizeAcademy($academy);
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (!is_array($record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Certaines vacances communes sont publiées avec population "-".
|
||||
// En filtrant uniquement "Élèves", elles disparaissaient du résultat.
|
||||
if (!$this->isStudentPopulation($record['population'] ?? '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recordAcademy = trim($this->stringValue($record['location'] ?? ''));
|
||||
if (!$zoneFallback && $recordAcademy !== '' && $this->normalizeAcademy($recordAcademy) !== $requestedAcademy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$description = trim($this->stringValue($record['description'] ?? ''));
|
||||
if (!$this->isVacationDescription($description)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startRaw = $this->stringValue($record['start_date'] ?? '');
|
||||
$endRaw = $this->stringValue($record['end_date'] ?? '');
|
||||
$start = $this->dateOnly($startRaw);
|
||||
$returnDate = $this->dateOnly($endRaw);
|
||||
if ($start === null || $returnDate === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// L'API donne la date de reprise. PTA utilise des bornes inclusives,
|
||||
// donc le dernier jour de vacances est la veille de la reprise.
|
||||
$end = (new DateTimeImmutable($returnDate))->modify('-1 day')->format('Y-m-d');
|
||||
if ($end < $start) {
|
||||
$end = $start;
|
||||
}
|
||||
|
||||
$zone = trim($this->stringValue($record['zones'] ?? ($record['zone'] ?? '')));
|
||||
$recordSchoolYear = trim($this->stringValue($record['annee_scolaire'] ?? $schoolYear));
|
||||
|
||||
$key = $description . '|' . $start . '|' . $end;
|
||||
$periods[$key] = [
|
||||
'libelle' => $description,
|
||||
'date_debut' => $start,
|
||||
'date_fin' => $end,
|
||||
'date_reprise_api' => $returnDate,
|
||||
'annee_scolaire' => $recordSchoolYear !== '' ? $recordSchoolYear : $schoolYear,
|
||||
'academie' => $recordAcademy !== '' && !$zoneFallback ? $recordAcademy : $academy,
|
||||
'zone' => $zone !== '' ? $zone : ($this->zoneForAcademy($academy) ?? ''),
|
||||
'source' => 'DATA_GOUV',
|
||||
];
|
||||
}
|
||||
|
||||
$periods = array_values($periods);
|
||||
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
|
||||
return $periods;
|
||||
}
|
||||
|
||||
private function isVacationDescription(string $description): bool
|
||||
{
|
||||
$normalized = $this->normalizeText($description);
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
if (str_contains($normalized, 'rentree') || str_contains($normalized, 'prerentree')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['vacance', 'conge', 'pont', 'ete austral', 'hiver austral'] as $keyword) {
|
||||
if (str_contains($normalized, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function academyVariants(string $academy): array
|
||||
{
|
||||
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
|
||||
$variants = [$academy];
|
||||
$variants[] = str_replace(['–', '—', '‑'], '-', $academy);
|
||||
$variants[] = $this->stripAccents($academy);
|
||||
$variants[] = str_replace('-', ' ', $academy);
|
||||
$variants[] = str_replace('-', ' ', $this->stripAccents($academy));
|
||||
|
||||
return array_values(array_unique(array_filter(array_map('trim', $variants))));
|
||||
}
|
||||
|
||||
private function zoneForAcademy(string $academy): ?string
|
||||
{
|
||||
$key = $this->normalizeAcademy($academy);
|
||||
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
|
||||
}
|
||||
|
||||
private function fetchJson(string $url): array
|
||||
{
|
||||
$body = false;
|
||||
$status = 0;
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Impossible d’initialiser cURL.');
|
||||
}
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 8,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.3'],
|
||||
]);
|
||||
$body = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException($error ?: 'Erreur réseau cURL.');
|
||||
}
|
||||
} else {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 20,
|
||||
'ignore_errors' => true,
|
||||
'header' => "Accept: application/json\r\nUser-Agent: PTA/7.3\r\n",
|
||||
],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.');
|
||||
}
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) {
|
||||
$status = (int) $matches[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
throw new RuntimeException('L’API a répondu avec le code HTTP ' . $status . '.');
|
||||
}
|
||||
|
||||
$data = json_decode((string) $body, true);
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('Le contenu reçu depuis l’API n’est pas un JSON valide.');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function quoteWhereValue(string $value): string
|
||||
{
|
||||
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
|
||||
}
|
||||
|
||||
private function dateOnly(string $value): ?string
|
||||
{
|
||||
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
private function stringValue(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return implode(', ', array_map(static fn(mixed $item): string => (string) $item, $value));
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
|
||||
private function isStudentPopulation(mixed $value): bool
|
||||
{
|
||||
$values = is_array($value) ? $value : [$value];
|
||||
foreach ($values as $item) {
|
||||
$population = $this->normalizeText((string) $item);
|
||||
if ($population === '' || in_array($population, ['-', 'eleves', 'tous', 'tout public'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizeAcademy(string $value): string
|
||||
{
|
||||
$value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value);
|
||||
return $this->normalizeText($value);
|
||||
}
|
||||
|
||||
private function normalizeText(string $value): string
|
||||
{
|
||||
$value = $this->stripAccents($value);
|
||||
$value = str_replace(['–', '—', '‑', '_'], '-', $value);
|
||||
$value = strtolower($value);
|
||||
$value = preg_replace('/[^a-z0-9-]+/', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private function stripAccents(string $value): string
|
||||
{
|
||||
return strtr($value, [
|
||||
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
|
||||
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
|
||||
'Ç' => 'C', 'ç' => 'c',
|
||||
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||||
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||||
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
|
||||
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
|
||||
'Ñ' => 'N', 'ñ' => 'n',
|
||||
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
|
||||
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
|
||||
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||||
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
|
||||
'Ý' => 'Y', 'Ÿ' => 'Y', 'ý' => 'y', 'ÿ' => 'y',
|
||||
'Œ' => 'OE', 'œ' => 'oe', 'Æ' => 'AE', 'æ' => 'ae',
|
||||
]);
|
||||
}
|
||||
}
|
||||
291
app/Services/SimplePdf.php
Normal file
291
app/Services/SimplePdf.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Petit générateur PDF autonome, volontairement limité aux besoins des plannings.
|
||||
* Il utilise les polices standard PDF Helvetica / Helvetica-Bold et ne requiert
|
||||
* aucune dépendance Composer.
|
||||
*/
|
||||
final class SimplePdf
|
||||
{
|
||||
public const A4_LANDSCAPE_WIDTH = 841.89;
|
||||
public const A4_LANDSCAPE_HEIGHT = 595.28;
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $pages = [];
|
||||
private string $currentPage = '';
|
||||
|
||||
public function __construct(
|
||||
private float $width = self::A4_LANDSCAPE_WIDTH,
|
||||
private float $height = self::A4_LANDSCAPE_HEIGHT,
|
||||
) {
|
||||
$this->addPage();
|
||||
}
|
||||
|
||||
public function width(): float
|
||||
{
|
||||
return $this->width;
|
||||
}
|
||||
|
||||
public function height(): float
|
||||
{
|
||||
return $this->height;
|
||||
}
|
||||
|
||||
public function addPage(): void
|
||||
{
|
||||
if ($this->currentPage !== '') {
|
||||
$this->pages[] = $this->currentPage;
|
||||
}
|
||||
$this->currentPage = '';
|
||||
}
|
||||
|
||||
public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void
|
||||
{
|
||||
$font = $bold ? 'F2' : 'F1';
|
||||
$encoded = $this->escapeText($text);
|
||||
$pdfY = $this->height - $y;
|
||||
$this->currentPage .= sprintf(
|
||||
"BT /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$pdfY,
|
||||
$encoded
|
||||
);
|
||||
}
|
||||
|
||||
public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F G %.2F %.2F m %.2F %.2F l S\n",
|
||||
$gray,
|
||||
$x1,
|
||||
$this->height - $y1,
|
||||
$x2,
|
||||
$this->height - $y2
|
||||
);
|
||||
}
|
||||
|
||||
public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F G %.2F %.2F %.2F %.2F re S\n",
|
||||
$strokeGray,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F g %.2F %.2F %.2F %.2F re f 0 g\n",
|
||||
$gray,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F %.3F %.3F rg %.2F %.2F %.2F %.2F re f 0 g\n",
|
||||
$r,
|
||||
$g,
|
||||
$b,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F %.3F %.3F RG %.2F w %.2F %.2F %.2F %.2F re S 0 G 1 w\n",
|
||||
$r,
|
||||
$g,
|
||||
$b,
|
||||
$lineWidth,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
$font = $bold ? 'F2' : 'F1';
|
||||
$encoded = $this->escapeText($text);
|
||||
$pdfY = $this->height - $y;
|
||||
$this->currentPage .= sprintf(
|
||||
"BT %.3F %.3F %.3F rg /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET 0 g\n",
|
||||
$r,
|
||||
$g,
|
||||
$b,
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$pdfY,
|
||||
$encoded
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function wrap(string $text, float $maxWidth, float $fontSize = 9): array
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
|
||||
if ($text === '') {
|
||||
return [''];
|
||||
}
|
||||
|
||||
// Helvetica moyenne : environ 0,52 em par caractère. Une estimation
|
||||
// conservatrice évite les débordements sans embarquer les métriques AFM.
|
||||
$maxChars = max(4, (int) floor($maxWidth / max(1.0, $fontSize * 0.52)));
|
||||
$words = preg_split('/\s+/u', $text) ?: [$text];
|
||||
$lines = [];
|
||||
$line = '';
|
||||
|
||||
foreach ($words as $word) {
|
||||
$candidate = $line === '' ? $word : $line . ' ' . $word;
|
||||
if ($this->stringLength($candidate) <= $maxChars) {
|
||||
$line = $candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
while ($this->stringLength($word) > $maxChars) {
|
||||
$lines[] = $this->stringSlice($word, 0, $maxChars);
|
||||
$word = $this->stringSlice($word, $maxChars);
|
||||
}
|
||||
$line = $word;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
return $lines ?: [''];
|
||||
}
|
||||
|
||||
public function output(): string
|
||||
{
|
||||
if ($this->currentPage !== '' || $this->pages === []) {
|
||||
$this->pages[] = $this->currentPage;
|
||||
$this->currentPage = '';
|
||||
}
|
||||
|
||||
$objects = [];
|
||||
$objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||
$objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>';
|
||||
$objects[4] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>';
|
||||
|
||||
$pageObjectIds = [];
|
||||
$nextObjectId = 5;
|
||||
|
||||
foreach ($this->pages as $content) {
|
||||
$pageObjectId = $nextObjectId++;
|
||||
$contentObjectId = $nextObjectId++;
|
||||
$pageObjectIds[] = $pageObjectId;
|
||||
|
||||
$objects[$pageObjectId] = sprintf(
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>',
|
||||
$this->width,
|
||||
$this->height,
|
||||
$contentObjectId
|
||||
);
|
||||
$objects[$contentObjectId] = "<< /Length " . strlen($content) . ">>\nstream\n" . $content . "endstream";
|
||||
}
|
||||
|
||||
$kids = implode(' ', array_map(static fn (int $id): string => $id . ' 0 R', $pageObjectIds));
|
||||
$objects[2] = sprintf('<< /Type /Pages /Kids [%s] /Count %d >>', $kids, count($pageObjectIds));
|
||||
ksort($objects);
|
||||
|
||||
$pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
|
||||
$offsets = [0 => 0];
|
||||
|
||||
foreach ($objects as $id => $body) {
|
||||
$offsets[$id] = strlen($pdf);
|
||||
$pdf .= $id . " 0 obj\n" . $body . "\nendobj\n";
|
||||
}
|
||||
|
||||
$xrefOffset = strlen($pdf);
|
||||
$maxObjectId = max(array_keys($objects));
|
||||
$pdf .= "xref\n0 " . ($maxObjectId + 1) . "\n";
|
||||
$pdf .= "0000000000 65535 f \n";
|
||||
for ($id = 1; $id <= $maxObjectId; $id++) {
|
||||
$offset = $offsets[$id] ?? 0;
|
||||
$pdf .= sprintf('%010d 00000 n ', $offset) . "\n";
|
||||
}
|
||||
|
||||
$pdf .= "trailer\n<< /Size " . ($maxObjectId + 1) . " /Root 1 0 R >>\n";
|
||||
$pdf .= "startxref\n" . $xrefOffset . "\n%%EOF";
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function stringLength(string $value): int
|
||||
{
|
||||
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
|
||||
}
|
||||
|
||||
private function stringSlice(string $value, int $start, ?int $length = null): string
|
||||
{
|
||||
if (function_exists('mb_substr')) {
|
||||
return mb_substr($value, $start, $length, 'UTF-8');
|
||||
}
|
||||
return $length === null ? substr($value, $start) : substr($value, $start, $length);
|
||||
}
|
||||
|
||||
/** @return array{0:float,1:float,2:float} */
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
|
||||
$hex = '000000';
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($hex, 0, 2)) / 255,
|
||||
hexdec(substr($hex, 2, 2)) / 255,
|
||||
hexdec(substr($hex, 4, 2)) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
private function escapeText(string $text): string
|
||||
{
|
||||
$converted = function_exists('iconv')
|
||||
? iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text)
|
||||
: false;
|
||||
if ($converted === false) {
|
||||
$converted = preg_replace('/[^\x20-\x7E]/', '?', $text) ?? $text;
|
||||
}
|
||||
|
||||
return str_replace(
|
||||
['\\', '(', ')', "\r", "\n"],
|
||||
['\\\\', '\\(', '\\)', '', ' '],
|
||||
$converted
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/Views/layouts/footer.php
Normal file
36
app/Views/layouts/footer.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/** @var string $csrfToken */
|
||||
/** @var array $motifs */
|
||||
/** @var array $scripts */
|
||||
/** @var array $modules */
|
||||
/** @var array $pageConfig */
|
||||
/** @var array $accessProfile */
|
||||
/** @var array $permissions */
|
||||
?>
|
||||
</main>
|
||||
<script>
|
||||
window.PTA_CONFIG = {
|
||||
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
||||
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||
permissions: <?= json_encode($permissions, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||
motifs: <?= json_encode(array_map(static function (array $motif): array {
|
||||
return [
|
||||
'id' => (int) $motif['id_motif'],
|
||||
'code' => (string) $motif['code'],
|
||||
'label' => (string) $motif['libelle'],
|
||||
'quota' => (int) $motif['compte_dans_quota'] === 1,
|
||||
];
|
||||
}, $motifs), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>
|
||||
};
|
||||
window.PTA_PAGE_CONFIG = <?= json_encode([
|
||||
'modules' => $modules,
|
||||
'params' => $pageConfig,
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
||||
</script>
|
||||
<script src="assets/js/core.js?v=7.7"></script>
|
||||
<?php foreach ($scripts as $script): ?>
|
||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
|
||||
<?php endforeach; ?>
|
||||
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
|
||||
</body>
|
||||
</html>
|
||||
63
app/Views/layouts/header.php
Normal file
63
app/Views/layouts/header.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/** @var string $pageTitle */
|
||||
/** @var string $activePage */
|
||||
/** @var array $allowedPages */
|
||||
/** @var array $accessProfile */
|
||||
|
||||
$navigation = [
|
||||
'planning' => ['planning.php', 'Saisie'],
|
||||
'pending' => ['pending.php', 'À valider'],
|
||||
'coverage' => ['coverage.php', 'Couverture'],
|
||||
'structures' => ['structures.php', 'Par lieu'],
|
||||
'agents' => ['agents.php', ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Par agent'],
|
||||
'annual' => ['annual.php', 'Vue annuelle'],
|
||||
'pta' => ['pta.php', 'PTA détaillé'],
|
||||
'teams' => ['teams.php', 'Équipes'],
|
||||
'vacations' => ['vacations.php', 'Vacances'],
|
||||
'administration' => ['administration.php', 'Administration'],
|
||||
];
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.7">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">PTA</p>
|
||||
<h1><?= htmlspecialchars($pageTitle) ?></h1>
|
||||
<p class="subtitle">Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<?php if ($activePage === 'planning'): ?>
|
||||
<div id="planning-status" class="status-badge status-neutral">Nouveau planning</div>
|
||||
<?php endif; ?>
|
||||
<div class="access-profile-badge">
|
||||
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
|
||||
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
|
||||
<?php if (!empty($accessProfile['structure_name']) && ($accessProfile['role'] ?? '') === 'AGENT'): ?>
|
||||
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if (!empty($_GET['access_denied'])): ?>
|
||||
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<nav class="view-tabs" aria-label="Navigation principale">
|
||||
<?php foreach ($navigation as $key => [$href, $label]): ?>
|
||||
<?php if (!in_array($key, $allowedPages, true)) continue; ?>
|
||||
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
|
||||
<?= htmlspecialchars($label) ?>
|
||||
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
4
app/Views/pages/administration.php
Normal file
4
app/Views/pages/administration.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/administration.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/agents.php
Normal file
4
app/Views/pages/agents.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/agent_overview.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/annual.php
Normal file
4
app/Views/pages/annual.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/annual_overview.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/coverage.php
Normal file
4
app/Views/pages/coverage.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/coverage_alerts.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/pending.php
Normal file
4
app/Views/pages/pending.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/pending_validation.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/planning.php
Normal file
4
app/Views/pages/planning.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/planning_editor.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/pta.php
Normal file
4
app/Views/pages/pta.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/pta.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
120
app/Views/pages/role.php
Normal file
120
app/Views/pages/role.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/** @var string $csrfToken */
|
||||
/** @var array $agents */
|
||||
/** @var array $structures */
|
||||
/** @var string $error */
|
||||
/** @var array|null $currentProfile */
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Choisir votre accès — PTA</title>
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.4">
|
||||
</head>
|
||||
<body class="role-page-body">
|
||||
<main class="role-page-shell">
|
||||
<section class="role-intro">
|
||||
<p class="eyebrow">PTA</p>
|
||||
<h1>Quel est votre rôle ?</h1>
|
||||
<p>Votre choix détermine les pages visibles et les actions autorisées pendant cette session.</p>
|
||||
<?php if ($currentProfile): ?>
|
||||
<div class="role-current-profile">
|
||||
Profil actuel : <strong><?= htmlspecialchars((string) $currentProfile['role_label']) ?></strong>
|
||||
— <?= htmlspecialchars((string) $currentProfile['label']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error !== ''): ?>
|
||||
<div class="message message-error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<form method="post" class="role-selection-form" id="role-selection-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
|
||||
<div class="role-choice-grid">
|
||||
<label class="role-choice-card">
|
||||
<input type="radio" name="role" value="AGENT" required>
|
||||
<span class="role-choice-content">
|
||||
<strong>Agent</strong>
|
||||
<small>Lecture de mon planning et du planning de mon lieu de rattachement.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="role-choice-card">
|
||||
<input type="radio" name="role" value="RESPONSABLE_STRUCTURE" required>
|
||||
<span class="role-choice-content">
|
||||
<strong>Responsable de structure</strong>
|
||||
<small>Saisie et modification des horaires et motifs de mon lieu, uniquement en brouillon.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="role-choice-card">
|
||||
<input type="radio" name="role" value="SERVICE_ENFANCE" required>
|
||||
<span class="role-choice-content">
|
||||
<strong>Service Enfance</strong>
|
||||
<small>Contrôle complet, prévisionnel N+1, administration et validation des plannings.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="agent-context" class="role-context-card" hidden>
|
||||
<label class="field">
|
||||
<span>Je suis l’agent</span>
|
||||
<select name="agent_id" id="role-agent-select">
|
||||
<option value="">Choisir mon identité</option>
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<option value="<?= (int) $agent['id_agent'] ?>">
|
||||
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' (' . $agent['matricule'] . ')') ?>
|
||||
<?= !empty($agent['structure_nom']) ? ' — ' . htmlspecialchars((string) $agent['structure_nom']) : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="responsable-context" class="role-context-card" hidden>
|
||||
<label class="field">
|
||||
<span>Je suis responsable du lieu</span>
|
||||
<select name="structure_id" id="role-structure-select">
|
||||
<option value="">Choisir le lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars((string) $structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="role-form-actions">
|
||||
<button type="submit" class="button button-primary">Accéder à PTA</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside class="role-security-note">
|
||||
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
|
||||
</aside>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const agentContext = document.querySelector('#agent-context');
|
||||
const responsableContext = document.querySelector('#responsable-context');
|
||||
const agentSelect = document.querySelector('#role-agent-select');
|
||||
const structureSelect = document.querySelector('#role-structure-select');
|
||||
|
||||
function refresh() {
|
||||
const role = document.querySelector('input[name="role"]:checked')?.value || '';
|
||||
agentContext.hidden = role !== 'AGENT';
|
||||
responsableContext.hidden = role !== 'RESPONSABLE_STRUCTURE';
|
||||
agentSelect.required = role === 'AGENT';
|
||||
structureSelect.required = role === 'RESPONSABLE_STRUCTURE';
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="role"]').forEach((input) => input.addEventListener('change', refresh));
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4
app/Views/pages/structures.php
Normal file
4
app/Views/pages/structures.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/structure_overview.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/teams.php
Normal file
4
app/Views/pages/teams.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/teams.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
4
app/Views/pages/vacations.php
Normal file
4
app/Views/pages/vacations.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/vacation_calendar.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
356
app/Views/partials/administration.php
Normal file
356
app/Views/partials/administration.php
Normal file
@@ -0,0 +1,356 @@
|
||||
<section id="assignment-view" class="app-view page-content">
|
||||
<div class="admin-grid">
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Ajouter un lieu d’affectation</h2>
|
||||
<p class="section-subtitle">Chaque lieu possède son propre type : périscolaire ou extrascolaire.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="create-structure-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Code <strong>*</strong></span>
|
||||
<input id="new-structure-code" name="code" type="text" maxlength="50" placeholder="Ex. CRECHE_MATUSALEME" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom <strong>*</strong></span>
|
||||
<input id="new-structure-name" name="nom" type="text" maxlength="150" placeholder="Ex. Crèche Matusalème" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type d’affectation <strong>*</strong></span>
|
||||
<select id="new-structure-type" name="type_affectation" required>
|
||||
<option value="PERISCOLAIRE">Périscolaire</option>
|
||||
<option value="EXTRASCOLAIRE">Extrascolaire</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse</span>
|
||||
<input id="new-structure-address" name="adresse" type="text" maxlength="255" placeholder="Adresse du lieu">
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<div id="create-structure-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Ajouter le lieu</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Ajouter un agent</h2>
|
||||
<p class="section-subtitle">L’agent reçoit un lieu principal à sa création, mais peut être intégré aussi bien aux équipes périscolaires qu’extrascolaires et travailler ponctuellement sur d’autres lieux.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="create-agent-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Matricule <strong>*</strong></span>
|
||||
<input id="new-agent-matricule" name="matricule" type="text" maxlength="50" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Poste PTA <strong>*</strong></span>
|
||||
<select id="new-agent-post" name="id_poste" required>
|
||||
<option value="">Choisir un poste</option>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type de contrat <strong>*</strong></span>
|
||||
<select id="new-agent-contract-type" required>
|
||||
<option value="PERMANENT">Permanent — soumis au PTA</option>
|
||||
<option value="TEMPORAIRE">Temporaire — hors périmètre PTA</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation principal <strong>*</strong></span>
|
||||
<select id="new-agent-structure" name="structure_id" required>
|
||||
<option value="">Choisir un lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars($structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom <strong>*</strong></span>
|
||||
<input id="new-agent-name" name="nom" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Prénom <strong>*</strong></span>
|
||||
<input id="new-agent-firstname" name="prenom" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-mail</span>
|
||||
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Téléphone</span>
|
||||
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Qualification</span>
|
||||
<select id="new-agent-qualified" name="est_diplome">
|
||||
<option value="0">Non diplômé</option>
|
||||
<option value="1">Diplômé</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Diplôme</span>
|
||||
<input id="new-agent-diploma" name="diplome_libelle" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…" disabled>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse personnelle</span>
|
||||
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Adresse de l’agent"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Quotité de travail <strong>*</strong></span>
|
||||
<div class="input-suffix">
|
||||
<input id="new-agent-rate" name="quotite_travail" type="number" min="1" max="100" step="0.01" value="100" required>
|
||||
<span>%</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début du contrat <strong>*</strong></span>
|
||||
<input id="new-agent-contract-start" name="date_debut_contrat" type="date" value="<?= date('Y-m-d') ?>" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de fin du contrat</span>
|
||||
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="new-agent-training-planned" type="checkbox">
|
||||
<span>Une formation est déjà prévue dans la répartition annuelle : ne pas ajouter l’enveloppe complémentaire de 14 h</span>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Observations utiles à la construction du PTA</span>
|
||||
<textarea id="new-agent-pta-comment" maxlength="1000" rows="3" placeholder="Disponibilités particulières, organisation connue…"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">Le quota PTA est suivi sur l’année civile, du 1er janvier au 31 décembre. À 100 %, la référence est de 1 607 h. La date de début du contrat reste une information RH mais ne décale pas l’année de référence.</p>
|
||||
<div class="admin-form-actions">
|
||||
<div id="create-agent-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Ajouter l’agent</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Paramètres des lieux d’affectation</h2>
|
||||
<p class="section-subtitle">Le type d’affectation est défini au niveau du lieu : par exemple « Crèche Matusalème — Périscolaire ».</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="structure-settings-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table assignment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lieu</th>
|
||||
<th>Code</th>
|
||||
<th>Type d’affectation</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="structure-settings-body">
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<tr data-structure-id="<?= (int) $structure['id_structure'] ?>">
|
||||
<td><strong><?= htmlspecialchars($structure['nom']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($structure['code']) ?></td>
|
||||
<td>
|
||||
<select class="structure-type-select" aria-label="Type d’affectation de <?= htmlspecialchars($structure['nom']) ?>">
|
||||
<option value="PERISCOLAIRE" <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'PERISCOLAIRE' ? 'selected' : '' ?>>Périscolaire</option>
|
||||
<option value="EXTRASCOLAIRE" <?= ($structure['type_affectation'] ?? '') === 'EXTRASCOLAIRE' ? 'selected' : '' ?>>Extrascolaire</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><button type="button" class="button button-secondary save-structure-type">Enregistrer</button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card coverage-settings-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Horaires de couverture des lieux</h2>
|
||||
<p class="section-subtitle">Définissez les périodes pendant lesquelles un nombre minimum d’agents doit être physiquement présent. Seules les affectations « Heure de travail » comptent comme présence.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selection-grid overview-filters">
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation</span>
|
||||
<select id="coverage-rules-structure">
|
||||
<option value="">Choisir un lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="overview-filter-actions">
|
||||
<button id="load-coverage-rules" class="button button-secondary filter-button" type="button">Charger</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="coverage-rules-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div id="coverage-rules-editor" class="coverage-rules-editor">
|
||||
<div class="empty-state">Choisissez un lieu pour configurer les plages où une présence minimale est obligatoire.</div>
|
||||
</div>
|
||||
<div class="coverage-rules-actions">
|
||||
<button id="save-coverage-rules" class="button button-primary" type="button" disabled>Enregistrer les horaires de couverture</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Informations des agents</h2>
|
||||
<p class="section-subtitle">Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="assignment-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table assignment-table agent-information-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Matricule</th>
|
||||
<th>Poste</th>
|
||||
<th>Qualification</th>
|
||||
<th>Téléphone</th>
|
||||
<th>Début contrat</th>
|
||||
<th>Lieu principal</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agent-assignment-body">
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<tr
|
||||
data-agent-id="<?= (int) $agent['id_agent'] ?>"
|
||||
data-email="<?= htmlspecialchars((string) ($agent['email'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-telephone="<?= htmlspecialchars((string) ($agent['telephone'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-adresse="<?= htmlspecialchars((string) ($agent['adresse'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-est-diplome="<?= !empty($agent['est_diplome']) ? '1' : '0' ?>"
|
||||
data-diplome-libelle="<?= htmlspecialchars((string) ($agent['diplome_libelle'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-date-debut-contrat="<?= htmlspecialchars((string) ($agent['date_debut_contrat'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
|
||||
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
|
||||
data-formation-repartition-annuelle="<?= !empty($agent['formation_repartition_annuelle']) ? '1' : '0' ?>"
|
||||
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
|
||||
>
|
||||
<td><strong class="agent-row-name"><?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($agent['matricule']) ?></td>
|
||||
<td class="agent-row-post"><?= htmlspecialchars((string) (($agent['poste_libelle'] ?? '') ?: 'Non renseigné')) ?></td>
|
||||
<td class="agent-row-qualification">
|
||||
<?= !empty($agent['est_diplome'])
|
||||
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' — ' . htmlspecialchars($agent['diplome_libelle']) : '')
|
||||
: 'Non diplômé' ?>
|
||||
</td>
|
||||
<td class="agent-row-phone"><?= htmlspecialchars((string) (($agent['telephone'] ?? '') ?: 'Non renseigné')) ?></td>
|
||||
<td class="agent-row-contract-start"><?= htmlspecialchars((string) (($agent['date_debut_contrat'] ?? '') ?: 'Non renseignée')) ?></td>
|
||||
<td class="agent-row-structure"><?= htmlspecialchars((string) (($agent['structure_nom'] ?? '') ?: 'Sans lieu principal')) ?></td>
|
||||
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div id="agent-profile-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-profile-modal-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="agent-profile-modal-title">Modifier les informations de l’agent</h2>
|
||||
<p id="agent-profile-modal-subtitle" class="section-subtitle"></p>
|
||||
</div>
|
||||
<button id="close-agent-profile-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<form id="agent-profile-form" class="admin-form">
|
||||
<input id="edit-agent-profile-id" type="hidden">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Poste PTA <strong>*</strong></span>
|
||||
<select id="edit-agent-profile-post" required>
|
||||
<option value="">Choisir un poste</option>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type de contrat <strong>*</strong></span>
|
||||
<select id="edit-agent-profile-contract-type" required>
|
||||
<option value="PERMANENT">Permanent</option>
|
||||
<option value="TEMPORAIRE">Temporaire</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-mail</span>
|
||||
<input id="edit-agent-profile-email" type="email" maxlength="255">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Téléphone</span>
|
||||
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Qualification</span>
|
||||
<select id="edit-agent-profile-qualified">
|
||||
<option value="0">Non diplômé</option>
|
||||
<option value="1">Diplômé</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Diplôme</span>
|
||||
<input id="edit-agent-profile-diploma" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…">
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse personnelle</span>
|
||||
<textarea id="edit-agent-profile-address" maxlength="255" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début du contrat <strong>*</strong></span>
|
||||
<input id="edit-agent-profile-contract-start" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de fin du contrat</span>
|
||||
<input id="edit-agent-profile-contract-end" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="edit-agent-profile-training-planned" type="checkbox">
|
||||
<span>Formation déjà intégrée dans la répartition annuelle</span>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Observations PTA</span>
|
||||
<textarea id="edit-agent-profile-pta-comment" maxlength="1000" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Lieu d’affectation principal</span>
|
||||
<select id="edit-agent-profile-structure">
|
||||
<option value="">Sans lieu principal</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars($structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="agent-profile-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions">
|
||||
<button id="cancel-agent-profile" type="button" class="button button-secondary">Annuler</button>
|
||||
<button type="submit" class="button button-primary">Enregistrer les informations</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
136
app/Views/partials/agent_overview.php
Normal file
136
app/Views/partials/agent_overview.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<section id="agent-view" class="app-view page-content">
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2><?= ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Vue individuelle d’un agent' ?></h2>
|
||||
<p class="section-subtitle"><?= ($accessProfile['role'] ?? '') === 'AGENT'
|
||||
? 'Consultation de votre planning mensuel et suivi de votre quota.'
|
||||
: (!empty($permissions['can_copy_full_week'])
|
||||
? 'Planning mensuel, modification directe des créneaux, duplication de semaines et suivi du quota annuel.'
|
||||
: 'Planning mensuel des agents du lieu, modification des créneaux du lieu et suivi du quota annuel.') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selection-grid overview-filters">
|
||||
<label class="field">
|
||||
<span>Agent</span>
|
||||
<select id="overview-agent">
|
||||
<option value="">Choisir un agent</option>
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<option value="<?= (int) $agent['id_agent'] ?>">
|
||||
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' (' . $agent['matricule'] . ')') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Mois</span>
|
||||
<input id="agent-overview-month" type="month" value="<?= htmlspecialchars((new DateTimeImmutable())->format('Y-m')) ?>">
|
||||
</label>
|
||||
<button id="load-agent-overview" class="button button-primary filter-button" type="button">Afficher le mois</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card overview-card">
|
||||
<div id="agent-overview-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div id="agent-overview-empty" class="empty-state">Choisissez un agent et un mois.</div>
|
||||
<div id="agent-overview-content" hidden></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<?php if (!empty($permissions['can_edit_draft'])): ?>
|
||||
<div id="agent-entry-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-entry-modal-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="agent-entry-modal-title">Modifier un créneau</h2>
|
||||
<p class="section-subtitle">Modifiez directement le jour, le lieu, le motif ou les horaires. Un déplacement vers une autre semaine est autorisé.</p>
|
||||
</div>
|
||||
<button id="close-agent-entry-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form id="agent-entry-edit-form" class="admin-form">
|
||||
<input id="edit-agent-entry-id" type="hidden">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Jour</span>
|
||||
<input id="edit-agent-entry-date" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation</span>
|
||||
<select id="edit-agent-entry-structure" required>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Motif</span>
|
||||
<select id="edit-agent-entry-motif" required>
|
||||
<?php foreach ($motifs as $motif): ?>
|
||||
<option value="<?= (int) $motif['id_motif'] ?>"><?= htmlspecialchars($motif['libelle']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field edit-time-range-field">
|
||||
<span>Horaires</span>
|
||||
<div class="edit-time-range">
|
||||
<input id="edit-agent-entry-start" type="time" step="900" required aria-label="Heure de début">
|
||||
<span>→</span>
|
||||
<input id="edit-agent-entry-end" type="time" step="900" required aria-label="Heure de fin">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">Toute modification d’un planning validé le repasse automatiquement en brouillon afin qu’il soit contrôlé et validé à nouveau.</p>
|
||||
<div id="agent-entry-edit-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions agent-entry-modal-actions">
|
||||
<button id="delete-agent-entry-edit" type="button" class="button button-danger">Supprimer le créneau</button>
|
||||
<span class="modal-action-spacer" aria-hidden="true"></span>
|
||||
<button id="cancel-agent-entry-edit" type="button" class="button button-secondary">Annuler</button>
|
||||
<button id="save-agent-entry-edit" type="submit" class="button button-primary">Enregistrer la modification</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($permissions['can_copy_full_week'])): ?>
|
||||
<div id="agent-week-copy-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card planning-entry-action-card" role="dialog" aria-modal="true" aria-labelledby="agent-week-copy-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="agent-week-copy-title">Dupliquer une semaine</h2>
|
||||
<p id="agent-week-copy-summary" class="section-subtitle"></p>
|
||||
</div>
|
||||
<button id="close-agent-week-copy-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form id="agent-week-copy-form" class="admin-form">
|
||||
<input id="agent-week-copy-source" type="hidden">
|
||||
<label class="field">
|
||||
<span>Semaine cible</span>
|
||||
<input id="agent-week-copy-target" type="week" required>
|
||||
</label>
|
||||
|
||||
<fieldset class="entry-action-mode">
|
||||
<legend>Mode de duplication</legend>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="agent-week-copy-mode" value="merge" checked>
|
||||
<span><strong>Compléter</strong><small>Conserve les créneaux déjà présents. Refus en cas de chevauchement.</small></span>
|
||||
</label>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="agent-week-copy-mode" value="replace">
|
||||
<span><strong>Remplacer</strong><small>Efface les créneaux de la semaine cible avant la copie.</small></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div id="agent-week-copy-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions">
|
||||
<button id="cancel-agent-week-copy" type="button" class="button button-secondary">Annuler</button>
|
||||
<button id="confirm-agent-week-copy" type="submit" class="button button-primary">Dupliquer la semaine</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
101
app/Views/partials/annual_overview.php
Normal file
101
app/Views/partials/annual_overview.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
$currentYear = (int) date('Y');
|
||||
?>
|
||||
<section class="page-content annual-overview-page">
|
||||
<section class="card annual-filter-card no-print">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Prévisionnel annuel de l’agent</h2>
|
||||
<p class="section-subtitle">Vue de contrôle de janvier à décembre, inspirée du tableau d’annualisation utilisé par le service.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selection-grid overview-filters annual-filters">
|
||||
<label class="field">
|
||||
<span>Agent</span>
|
||||
<select id="annual-agent">
|
||||
<option value="">Choisir un agent</option>
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<option value="<?= (int) $agent['id_agent'] ?>">
|
||||
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' — ' . ($agent['poste_libelle'] ?? 'Poste non renseigné')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Année affichée</span>
|
||||
<input id="annual-year" type="number" min="2000" max="2100" value="<?= $currentYear ?>">
|
||||
</label>
|
||||
<div class="overview-filter-actions annual-filter-actions">
|
||||
<button id="load-annual" class="button button-primary" type="button">Afficher l’année</button>
|
||||
<button id="annual-print" class="button button-secondary" type="button" disabled>Imprimer</button>
|
||||
<button id="annual-fullscreen" class="button button-secondary" type="button" disabled>Plein écran</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="annual-message" class="message" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="annual-content" class="annual-sheet" hidden>
|
||||
<header class="annual-sheet-header">
|
||||
<div class="annual-title-row">
|
||||
<span class="annual-direction">Direction Enfance</span>
|
||||
<h2 id="annual-title">ANNUALISATION</h2>
|
||||
<span id="annual-updated" class="annual-updated"></span>
|
||||
</div>
|
||||
<div class="annual-identity-grid">
|
||||
<div><span>Nom de l’agent</span><strong id="annual-agent-name">—</strong></div>
|
||||
<div><span>Matricule</span><strong id="annual-agent-number">—</strong></div>
|
||||
<div><span>Quotité de travail</span><strong id="annual-rate">—</strong></div>
|
||||
<div><span>Nombre d’heures</span><strong id="annual-target">—</strong></div>
|
||||
<div><span>Statut</span><strong id="annual-contract">—</strong></div>
|
||||
<div><span>Lieu principal</span><strong id="annual-location">—</strong></div>
|
||||
<div><span>Poste</span><strong id="annual-post">—</strong></div>
|
||||
<div><span>Reste à affecter</span><strong id="annual-remaining">—</strong></div>
|
||||
</div>
|
||||
<div id="annual-quota-periods" class="annual-quota-periods"></div>
|
||||
<div class="annual-toolbar no-print">
|
||||
<a id="annual-open-pta" class="button button-secondary" href="pta.php">Ouvrir le PTA détaillé</a>
|
||||
<a id="annual-open-month" class="button button-secondary" href="agents.php">Ouvrir la vue mensuelle</a>
|
||||
<p>Cliquer sur une journée pour ouvrir sa semaine dans l’écran de planning.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="annual-summary-strip">
|
||||
<article><span>Prévisionnel affiché</span><strong id="annual-calendar-total">0 h 00</strong></article>
|
||||
<article><span>Heures validées</span><strong id="annual-validated-total">0 h 00</strong></article>
|
||||
<article><span>Heures en brouillon</span><strong id="annual-draft-total">0 h 00</strong></article>
|
||||
<article><span>Période visualisée</span><strong id="annual-year-label">—</strong></article>
|
||||
</section>
|
||||
|
||||
<div class="annual-grid-scroll" tabindex="0" aria-label="Tableau annuel défilant horizontalement">
|
||||
<div id="annual-months" class="annual-months"></div>
|
||||
</div>
|
||||
|
||||
<footer class="annual-sheet-footer">
|
||||
<div id="annual-legend" class="annual-legend"></div>
|
||||
<div class="annual-abbreviations">
|
||||
<strong>Colonnes :</strong>
|
||||
<span><b>AP</b> Activité périscolaire</span>
|
||||
<span><b>PP</b> Préparation périscolaire</span>
|
||||
<span><b>AE</b> Activité extrascolaire</span>
|
||||
<span><b>PE</b> Préparation extrascolaire</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section class="annual-validation-card no-print" aria-labelledby="annual-validation-title">
|
||||
<div class="annual-validation-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Service Enfance</p>
|
||||
<h3 id="annual-validation-title">Validation annuelle</h3>
|
||||
<p>Contrôler puis valider en une fois tous les plannings en brouillon de l’agent sur l’année affichée.</p>
|
||||
</div>
|
||||
<button id="annual-validate-year" class="button button-primary" type="button">
|
||||
Valider toutes les semaines de l’année
|
||||
</button>
|
||||
</div>
|
||||
<p class="annual-validation-note">
|
||||
En cas d’erreur, aucune semaine n’est validée. Les semaines à corriger peuvent être ouvertes dans un nouvel onglet afin de conserver cette vue annuelle ouverte.
|
||||
</p>
|
||||
<div id="annual-validation-result" class="annual-validation-result" aria-live="polite"></div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
73
app/Views/partials/coverage_alerts.php
Normal file
73
app/Views/partials/coverage_alerts.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<section id="coverage-view" class="app-view page-content">
|
||||
<section class="card coverage-header-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Contrôle de couverture des lieux</h2>
|
||||
<p class="section-subtitle">Repérez les plages où le nombre d’agents en « Heure de travail » est inférieur au minimum attendu.</p>
|
||||
</div>
|
||||
<button id="refresh-coverage-alerts" class="button button-secondary" type="button">Actualiser</button>
|
||||
</div>
|
||||
|
||||
<div class="selection-grid overview-filters coverage-filters">
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation</span>
|
||||
<select id="coverage-structure-filter">
|
||||
<option value="">Tous les lieux</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Semaine</span>
|
||||
<input id="coverage-week" type="week" value="<?= htmlspecialchars($currentWeek) ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="coverage-summary" aria-live="polite">
|
||||
<article class="coverage-summary-card coverage-summary-danger">
|
||||
<span>Créneaux insuffisamment couverts</span>
|
||||
<strong id="coverage-gap-count">0</strong>
|
||||
<small>plage(s) à traiter</small>
|
||||
</article>
|
||||
<article class="coverage-summary-card">
|
||||
<span>Lieux concernés</span>
|
||||
<strong id="coverage-structure-count">0</strong>
|
||||
<small>avec un manque</small>
|
||||
</article>
|
||||
<article class="coverage-summary-card">
|
||||
<span>Temps non couvert</span>
|
||||
<strong id="coverage-gap-hours">0 h 00</strong>
|
||||
<small>durée cumulée</small>
|
||||
</article>
|
||||
<article class="coverage-summary-card">
|
||||
<span>Couvertures non configurées</span>
|
||||
<strong id="coverage-unconfigured-count">0</strong>
|
||||
<small>lieu(x)</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card coverage-list-card">
|
||||
<div id="coverage-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div id="coverage-loading" class="empty-state" hidden>Analyse de la couverture en cours…</div>
|
||||
<div id="coverage-empty" class="empty-state" hidden>Aucun manque de couverture détecté sur cette semaine.</div>
|
||||
<div id="coverage-content" class="coverage-table-wrap" hidden>
|
||||
<table class="coverage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lieu</th>
|
||||
<th>Jour</th>
|
||||
<th>Créneau</th>
|
||||
<th>Minimum attendu</th>
|
||||
<th>Planifié</th>
|
||||
<th>Manque</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="coverage-alerts-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="coverage-unconfigured" class="coverage-unconfigured" hidden></div>
|
||||
</section>
|
||||
</section>
|
||||
51
app/Views/partials/pending_validation.php
Normal file
51
app/Views/partials/pending_validation.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<section id="pending-validation-view" class="app-view page-content">
|
||||
<section class="card pending-validation-header-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Plannings en attente de validation</h2>
|
||||
<p class="section-subtitle">Retrouvez tous les brouillons contenant des affectations. Ouvrez un planning pour le contrôler puis le valider directement dans l’écran de saisie.</p>
|
||||
</div>
|
||||
<button id="refresh-pending-validation" class="button button-secondary" type="button">Actualiser</button>
|
||||
</div>
|
||||
|
||||
<div class="pending-validation-summary" aria-live="polite">
|
||||
<article class="pending-summary-card">
|
||||
<span>À valider</span>
|
||||
<strong id="pending-validation-count">0</strong>
|
||||
<small>planning(s)</small>
|
||||
</article>
|
||||
<article class="pending-summary-card">
|
||||
<span>Volume en attente</span>
|
||||
<strong id="pending-validation-hours">0 h 00</strong>
|
||||
<small>toutes affectations</small>
|
||||
</article>
|
||||
<article class="pending-summary-card">
|
||||
<span>Heures décomptées</span>
|
||||
<strong id="pending-validation-counted-hours">0 h 00</strong>
|
||||
<small>impact sur les quotas</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card pending-validation-list-card">
|
||||
<div id="pending-validation-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div id="pending-validation-loading" class="empty-state" hidden>Chargement des plannings à valider…</div>
|
||||
<div id="pending-validation-empty" class="empty-state" hidden>Aucun planning n’est actuellement en attente de validation.</div>
|
||||
<div id="pending-validation-content" class="pending-validation-table-wrap" hidden>
|
||||
<table class="pending-validation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Lieu d’affectation</th>
|
||||
<th>Semaine</th>
|
||||
<th>Créneaux</th>
|
||||
<th>Volume</th>
|
||||
<th>Dernière modification</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pending-validation-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
330
app/Views/partials/planning_editor.php
Normal file
330
app/Views/partials/planning_editor.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<section id="editor-view" class="app-view page-content">
|
||||
<?php if (($accessProfile['role'] ?? '') === 'RESPONSABLE_STRUCTURE'): ?>
|
||||
<div class="message message-info role-page-notice">
|
||||
Vous pouvez saisir et corriger les horaires et motifs de votre lieu. Les modifications sont enregistrées en <strong>brouillon</strong> ; seul le Service Enfance peut les valider.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<section class="card selection-card">
|
||||
<h2>1. Sélection du planning</h2>
|
||||
<div class="selection-grid">
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation <strong>*</strong></span>
|
||||
<select id="structure" required>
|
||||
<option value="">Choisir un lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Agent <strong>*</strong></span>
|
||||
<select id="agent" required disabled>
|
||||
<option value="">Choisir d’abord un lieu d’affectation</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Semaine <strong>*</strong></span>
|
||||
<input id="week" type="week" value="<?= htmlspecialchars($currentWeek) ?>" required disabled>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quota-strip" id="editor-quota" hidden>
|
||||
<article class="quota-card">
|
||||
<span>Quotité</span>
|
||||
<strong id="quota-rate">100 %</strong>
|
||||
</article>
|
||||
<article class="quota-card">
|
||||
<span>Quota de la période</span>
|
||||
<strong id="quota-target">1 607 h 00</strong>
|
||||
<small id="quota-period">Année civile</small>
|
||||
</article>
|
||||
<article class="quota-card">
|
||||
<span>Déjà affecté</span>
|
||||
<strong id="quota-used">0 h 00</strong>
|
||||
<small id="quota-used-detail">Validé + brouillon</small>
|
||||
</article>
|
||||
<article class="quota-card quota-card-emphasis">
|
||||
<span>Reste à affecter</span>
|
||||
<strong id="quota-remaining">1 607 h 00</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card" id="entry-card" aria-disabled="true">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>2. Saisir ou modifier les affectations</h2>
|
||||
<p class="section-subtitle">Chaque plage horaire possède son propre motif et sa propre règle de décompte du quota annuel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weekly-entry-toolbar">
|
||||
<p class="weekly-entry-help">Les horaires déjà enregistrés sont rechargés automatiquement. Modifiez-les directement, puis appliquez les horaires avant d’enregistrer.</p>
|
||||
</div>
|
||||
|
||||
<div class="weekly-entry-scroll">
|
||||
<table class="weekly-entry-table" aria-label="Affectations du lundi au vendredi">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Jour</th>
|
||||
<th>Affectations et plages horaires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="weekly-entry-body">
|
||||
<tr>
|
||||
<td colspan="2" class="weekly-entry-placeholder">Sélectionnez d’abord un lieu d’affectation, un agent et une semaine.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="weekly-entry-actions">
|
||||
<button id="add-entry" class="button button-primary button-add" type="button" disabled>
|
||||
Appliquer les horaires renseignés
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">Chaque plage peut avoir un motif différent : par exemple Travail le matin puis Formation l’après-midi. Les heures sont saisies par pas de 15 minutes. Les créneaux déjà affectés à l’agent dans un autre lieu apparaissent dans le planning global et bloquent toute nouvelle saisie qui les chevaucherait.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>3. Planning global de l’agent</h2>
|
||||
<p id="week-label" class="section-subtitle">Toutes les affectations de l’agent sur la semaine, quel que soit le lieu d’affectation. Cliquez sur un créneau pour le déplacer ou le dupliquer, ou glissez-le directement vers un autre jour.</p>
|
||||
</div>
|
||||
<div class="planning-summary-actions">
|
||||
<div class="totals">
|
||||
<span>Total semaine décompté</span>
|
||||
<strong id="work-total">0 h 00</strong>
|
||||
</div>
|
||||
<?php if (!empty($permissions['can_use_templates'])): ?>
|
||||
<button id="save-week-template" class="button button-secondary" type="button" disabled>
|
||||
Enregistrer comme semaine type
|
||||
</button>
|
||||
<button id="apply-week-template" class="button button-secondary" type="button" disabled>
|
||||
Importer une semaine type
|
||||
</button>
|
||||
<button id="apply-week-template-period" class="button button-secondary" type="button" disabled>
|
||||
Appliquer sur une période
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button id="open-agent-pdf" class="button button-secondary button-pdf" type="button" disabled>
|
||||
Voir le PDF de l’agent (7 semaines)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty-state">Aucune affectation pour le moment.</div>
|
||||
<div id="planning-board" class="planning-board" hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="actions-bar">
|
||||
<div id="message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="action-buttons">
|
||||
<?php if (!empty($permissions['can_edit_draft'])): ?>
|
||||
<button id="modify-planning" class="button button-warning" type="button" hidden>Modifier le planning</button>
|
||||
<button id="save-draft" class="button button-secondary" type="button" disabled>Enregistrer le brouillon</button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($permissions['can_validate'])): ?>
|
||||
<button id="validate-planning" class="button button-success" type="button" disabled>Valider le planning</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
||||
<div id="planning-entry-action-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card planning-entry-action-card" role="dialog" aria-modal="true" aria-labelledby="planning-entry-action-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="planning-entry-action-title">Déplacer ou dupliquer un créneau</h2>
|
||||
<p id="planning-entry-action-summary" class="section-subtitle"></p>
|
||||
</div>
|
||||
<button id="close-planning-entry-action-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form id="planning-entry-action-form" class="admin-form">
|
||||
<fieldset class="entry-action-mode">
|
||||
<legend>Action à effectuer</legend>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="planning-entry-action" value="move" checked>
|
||||
<span><strong>Déplacer</strong><small>Le créneau change de jour.</small></span>
|
||||
</label>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="planning-entry-action" value="duplicate">
|
||||
<span><strong>Dupliquer</strong><small>Le créneau d’origine est conservé.</small></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="field">
|
||||
<span>Jour cible</span>
|
||||
<select id="planning-entry-target-date" required></select>
|
||||
</label>
|
||||
|
||||
<p class="hint">Astuce : vous pouvez aussi glisser-déposer directement un créneau vers un autre jour. Maintenez <strong>Ctrl</strong> (ou <strong>⌘</strong> sur Mac) pendant le dépôt pour le dupliquer.</p>
|
||||
<div id="planning-entry-action-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions">
|
||||
<button id="cancel-planning-entry-action" type="button" class="button button-secondary">Annuler</button>
|
||||
<button id="confirm-planning-entry-action" type="submit" class="button button-primary">Appliquer</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="save-week-template-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card week-template-modal-card" role="dialog" aria-modal="true" aria-labelledby="save-week-template-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="save-week-template-title">Enregistrer une semaine type</h2>
|
||||
<p class="section-subtitle">Le modèle mémorisera toutes les affectations de l’agent sur la semaine, tous lieux confondus.</p>
|
||||
</div>
|
||||
<button id="close-save-week-template-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form id="save-week-template-form" class="admin-form">
|
||||
<label class="field">
|
||||
<span>Nom de la semaine type <strong>*</strong></span>
|
||||
<input id="week-template-name" type="text" maxlength="120" placeholder="Ex. Semaine scolaire classique" required>
|
||||
</label>
|
||||
<div class="week-template-context" id="save-week-template-context"></div>
|
||||
<div id="save-week-template-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions">
|
||||
<button id="cancel-save-week-template" type="button" class="button button-secondary">Annuler</button>
|
||||
<button type="submit" class="button button-primary">Enregistrer le modèle</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="apply-week-template-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card week-template-modal-card" role="dialog" aria-modal="true" aria-labelledby="apply-week-template-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="apply-week-template-title">Importer une semaine type</h2>
|
||||
<p class="section-subtitle">Le modèle sera appliqué à la semaine actuellement sélectionnée pour cet agent.</p>
|
||||
</div>
|
||||
<button id="close-apply-week-template-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form id="apply-week-template-form" class="admin-form">
|
||||
<label class="field">
|
||||
<span>Semaine type <strong>*</strong></span>
|
||||
<select id="week-template-select" required>
|
||||
<option value="">Chargement...</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div id="week-template-details" class="week-template-details"></div>
|
||||
|
||||
<fieldset class="entry-action-mode week-template-mode">
|
||||
<legend>Mode d’import</legend>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="week-template-mode" value="merge" checked>
|
||||
<span><strong>Compléter la semaine</strong><small>Conserve les affectations existantes. L’import est refusé en cas de chevauchement.</small></span>
|
||||
</label>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="week-template-mode" value="replace">
|
||||
<span><strong>Remplacer la semaine</strong><small>Supprime les affectations existantes de l’agent sur cette semaine avant d’appliquer le modèle.</small></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="week-template-danger" id="week-template-replace-warning" hidden>
|
||||
Le mode « Remplacer » efface les affectations actuelles de l’agent sur la semaine sélectionnée, tous lieux confondus.
|
||||
</div>
|
||||
|
||||
<div id="apply-week-template-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions week-template-modal-actions">
|
||||
<button id="delete-week-template" type="button" class="button button-danger" disabled>Supprimer ce modèle</button>
|
||||
<span class="modal-action-spacer"></span>
|
||||
<button id="cancel-apply-week-template" type="button" class="button button-secondary">Annuler</button>
|
||||
<button type="submit" class="button button-primary">Importer le modèle</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="apply-week-template-period-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card week-template-period-modal-card" role="dialog" aria-modal="true" aria-labelledby="apply-week-template-period-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="apply-week-template-period-title">Appliquer une semaine type sur une période</h2>
|
||||
<p class="section-subtitle">Les périodes de vacances déjà validées dans PTA sont prises en compte avant toute création de planning.</p>
|
||||
</div>
|
||||
<button id="close-apply-week-template-period-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form id="apply-week-template-period-form" class="admin-form">
|
||||
<div class="week-template-period-grid">
|
||||
<label class="field">
|
||||
<span>Semaine type <strong>*</strong></span>
|
||||
<select id="week-template-period-select" required>
|
||||
<option value="">Chargement...</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début <strong>*</strong></span>
|
||||
<input id="week-template-period-start" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de fin <strong>*</strong></span>
|
||||
<input id="week-template-period-end" type="date" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="entry-action-mode week-template-mode">
|
||||
<legend>Périodes à appliquer</legend>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="week-template-period-filter" value="all" checked>
|
||||
<span><strong>Toutes les semaines</strong><small>Répète la semaine type sur toutes les dates de la période, qu’elles soient scolaires ou pendant les vacances.</small></span>
|
||||
</label>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="week-template-period-filter" value="school">
|
||||
<span><strong>Uniquement les périodes scolaires</strong><small>Applique le modèle uniquement aux jours situés hors des vacances enregistrées dans PTA.</small></span>
|
||||
</label>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="week-template-period-filter" value="extra">
|
||||
<span><strong>Uniquement les périodes extrascolaires</strong><small>Applique le modèle uniquement aux jours compris dans les vacances scolaires enregistrées dans PTA.</small></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="week-template-period-rule-note">
|
||||
<strong>Calendrier :</strong>
|
||||
le caractère scolaire ou extrascolaire d’une date est déterminé à partir des périodes de vacances validées dans PTA.
|
||||
Le choix ci-dessus pilote l’application du modèle ; il n’est pas déduit du lieu d’affectation.
|
||||
</div>
|
||||
|
||||
<fieldset class="entry-action-mode week-template-mode">
|
||||
<legend>Mode d’application</legend>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="week-template-period-mode" value="merge" checked>
|
||||
<span><strong>Compléter les plannings</strong><small>Conserve les affectations déjà existantes. L’application est bloquée si un chevauchement est détecté.</small></span>
|
||||
</label>
|
||||
<label class="entry-action-option">
|
||||
<input type="radio" name="week-template-period-mode" value="replace">
|
||||
<span><strong>Remplacer les jours concernés</strong><small>Supprime les affectations existantes uniquement sur les dates où le modèle doit réellement s’appliquer, puis recrée le planning.</small></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="week-template-danger" id="week-template-period-replace-warning" hidden>
|
||||
Les journées exclues par le filtre de calendrier ne seront pas effacées. Seules les dates sur lesquelles le modèle crée au moins un créneau seront remplacées.
|
||||
</div>
|
||||
|
||||
<div class="week-template-period-preview-actions">
|
||||
<button id="preview-week-template-period" type="button" class="button button-secondary">Prévisualiser l’application</button>
|
||||
</div>
|
||||
|
||||
<div id="week-template-period-preview" class="week-template-period-preview" hidden></div>
|
||||
<div id="apply-week-template-period-message" class="message" role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="admin-form-actions modal-actions">
|
||||
<button id="cancel-apply-week-template-period" type="button" class="button button-secondary">Annuler</button>
|
||||
<button id="confirm-apply-week-template-period" type="submit" class="button button-primary" disabled>Appliquer sur la période</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
133
app/Views/partials/pta.php
Normal file
133
app/Views/partials/pta.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<section class="page-content pta-page">
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>PTA annuel de l’agent</h2>
|
||||
<p class="section-subtitle">Synthèse de l’année civile, répartition des 1 607 heures proratisées et contrôles métier. Un même agent peut intervenir en périscolaire et en extrascolaire.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selection-grid overview-filters">
|
||||
<label class="field">
|
||||
<span>Agent soumis au PTA</span>
|
||||
<select id="pta-agent">
|
||||
<option value="">Choisir un agent</option>
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<option value="<?= (int) $agent['id_agent'] ?>">
|
||||
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' — ' . ($agent['poste_libelle'] ?? 'Poste non renseigné')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de référence</span>
|
||||
<input id="pta-reference-date" type="date" value="<?= htmlspecialchars(date('Y-m-d')) ?>">
|
||||
</label>
|
||||
<div class="overview-filter-actions">
|
||||
<button id="load-pta" type="button" class="button button-primary filter-button">Afficher le PTA</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pta-message" class="message" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="pta-content" hidden>
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 id="pta-agent-name">Synthèse</h2>
|
||||
<p id="pta-agent-meta" class="section-subtitle"></p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<a id="pta-open-planning" class="button button-secondary" href="planning.php">Ouvrir le planning</a>
|
||||
<a id="pta-open-agent-view" class="button button-secondary" href="agents.php">Vue mensuelle</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pta-summary-cards" class="summary-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Répartition du temps</h2>
|
||||
<p class="section-subtitle">Les congés annuels et les JNT restent visibles, même lorsqu’ils ne décomptent pas le quota.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table">
|
||||
<thead><tr><th>Catégorie</th><th>Durée</th><th>Jours concernés</th></tr></thead>
|
||||
<tbody id="pta-category-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Contrôles du PTA</h2>
|
||||
<p class="section-subtitle">Les alertes signalent les incohérences à contrôler ; elles ne remplacent pas la validation RH ou réglementaire.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pta-controls" class="control-list"></div>
|
||||
</section>
|
||||
|
||||
<div class="admin-grid">
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Contraintes prioritaires</h2>
|
||||
<p class="section-subtitle">Préconisation médicale ou temps partiel thérapeutique applicable sur une période.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="pta-constraint-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Type</span>
|
||||
<select id="pta-constraint-type" required>
|
||||
<option value="MEDICALE">Préconisation médicale</option>
|
||||
<option value="TEMPS_PARTIEL_THERAPEUTIQUE">Temps partiel thérapeutique</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Début</span><input id="pta-constraint-start" type="date" required></label>
|
||||
<label class="field"><span>Fin</span><input id="pta-constraint-end" type="date"></label>
|
||||
<label class="field"><span>Quotité temporaire (%)</span><input id="pta-constraint-rate" type="number" min="1" max="100" step="0.01"></label>
|
||||
<label class="field"><span>Maximum par jour (h)</span><input id="pta-constraint-day-max" type="number" min="0.25" step="0.25"></label>
|
||||
<label class="field"><span>Maximum par semaine (h)</span><input id="pta-constraint-week-max" type="number" min="0.25" step="0.25"></label>
|
||||
<label class="check-field"><input id="pta-forbid-morning" type="checkbox"> <span>Interdit le matin</span></label>
|
||||
<label class="check-field"><input id="pta-forbid-midday" type="checkbox"> <span>Interdit le midi</span></label>
|
||||
<label class="check-field"><input id="pta-forbid-evening" type="checkbox"> <span>Interdit le soir</span></label>
|
||||
<label class="field admin-field-wide"><span>Commentaire prioritaire</span><textarea id="pta-constraint-comment" rows="3" maxlength="1000" required></textarea></label>
|
||||
</div>
|
||||
<div class="admin-form-actions"><button class="button button-primary" type="submit">Enregistrer la contrainte</button></div>
|
||||
</form>
|
||||
<div id="pta-constraint-list" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Préférences et interdictions de lieu</h2>
|
||||
<p class="section-subtitle">Ces informations orientent la composition des équipes après les contraintes prioritaires.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="pta-wish-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Lieu</span>
|
||||
<select id="pta-wish-structure" required>
|
||||
<option value="">Choisir un lieu</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Choix</span><select id="pta-wish-type"><option value="PREFERENCE">Préférence</option><option value="INTERDICTION">Interdiction</option></select></label>
|
||||
<label class="field"><span>Priorité</span><input id="pta-wish-priority" type="number" min="1" max="5" value="1"></label>
|
||||
<label class="field"><span>Distance domicile-lieu (km)</span><input id="pta-wish-distance" type="number" min="0" step="0.1"></label>
|
||||
<label class="field admin-field-wide"><span>Commentaire</span><textarea id="pta-wish-comment" rows="3" maxlength="500"></textarea></label>
|
||||
</div>
|
||||
<div class="admin-form-actions"><button class="button button-primary" type="submit">Enregistrer le choix</button></div>
|
||||
</form>
|
||||
<div id="pta-wish-list" class="stack-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
35
app/Views/partials/structure_overview.php
Normal file
35
app/Views/partials/structure_overview.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<section id="structure-view" class="app-view page-content">
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2><?= ($accessProfile['role'] ?? '') === 'AGENT' ? 'Planning de mon lieu d’affectation' : 'Vue d’ensemble d’un lieu d’affectation' ?></h2>
|
||||
<p class="section-subtitle">Les agents rattachés par défaut à ce lieu et ceux qui y sont effectivement planifiés sur la semaine. Cette vue est en lecture seule.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selection-grid overview-filters">
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation</span>
|
||||
<select id="overview-structure">
|
||||
<option value="">Choisir un lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?> — <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire' ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Semaine</span>
|
||||
<input id="overview-week" type="week" value="<?= htmlspecialchars($currentWeek) ?>">
|
||||
</label>
|
||||
<div class="overview-filter-actions">
|
||||
<button id="load-structure-overview" class="button button-primary filter-button" type="button">Afficher</button>
|
||||
<button id="open-structure-pdf" class="button button-secondary filter-button button-pdf" type="button" disabled>Voir le PDF du lieu</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card overview-card">
|
||||
<div id="structure-overview-empty" class="empty-state">Choisissez un lieu d’affectation et une semaine.</div>
|
||||
<div id="structure-overview-content" hidden></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
54
app/Views/partials/teams.php
Normal file
54
app/Views/partials/teams.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<section class="page-content team-page">
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Élaborer les équipes</h2>
|
||||
<p class="section-subtitle">Équipes périscolaires, mercredis scolaires et périodes extrascolaires, avec contrôle de l’effectif et de la qualification. Un même agent peut appartenir à plusieurs types d’équipes au cours de l’année.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="team-create-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field"><span>Nom de l’équipe</span><input id="team-label" type="text" maxlength="180" required placeholder="Ex. Vacances d’hiver — Équipe Matusalème"></label>
|
||||
<label class="field"><span>Type</span><select id="team-type" required><option value="MERCREDI_PERISCOLAIRE">Mercredi périscolaire</option><option value="EXTRASCOLAIRE">Extrascolaire</option><option value="PERISCOLAIRE_SEMAINE">Périscolaire semaine</option></select></label>
|
||||
<label class="field"><span>Lieu</span><select id="team-structure" required><option value="">Choisir un lieu</option><?php foreach ($structures as $structure): ?><option value="<?= (int) $structure['id_structure'] ?>"><?= htmlspecialchars($structure['nom']) ?></option><?php endforeach; ?></select></label>
|
||||
<label class="field"><span>Date de début</span><input id="team-start" type="date" required></label>
|
||||
<label class="field"><span>Date de fin</span><input id="team-end" type="date" required></label>
|
||||
<label class="field"><span>Enfants de moins de 6 ans</span><input id="team-under-six" type="number" min="0" value="0"></label>
|
||||
<label class="field"><span>Enfants de 6 ans et plus</span><input id="team-over-six" type="number" min="0" value="0"></label>
|
||||
<label class="field"><span>Ratio moins de 6 ans (1 pour)</span><input id="team-ratio-under-six" type="number" min="1" value="8"></label>
|
||||
<label class="field"><span>Ratio 6 ans et plus (1 pour)</span><input id="team-ratio-over-six" type="number" min="1" value="12"></label>
|
||||
<label class="field"><span>Minimum d’agents</span><input id="team-min-agents" type="number" min="1" value="1"></label>
|
||||
<label class="field"><span>Diplômés minimum (%)</span><input id="team-qualified-rate" type="number" min="0" max="100" step="0.01" value="50"></label>
|
||||
<label class="field admin-field-wide"><span>Commentaire</span><textarea id="team-comment" rows="3" maxlength="1000"></textarea></label>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<div id="team-create-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Créer l’équipe</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Équipes enregistrées</h2>
|
||||
<p class="section-subtitle">Les seuils d’encadrement et de qualification sont paramétrables pour chaque équipe avant validation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selection-grid overview-filters">
|
||||
<label class="field"><span>Du</span><input id="team-filter-start" type="date"></label>
|
||||
<label class="field"><span>Au</span><input id="team-filter-end" type="date"></label>
|
||||
<div class="overview-filter-actions"><button id="load-teams" class="button button-secondary filter-button" type="button">Actualiser</button></div>
|
||||
</div>
|
||||
<div id="team-list-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div id="team-list" class="team-list"><div class="empty-state">Chargez les équipes pour commencer.</div></div>
|
||||
</section>
|
||||
|
||||
<template id="team-agent-options">
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<option value="<?= (int) $agent['id_agent'] ?>">
|
||||
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' — ' . ($agent['poste_libelle'] ?? 'Poste non renseigné') . (!empty($agent['est_diplome']) ? ' — diplômé' : ' — non diplômé')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</template>
|
||||
</section>
|
||||
144
app/Views/partials/vacation_calendar.php
Normal file
144
app/Views/partials/vacation_calendar.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
$currentYear = (int) date('Y');
|
||||
$currentMonth = (int) date('n');
|
||||
$schoolYearStart = $currentMonth >= 8 ? $currentYear : $currentYear - 1;
|
||||
$defaultSchoolYear = $schoolYearStart . '-' . ($schoolYearStart + 1);
|
||||
$defaultCalendarYear = $currentYear;
|
||||
?>
|
||||
<section id="vacation-view" class="app-view page-content">
|
||||
<section class="card vacation-import-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Vacances scolaires</h2>
|
||||
<p class="section-subtitle">Consultez toutes les vacances d’une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vacation-api-note">
|
||||
<strong>Principe :</strong> une journée comprise dans une période de vacances enregistrée est considérée comme <strong>extrascolaire</strong>. En dehors de ces périodes, elle est considérée comme <strong>périscolaire</strong>.
|
||||
</div>
|
||||
|
||||
<div class="selection-grid vacation-filters">
|
||||
<label class="field">
|
||||
<span>Académie</span>
|
||||
<input id="vacation-academy" type="text" maxlength="100" value="Orléans-Tours" placeholder="Ex. Orléans-Tours" list="vacation-academies" autocomplete="off">
|
||||
<datalist id="vacation-academies">
|
||||
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
|
||||
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
|
||||
<option value="Lille"><option value="Limoges"><option value="Lyon"><option value="Montpellier">
|
||||
<option value="Nancy-Metz"><option value="Nantes"><option value="Nice"><option value="Normandie">
|
||||
<option value="Orléans-Tours"><option value="Paris"><option value="Poitiers"><option value="Reims">
|
||||
<option value="Rennes"><option value="Strasbourg"><option value="Toulouse"><option value="Versailles">
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Année civile</span>
|
||||
<input id="vacation-calendar-year" type="number" min="2000" max="2100" value="<?= (int) $defaultCalendarYear ?>" placeholder="2026">
|
||||
</label>
|
||||
<div class="overview-filter-actions">
|
||||
<button id="preview-vacations" type="button" class="button button-secondary filter-button">Consulter les dates proposées</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vacation-preview-message" class="message" role="status" aria-live="polite"></div>
|
||||
|
||||
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
|
||||
<div class="section-heading compact-heading">
|
||||
<div>
|
||||
<h3>Dates proposées par l’API</h3>
|
||||
<p class="section-subtitle">Toutes les périodes de l’année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
|
||||
</div>
|
||||
<label class="vacation-select-all">
|
||||
<input id="vacation-select-all" type="checkbox">
|
||||
Tout sélectionner
|
||||
</label>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table vacation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Importer</th>
|
||||
<th>Période</th>
|
||||
<th>Début</th>
|
||||
<th>Fin</th>
|
||||
<th>Zone / académie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vacation-preview-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<button id="save-selected-vacations" type="button" class="button button-primary">Enregistrer les périodes sélectionnées</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card vacation-manual-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Ajouter une période manuellement</h2>
|
||||
<p class="section-subtitle">Utilisez ce formulaire pour corriger le calendrier ou ajouter une période qui n’apparaît pas dans l’API.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="manual-vacation-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Libellé <strong>*</strong></span>
|
||||
<input id="manual-vacation-label" type="text" maxlength="150" placeholder="Ex. Vacances de la Toussaint" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Année scolaire <strong>*</strong></span>
|
||||
<input id="manual-vacation-school-year" type="text" maxlength="9" value="<?= htmlspecialchars($defaultSchoolYear) ?>" pattern="\d{4}-\d{4}" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début <strong>*</strong></span>
|
||||
<input id="manual-vacation-start" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Dernier jour de vacances <strong>*</strong></span>
|
||||
<input id="manual-vacation-end" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Académie</span>
|
||||
<input id="manual-vacation-academy" type="text" maxlength="100" value="Orléans-Tours">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Zone</span>
|
||||
<input id="manual-vacation-zone" type="text" maxlength="50" placeholder="Ex. Zone B">
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<div id="manual-vacation-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button type="submit" class="button button-primary">Ajouter la période</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Périodes enregistrées</h2>
|
||||
<p class="section-subtitle">Ces périodes sont celles réellement utilisées par PTA pour distinguer périscolaire et extrascolaire.</p>
|
||||
</div>
|
||||
<button id="refresh-vacations" type="button" class="button button-secondary">Actualiser</button>
|
||||
</div>
|
||||
<div id="vacation-saved-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table vacation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Période</th>
|
||||
<th>Dates</th>
|
||||
<th>Année scolaire</th>
|
||||
<th>Académie / zone</th>
|
||||
<th>Source</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vacation-saved-body">
|
||||
<tr><td colspan="6"><div class="empty-state">Chargement des périodes enregistrées…</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
17
app/autoload.php
Normal file
17
app/autoload.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'App\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
14
app/bootstrap.php
Normal file
14
app/bootstrap.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SchemaModel;
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/db.php';
|
||||
(new SchemaModel($pdo))->assertCurrent();
|
||||
|
||||
return $pdo;
|
||||
Reference in New Issue
Block a user