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]); } /** * Contenu détaillé d'une semaine type, pour l'ouvrir dans l'éditeur. */ public function show(): void { $this->access->requireService(); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT); if (!$agentId || !$templateId) { JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']); } $template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId); if ($template === null) { JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']); } $entries = array_map(static function (array $entry): array { return [ 'jour_semaine' => (int) $entry['jour_semaine'], 'id_structure' => (int) $entry['id_structure'], 'id_motif' => (int) $entry['id_motif'], // L'éditeur manipule des heures au format HH:MM. 'heure_debut' => substr((string) $entry['heure_debut'], 0, 5), 'heure_fin' => substr((string) $entry['heure_fin'], 0, 5), 'structure_nom' => (string) $entry['structure_nom'], 'structure_active' => (int) $entry['structure_actif'] === 1, 'motif_libelle' => (string) $entry['motif_libelle'], 'motif_actif' => (int) $entry['motif_actif'] === 1, 'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1, ]; }, $this->templateModel->entries((int) $templateId)); JsonResponse::send(200, [ 'template' => [ 'id' => (int) $template['id_modele'], 'name' => (string) $template['nom'], 'updated_at' => (string) $template['date_modification'], ], 'entries' => $entries, ]); } /** * Enregistre une semaine type composée dans l'éditeur. */ public function storeEntries(): void { $this->access->requireService(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); $name = (string) ($payload['name'] ?? ''); $entries = $payload['entries'] ?? null; $replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN); if (!$agentId || !is_array($entries)) { JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']); } $agent = $this->agentModel->findActive((int) $agentId); if ($agent === null) { JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); } // Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un // agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable. $allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent); $allowedStructureIds = array_map( static fn (array $structure): int => (int) $structure['id_structure'], (new StructureModel($this->pdo))->allActive() ); try { $template = $this->templateModel->saveFromEntries( (int) $agentId, $name, $entries, $allowedMotifIds, $allowedStructureIds, $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' => Security::debugEnabled() ? $e->getMessage() : null, ]); } JsonResponse::send(200, [ 'success' => true, 'template' => [ 'id' => (int) $template['id_modele'], 'name' => (string) $template['nom'], 'entry_count' => (int) $template['nombre_creneaux'], ], 'message' => sprintf( 'La semaine type « %s » a été enregistrée avec %d plage%s.', $template['nom'], $template['nombre_creneaux'], $template['nombre_creneaux'] > 1 ? 's' : '' ), ]); } 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; } }