Droit à l'enveloppe de formation par agent, et motif « Formation » filtré à la saisie

Nouvelle colonne `agent.droit_enveloppe_formation`, cochée par défaut, saisie à
la création de l'agent et modifiable depuis sa fiche. Un agent sans ce droit :

- ne voit plus le motif « Formation » dans le sélecteur de motif de l'éditeur de
  planning ni dans le modal de la vue individuelle ;
- se voit refuser toute affectation de ce motif par le serveur, avec un message
  qui nomme le motif et l'agent (save_planning.php et update_agent_entry.php) ;
- a une enveloppe de formation de 0 min au PTA annuel, et une ALERTE
  FORMATION_SANS_DROIT si des heures de formation subsistent dans son planning.

À ne pas confondre avec `formation_repartition_annuelle`, qui existait déjà :
celle-ci dit que la formation est déjà comptée dans la répartition annuelle —
l'agent y a droit, l'enveloppe complémentaire n'est simplement pas ajoutée. Les
deux règles sont désormais rassemblées dans AgentModel::trainingEnvelopeMinutes(),
qui remplace les deux calculs « 840 ou 0 » jusque-là dupliqués.

Le motif interdit reste affiché lorsqu'il est déjà celui de la plage en cours
d'édition : le retirer de la liste changerait silencieusement le motif d'une
saisie existante. C'est le contrôle de saisie, puis le serveur, qui demandent
alors de corriger la plage.

La migration est nommée v7_9b et non v7_10 : les fichiers sont joués dans
l'ordre alphabétique, où « v7_10 » passerait avant « v7_8 ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PTA
2026-08-11 15:30:43 +02:00
parent 935cc3ea1c
commit 30cb50031d
16 changed files with 242 additions and 17 deletions

View File

