From b3e53f83b871a7f3e32fb797074d51c245ea7062 Mon Sep 17 00:00:00 2001 From: PTA Date: Fri, 21 Aug 2026 11:44:08 +0200 Subject: [PATCH] =?UTF-8?q?Modification=20page=20"A=20valider"=20pour=20re?= =?UTF-8?q?grouper=20les=20cr=C3=A9neaux=20des=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Controllers/PageController.php | 10 +- app/Views/layouts/footer.php | 6 +- app/Views/layouts/header.php | 2 +- app/Views/pages/role.php | 2 +- app/Views/partials/pending_validation.php | 18 +- assets/js/pending-validation.js | 207 ++++++++++++++++++---- assets/style.css | 69 +++++++- 7 files changed, 260 insertions(+), 54 deletions(-) diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php index 5cf7d21..5329f4c 100644 --- a/app/Controllers/PageController.php +++ b/app/Controllers/PageController.php @@ -205,7 +205,7 @@ final class PageController extends Controller { $this->access->requirePage('pta'); $this->renderPage('pta', 'PTA annuel par agent', 'pta', [ - 'pageSubtitle' => 'Le PTA détaillé d’un agent : souhaits de lieux, contraintes et répartition des heures.', + 'pageSubtitle' => 'Le PTA détaillé d\'un agent : souhaits de lieux, contraintes et répartition des heures.', 'agents' => (new AgentModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(), 'scripts' => ['pta.js'], @@ -303,8 +303,8 @@ final class PageController extends Controller public function manage_structures(): void { $this->access->requirePage('manage_structures'); - $this->renderPage('manage_structures', 'Lieux d’affectation', 'manage_structures', [ - 'pageSubtitle' => 'Créer un lieu d’affectation et définir son type : périscolaire ou extrascolaire.', + $this->renderPage('manage_structures', 'Lieux d\'affectation', 'manage_structures', [ + 'pageSubtitle' => 'Créer un lieu d\'affectation et définir son type : périscolaire ou extrascolaire.', 'structures' => (new StructureModel($this->pdo))->allActive(), 'scripts' => ['administration.js'], 'modules' => ['administration'], @@ -323,7 +323,7 @@ final class PageController extends Controller { $this->access->requirePage('dsi'); $this->renderPage('dsi', 'Espace DSI', 'dsi', [ - 'pageSubtitle' => 'Les comptes d’accès de l’application, leurs jetons, et le journal des connexions, des écritures et des refus.', + 'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.', 'scripts' => ['dsi.js'], 'modules' => ['dsi'], 'pageConfig' => [ @@ -343,7 +343,7 @@ final class PageController extends Controller public function faq(): void { $this->access->requirePage('faq'); - $this->renderPage('faq', 'Guide d’utilisation', 'faq', [ + $this->renderPage('faq', 'Guide d\'utilisation', 'faq', [ 'pageSubtitle' => 'Le parcours complet, du paramétrage des lieux à la validation des plannings, et les réponses aux questions les plus fréquentes.', ]); } diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index f31ce0f..ccb33da 100644 --- a/app/Views/layouts/footer.php +++ b/app/Views/layouts/footer.php @@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = $pageConfig, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index 34f2bc2..692316a 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -104,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true); <?= htmlspecialchars($pageTitle) ?> — PTA - +
diff --git a/app/Views/pages/role.php b/app/Views/pages/role.php index 21d3dd5..d8dcb4c 100644 --- a/app/Views/pages/role.php +++ b/app/Views/pages/role.php @@ -43,7 +43,7 @@ $titre = $titres[$etape] ?? 'Connexion'; <?= htmlspecialchars($titre) ?> — PTA - +
diff --git a/app/Views/partials/pending_validation.php b/app/Views/partials/pending_validation.php index 233e04f..f98db61 100644 --- a/app/Views/partials/pending_validation.php +++ b/app/Views/partials/pending_validation.php @@ -1,8 +1,13 @@

Plannings en attente de validation

-

Retrouvez tous les brouillons contenant des affectations. Ouvrez un planning pour le contrôler puis le valider directement dans l’écran de saisie.

+

Chaque ligne regroupe les brouillons d’un agent. Dépliez-la pour voir le détail des semaines, puis ouvrez celle à contrôler pour la valider dans l’écran de saisie.

- À valider - 0 - planning(s) + Agents concernés + 0 + 0 planning(s) à valider
Volume en attente @@ -47,8 +52,7 @@ Agent Lieu d’affectation - Semaine - Créneaux + Semaines à valider Volume Dernière modification diff --git a/assets/js/pending-validation.js b/assets/js/pending-validation.js index 035e82d..89a9d7c 100644 --- a/assets/js/pending-validation.js +++ b/assets/js/pending-validation.js @@ -1,13 +1,22 @@ /** * File des plannings à valider (`PTA.modules.pendingValidation`), écran pending.php. * - * Liste les brouillons NON VIDES, du plus ancien au plus récent — l'ordre de - * traitement attendu, garanti par l'API. Cliquer sur une ligne ouvre l'écran de - * saisie positionné sur ce planning, défilé jusqu'aux boutons d'action. + * L'API renvoie un brouillon NON VIDE par ligne, du plus ancien au plus récent + * — l'ordre de traitement attendu. Le module REGROUPE ces lignes PAR AGENT : + * une ligne par agent (nom, lieu d'affectation, nombre de semaines), dépliable + * sur le détail semaine par semaine. C'est le seul niveau de regroupement : + * un agent planifié sur plusieurs lieux la même période reste une seule ligne, + * la colonne « lieu » énumère alors ses lieux. + * + * L'ordre des agents suit celui de l'API : le premier agent est celui qui porte + * le brouillon le plus ancien. Ouvrir une semaine ouvre l'écran de saisie + * positionné sur ce planning, défilé jusqu'aux boutons d'action. * * Ce module est aussi rafraîchi en mode silencieux par l'éditeur de planning * après chaque enregistrement ou validation, et alimente la pastille « À - * valider » de la barre de navigation. + * valider » de la barre de navigation. Les agents dépliés le restent d'un + * rafraîchissement à l'autre (`expandedAgents`), sans quoi une validation + * refermerait la liste sous les yeux de l'utilisateur. * * Deux totaux distincts sont affichés : les heures SAISIES et les heures * DÉCOMPTÉES du quota. Congés et absences non décomptées figurent au planning @@ -25,6 +34,7 @@ const badge = document.querySelector('#pending-validation-tab-badge'); const refreshButton = document.querySelector('#refresh-pending-validation'); const countElement = document.querySelector('#pending-validation-count'); + const agentsElement = document.querySelector('#pending-validation-agents'); const hoursElement = document.querySelector('#pending-validation-hours'); const countedHoursElement = document.querySelector('#pending-validation-counted-hours'); const loadingElement = document.querySelector('#pending-validation-loading'); @@ -34,7 +44,10 @@ const messageElement = document.querySelector('#pending-validation-message'); let items = []; + let groups = []; let loading = false; + /** Agents dépliés, conservés entre deux rendus. */ + const expandedAgents = new Set(); /** Recompose « 2026-W08 » depuis l'année et le numéro de semaine. */ function weekValue(item) { @@ -76,14 +89,139 @@ messageElement.textContent = text; } + /** + * Regroupe les plannings par agent en conservant l'ordre d'arrivée : le + * premier groupe est donc celui du brouillon le plus ancien, et à + * l'intérieur d'un groupe les semaines restent chronologiques. + */ + function groupByAgent(list) { + const byAgent = new Map(); + list.forEach((item) => { + const agentId = Number(item.id_agent); + let group = byAgent.get(agentId); + if (!group) { + group = { + id_agent: agentId, + agent_nom: item.agent_nom, + agent_prenom: item.agent_prenom, + matricule: item.matricule || '', + structures: new Map(), + plannings: [], + total_minutes: 0, + minutes_decomptees: 0, + nombre_creneaux: 0, + date_modification: null, + }; + byAgent.set(agentId, group); + } + group.plannings.push(item); + group.total_minutes += Number(item.total_minutes || 0); + group.minutes_decomptees += Number(item.minutes_decomptees || 0); + group.nombre_creneaux += Number(item.nombre_creneaux || 0); + if (!group.structures.has(Number(item.id_structure))) { + group.structures.set(Number(item.id_structure), { + nom: item.structure_nom, + type: item.structure_type_affectation, + }); + } + if (!group.date_modification || String(item.date_modification) > String(group.date_modification)) { + group.date_modification = item.date_modification; + } + }); + return [...byAgent.values()]; + } + + /** Colonne « lieu » : le lieu unique, ou l'énumération des lieux concernés. */ + function structureCell(group) { + const structures = [...group.structures.values()]; + if (structures.length === 1) { + return `${escapeHtml(structures[0].nom)} + ${escapeHtml(structureTypeLabel(structures[0].type))}`; + } + return `${structures.length} lieux + ${escapeHtml(structures.map((structure) => structure.nom).join(' · '))}`; + } + + /** Une semaine du détail déplié. */ + function weekRow(item) { + const week = weekValue(item); + const rowId = Number(item.id_planning); + return ` +
  • +
    + Semaine ${Number(item.numero_semaine)} + ${formatDate(item.date_debut)} → ${formatDate(item.date_fin_ouvrable || item.date_fin)} +
    +
    + ${escapeHtml(item.structure_nom)} + ${escapeHtml(structureTypeLabel(item.structure_type_affectation))} +
    +
    + ${formatDuration(Number(item.total_minutes || 0))} + ${Number(item.nombre_creneaux)} créneau(x) · ${formatDuration(Number(item.minutes_decomptees || 0))} décomptées +
    +
    + Modifié le ${formatDateTime(item.date_modification)} +
    +
    + +
    +
  • `; + } + + /** Ligne d'agent + ligne de détail associée (masquée si l'agent est replié). */ + function agentRows(group) { + const expanded = expandedAgents.has(group.id_agent); + const weeks = group.plannings.length; + const identite = `${escapeHtml(group.agent_prenom)} ${escapeHtml(group.agent_nom)}`; + return ` + + + ${identite} + ${escapeHtml(group.matricule)} + + ${structureCell(group)} + + ${weeks} + ${group.nombre_creneaux} créneau(x) + + + ${formatDuration(group.total_minutes)} + ${formatDuration(group.minutes_decomptees)} décomptées + + ${formatDateTime(group.date_modification)} + + ${expanded ? 'Masquer le détail' : 'Voir le détail'} + + + + + +
      ${group.plannings.map(weekRow).join('')}
    + + `; + } + /** Rend le tableau de la file et ses compteurs de synthèse. */ function render(data) { items = Array.isArray(data.plannings) ? data.plannings : []; + groups = groupByAgent(items); const summary = data.summary || {}; const count = Number(summary.count ?? items.length); + // Un agent replié entre-temps ne doit pas rester dans le jeu d'agents + // dépliés : ses plannings ont pu être validés depuis. + const presents = new Set(groups.map((group) => group.id_agent)); + [...expandedAgents].forEach((agentId) => { + if (!presents.has(agentId)) expandedAgents.delete(agentId); + }); + setBadge(count); if (countElement) countElement.textContent = String(count); + if (agentsElement) agentsElement.textContent = String(groups.length); if (hoursElement) hoursElement.textContent = formatDuration(Number(summary.total_minutes || 0)); if (countedHoursElement) countedHoursElement.textContent = formatDuration(Number(summary.counted_minutes || 0)); @@ -92,34 +230,7 @@ if (contentElement) contentElement.hidden = items.length === 0; if (!bodyElement) return; - bodyElement.innerHTML = items.map((item) => { - const week = weekValue(item); - const rowId = Number(item.id_planning); - return ` - - - ${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)} - ${escapeHtml(item.matricule || '')} - - - ${escapeHtml(item.structure_nom)} - ${escapeHtml(structureTypeLabel(item.structure_type_affectation))} - - - Semaine ${Number(item.numero_semaine)} - ${formatDate(item.date_debut)} → ${formatDate(item.date_fin_ouvrable || item.date_fin)} - - ${Number(item.nombre_creneaux)} - - ${formatDuration(Number(item.total_minutes || 0))} - ${formatDuration(Number(item.minutes_decomptees || 0))} décomptées - - ${formatDateTime(item.date_modification)} - - - - `; - }).join(''); + bodyElement.innerHTML = groups.map(agentRows).join(''); } /** @@ -169,9 +280,29 @@ window.location.href = `planning.php?${params.toString()}`; } + /** Déplie ou replie un agent, sans recharger la file. */ + function toggleAgent(agentId, row) { + const expanded = expandedAgents.has(agentId); + if (expanded) expandedAgents.delete(agentId); + else expandedAgents.add(agentId); + const group = groups.find((candidate) => candidate.id_agent === agentId); + const identite = group ? `${group.agent_prenom} ${group.agent_nom}` : ''; + const weeks = group ? group.plannings.length : 0; + + row.classList.toggle('is-expanded', !expanded); + row.setAttribute('aria-expanded', expanded ? 'false' : 'true'); + row.setAttribute('aria-label', `${expanded ? 'Déplier' : 'Replier'} les ${weeks} semaine(s) à valider de ${identite}`); + const hint = row.querySelector('.pending-toggle-hint'); + if (hint) hint.textContent = expanded ? 'Voir le détail' : 'Masquer le détail'; + const chevron = row.querySelector('.pending-toggle-chevron'); + if (chevron) chevron.textContent = expanded ? '▼' : '▲'; + const detail = bodyElement?.querySelector(`[data-agent-detail-id="${agentId}"]`); + if (detail) detail.hidden = expanded; + } + /** * Retrouve le planning correspondant à l'élément cliqué, via le - * `data-planning-id` porté par la ligne. Support de la délégation + * `data-planning-id` porté par la semaine. Support de la délégation * d'événements sur le corps du tableau. */ function findItemFromEventTarget(target) { @@ -185,7 +316,12 @@ refreshButton?.addEventListener('click', () => refresh()); bodyElement?.addEventListener('click', (event) => { const item = findItemFromEventTarget(event.target); - if (item) openPlanning(item); + if (item) { + openPlanning(item); + return; + } + const row = event.target.closest('.pending-validation-row'); + if (row) toggleAgent(Number(row.dataset.agentId), row); }); bodyElement?.addEventListener('keydown', (event) => { if (!['Enter', ' '].includes(event.key)) return; @@ -193,8 +329,7 @@ const row = event.target.closest('.pending-validation-row'); if (!row) return; event.preventDefault(); - const item = findItemFromEventTarget(row); - if (item) openPlanning(item); + toggleAgent(Number(row.dataset.agentId), row); }); refresh({ silent: true }); diff --git a/assets/style.css b/assets/style.css index acef6cd..d45f217 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1420,7 +1420,7 @@ body.modal-open { .pending-validation-table { width: 100%; - min-width: 1040px; + min-width: 900px; border-collapse: collapse; } @@ -1482,6 +1482,73 @@ body.modal-open { white-space: nowrap; } +/* Ligne d'agent dépliée : le détail des semaines lui est visuellement rattaché. */ +.pending-validation-row.is-expanded { + background: #fffaf0; + box-shadow: inset 4px 0 0 #b06a00; +} + +.pending-toggle-hint { + color: #6b7280; + font-size: 0.85rem; + margin-right: 0.5rem; + white-space: nowrap; +} + +.pending-toggle-chevron { + color: #b06a00; + font-size: 0.7rem; +} + +.pending-validation-detail-row > td { + padding: 0 0.8rem 0.9rem 0.8rem; + background: #fcfdff; +} + +.pending-week-list { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.pending-week-item { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1.1fr) auto; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 0.85rem; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fff; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.pending-week-item:hover { + border-color: #b06a00; + box-shadow: 0 1px 6px rgba(176, 106, 0, 0.15); +} + +.pending-week-item strong, +.pending-week-item small { + display: block; +} + +.pending-week-item small { + margin-top: 0.15rem; + color: #6b7280; +} + +.pending-week-action { + text-align: right; +} + +.pending-week-action .button { + white-space: nowrap; +} + @media (max-width: 760px) { .pending-validation-summary { grid-template-columns: 1fr;