Files
pta/assets/js/coverage.js
2026-08-20 13:22:49 +02:00

340 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Contrôle de couverture (`PTA.modules.coverage`).
*
* Chargé par DEUX écrans, avec des rôles différents :
* - coverage.php : consultation des alertes de la semaine ;
* - administration.php : édition des règles de couverture des lieux.
* Les sélecteurs des deux moitiés sont donc absents l'un chez l'autre, d'où les
* `if (!element) return;` systématiques.
*
* Une « alerte » est une plage où le nombre d'agents planifiés passe sous le
* minimum attendu du lieu. Le serveur découpe au quart d'heure puis refusionne
* les créneaux consécutifs identiques ; ce module se contente d'afficher les
* intervalles reçus.
*
* L'ÉDITEUR DE RÈGLES fonctionne en REMPLACEMENT INTÉGRAL : `collectRules()` lit
* la totalité de l'écran et l'envoie d'un bloc. Un jour vidé de ses plages
* supprime les règles correspondantes — c'est le comportement voulu.
*/
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant coverage.js');
const { elements, utils } = PTA;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils;
const badge = document.querySelector('#coverage-tab-badge');
const weekInput = document.querySelector('#coverage-week');
const structureFilter = document.querySelector('#coverage-structure-filter');
const refreshButton = document.querySelector('#refresh-coverage-alerts');
const loadingElement = document.querySelector('#coverage-loading');
const emptyElement = document.querySelector('#coverage-empty');
const contentElement = document.querySelector('#coverage-content');
const bodyElement = document.querySelector('#coverage-alerts-body');
const messageElement = document.querySelector('#coverage-message');
const unconfiguredElement = document.querySelector('#coverage-unconfigured');
const ruleStructureSelect = document.querySelector('#coverage-rules-structure');
const ruleEditor = document.querySelector('#coverage-rules-editor');
const ruleMessage = document.querySelector('#coverage-rules-message');
const loadRulesButton = document.querySelector('#load-coverage-rules');
const saveRulesButton = document.querySelector('#save-coverage-rules');
const dayNames = {
1: 'Lundi',
2: 'Mardi',
3: 'Mercredi',
4: 'Jeudi',
5: 'Vendredi',
};
let alerts = [];
let loading = false;
/** Pastille d'onglet « Couverture », masquée quand il n'y a rien à signaler. */
function setBadge(count) {
if (!badge) return;
badge.textContent = String(count);
badge.hidden = count === 0;
badge.setAttribute('aria-label', `${count} plage${count > 1 ? 's' : ''} avec couverture insuffisante`);
}
function setMessage(text = '', type = 'info') {
if (!messageElement) return;
messageElement.className = text ? `message message-${type}` : 'message';
messageElement.textContent = text;
}
/**
* Rend le tableau des alertes.
* Les lieux NON CONFIGURÉS sont affichés séparément : sans règle, ils ne
* peuvent produire aucune alerte et passeraient à tort pour conformes.
*/
function renderAlerts(data) {
alerts = Array.isArray(data.alerts) ? data.alerts : [];
const summary = data.summary || {};
const unconfigured = Array.isArray(data.unconfigured_structures) ? data.unconfigured_structures : [];
setBadge(alerts.length);
document.querySelector('#coverage-gap-count').textContent = String(Number(summary.gap_count || 0));
document.querySelector('#coverage-structure-count').textContent = String(Number(summary.affected_structure_count || 0));
document.querySelector('#coverage-gap-hours').textContent = formatDuration(Number(summary.gap_minutes || 0));
document.querySelector('#coverage-unconfigured-count').textContent = String(Number(summary.unconfigured_structure_count || 0));
if (loadingElement) loadingElement.hidden = true;
if (emptyElement) emptyElement.hidden = alerts.length !== 0;
if (contentElement) contentElement.hidden = alerts.length === 0;
if (bodyElement) {
bodyElement.innerHTML = alerts.map((alert, index) => {
const missing = Number(alert.agents_manquants || 0);
return `
<tr class="coverage-alert-row" data-alert-index="${index}">
<td>
<strong>${escapeHtml(alert.structure_nom)}</strong>
<small>${escapeHtml(alert.structure_code || '')}</small>
</td>
<td>
<strong>${escapeHtml(alert.jour_libelle)}</strong>
<small>${formatDate(alert.date)}</small>
</td>
<td><strong>${escapeHtml(alert.heure_debut)}–${escapeHtml(alert.heure_fin)}</strong></td>
<td>${Number(alert.minimum_agents)}</td>
<td>${Number(alert.agents_planifies)}</td>
<td><span class="coverage-missing-chip">${missing} agent${missing > 1 ? 's' : ''}</span></td>
<td class="coverage-action-cell"><button type="button" class="button button-primary open-coverage-gap" data-alert-index="${index}">Planifier un agent</button></td>
</tr>`;
}).join('');
}
if (unconfiguredElement) {
unconfiguredElement.hidden = unconfigured.length === 0;
unconfiguredElement.innerHTML = unconfigured.length ? `
<strong>Couverture non configurée pour ${unconfigured.length} lieu${unconfigured.length > 1 ? 'x' : ''}</strong>
<p>${unconfigured.map((item) => escapeHtml(item.nom)).join(', ')}. Configurez les plages attendues dans la page Administration pour pouvoir détecter les créneaux vides.</p>
` : '';
}
}
/**
* Recharge les alertes de la semaine et du lieu sélectionnés.
* Appelée aussi par l'éditeur de planning après un enregistrement, en mode
* silencieux : la couverture dépend de ce qui vient d'être saisi.
*
* @param {{silent?: boolean}} options
*/
async function refresh(options = {}) {
if (loading) return;
const week = weekInput?.value;
if (!week) return;
loading = true;
if (!options.silent) setMessage('');
if (loadingElement) loadingElement.hidden = false;
if (refreshButton) refreshButton.disabled = true;
try {
const params = new URLSearchParams({ week });
if (structureFilter?.value) params.set('structure_id', structureFilter.value);
const response = await fetch(`api/coverage_alerts.php?${params.toString()}`, { headers: { Accept: 'application/json' } });
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’analyser la couverture.'));
renderAlerts(data);
} catch (error) {
if (loadingElement) loadingElement.hidden = true;
setMessage(error.message, 'error');
} finally {
loading = false;
if (refreshButton) refreshButton.disabled = false;
}
}
/**
* Ouvre l'écran de saisie sur le lieu et la semaine du manque, avec un
* `notice` qui rappelle la plage à couvrir : sans lui, l'utilisateur
* arriverait sur une semaine complète sans savoir où agir.
*/
function openGap(alert) {
if (!alert) return;
const params = new URLSearchParams({
structure_id: String(alert.id_structure),
week: weekInput?.value || '',
notice: `Couverture insuffisante le ${alert.jour_libelle.toLowerCase()} ${formatDate(alert.date)} de ${alert.heure_debut} à ${alert.heure_fin} sur ${alert.structure_nom}. Choisissez un agent à affecter.`,
});
window.location.href = `planning.php?${params.toString()}`;
}
/**
* Balisage d'une plage de couverture : début, fin, effectif minimal.
* `step="900"` contraint la saisie au quart d'heure, comme partout ailleurs.
*/
function coverageRuleRow(rule = {}) {
return `
<div class="coverage-rule-row">
<label class="field compact-field">
<span>Début</span>
<input class="coverage-rule-start" type="time" step="900" value="${escapeHtml(rule.heure_debut || '')}" required>
</label>
<label class="field compact-field">
<span>Fin</span>
<input class="coverage-rule-end" type="time" step="900" value="${escapeHtml(rule.heure_fin || '')}" required>
</label>
<label class="field compact-field coverage-rule-minimum-field">
<span>Agents min.</span>
<input class="coverage-rule-minimum" type="number" min="1" max="50" step="1" value="${Number(rule.minimum_agents || 1)}" required>
</label>
<button type="button" class="button button-danger remove-coverage-rule" aria-label="Supprimer cette plage">Supprimer</button>
</div>`;
}
/**
* Rend l'éditeur : une section par jour ouvré, TOUJOURS les cinq, même sans
* règle. C'est ce qui permet d'ajouter une plage sur un jour vide.
*/
function renderRuleEditor(rules = []) {
if (!ruleEditor) return;
const grouped = new Map();
for (let day = 1; day <= 5; day += 1) grouped.set(day, []);
rules.forEach((rule) => grouped.get(Number(rule.jour_semaine))?.push(rule));
ruleEditor.innerHTML = Array.from(grouped.entries()).map(([day, dayRules]) => `
<section class="coverage-rule-day" data-day="${day}">
<div class="coverage-rule-day-header">
<strong>${dayNames[day]}</strong>
<button type="button" class="button button-secondary add-coverage-rule">+ Ajouter une plage</button>
</div>
<div class="coverage-rule-day-ranges">
${dayRules.length ? dayRules.map(coverageRuleRow).join('') : '<p class="coverage-rule-empty">Aucune couverture obligatoire configurée.</p>'}
</div>
</section>`).join('');
if (saveRulesButton) saveRulesButton.disabled = false;
}
/** Charge les règles du lieu sélectionné dans l'éditeur. */
async function loadRules() {
const structureId = ruleStructureSelect?.value;
if (!structureId || !ruleEditor) {
if (ruleEditor) ruleEditor.innerHTML = '<div class="empty-state">Choisissez un lieu d’affectation.</div>';
if (saveRulesButton) saveRulesButton.disabled = true;
return;
}
if (ruleMessage) {
ruleMessage.className = 'message';
ruleMessage.textContent = '';
}
ruleEditor.innerHTML = '<div class="empty-state">Chargement des règles de couverture…</div>';
try {
const response = await fetch(`api/structure_coverage_rules.php?structure_id=${encodeURIComponent(structureId)}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les règles de couverture.'));
renderRuleEditor(data.rules || []);
} catch (error) {
ruleEditor.innerHTML = '';
if (ruleMessage) {
ruleMessage.className = 'message message-error';
ruleMessage.textContent = error.message;
}
}
}
/**
* Lit la totalité de l'éditeur et produit la liste à envoyer.
* Une ligne entièrement vide est ignorée sans erreur : c'est une plage
* ajoutée puis abandonnée, pas une saisie incomplète.
*/
function collectRules() {
const rules = [];
ruleEditor?.querySelectorAll('.coverage-rule-day').forEach((daySection) => {
const day = Number(daySection.dataset.day);
daySection.querySelectorAll('.coverage-rule-row').forEach((row) => {
const start = row.querySelector('.coverage-rule-start')?.value || '';
const end = row.querySelector('.coverage-rule-end')?.value || '';
const minimum = Number(row.querySelector('.coverage-rule-minimum')?.value || 1);
if (!start && !end) return;
rules.push({ jour_semaine: day, heure_debut: start, heure_fin: end, minimum_agents: minimum });
});
});
return rules;
}
/**
* Enregistre les règles du lieu — REMPLACEMENT INTÉGRAL.
* Chevauchements et horaires incohérents sont refusés côté serveur, dont le
* message est affiché tel quel.
*/
async function saveRules() {
const structureId = ruleStructureSelect?.value;
if (!structureId || !saveRulesButton || !ruleMessage) return;
saveRulesButton.disabled = true;
ruleMessage.className = 'message';
ruleMessage.textContent = '';
try {
const response = await fetch('api/save_structure_coverage_rules.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
structure_id: Number(structureId),
rules: collectRules(),
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer les règles de couverture.'));
renderRuleEditor(data.rules || []);
ruleMessage.className = 'message message-success';
ruleMessage.textContent = data.message;
await refresh({ silent: true });
if (document.querySelector('#overview-structure')?.value === String(structureId)) {
await PTA.modules.structureOverview?.load?.();
}
} catch (error) {
ruleMessage.className = 'message message-error';
ruleMessage.textContent = error.message;
} finally {
saveRulesButton.disabled = false;
}
}
function init() {
refreshButton?.addEventListener('click', () => refresh());
weekInput?.addEventListener('change', () => refresh());
structureFilter?.addEventListener('change', () => refresh());
bodyElement?.addEventListener('click', (event) => {
const button = event.target.closest('.open-coverage-gap');
if (!button) return;
openGap(alerts[Number(button.dataset.alertIndex)]);
});
ruleStructureSelect?.addEventListener('change', loadRules);
loadRulesButton?.addEventListener('click', loadRules);
saveRulesButton?.addEventListener('click', saveRules);
ruleEditor?.addEventListener('click', (event) => {
const addButton = event.target.closest('.add-coverage-rule');
if (addButton) {
const day = addButton.closest('.coverage-rule-day');
const ranges = day?.querySelector('.coverage-rule-day-ranges');
if (!ranges) return;
ranges.querySelector('.coverage-rule-empty')?.remove();
ranges.insertAdjacentHTML('beforeend', coverageRuleRow());
return;
}
const removeButton = event.target.closest('.remove-coverage-rule');
if (removeButton) {
const ranges = removeButton.closest('.coverage-rule-day-ranges');
removeButton.closest('.coverage-rule-row')?.remove();
if (ranges && !ranges.querySelector('.coverage-rule-row')) {
ranges.innerHTML = '<p class="coverage-rule-empty">Aucune couverture obligatoire configurée.</p>';
}
}
});
refresh({ silent: true });
}
PTA.modules.coverage = { init, refresh, loadRules };
})();