@@ -362,6 +362,9 @@ final class AgentController
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); $trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); $ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
@@ -406,6 +409,7 @@ final class AgentController
'id_poste' => (int) $postId, 'id_poste' => (int) $postId,
'type_contrat' => $contractType, 'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned, 'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
@@ -437,6 +441,7 @@ final class AgentController
'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''), '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, 'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0, 'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0,
'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]), ]),
]); ]);
@@ -462,6 +467,9 @@ final class AgentController
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); $trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); $ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
$rawStructure = $payload['structure_id'] ?? null; $rawStructure = $payload['structure_id'] ?? null;
@@ -503,6 +511,7 @@ final class AgentController
'id_poste' => (int) $postId, 'id_poste' => (int) $postId,
'type_contrat' => $contractType, 'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned, 'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
'structure_id' => $structureId !== null ? (int) $structureId : null, 'structure_id' => $structureId !== null ? (int) $structureId : null,
]); ]);

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\MotifCategories;
use App\Core\Request; use App\Core\Request;
use App\Models\AgentModel; use App\Models\AgentModel;
use App\Models\MotifModel; use App\Models\MotifModel;
@@ -98,7 +99,8 @@ final class PlanningController
if (!is_array($rawEntries)) { if (!is_array($rawEntries)) {
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']); JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
} }
if ($this->agentModel->findActive((int) $agentId) === null) { $agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
} }
if ($this->structureModel->findActive((int) $structureId) === null) { if ($this->structureModel->findActive((int) $structureId) === null) {
@@ -107,7 +109,10 @@ final class PlanningController
[$year, $weekNumber] = $week; [$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); $weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds()); // Les motifs autorisés dépendent de l'agent : « Formation » n'est pas
// affectable à un agent sans droit à l'enveloppe de formation.
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
$this->assertNoInternalOverlap($entries); $this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap( $this->assertNoOtherLocationOverlap(
$entries, $entries,
@@ -169,7 +174,12 @@ final class PlanningController
if ($this->structureModel->findActive((int) $structureId) === null) { if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
} }
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) { $entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
if ($entryAgent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
$this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent);
if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), true)) {
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
} }
@@ -550,6 +560,48 @@ final class PlanningController
return [$year, $week]; return [$year, $week];
} }
/**
* Refuse les affectations portant un motif fermé à l'agent, avec un message
* qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais
* sans dire pourquoi. L'interface masque déjà ces motifs — reste les
* plannings saisis avant le retrait du droit, et les appels directs.
*
* @param array<int, mixed> $rawEntries
*/
private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void
{
if (AgentModel::hasTrainingRight($agent)) {
return;
}
$restricted = [];
foreach ($this->motifModel->allActive() as $motif) {
if (MotifCategories::requiresTrainingRight((string) $motif['code'])) {
$restricted[(int) $motif['id_motif']] = (string) $motif['libelle'];
}
}
if ($restricted === []) {
return;
}
foreach ($rawEntries as $entry) {
if (!is_array($entry)) {
continue;
}
$motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT);
if (isset($restricted[$motifId])) {
JsonResponse::send(422, [
'error' => sprintf(
'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.',
$restricted[$motifId],
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? '')
),
]);
}
}
}
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{ {
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);

View File

@@ -47,6 +47,16 @@ final class MotifCategories
return strtoupper(trim((string) $code)); return strtoupper(trim((string) $code));
} }
/**
* Motif dont l'affectation dépend d'un droit porté par l'agent : la formation
* n'est ouverte qu'aux agents bénéficiant de l'enveloppe annuelle de 14 h
* (colonne `droit_enveloppe_formation`, voir AgentModel::hasTrainingRight()).
*/
public static function requiresTrainingRight(?string $code): bool
{
return self::normalizeCode($code) === self::FORMATION;
}
/** /**
* Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il * Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il
* décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté * décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté

View File

@@ -10,11 +10,40 @@ use Throwable;
final class AgentModel extends BaseModel final class AgentModel extends BaseModel
{ {
/** Enveloppe annuelle complémentaire de formation : 14 h. */
public const ENVELOPPE_FORMATION_MINUTES = 840;
/**
* Un agent sans droit à l'enveloppe de formation ne peut pas se voir
* affecter le motif « Formation ». Le droit vaut par défaut lorsque
* l'information n'est pas projetée : ne jamais retirer un droit sur une
* donnée absente.
*/
public static function hasTrainingRight(array $agent): bool
{
return (int) ($agent['droit_enveloppe_formation'] ?? 1) === 1;
}
/**
* Minutes de formation ouvertes à l'agent sur l'année : rien s'il n'y a pas
* droit, rien non plus si sa formation est déjà comptée dans la répartition
* annuelle, l'enveloppe complémentaire sinon.
*/
public static function trainingEnvelopeMinutes(array $agent): int
{
if (!self::hasTrainingRight($agent) || !empty($agent['formation_repartition_annuelle'])) {
return 0;
}
return self::ENVELOPPE_FORMATION_MINUTES;
}
private const AGENT_SELECT = <<<'SQL' private const AGENT_SELECT = <<<'SQL'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email, SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle, a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat, a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
a.formation_repartition_annuelle, a.commentaire_pta, a.formation_repartition_annuelle, a.droit_enveloppe_formation,
a.commentaire_pta,
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle, a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
po.famille AS poste_famille, po.heures_mercredi_minutes, po.famille AS poste_famille, po.heures_mercredi_minutes,
po.heures_extrascolaire_minutes, po.autorise_preparation, po.heures_extrascolaire_minutes, po.autorise_preparation,
@@ -129,7 +158,10 @@ final class AgentModel extends BaseModel
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year)); $periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
$quotaReference = QuotaModel::REFERENCE_MINUTES; $quotaReference = QuotaModel::REFERENCE_MINUTES;
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100)); $quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840; $trainingEnvelope = self::trainingEnvelopeMinutes([
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'],
]);
try { try {
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
@@ -139,14 +171,14 @@ final class AgentModel extends BaseModel
matricule, nom, prenom, email, telephone, adresse, matricule, nom, prenom, email, telephone, adresse,
est_diplome, diplome_libelle, date_debut_contrat, est_diplome, diplome_libelle, date_debut_contrat,
id_poste, type_contrat, date_fin_contrat, id_poste, type_contrat, date_fin_contrat,
formation_repartition_annuelle, commentaire_pta, formation_repartition_annuelle, droit_enveloppe_formation,
id_structure, actif commentaire_pta, id_structure, actif
) VALUES ( ) VALUES (
:matricule, :nom, :prenom, :email, :telephone, :adresse, :matricule, :nom, :prenom, :email, :telephone, :adresse,
:est_diplome, :diplome_libelle, :date_debut_contrat, :est_diplome, :diplome_libelle, :date_debut_contrat,
:id_poste, :type_contrat, :date_fin_contrat, :id_poste, :type_contrat, :date_fin_contrat,
:formation_repartition_annuelle, :commentaire_pta, :formation_repartition_annuelle, :droit_enveloppe_formation,
:structure_id, TRUE :commentaire_pta, :structure_id, TRUE
)' )'
); );
$insertAgent->execute([ $insertAgent->execute([
@@ -163,6 +195,7 @@ final class AgentModel extends BaseModel
'type_contrat' => $data['type_contrat'], 'type_contrat' => $data['type_contrat'],
'date_fin_contrat' => $contractEnd?->format('Y-m-d'), 'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0, 'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0,
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
'commentaire_pta' => $data['commentaire_pta'], 'commentaire_pta' => $data['commentaire_pta'],
'structure_id' => $data['structure_id'], 'structure_id' => $data['structure_id'],
]); ]);
@@ -261,6 +294,7 @@ final class AgentModel extends BaseModel
type_contrat = :type_contrat, type_contrat = :type_contrat,
date_fin_contrat = :date_fin_contrat, date_fin_contrat = :date_fin_contrat,
formation_repartition_annuelle = :formation_repartition_annuelle, formation_repartition_annuelle = :formation_repartition_annuelle,
droit_enveloppe_formation = :droit_enveloppe_formation,
commentaire_pta = :commentaire_pta, commentaire_pta = :commentaire_pta,
id_structure = :structure_id id_structure = :structure_id
WHERE id_agent = :agent_id' WHERE id_agent = :agent_id'
@@ -276,6 +310,7 @@ final class AgentModel extends BaseModel
$update->bindValue(':type_contrat', $data['type_contrat']); $update->bindValue(':type_contrat', $data['type_contrat']);
$update->bindValue(':date_fin_contrat', $data['date_fin_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(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':commentaire_pta', $data['commentaire_pta']); $update->bindValue(':commentaire_pta', $data['commentaire_pta']);
if ($newStructureId === null) { if ($newStructureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL); $update->bindValue(':structure_id', null, PDO::PARAM_NULL);

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Core\MotifCategories;
final class MotifModel extends BaseModel final class MotifModel extends BaseModel
{ {
public function allActive(): array public function allActive(): array
@@ -23,4 +25,29 @@ final class MotifModel extends BaseModel
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN) $this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
); );
} }
/**
* Motifs réellement affectables à un agent : « Formation » disparaît de la
* liste dès lors que l'agent n'a pas droit à l'enveloppe de formation.
*/
public function allActiveForAgent(array $agent): array
{
if (AgentModel::hasTrainingRight($agent)) {
return $this->allActive();
}
return array_values(array_filter(
$this->allActive(),
static fn(array $motif): bool => !MotifCategories::requiresTrainingRight((string) $motif['code'])
));
}
/** @return int[] */
public function activeIdsForAgent(array $agent): array
{
return array_map(
static fn(array $motif): int => (int) $motif['id_motif'],
$this->allActiveForAgent($agent)
);
}
} }

