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

@@ -212,6 +212,7 @@
row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : '';
row.dataset.typeContrat = agent.type_contrat || 'PERMANENT';
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.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-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-right').checked = row.dataset.droitEnveloppeFormation !== '0';
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
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),
type_contrat: document.querySelector('#edit-agent-profile-contract-type').value,
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,
structure_id: structureValue ? Number(structureValue) : null,
}),
@@ -408,6 +411,7 @@
id_poste: Number(document.querySelector('#new-agent-post').value),
type_contrat: document.querySelector('#new-agent-contract-type').value,
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,
}),
});

View File

@@ -254,6 +254,28 @@
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) {
const entry = findEntry(entryId);
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-date').value = entry.date_jour;
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-start').value = entry.heure_debut;
document.querySelector('#edit-agent-entry-end').value = entry.heure_fin;

View File

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

View File

@@ -123,8 +123,24 @@
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) {
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) => {
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
@@ -665,6 +681,7 @@
state.entries = [];
state.otherStructureEntries = [];
state.currentStructure = null;
state.currentAgent = null;
state.planningId = null;
state.quota = null;
state.savedCurrentCountedMinutes = 0;
@@ -726,6 +743,7 @@
state.entries = [];
state.otherStructureEntries = [];
state.currentStructure = null;
state.currentAgent = null;
state.planningId = null;
state.savedCurrentCountedMinutes = 0;
setStatus(null);
@@ -744,6 +762,7 @@
state.quota = data.quota || null;
state.currentStructure = data.current_structure || null;
state.currentAgent = data.agent || null;
const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day]));
state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) }));
state.otherStructureEntries = (data.other_entries || []).map((entry) => ({
@@ -835,6 +854,11 @@
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) {
errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`);
return;