View File

@@ -145,7 +145,7 @@ final class PtaModel extends BaseModel
return $pta; return $pta;
} }
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; $training = AgentModel::trainingEnvelopeMinutes($agent);
$insert = $this->pdo->prepare( $insert = $this->pdo->prepare(
"INSERT INTO pta_annuel ( "INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail, id_agent, date_debut, date_fin, quotite_travail,
@@ -378,7 +378,13 @@ final class PtaModel extends BaseModel
} }
$formationMinutes = $categories['FORMATION']; $formationMinutes = $categories['FORMATION'];
$formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; $formationTarget = AgentModel::trainingEnvelopeMinutes($agent);
if (!AgentModel::hasTrainingRight($agent) && $formationMinutes > 0) {
$controls[] = $this->control('ALERTE', 'FORMATION_SANS_DROIT', sprintf(
'Formation : %s planifiées alors que l’agent n’a pas droit à l’enveloppe de formation. Ces plages sont à retirer.',
$this->formatMinutes($formationMinutes)
));
}
if ($formationTarget > 0 && $formationMinutes > $formationTarget) { if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf( $controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.', 'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',

View File

@@ -52,7 +52,7 @@ final class SchemaModel extends BaseModel
$requiredColumns = [ $requiredColumns = [
'creneau_horaire' => ['id_motif'], 'creneau_horaire' => ['id_motif'],
'planning' => ['date_validation'], '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'], 'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'droit_enveloppe_formation', 'commentaire_pta'],
'structure' => ['type_affectation'], 'structure' => ['type_affectation'],
]; ];

View File

@@ -28,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig, 'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script> </script>
<script src="assets/js/core.js?v=7.11"></script> <script src="assets/js/core.js?v=7.12"></script>
<?php foreach ($scripts as $script): ?> <?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.11"></script> <script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.12"></script>
<?php endforeach; ?> <?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.11"></script> <script src="assets/js/page-bootstrap.js?v=7.12"></script>
</body> </body>
</html> </html>

View File

@@ -67,7 +67,7 @@ $showGroupLabels = $visiblePageCount > 4;
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title> <title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.11"> <link rel="stylesheet" href="assets/style.css?v=7.12">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">

View File

@@ -12,7 +12,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choisir votre accès — PTA</title> <title>Choisir votre accès — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.11"> <link rel="stylesheet" href="assets/style.css?v=7.12">
</head> </head>
<body class="role-page-body"> <body class="role-page-body">
<main class="role-page-shell"> <main class="role-page-shell">

View File

@@ -122,6 +122,10 @@
<span>Date de fin du contrat</span> <span>Date de fin du contrat</span>
<input id="new-agent-contract-end" name="date_fin_contrat" type="date"> <input id="new-agent-contract-end" name="date_fin_contrat" type="date">
</label> </label>
<label class="check-field admin-field-wide">
<input id="new-agent-training-right" type="checkbox" checked>
<span>Droit à l’enveloppe de formation de 14 h. Décoché, le motif « Formation » ne pourra pas être affecté à cet agent lors de la saisie des horaires.</span>
</label>
<label class="check-field admin-field-wide"> <label class="check-field admin-field-wide">
<input id="new-agent-training-planned" type="checkbox"> <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> <span>Une formation est déjà prévue dans la répartition annuelle : ne pas ajouter l’enveloppe complémentaire de 14 h</span>
@@ -243,6 +247,7 @@
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>" data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>" data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
data-formation-repartition-annuelle="<?= !empty($agent['formation_repartition_annuelle']) ? '1' : '0' ?>" data-formation-repartition-annuelle="<?= !empty($agent['formation_repartition_annuelle']) ? '1' : '0' ?>"
data-droit-enveloppe-formation="<?= !isset($agent['droit_enveloppe_formation']) || !empty($agent['droit_enveloppe_formation']) ? '1' : '0' ?>"
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>" data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>" data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
> >
@@ -325,6 +330,10 @@
<span>Date de fin du contrat</span> <span>Date de fin du contrat</span>
<input id="edit-agent-profile-contract-end" type="date"> <input id="edit-agent-profile-contract-end" type="date">
</label> </label>
<label class="check-field admin-field-wide">
<input id="edit-agent-profile-training-right" type="checkbox">
<span>Droit à l’enveloppe de formation de 14 h</span>
</label>
<label class="check-field admin-field-wide"> <label class="check-field admin-field-wide">
<input id="edit-agent-profile-training-planned" type="checkbox"> <input id="edit-agent-profile-training-planned" type="checkbox">
<span>Formation déjà intégrée dans la répartition annuelle</span> <span>Formation déjà intégrée dans la répartition annuelle</span>

View File

@@ -212,6 +212,7 @@
row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : ''; row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : '';
row.dataset.typeContrat = agent.type_contrat || 'PERMANENT'; row.dataset.typeContrat = agent.type_contrat || 'PERMANENT';
row.dataset.formationRepartitionAnnuelle = Number(agent.formation_repartition_annuelle) ? '1' : '0'; row.dataset.formationRepartitionAnnuelle = Number(agent.formation_repartition_annuelle) ? '1' : '0';
row.dataset.droitEnveloppeFormation = Number(agent.droit_enveloppe_formation ?? 1) ? '1' : '0';
row.dataset.commentairePta = agent.commentaire_pta || ''; row.dataset.commentairePta = agent.commentaire_pta || '';
row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : ''; row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : '';
} }
@@ -256,6 +257,7 @@
document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || ''; document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || '';
document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT'; document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT';
document.querySelector('#edit-agent-profile-training-planned').checked = row.dataset.formationRepartitionAnnuelle === '1'; document.querySelector('#edit-agent-profile-training-planned').checked = row.dataset.formationRepartitionAnnuelle === '1';
document.querySelector('#edit-agent-profile-training-right').checked = row.dataset.droitEnveloppeFormation !== '0';
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || ''; document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || ''; document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma')); toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma'));
@@ -303,6 +305,7 @@
id_poste: Number(document.querySelector('#edit-agent-profile-post').value), id_poste: Number(document.querySelector('#edit-agent-profile-post').value),
type_contrat: document.querySelector('#edit-agent-profile-contract-type').value, type_contrat: document.querySelector('#edit-agent-profile-contract-type').value,
formation_repartition_annuelle: document.querySelector('#edit-agent-profile-training-planned').checked, formation_repartition_annuelle: document.querySelector('#edit-agent-profile-training-planned').checked,
droit_enveloppe_formation: document.querySelector('#edit-agent-profile-training-right').checked,
commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value, commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value,
structure_id: structureValue ? Number(structureValue) : null, structure_id: structureValue ? Number(structureValue) : null,
}), }),
@@ -408,6 +411,7 @@
id_poste: Number(document.querySelector('#new-agent-post').value), id_poste: Number(document.querySelector('#new-agent-post').value),
type_contrat: document.querySelector('#new-agent-contract-type').value, type_contrat: document.querySelector('#new-agent-contract-type').value,
formation_repartition_annuelle: document.querySelector('#new-agent-training-planned').checked, formation_repartition_annuelle: document.querySelector('#new-agent-training-planned').checked,
droit_enveloppe_formation: document.querySelector('#new-agent-training-right').checked,
commentaire_pta: document.querySelector('#new-agent-pta-comment').value, commentaire_pta: document.querySelector('#new-agent-pta-comment').value,
}), }),
}); });

View File

@@ -254,6 +254,28 @@
setMessage(message); setMessage(message);
} }
/**
* Le sélecteur de motif est rendu côté serveur avec tous les motifs actifs :
* on le reconstruit à l'ouverture pour retirer « Formation » aux agents sans
* droit à l'enveloppe de formation. Le motif déjà porté par le créneau reste
* proposé, sans quoi le modal changerait silencieusement de motif.
*/
function renderMotifOptions(selectedMotifId) {
const select = document.querySelector('#edit-agent-entry-motif');
const motifs = window.PTA_CONFIG?.motifs || [];
if (!select || motifs.length === 0) return;
const agent = shared.lastAgentOverviewData?.agent;
const hasTrainingRight = Number(agent?.droit_enveloppe_formation ?? 1) !== 0;
select.innerHTML = motifs
.filter((motif) => hasTrainingRight
|| String(motif.code).toUpperCase() !== 'FORMATION'
|| Number(motif.id) === Number(selectedMotifId))
.map((motif) => `<option value="${Number(motif.id)}">${escapeHtml(motif.label)}</option>`)
.join('');
}
function openModal(entryId) { function openModal(entryId) {
const entry = findEntry(entryId); const entry = findEntry(entryId);
if (!entry || !canEditEntry(entry)) return; if (!entry || !canEditEntry(entry)) return;
@@ -263,6 +285,7 @@
document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau)); document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau));
document.querySelector('#edit-agent-entry-date').value = entry.date_jour; document.querySelector('#edit-agent-entry-date').value = entry.date_jour;
document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure)); document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure));
renderMotifOptions(Number(entry.id_motif));
document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif)); document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif));
document.querySelector('#edit-agent-entry-start').value = entry.heure_debut; document.querySelector('#edit-agent-entry-start').value = entry.heure_debut;
document.querySelector('#edit-agent-entry-end').value = entry.heure_fin; document.querySelector('#edit-agent-entry-end').value = entry.heure_fin;

View File

@@ -31,6 +31,7 @@
entries: [], entries: [],
otherStructureEntries: [], otherStructureEntries: [],
currentStructure: null, currentStructure: null,
currentAgent: null,
planningId: null, planningId: null,
planningStatus: null, planningStatus: null,
weekDays: [], weekDays: [],

View File

@@ -123,8 +123,24 @@
return motif ? Number(motif.id) : null; return motif ? Number(motif.id) : null;
} }
/**
* Un agent sans droit à l'enveloppe de formation ne peut pas recevoir le motif
* « Formation ». Le droit vaut tant que l'agent n'est pas chargé : on ne retire
* jamais une possibilité sur une donnée absente.
*/
function agentHasTrainingRight() {
return Number(state.currentAgent?.droit_enveloppe_formation ?? 1) !== 0;
}
function isMotifAllowedForAgent(motif) {
return agentHasTrainingRight() || String(motif.code).toUpperCase() !== 'FORMATION';
}
function motifOptionsHtml(selectedMotifId = null) { function motifOptionsHtml(selectedMotifId = null) {
return (window.PTA_CONFIG?.motifs || []) return (window.PTA_CONFIG?.motifs || [])
// Un motif interdit reste proposé s'il est déjà celui de la plage :
// le retirer changerait silencieusement le motif d'une saisie existante.
.filter((motif) => isMotifAllowedForAgent(motif) || Number(motif.id) === Number(selectedMotifId))
.map((motif) => { .map((motif) => {
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : ''; const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota'; const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
@@ -665,6 +681,7 @@
state.entries = []; state.entries = [];
state.otherStructureEntries = []; state.otherStructureEntries = [];
state.currentStructure = null; state.currentStructure = null;
state.currentAgent = null;
state.planningId = null; state.planningId = null;
state.quota = null; state.quota = null;
state.savedCurrentCountedMinutes = 0; state.savedCurrentCountedMinutes = 0;
@@ -726,6 +743,7 @@
state.entries = []; state.entries = [];
state.otherStructureEntries = []; state.otherStructureEntries = [];
state.currentStructure = null; state.currentStructure = null;
state.currentAgent = null;
state.planningId = null; state.planningId = null;
state.savedCurrentCountedMinutes = 0; state.savedCurrentCountedMinutes = 0;
setStatus(null); setStatus(null);
@@ -744,6 +762,7 @@
state.quota = data.quota || null; state.quota = data.quota || null;
state.currentStructure = data.current_structure || null; state.currentStructure = data.current_structure || null;
state.currentAgent = data.agent || null;
const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day])); const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day]));
state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) })); state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) }));
state.otherStructureEntries = (data.other_entries || []).map((entry) => ({ state.otherStructureEntries = (data.other_entries || []).map((entry) => ({
@@ -835,6 +854,11 @@
return; return;
} }
if (!isMotifAllowedForAgent(motif)) {
errors.push(`${day?.label || row.dataset.date} ${label} : le motif « ${motif.label} » n’est pas ouvert à cet agent, qui n’a pas droit à l’enveloppe de formation.`);
return;
}
if (!start || !end) { if (!start || !end) {
errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`); errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`);
return; return;

View File

@@ -0,0 +1,25 @@
-- ============================================================================
-- PTA — Droit à l'enveloppe annuelle de formation (14 h)
-- ============================================================================
-- Tous les agents ne bénéficient pas de l'enveloppe complémentaire de 14 h de
-- formation. Cette colonne porte ce droit, coché par défaut à la création d'un
-- agent. Un agent sans ce droit ne peut plus se voir affecter le motif
-- « Formation » lors de la saisie des horaires, et son enveloppe de formation
-- au PTA annuel vaut 0.
--
-- À ne pas confondre avec `formation_repartition_annuelle`, déjà présente :
-- celle-ci signale que la formation est DÉJÀ comptée dans la répartition
-- annuelle de l'agent — l'agent y a droit, l'enveloppe complémentaire n'est
-- simplement pas ajoutée par-dessus.
--
-- Nommage : les migrations sont jouées dans l'ordre alphabétique. Le suffixe
-- « 9b » place ce fichier après migration_v7_9_tableau_de_bord.sql, ce qu'un
-- « v7_10 » ou « v7_11 » n'aurait pas fait ('1' trie avant '8' et '9').
--
-- Idempotente : rejouable sans effet de bord.
-- ============================================================================
ALTER TABLE agent
ADD COLUMN IF NOT EXISTS droit_enveloppe_formation BOOLEAN NOT NULL DEFAULT TRUE
COMMENT 'Droit à l''enveloppe annuelle complémentaire de 14 h de formation'
AFTER formation_repartition_annuelle;