diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php index 713440e..5ff3282 100644 --- a/app/Controllers/PageController.php +++ b/app/Controllers/PageController.php @@ -264,7 +264,10 @@ final class PageController extends Controller // V7.27 : la distribution des jetons de terrain rejoint cet écran. // Le contenu de la section vient entièrement de l'API — le HTML // servi ne porte aucune information sur les comptes. - 'scripts' => ['coverage.js', 'access-tokens.js'], + // `comptes-acces.js` porte la fabrique de l'écran des comptes, + // partagée avec l'espace DSI : il doit précéder `access-tokens.js`, + // qui l'appelle dès son chargement. + 'scripts' => ['coverage.js', 'comptes-acces.js', 'access-tokens.js'], 'modules' => ['coverage', 'accessTokens'], ]); } @@ -332,7 +335,9 @@ 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.', - 'scripts' => ['dsi.js'], + // Même fabrique que l'onglet Administration, et même contrainte + // d'ordre : elle doit être chargée avant `dsi.js`. + 'scripts' => ['comptes-acces.js', 'dsi.js'], 'modules' => ['dsi'], 'pageConfig' => [ // Lien profond « journal de ce compte » : filtre d'emblée le diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index afb96a4..6cdf6b6 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 896c497..97ea97c 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 b66c6d8..37a2d28 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/assets/js/access-tokens.js b/assets/js/access-tokens.js index 7c44cf3..f9fd4aa 100644 --- a/assets/js/access-tokens.js +++ b/assets/js/access-tokens.js @@ -11,335 +11,35 @@ * le serveur les refuse en 403 (AccessAccountController) : l'écran ne fait que * refléter une frontière qu'il ne décide pas. * - * RÈGLE CENTRALE, héritée de l'espace DSI : un jeton n'existe en clair que dans - * la réponse qui vient de le produire. Il est affiché dans un panneau dédié, qui - * reste ouvert tant qu'il n'a pas été refermé, et n'est jamais rechargé depuis - * le serveur — la base n'en garde qu'une empreinte. + * Tout le comportement vient de `comptes-acces.js`, partagé avec l'espace DSI : + * ce fichier ne fait que le brancher sur les ancres HTML `access-*` et sur les + * endpoints délégués `api/acces_*.php`. La version DSI du même écran ajoute, en + * plus, les rôles privilégiés, les mots de passe et le journal — c'est la seule + * différence de fond entre les deux. */ (() => { 'use strict'; const PTA = window.PTA; - // core.js absent : la page reste lisible, simplement non interactive. - if (!PTA) return; + // core.js ou comptes-acces.js absent : la page reste lisible, simplement non + // interactive. Ces deux fichiers sont chargés avant celui-ci par footer.php, + // dans l'ordre déclaré par PageController. + if (!PTA?.shared?.ecranComptesAcces) return; - const { readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils; - const el = (id) => document.getElementById(id); + const ecran = PTA.shared.ecranComptesAcces({ + prefixe: 'access', + endpoints: { + liste: 'api/acces_comptes.php', + creer: 'api/acces_compte_creer.php', + jeton: 'api/acces_compte_jeton.php', + statut: 'api/acces_compte_statut.php', + manquants: 'api/acces_comptes_manquants.php', + }, + // Ni champ « Nom du titulaire » ni aide de saisie propres : les deux + // rôles distribués ici pointent vers une identité déjà en base, et leurs + // aides sont celles, communes, de la fabrique. + }); - const LIBELLES_ROLE = { - RESPONSABLE_STRUCTURE: 'Responsable de structure', - AGENT: 'Agent', - }; - - /** - * Ce qu'il faut renseigner change selon le type de compte : l'écran - * l'explique plutôt que de laisser deviner pourquoi un champ change. - */ - const AIDES_ROLE = { - AGENT: 'Compte en lecture seule sur son propre planning. Seuls les agents actifs qui n’ont pas encore de compte sont proposés.', - RESPONSABLE_STRUCTURE: 'Compte d’édition des brouillons d’un seul lieu. Seuls les lieux actifs qui n’ont pas encore de compte sont proposés.', - }; - - /** Message dans un des bandeaux de la section. */ - function message(id, texte, type = 'info') { - const boite = el(id); - if (!boite) return; - boite.className = `message message-${type}`; - boite.textContent = texte; - } - - /** Appel d'API mutualisé : toute réponse non-OK devient une exception. */ - async function api(url, options = {}) { - const reponse = await fetch(url, options); - const donnees = await readJsonResponse(reponse); - if (!reponse.ok) throw new Error(apiErrorMessage(donnees, 'Opération impossible.')); - return donnees; - } - - /** Raccourci des appels mutants : POST JSON avec le jeton CSRF dans le corps. */ - function poster(url, charge) { - return api(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, ...charge }), - }); - } - - /** « 2026-08-20 09:41:07 » → « 20/08/2026 09:41 ». */ - function formatDateHeure(valeur) { - if (!valeur) return '—'; - const [date, heure = ''] = String(valeur).split(' '); - const [annee, mois, jour] = date.split('-'); - return `${jour}/${mois}/${annee} ${heure.slice(0, 5)}`; - } - - // ------------------------------------------------------------------ jetons - - /** - * Affiche les jetons produits par la dernière action. - * - * @param {Array<{libelle: string, role: string, jeton: string}>} jetons - */ - function afficherJetons(jetons) { - const panneau = el('access-token-panel'); - const liste = el('access-token-list'); - if (!panneau || !liste || !jetons.length) return; - - liste.innerHTML = jetons.map((entree) => ` -
-
- ${escapeHtml(entree.libelle)} - ${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)} -
- ${escapeHtml(entree.jeton)} -
- `).join(''); - - panneau.hidden = false; - message('access-token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture.`, 'warning'); - panneau.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - - /** - * Copie les jetons affichés dans le presse-papiers. - * - * L'API Clipboard n'existe QUE dans un contexte sécurisé : sur une - * installation en http:// sur le réseau de la collectivité, elle est - * absente. D'où le repli par zone de texte masquée, puis, en dernier - * ressort, l'invitation à sélectionner à la main. - */ - async function copierJetons() { - const lignes = Array.from(document.querySelectorAll('#access-token-list .dsi-token-row')).map((ligne) => { - const identite = ligne.querySelector('strong')?.textContent || ''; - const jeton = ligne.querySelector('.dsi-token-value')?.textContent || ''; - return `${identite}\t${jeton}`; - }); - if (!lignes.length) return; - - const texte = lignes.join('\n'); - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(texte); - } else { - const zone = document.createElement('textarea'); - zone.value = texte; - zone.setAttribute('readonly', 'readonly'); - zone.className = 'dsi-clipboard-relay'; - document.body.appendChild(zone); - zone.select(); - document.execCommand('copy'); - zone.remove(); - } - message('access-token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success'); - } catch (erreur) { - message('access-token-message', 'Copie impossible sur ce navigateur : sélectionnez les jetons à la main.', 'error'); - } - } - - /** Referme le panneau des jetons ; leur contenu est perdu pour de bon. */ - function fermerJetons() { - const panneau = el('access-token-panel'); - if (!panneau) return; - panneau.hidden = true; - el('access-token-list').innerHTML = ''; - message('access-token-message', ''); - } - - // ----------------------------------------------------------------- comptes - - /** Une ligne du tableau des comptes. */ - function ligneCompte(compte) { - const identite = [ - `${escapeHtml(compte.identite_libelle)}`, - compte.matricule ? `
Matricule ${escapeHtml(compte.matricule)}` : '', - compte.commentaire ? `
${escapeHtml(compte.commentaire)}` : '', - ].join(''); - - const etats = []; - if (!compte.actif) etats.push('Désactivé'); - else if (!compte.identite_active) etats.push('Identité inactive'); - else etats.push('Actif'); - - const connexions = compte.nombre_connexions > 0 - ? `
${compte.nombre_connexions} connexion(s)` - : ''; - - return ` - - ${identite} - ${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)} - ${escapeHtml(compte.jeton_prefixe)}… - ${formatDateHeure(compte.date_derniere_connexion)}${connexions} - ${etats.join(' ')} - - - - - - `; - } - - /** - * Remplit les listes de création à partir des identités SANS compte : - * proposer un agent qui en a déjà un ne mènerait qu'à un refus du serveur - * (un seul compte par identité). - */ - function remplirIdentites(manquants) { - const agents = manquants.filter((identite) => identite.role === 'AGENT'); - const lieux = manquants.filter((identite) => identite.role === 'RESPONSABLE_STRUCTURE'); - - const selectAgent = el('access-account-agent'); - const selectLieu = el('access-account-structure'); - if (selectAgent) { - selectAgent.innerHTML = '' - + agents.map((agent) => ``).join(''); - } - if (selectLieu) { - selectLieu.innerHTML = '' - + lieux.map((lieu) => ``).join(''); - } - - const compteur = el('access-missing-count'); - if (compteur) { - compteur.textContent = String(manquants.length); - compteur.hidden = manquants.length === 0; - } - const bouton = el('access-generate-missing'); - if (bouton) bouton.disabled = manquants.length === 0; - } - - /** Recharge la liste des comptes selon les filtres courants. */ - async function chargerComptes() { - const parametres = new URLSearchParams(); - const role = el('access-filter-role')?.value || ''; - const etat = el('access-filter-state')?.value || ''; - const recherche = el('access-filter-search')?.value.trim() || ''; - if (role) parametres.set('role', role); - if (etat !== '') parametres.set('actif', etat); - if (recherche) parametres.set('q', recherche); - - message('access-accounts-message', 'Chargement des comptes…'); - try { - const donnees = await api(`api/acces_comptes.php?${parametres.toString()}`); - const comptes = donnees.comptes || []; - el('access-accounts-body').innerHTML = comptes.length - ? comptes.map(ligneCompte).join('') - : 'Aucun compte ne correspond à ces filtres.'; - remplirIdentites(donnees.manquants || []); - message('access-accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success'); - } catch (erreur) { - message('access-accounts-message', erreur.message, 'error'); - } - } - - /** Affiche les seuls champs d'identité utiles au type de compte choisi. */ - function ajusterFormulaireCompte() { - const role = el('access-account-role')?.value || ''; - const champAgent = el('access-account-agent-field'); - const champLieu = el('access-account-structure-field'); - if (!champAgent || !champLieu) return; - - champAgent.hidden = role !== 'AGENT'; - champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE'; - el('access-account-agent').required = role === 'AGENT'; - el('access-account-structure').required = role === 'RESPONSABLE_STRUCTURE'; - - const aide = el('access-account-hint'); - if (aide) aide.textContent = AIDES_ROLE[role] || ''; - } - - /** Crée un compte, puis affiche son jeton — unique occasion de le lire. */ - async function creerCompte(evenement) { - evenement.preventDefault(); - const role = el('access-account-role').value; - - try { - const donnees = await poster('api/acces_compte_creer.php', { - role, - agent_id: role === 'AGENT' ? Number(el('access-account-agent').value) : null, - structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(el('access-account-structure').value) : null, - commentaire: el('access-account-comment').value, - }); - message('access-account-message', donnees.message, 'success'); - el('access-account-comment').value = ''; - afficherJetons([{ - libelle: donnees.compte?.identite_libelle || '', - role: donnees.compte?.role || role, - jeton: donnees.jeton, - }]); - await chargerComptes(); - } catch (erreur) { - message('access-account-message', erreur.message, 'error'); - } - } - - /** Régénération, activation, suppression : délégation sur le tableau. */ - async function agirSurCompte(evenement) { - const ligne = evenement.target.closest('tr[data-compte-id]'); - if (!ligne) return; - const compteId = Number(ligne.dataset.compteId); - const titulaire = ligne.querySelector('strong')?.textContent || 'ce compte'; - - try { - if (evenement.target.closest('.access-regenerate')) { - if (!window.confirm(`Régénérer le jeton de ${titulaire} ? L’ancien cessera immédiatement de fonctionner.`)) return; - const donnees = await poster('api/acces_compte_jeton.php', { compte_id: compteId }); - afficherJetons([{ - libelle: donnees.compte?.identite_libelle || titulaire, - role: donnees.compte?.role || '', - jeton: donnees.jeton, - }]); - message('access-accounts-message', donnees.message, 'success'); - } else if (evenement.target.closest('.access-toggle')) { - const desactiver = evenement.target.textContent.trim().startsWith('Dés'); - const donnees = await poster('api/acces_compte_statut.php', { - compte_id: compteId, - action: desactiver ? 'DESACTIVER' : 'ACTIVER', - }); - message('access-accounts-message', donnees.message, 'success'); - } else if (evenement.target.closest('.access-delete')) { - if (!window.confirm(`Supprimer définitivement le compte de ${titulaire} ? Son jeton cessera de fonctionner ; ses traces dans le journal sont conservées.`)) return; - const donnees = await poster('api/acces_compte_statut.php', { compte_id: compteId, action: 'SUPPRIMER' }); - message('access-accounts-message', donnees.message, 'success'); - } else { - return; - } - await chargerComptes(); - } catch (erreur) { - message('access-accounts-message', erreur.message, 'error'); - } - } - - /** Équipe d'un coup toutes les identités actives sans compte. */ - async function genererManquants() { - if (!window.confirm('Générer un jeton pour chaque agent et chaque lieu actif qui n’en a pas ? Ils seront affichés une seule fois.')) return; - try { - const donnees = await poster('api/acces_comptes_manquants.php', {}); - message('access-accounts-message', donnees.message, 'success'); - afficherJetons(donnees.jetons || []); - await chargerComptes(); - } catch (erreur) { - message('access-accounts-message', erreur.message, 'error'); - } - } - - function init() { - // La section n'existe que sur l'écran Administration : ailleurs, le - // module se retire sans rien tenter. - if (!el('access-accounts-body')) return; - - el('access-account-role')?.addEventListener('change', ajusterFormulaireCompte); - el('access-account-form')?.addEventListener('submit', creerCompte); - el('access-accounts-body')?.addEventListener('click', agirSurCompte); - el('access-accounts-refresh')?.addEventListener('click', chargerComptes); - el('access-filter-role')?.addEventListener('change', chargerComptes); - el('access-filter-state')?.addEventListener('change', chargerComptes); - el('access-generate-missing')?.addEventListener('click', genererManquants); - el('access-token-copy')?.addEventListener('click', copierJetons); - el('access-token-close')?.addEventListener('click', fermerJetons); - - ajusterFormulaireCompte(); - chargerComptes(); - } - - PTA.modules.accessTokens = { init, refresh: chargerComptes }; + // La section n'existe que sur l'écran Administration : ailleurs, `attacher()` + // se retire sans rien tenter. + PTA.modules.accessTokens = { init: ecran.attacher, refresh: ecran.chargerComptes }; })(); diff --git a/assets/js/agent-access.js b/assets/js/agent-access.js index 6ce7bd2..98ea73f 100644 --- a/assets/js/agent-access.js +++ b/assets/js/agent-access.js @@ -26,7 +26,7 @@ const PTA = window.PTA; if (!PTA) return; - const { readJsonResponse, apiErrorMessage } = PTA.utils; + const { apiJson } = PTA.utils; const corps = () => document.querySelector('#agent-assignment-body'); @@ -41,14 +41,6 @@ boite.textContent = texte; } - /** Appel d'API mutualisé : toute réponse non-OK devient une exception. */ - async function api(url, options = {}) { - const reponse = await fetch(url, options); - const donnees = await readJsonResponse(reponse); - if (!reponse.ok) throw new Error(apiErrorMessage(donnees, 'Opération impossible.')); - return donnees; - } - /** * Met un bouton de ligne à l'état du compte correspondant. * @@ -100,7 +92,7 @@ if (!corps() || chargement) return; chargement = true; try { - const donnees = await api('api/acces_comptes.php?role=AGENT'); + const donnees = await apiJson('api/acces_comptes.php?role=AGENT'); comptesParAgent = new Map( (donnees.comptes || []) .filter((compte) => compte.id_agent !== null) @@ -130,7 +122,7 @@ bouton.disabled = true; try { - const donnees = await api('api/acces_compte_statut.php', { + const donnees = await apiJson('api/acces_compte_statut.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/assets/js/comptes-acces.js b/assets/js/comptes-acces.js new file mode 100644 index 0000000..55d83f5 --- /dev/null +++ b/assets/js/comptes-acces.js @@ -0,0 +1,402 @@ +/** + * Fabrique de l'écran « comptes d'accès » (`PTA.shared.ecranComptesAcces`). + * + * DEUX écrans distribuent des jetons, avec exactement le même bloc d'interface : + * - l'espace DSI (`dsi.php`), sur les quatre rôles, plus les mots de passe et + * le jeton d'installation ; + * - l'onglet Administration (`administration.php`), sur les seuls rôles AGENT + * et RESPONSABLE_STRUCTURE délégués au Service Enfance depuis la V7.27. + * + * Les deux modules en portaient chacun leur copie — panneau de jetons, tableau, + * formulaire de création, génération en lot — soit près de 300 lignes en double + * qui divergeaient déjà (le filtre de recherche n'était pas branché côté + * Administration). Tout cela vit ici, paramétré par un PRÉFIXE d'identifiants + * HTML : les deux partiels nomment leurs ancres à l'identique, `dsi-token-panel` + * d'un côté, `access-token-panel` de l'autre. Renommer une ancre dans un partiel + * sans la renommer dans l'autre casse la fabrique. + * + * Ce qui reste propre à un écran passe par les crochets de `reglages` : états et + * boutons supplémentaires sur une ligne, action inconnue de la fabrique, suite à + * donner après un chargement. + * + * RÈGLE CENTRALE, commune aux deux écrans : un jeton n'existe en clair que dans + * la réponse qui vient de le produire. Il est affiché dans un panneau dédié, qui + * reste ouvert tant qu'il n'a pas été refermé, et n'est JAMAIS rechargé depuis + * le serveur — la base n'en garde qu'une empreinte. + */ +(() => { + 'use strict'; + const PTA = window.PTA; + // core.js absent : la page reste lisible, simplement non interactive. + if (!PTA) return; + + const { escapeHtml, apiJson, postJson, showMessageIn, formatDateTime } = PTA.utils; + + /** Libellés des quatre rôles ; un écran n'en affiche que ceux qu'il liste. */ + const LIBELLES_ROLE = { + DSI: 'DSI', + SERVICE_ENFANCE: 'Service Enfance', + RESPONSABLE_STRUCTURE: 'Responsable de structure', + AGENT: 'Agent', + }; + + /** + * Ce qu'il faut renseigner change entièrement selon le type de compte : + * l'écran l'explique plutôt que de laisser deviner pourquoi un champ + * apparaît ou disparaît. Les deux rôles de terrain sont décrits ici, les + * rôles privilégiés le sont par l'espace DSI, seul à les proposer. + */ + const AIDES_ROLE = { + AGENT: 'Compte en lecture seule sur son propre planning. Seuls les agents actifs qui n’ont pas encore de compte sont proposés.', + RESPONSABLE_STRUCTURE: 'Compte d’édition des brouillons d’un seul lieu. Seuls les lieux actifs qui n’ont pas encore de compte sont proposés.', + }; + + const libelleRole = (role) => LIBELLES_ROLE[role] || role || ''; + + /** + * Construit l'écran des comptes pour un préfixe d'identifiants donné. + * + * @param {object} reglages + * @param {string} reglages.prefixe « dsi » ou « access » : préfixe de TOUTES les ancres HTML. + * @param {object} reglages.endpoints {liste, creer, jeton, statut, manquants}. + * @param {object} [reglages.aides] Aides de saisie à ajouter aux deux rôles de terrain. + * @param {boolean} [reglages.avecLibelle] Le formulaire porte le champ « Nom du titulaire » + * (rôles sans identité en base : Service Enfance, DSI). + * @param {string} [reglages.rappelJetons] Phrase ajoutée à l'avertissement du panneau de jetons. + * @param {Function} [reglages.etatsSupplementaires] (compte) => string[] de badges d'état. + * @param {Function} [reglages.actionsSupplementaires] (compte) => HTML de boutons en fin de ligne. + * @param {Function} [reglages.actionPersonnalisee] (evenement, contexte) => vrai s'il faut recharger. + * @param {Function} [reglages.apresChargement] (donnees) => void, après chaque chargement réussi. + * @returns {{attacher: Function, chargerComptes: Function, afficherJetons: Function}} + */ + function ecranComptesAcces(reglages) { + const { + prefixe, + endpoints, + aides = {}, + avecLibelle = false, + rappelJetons = '', + etatsSupplementaires = () => [], + actionsSupplementaires = () => '', + actionPersonnalisee = null, + apresChargement = null, + } = reglages; + + const aidesEcran = { ...AIDES_ROLE, ...aides }; + + /** Ancre HTML de cet écran : « account-role » → « dsi-account-role ». */ + const id = (suffixe) => `${prefixe}-${suffixe}`; + const champ = (suffixe) => document.getElementById(id(suffixe)); + const dire = (suffixe, texte, type) => showMessageIn(id(suffixe), texte, type); + + // ------------------------------------------------------------- jetons + + /** + * Affiche les jetons produits par la dernière action. + * + * @param {Array<{libelle: string, role: string, jeton: string}>} jetons + */ + function afficherJetons(jetons) { + const panneau = champ('token-panel'); + const liste = champ('token-list'); + if (!panneau || !liste || !jetons.length) return; + + liste.innerHTML = jetons.map((entree) => ` +
+
+ ${escapeHtml(entree.libelle)} + ${escapeHtml(libelleRole(entree.role))} +
+ ${escapeHtml(entree.jeton)} +
+ `).join(''); + + panneau.hidden = false; + dire('token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture.${rappelJetons}`, 'warning'); + panneau.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + /** + * Copie les jetons affichés dans le presse-papiers. + * + * L'API Clipboard n'existe QUE dans un contexte sécurisé : sur une + * installation en http:// sur le réseau de la collectivité, elle est + * absente. D'où le repli par zone de texte masquée, puis, en dernier + * ressort, l'invitation à sélectionner à la main. + */ + async function copierJetons() { + const lignes = Array.from(document.querySelectorAll(`#${id('token-list')} .dsi-token-row`)).map((ligne) => { + const identite = ligne.querySelector('strong')?.textContent || ''; + const jeton = ligne.querySelector('.dsi-token-value')?.textContent || ''; + return `${identite}\t${jeton}`; + }); + if (!lignes.length) return; + + const texte = lignes.join('\n'); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(texte); + } else { + const zone = document.createElement('textarea'); + zone.value = texte; + zone.setAttribute('readonly', 'readonly'); + zone.className = 'dsi-clipboard-relay'; + document.body.appendChild(zone); + zone.select(); + document.execCommand('copy'); + zone.remove(); + } + dire('token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success'); + } catch (erreur) { + dire('token-message', 'Copie impossible sur ce navigateur : sélectionnez les jetons à la main.', 'error'); + } + } + + /** Referme le panneau des jetons ; leur contenu est perdu pour de bon. */ + function fermerJetons() { + const panneau = champ('token-panel'); + if (!panneau) return; + panneau.hidden = true; + champ('token-list').innerHTML = ''; + dire('token-message', ''); + } + + // ------------------------------------------------------------ comptes + + /** + * Une ligne du tableau des comptes. + * + * Les classes d'action (`compte-regenerate`…) sont de simples points + * d'accroche pour la délégation de clic : aucune feuille de style ne les + * vise, la mise en forme passe par `dsi-actions-cell`. + */ + function ligneCompte(compte) { + const identite = [ + `${escapeHtml(compte.identite_libelle)}`, + compte.matricule ? `
Matricule ${escapeHtml(compte.matricule)}` : '', + compte.commentaire ? `
${escapeHtml(compte.commentaire)}` : '', + ].join(''); + + const etats = []; + if (!compte.actif) etats.push('Désactivé'); + else if (!compte.identite_active) etats.push('Identité inactive'); + else etats.push('Actif'); + etats.push(...etatsSupplementaires(compte)); + + const connexions = compte.nombre_connexions > 0 + ? `
${compte.nombre_connexions} connexion(s)` + : ''; + + return ` + + ${identite} + ${escapeHtml(libelleRole(compte.role))} + ${escapeHtml(compte.jeton_prefixe)}… + ${formatDateTime(compte.date_derniere_connexion)}${connexions} + ${etats.join(' ')} + + + + ${actionsSupplementaires(compte)} + + + `; + } + + /** + * Remplit les listes déroulantes de création à partir des identités SANS + * compte : proposer un agent qui en a déjà un ne mènerait qu'à un refus + * du serveur (un seul compte par identité). + */ + function remplirIdentites(manquants) { + const agents = manquants.filter((identite) => identite.role === 'AGENT'); + const lieux = manquants.filter((identite) => identite.role === 'RESPONSABLE_STRUCTURE'); + + const selectAgent = champ('account-agent'); + const selectLieu = champ('account-structure'); + if (selectAgent) { + selectAgent.innerHTML = '' + + agents.map((agent) => ``).join(''); + } + if (selectLieu) { + selectLieu.innerHTML = '' + + lieux.map((lieu) => ``).join(''); + } + + const compteur = champ('missing-count'); + if (compteur) { + compteur.textContent = String(manquants.length); + compteur.hidden = manquants.length === 0; + } + const bouton = champ('generate-missing'); + if (bouton) bouton.disabled = manquants.length === 0; + } + + /** Recharge la liste des comptes selon les filtres courants. */ + async function chargerComptes() { + const parametres = new URLSearchParams(); + const role = champ('filter-role')?.value || ''; + const etat = champ('filter-state')?.value || ''; + const recherche = champ('filter-search')?.value.trim() || ''; + if (role) parametres.set('role', role); + if (etat !== '') parametres.set('actif', etat); + if (recherche) parametres.set('q', recherche); + + dire('accounts-message', 'Chargement des comptes…'); + try { + const donnees = await apiJson(`${endpoints.liste}?${parametres.toString()}`); + const comptes = donnees.comptes || []; + champ('accounts-body').innerHTML = comptes.length + ? comptes.map(ligneCompte).join('') + : 'Aucun compte ne correspond à ces filtres.'; + remplirIdentites(donnees.manquants || []); + apresChargement?.(donnees); + + dire('accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success'); + } catch (erreur) { + dire('accounts-message', erreur.message, 'error'); + } + } + + /** Affiche les seuls champs d'identité utiles au type de compte choisi. */ + function ajusterFormulaireCompte() { + const role = champ('account-role')?.value || ''; + const champAgent = champ('account-agent-field'); + const champLieu = champ('account-structure-field'); + if (!champAgent || !champLieu) return; + + champAgent.hidden = role !== 'AGENT'; + champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE'; + champ('account-agent').required = role === 'AGENT'; + champ('account-structure').required = role === 'RESPONSABLE_STRUCTURE'; + + // Service Enfance et DSI ne pointent vers aucune identité en base : + // leur seul identifiant lisible est le libellé saisi ici. Les écrans + // qui ne proposent pas ces rôles n'ont pas le champ du tout. + const champLibelle = avecLibelle ? champ('account-label-field') : null; + if (champLibelle) { + champLibelle.hidden = role === 'AGENT' || role === 'RESPONSABLE_STRUCTURE'; + champ('account-label').required = !champLibelle.hidden; + } + + const aide = champ('account-hint'); + if (aide) aide.textContent = aidesEcran[role] || ''; + } + + /** Crée un compte, puis affiche son jeton — unique occasion de le lire. */ + async function creerCompte(evenement) { + evenement.preventDefault(); + const role = champ('account-role').value; + + try { + const charge = { + role, + agent_id: role === 'AGENT' ? Number(champ('account-agent').value) : null, + structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(champ('account-structure').value) : null, + commentaire: champ('account-comment').value, + }; + if (avecLibelle) charge.libelle = champ('account-label').value; + + const donnees = await postJson(endpoints.creer, charge); + dire('account-message', donnees.message, 'success'); + if (avecLibelle) champ('account-label').value = ''; + champ('account-comment').value = ''; + afficherJetons([{ + libelle: donnees.compte?.identite_libelle || '', + role: donnees.compte?.role || role, + jeton: donnees.jeton, + }]); + await chargerComptes(); + } catch (erreur) { + dire('account-message', erreur.message, 'error'); + } + } + + /** + * Régénération, activation, suppression : délégation sur le tableau. + * + * Les boutons propres à un écran ne sont pas reconnus ici : ils sont + * passés à `actionPersonnalisee`, qui répond si la liste doit être + * rechargée derrière. + */ + async function agirSurCompte(evenement) { + const ligne = evenement.target.closest('tr[data-compte-id]'); + const bouton = evenement.target.closest('button'); + if (!ligne || !bouton) return; + const compteId = Number(ligne.dataset.compteId); + const titulaire = ligne.querySelector('strong')?.textContent || 'ce compte'; + + try { + if (bouton.classList.contains('compte-regenerate')) { + if (!window.confirm(`Régénérer le jeton de ${titulaire} ? L’ancien cessera immédiatement de fonctionner.`)) return; + const donnees = await postJson(endpoints.jeton, { compte_id: compteId }); + afficherJetons([{ + libelle: donnees.compte?.identite_libelle || titulaire, + role: donnees.compte?.role || '', + jeton: donnees.jeton, + }]); + dire('accounts-message', donnees.message, 'success'); + } else if (bouton.classList.contains('compte-toggle')) { + // Le libellé du bouton porte l'état courant : le tableau est + // la seule source, il n'y a pas d'état gardé en mémoire. + const action = bouton.textContent.trim() === 'Désactiver' ? 'DESACTIVER' : 'ACTIVER'; + const donnees = await postJson(endpoints.statut, { compte_id: compteId, action }); + dire('accounts-message', donnees.message, 'success'); + } else if (bouton.classList.contains('compte-delete')) { + if (!window.confirm(`Supprimer définitivement le compte de ${titulaire} ? Son jeton cessera de fonctionner ; ses traces dans le journal sont conservées.`)) return; + const donnees = await postJson(endpoints.statut, { compte_id: compteId, action: 'SUPPRIMER' }); + dire('accounts-message', donnees.message, 'success'); + } else if (!await actionPersonnalisee?.(evenement, { bouton, compteId, titulaire })) { + return; + } + await chargerComptes(); + } catch (erreur) { + dire('accounts-message', erreur.message, 'error'); + } + } + + /** Équipe d'un coup toutes les identités actives sans compte. */ + async function genererManquants() { + const attendus = Number(champ('missing-count')?.textContent || 0); + if (!attendus) return; + if (!window.confirm(`Générer ${attendus} jeton(s) ? Ils ne seront affichés qu’une seule fois, tout de suite après.`)) return; + + try { + const donnees = await postJson(endpoints.manquants, {}); + dire('accounts-message', donnees.message, 'success'); + afficherJetons(donnees.jetons || []); + await chargerComptes(); + } catch (erreur) { + dire('accounts-message', erreur.message, 'error'); + } + } + + /** + * Branche l'écran et effectue le premier chargement. + * @returns {boolean} Faux si la page ne porte pas le bloc des comptes — + * le cas de l'onglet Administration vu par un autre rôle. + */ + function attacher() { + if (!champ('accounts-body')) return false; + + champ('account-role')?.addEventListener('change', ajusterFormulaireCompte); + champ('account-form')?.addEventListener('submit', creerCompte); + champ('accounts-body')?.addEventListener('click', agirSurCompte); + champ('accounts-refresh')?.addEventListener('click', chargerComptes); + champ('filter-role')?.addEventListener('change', chargerComptes); + champ('filter-state')?.addEventListener('change', chargerComptes); + champ('filter-search')?.addEventListener('change', chargerComptes); + champ('generate-missing')?.addEventListener('click', genererManquants); + champ('token-copy')?.addEventListener('click', copierJetons); + champ('token-close')?.addEventListener('click', fermerJetons); + + ajusterFormulaireCompte(); + chargerComptes(); + return true; + } + + return { attacher, chargerComptes, afficherJetons }; + } + + PTA.shared.ecranComptesAcces = ecranComptesAcces; +})(); diff --git a/assets/js/core.js b/assets/js/core.js index 2ad78a5..e7941e6 100644 --- a/assets/js/core.js +++ b/assets/js/core.js @@ -79,8 +79,10 @@ }; /** - * Cache de données brutes qu'un module expose aux autres, pour éviter de - * refaire un appel réseau déjà effectué. + * Ce qu'un module expose aux autres : des données brutes déjà chargées (pour + * éviter de refaire un appel réseau) et des FABRIQUES d'écran, quand deux + * pages rendent le même bloc à partir de la même API — voir + * `comptes-acces.js`, partagé par l'espace DSI et l'onglet Administration. */ const shared = { lastAgentOverviewData: null, @@ -177,6 +179,58 @@ return data?.details ? `${base} Détail : ${data.details}` : base; } + /** + * Appel d'API mutualisé : lit le JSON et transforme toute réponse non-OK en + * exception, de sorte que l'appelant n'a qu'un seul `catch` à écrire. + * + * Chaque écran avait recopié ces quatre lignes ; elles vivent ici depuis + * qu'elles servaient à l'identique dans quatre modules. + */ + async function apiJson(url, options = {}) { + const response = await fetch(url, options); + const data = await readJsonResponse(response); + if (!response.ok) throw new Error(apiErrorMessage(data, 'Opération impossible.')); + return data; + } + + /** + * Raccourci des appels mutants : POST JSON avec le jeton CSRF DANS LE CORPS. + * PTA ne lit jamais le jeton depuis un en-tête (voir Csrf::assertPayload). + */ + function postJson(url, payload) { + return apiJson(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ csrf_token: window.PTA_CONFIG?.csrfToken, ...payload }), + }); + } + + /** + * Message dans un bandeau désigné par son identifiant HTML. + * + * `showMessage()` vise le bandeau unique de l'éditeur de planning + * (`elements.messageBox`) ; les écrans qui en portent plusieurs — comptes, + * jetons, journal — passent par ici. + */ + function showMessageIn(id, text, type = 'info') { + const box = document.getElementById(id); + if (!box) return; + box.className = `message message-${type}`; + box.textContent = text; + } + + /** + * « 2026-08-20 09:41:07.482 » → « 20/08/2026 09:41 ». + * Découpage de chaîne pour la même raison que `formatDate()` : `new Date()` + * interpréterait la valeur en UTC. + */ + function formatDateTime(value) { + if (!value) return '—'; + const [date, time = ''] = String(value).split(' '); + const [year, month, day] = date.split('-'); + return `${day}/${month}/${year} ${time.slice(0, 5)}`; + } + /** * Date → « AAAA-MM-JJ » en heure LOCALE. * `toISOString()` convertirait en UTC et décalerait la date d'un jour selon @@ -284,6 +338,10 @@ clearMessage, readJsonResponse, apiErrorMessage, + apiJson, + postJson, + showMessageIn, + formatDateTime, parseIsoWeek, timeToMinutes, formatDuration, diff --git a/assets/js/dsi.js b/assets/js/dsi.js index 182ac45..803c58e 100644 --- a/assets/js/dsi.js +++ b/assets/js/dsi.js @@ -8,6 +8,13 @@ * - le JOURNAL D'ACTIVITÉ : recherche filtrée et paginée, statistiques de tête, * purge des entrées anciennes. * + * Le premier bloc est rendu par `comptes-acces.js`, partagé avec l'onglet + * Administration : ce fichier ne lui apporte que ce qui est propre à la DSI — + * les rôles privilégiés et leur champ « Nom du titulaire », les badges de mot de + * passe (V7.12), l'alerte du jeton d'installation et le bouton qui filtre le + * journal sur un compte. Le second bloc, le journal, n'existe QUE sur cet écran + * et vit donc entièrement ici. + * * RÈGLE CENTRALE DE L'ÉCRAN : un jeton n'existe en clair que dans la réponse qui * vient de le produire. Il est donc affiché dans un panneau dédié, qui reste * ouvert tant que la DSI ne l'a pas refermé, et n'est jamais rechargé depuis le @@ -17,11 +24,13 @@ (() => { 'use strict'; const PTA = window.PTA; - // core.js absent : la page reste lisible, simplement non interactive. - if (!PTA) return; + // core.js ou comptes-acces.js absent : la page reste lisible, simplement non + // interactive. + if (!PTA?.shared?.ecranComptesAcces) return; - const { readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils; + const { apiJson, postJson, showMessageIn, formatDateTime, escapeHtml } = PTA.utils; const el = (id) => document.getElementById(id); + const message = (id, texte, type = 'info') => showMessageIn(id, texte, type); /** Page courante du journal, seul état conservé par le module. */ let pageJournal = 1; @@ -41,352 +50,89 @@ SYSTEME: 'Système', }; - /** Message dans un des bandeaux de la page. */ - function message(id, texte, type = 'info') { - const boite = el(id); - if (!boite) return; - boite.className = `message message-${type}`; - boite.textContent = texte; - } - - /** - * Appel d'API mutualisé : lit le JSON et transforme toute réponse non-OK en - * exception. Toutes les actions de la page passent par ici. - */ - async function api(url, options = {}) { - const reponse = await fetch(url, options); - const donnees = await readJsonResponse(reponse); - if (!reponse.ok) throw new Error(apiErrorMessage(donnees, 'Opération impossible.')); - return donnees; - } - - /** Raccourci des appels mutants : POST JSON avec le jeton CSRF du corps. */ - function poster(url, charge) { - return api(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, ...charge }), - }); - } - - /** « 2026-08-20 09:41:07.482 » → « 20/08/2026 09:41 ». */ - function formatDateHeure(valeur) { - if (!valeur) return '—'; - const [date, heure = ''] = String(valeur).split(' '); - const [annee, mois, jour] = date.split('-'); - return `${jour}/${mois}/${annee} ${heure.slice(0, 5)}`; - } - - // ------------------------------------------------------------------ jetons - - /** - * Affiche les jetons produits par la dernière action. - * - * @param {Array<{libelle: string, role: string, jeton: string}>} jetons - */ - function afficherJetons(jetons) { - const panneau = el('dsi-token-panel'); - const liste = el('dsi-token-list'); - if (!panneau || !liste || !jetons.length) return; - - liste.innerHTML = jetons.map((entree) => ` -
-
- ${escapeHtml(entree.libelle)} - ${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)} -
- ${escapeHtml(entree.jeton)} -
- `).join(''); - - panneau.hidden = false; - message('dsi-token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture. Les comptes Service Enfance et DSI choisiront en plus un mot de passe à leur première connexion.`, 'warning'); - panneau.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - - /** - * Copie les jetons affichés dans le presse-papiers. - * - * L'API Clipboard n'existe QUE dans un contexte sécurisé : sur une - * installation en http:// sur le réseau de la collectivité, elle est - * absente. D'où le repli par zone de texte masquée, puis, en dernier - * ressort, l'invitation à sélectionner à la main. - */ - async function copierJetons() { - const lignes = Array.from(document.querySelectorAll('#dsi-token-list .dsi-token-row')).map((ligne) => { - const identite = ligne.querySelector('strong')?.textContent || ''; - const jeton = ligne.querySelector('.dsi-token-value')?.textContent || ''; - return `${identite}\t${jeton}`; - }); - if (!lignes.length) return; - - const texte = lignes.join('\n'); - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(texte); - } else { - const zone = document.createElement('textarea'); - zone.value = texte; - zone.setAttribute('readonly', 'readonly'); - zone.className = 'dsi-clipboard-relay'; - document.body.appendChild(zone); - zone.select(); - document.execCommand('copy'); - zone.remove(); - } - message('dsi-token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success'); - } catch (erreur) { - message('dsi-token-message', 'Copie impossible sur ce navigateur : sélectionnez les jetons à la main.', 'error'); - } - } - - /** Referme le panneau des jetons ; leur contenu est perdu pour de bon. */ - function fermerJetons() { - const panneau = el('dsi-token-panel'); - if (!panneau) return; - panneau.hidden = true; - el('dsi-token-list').innerHTML = ''; - message('dsi-token-message', ''); - } - // ----------------------------------------------------------------- comptes - /** Une ligne du tableau des comptes. */ - function ligneCompte(compte) { - const identite = [ - `${escapeHtml(compte.identite_libelle)}`, - compte.matricule ? `
Matricule ${escapeHtml(compte.matricule)}` : '', - compte.commentaire ? `
${escapeHtml(compte.commentaire)}` : '', - ].join(''); - + /** + * Badges d'état propres à la DSI, ajoutés à « Actif / Désactivé ». + * + * Second facteur (V7.12) : seuls les comptes Service Enfance et DSI en + * portent un. « À définir » n'est pas une anomalie — c'est l'état normal + * d'un compte neuf ou fraîchement réinitialisé. + */ + function etatsCompte(compte) { const etats = []; - if (!compte.actif) etats.push('Désactivé'); - else if (!compte.identite_active) etats.push('Identité inactive'); - else etats.push('Actif'); if (compte.jeton_installation) etats.push('Jeton d’installation'); - // Second facteur (V7.12) : seuls les comptes Service Enfance et DSI en - // portent un. « À définir » n'est pas une anomalie — c'est l'état normal - // d'un compte neuf ou fraîchement réinitialisé. if (compte.mot_de_passe_requis) { etats.push(compte.mot_de_passe_defini ? 'Mot de passe défini' : 'Mot de passe à définir'); } - - const connexions = compte.nombre_connexions > 0 - ? `
${compte.nombre_connexions} connexion(s)` - : ''; - - return ` - - ${identite} - ${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)} - ${escapeHtml(compte.jeton_prefixe)}… - ${formatDateHeure(compte.date_derniere_connexion)}${connexions} - ${etats.join(' ')} - - - - ${compte.mot_de_passe_requis && compte.mot_de_passe_defini - ? '' - : ''} - - - - `; + return etats; } /** - * Remplit les listes déroulantes de création à partir des identités SANS - * compte : proposer un agent qui en a déjà un ne mènerait qu'à un refus du - * serveur (un seul compte par identité). + * Boutons propres à la DSI en fin de ligne : effacement du mot de passe (la + * DSI peut l'effacer, jamais en poser un) et accès au journal du compte. */ - function remplirIdentites(manquants) { - const agents = manquants.filter((identite) => identite.role === 'AGENT'); - const lieux = manquants.filter((identite) => identite.role === 'RESPONSABLE_STRUCTURE'); - - const selectAgent = el('dsi-account-agent'); - const selectLieu = el('dsi-account-structure'); - if (selectAgent) { - selectAgent.innerHTML = '' - + agents.map((agent) => ``).join(''); - } - if (selectLieu) { - selectLieu.innerHTML = '' - + lieux.map((lieu) => ``).join(''); - } - - const compteur = el('dsi-missing-count'); - if (compteur) { - compteur.textContent = String(manquants.length); - compteur.hidden = manquants.length === 0; - } - const bouton = el('dsi-generate-missing'); - if (bouton) bouton.disabled = manquants.length === 0; + function actionsCompte(compte) { + return [ + compte.mot_de_passe_requis && compte.mot_de_passe_defini + ? '' + : '', + '', + ].join(''); } - /** Recharge la liste des comptes selon les filtres courants. */ - async function chargerComptes() { - const parametres = new URLSearchParams(); - const role = el('dsi-filter-role')?.value || ''; - const etat = el('dsi-filter-state')?.value || ''; - const recherche = el('dsi-filter-search')?.value.trim() || ''; - if (role) parametres.set('role', role); - if (etat !== '') parametres.set('actif', etat); - if (recherche) parametres.set('q', recherche); + /** + * Traite les deux boutons que la fabrique ne connaît pas. + * @returns {boolean} Vrai s'il faut recharger la liste des comptes derrière. + */ + async function actionCompte(evenement, { bouton, compteId, titulaire }) { + if (bouton.classList.contains('compte-reset-password')) { + if (!window.confirm(`Effacer le mot de passe de ${titulaire} ? Son jeton reste valable, et il en choisira un nouveau à sa prochaine connexion.`)) return false; + const donnees = await postJson('api/dsi_compte_mot_de_passe.php', { compte_id: compteId }); + message('dsi-accounts-message', donnees.message, 'success'); + return true; + } - message('dsi-accounts-message', 'Chargement des comptes…'); - try { - const donnees = await api(`api/dsi_comptes.php?${parametres.toString()}`); - const comptes = donnees.comptes || []; - el('dsi-accounts-body').innerHTML = comptes.length - ? comptes.map(ligneCompte).join('') - : 'Aucun compte ne correspond à ces filtres.'; - remplirIdentites(donnees.manquants || []); + if (bouton.classList.contains('compte-journal')) { + // Filtre le journal sur ce compte, sans quitter la page ni toucher + // à la liste des comptes. + el('dsi-journal-search').value = ''; + pageJournal = 1; + await chargerJournal({ id_compte: compteId }); + el('dsi-journal-body')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + return false; + } + const ecranComptes = PTA.shared.ecranComptesAcces({ + prefixe: 'dsi', + endpoints: { + liste: 'api/dsi_comptes.php', + creer: 'api/dsi_compte_creer.php', + jeton: 'api/dsi_compte_jeton.php', + statut: 'api/dsi_compte_statut.php', + manquants: 'api/dsi_comptes_manquants.php', + }, + // Seul l'espace DSI crée des comptes sans identité en base : eux seuls + // ont besoin du champ libre « Nom du titulaire », et de ces deux aides. + avecLibelle: true, + aides: { + SERVICE_ENFANCE: 'Compte d’administration métier : accès à tous les lieux, à tous les agents et à la validation des plannings. Il n’est rattaché à aucun agent en base — indiquez simplement le nom de son titulaire. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.', + DSI: 'Compte d’administration technique : comptes d’accès et journal uniquement, aucun accès aux plannings. Prévoyez-en au moins deux, pour ne pas dépendre d’un seul jeton. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.', + }, + rappelJetons: ' Les comptes Service Enfance et DSI choisiront en plus un mot de passe à leur première connexion.', + etatsSupplementaires: etatsCompte, + actionsSupplementaires: actionsCompte, + actionPersonnalisee: actionCompte, + // Le jeton d'installation est publié en clair dans la migration SQL : + // l'alerte reste affichée tant qu'il n'a pas été régénéré. + apresChargement: (donnees) => { const alerte = el('dsi-installation-warning'); if (alerte) alerte.hidden = !donnees.jeton_installation_actif; - - message('dsi-accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success'); - } catch (erreur) { - message('dsi-accounts-message', erreur.message, 'error'); - } - } - - /** - * Ce qu'il faut renseigner change entièrement selon le type de compte : - * l'écran l'explique plutôt que de laisser deviner pourquoi un champ - * apparaît ou disparaît. - */ - const AIDES_ROLE = { - SERVICE_ENFANCE: 'Compte d’administration métier : accès à tous les lieux, à tous les agents et à la validation des plannings. Il n’est rattaché à aucun agent en base — indiquez simplement le nom de son titulaire. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.', - DSI: 'Compte d’administration technique : comptes d’accès et journal uniquement, aucun accès aux plannings. Prévoyez-en au moins deux, pour ne pas dépendre d’un seul jeton. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.', - AGENT: 'Compte en lecture seule sur son propre planning. Seuls les agents actifs qui n’ont pas encore de compte sont proposés.', - RESPONSABLE_STRUCTURE: 'Compte d’édition des brouillons d’un seul lieu. Seuls les lieux actifs qui n’ont pas encore de compte sont proposés.', - }; - - /** Affiche les seuls champs d'identité utiles au type de compte choisi. */ - function ajusterFormulaireCompte() { - const role = el('dsi-account-role')?.value || ''; - const champAgent = el('dsi-account-agent-field'); - const champLieu = el('dsi-account-structure-field'); - const champLibelle = el('dsi-account-label-field'); - if (!champAgent || !champLieu || !champLibelle) return; - - champAgent.hidden = role !== 'AGENT'; - champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE'; - // Service Enfance et DSI ne pointent vers aucune identité en base : leur - // seul identifiant lisible est le libellé saisi ici. - champLibelle.hidden = role === 'AGENT' || role === 'RESPONSABLE_STRUCTURE'; - - el('dsi-account-agent').required = role === 'AGENT'; - el('dsi-account-structure').required = role === 'RESPONSABLE_STRUCTURE'; - el('dsi-account-label').required = !champLibelle.hidden; - - const aide = el('dsi-account-hint'); - if (aide) aide.textContent = AIDES_ROLE[role] || ''; - } - - /** Crée un compte, puis affiche son jeton — unique occasion de le lire. */ - async function creerCompte(evenement) { - evenement.preventDefault(); - const role = el('dsi-account-role').value; - - try { - const donnees = await poster('api/dsi_compte_creer.php', { - role, - agent_id: role === 'AGENT' ? Number(el('dsi-account-agent').value) : null, - structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(el('dsi-account-structure').value) : null, - libelle: el('dsi-account-label').value, - commentaire: el('dsi-account-comment').value, - }); - message('dsi-account-message', donnees.message, 'success'); - el('dsi-account-label').value = ''; - el('dsi-account-comment').value = ''; - afficherJetons([{ - libelle: donnees.compte?.identite_libelle || '', - role: donnees.compte?.role || role, - jeton: donnees.jeton, - }]); - await chargerComptes(); - } catch (erreur) { - message('dsi-account-message', erreur.message, 'error'); - } - } - - /** Régénération, activation, suppression : délégation sur le tableau. */ - async function agirSurCompte(evenement) { - const ligne = evenement.target.closest('tr[data-compte-id]'); - if (!ligne) return; - const compteId = Number(ligne.dataset.compteId); - const titulaire = ligne.querySelector('strong')?.textContent || 'ce compte'; - - try { - if (evenement.target.closest('.dsi-regenerate')) { - if (!window.confirm(`Régénérer le jeton de ${titulaire} ? L’ancien cessera immédiatement de fonctionner.`)) return; - const donnees = await poster('api/dsi_compte_jeton.php', { compte_id: compteId }); - afficherJetons([{ - libelle: donnees.compte?.identite_libelle || titulaire, - role: donnees.compte?.role || '', - jeton: donnees.jeton, - }]); - message('dsi-accounts-message', donnees.message, 'success'); - await chargerComptes(); - return; - } - - if (evenement.target.closest('.dsi-reset-password')) { - if (!window.confirm(`Effacer le mot de passe de ${titulaire} ? Son jeton reste valable, et il en choisira un nouveau à sa prochaine connexion.`)) return; - const donnees = await poster('api/dsi_compte_mot_de_passe.php', { compte_id: compteId }); - message('dsi-accounts-message', donnees.message, 'success'); - await chargerComptes(); - return; - } - - const bascule = evenement.target.closest('.dsi-toggle'); - if (bascule) { - const action = bascule.textContent.trim() === 'Désactiver' ? 'DESACTIVER' : 'ACTIVER'; - const donnees = await poster('api/dsi_compte_statut.php', { compte_id: compteId, action }); - message('dsi-accounts-message', donnees.message, 'success'); - await chargerComptes(); - return; - } - - if (evenement.target.closest('.dsi-delete')) { - if (!window.confirm(`Supprimer définitivement le compte de ${titulaire} ? Ses traces dans le journal seront conservées.`)) return; - const donnees = await poster('api/dsi_compte_statut.php', { compte_id: compteId, action: 'SUPPRIMER' }); - message('dsi-accounts-message', donnees.message, 'success'); - await chargerComptes(); - return; - } - - if (evenement.target.closest('.dsi-see-journal')) { - // Filtre le journal sur ce compte, sans quitter la page. - el('dsi-journal-search').value = ''; - pageJournal = 1; - await chargerJournal({ id_compte: compteId }); - el('dsi-journal-body')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } catch (erreur) { - message('dsi-accounts-message', erreur.message, 'error'); - } - } - - /** Équipe d'un coup toutes les identités actives sans compte. */ - async function genererManquants() { - const attendus = Number(el('dsi-missing-count')?.textContent || 0); - if (!attendus) return; - if (!window.confirm(`Générer ${attendus} jeton(s) ? Ils ne seront affichés qu’une seule fois, tout de suite après.`)) return; - - try { - const donnees = await poster('api/dsi_comptes_manquants.php', {}); - message('dsi-accounts-message', donnees.message, 'success'); - afficherJetons(donnees.jetons || []); - await chargerComptes(); - } catch (erreur) { - message('dsi-accounts-message', erreur.message, 'error'); - } - } + }, + }); // ----------------------------------------------------------------- journal @@ -406,7 +152,7 @@ return ` - ${formatDateHeure(entree.date_evenement)} + ${formatDateTime(entree.date_evenement)} ${escapeHtml(LIBELLES_CATEGORIE[entree.categorie] || entree.categorie)} ${escapeHtml(entree.acteur || 'Anonyme')}
${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role || '—')} ${escapeHtml(entree.action)} @@ -454,7 +200,7 @@ message('dsi-journal-message', 'Chargement du journal…'); try { - const donnees = await api(`api/dsi_journal.php?${parametres.toString()}`); + const donnees = await apiJson(`api/dsi_journal.php?${parametres.toString()}`); const entrees = donnees.entrees || []; el('dsi-journal-body').innerHTML = entrees.length ? entrees.map(ligneJournal).join('') @@ -480,7 +226,7 @@ if (!window.confirm(`Supprimer définitivement les entrées de plus de ${jours} jours ?`)) return; try { - const donnees = await poster('api/dsi_journal_purge.php', { jours }); + const donnees = await postJson('api/dsi_journal_purge.php', { jours }); message('dsi-journal-message', donnees.message, 'success'); pageJournal = 1; await chargerJournal(); @@ -492,16 +238,9 @@ function init() { const params = window.PTA_PAGE_CONFIG?.params || {}; - el('dsi-account-role')?.addEventListener('change', ajusterFormulaireCompte); - el('dsi-account-form')?.addEventListener('submit', creerCompte); - el('dsi-accounts-body')?.addEventListener('click', agirSurCompte); - el('dsi-accounts-refresh')?.addEventListener('click', chargerComptes); - el('dsi-filter-role')?.addEventListener('change', chargerComptes); - el('dsi-filter-state')?.addEventListener('change', chargerComptes); - el('dsi-filter-search')?.addEventListener('change', chargerComptes); - el('dsi-generate-missing')?.addEventListener('click', genererManquants); - el('dsi-token-copy')?.addEventListener('click', copierJetons); - el('dsi-token-close')?.addEventListener('click', fermerJetons); + // Comptes, jetons et formulaire de création : branchés et chargés par la + // fabrique partagée. + ecranComptes.attacher(); ['dsi-journal-category', 'dsi-journal-result', 'dsi-journal-role', 'dsi-journal-from', 'dsi-journal-to', 'dsi-journal-search'] .forEach((identifiant) => el(identifiant)?.addEventListener('change', () => { @@ -519,11 +258,9 @@ }); el('dsi-purge-form')?.addEventListener('submit', purgerJournal); - ajusterFormulaireCompte(); - chargerComptes(); // Lien profond « journal de ce compte » venu d'une autre page. chargerJournal(params.compteId ? { id_compte: params.compteId } : {}); } - PTA.modules.dsi = { init, chargerComptes, chargerJournal }; + PTA.modules.dsi = { init, chargerComptes: ecranComptes.chargerComptes, chargerJournal }; })(); diff --git a/assets/js/teams.js b/assets/js/teams.js index 5b4d90c..92777de 100644 --- a/assets/js/teams.js +++ b/assets/js/teams.js @@ -17,7 +17,7 @@ const PTA = window.PTA; // core.js absent : la page reste lisible, simplement non interactive. if (!PTA) return; - const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils; + const { apiJson, escapeHtml, formatDate } = PTA.utils; const el = (id) => document.getElementById(id); /** Message dans le bandeau de la page. */ @@ -28,17 +28,6 @@ box.textContent = text; } - /** - * Appel d'API mutualisé : lit le JSON et transforme toute réponse non-OK en - * exception. Toutes les actions de la page passent par ici. - */ - async function api(url, options = {}) { - const response = await fetch(url, options); - const data = await readJsonResponse(response); - if (!response.ok) throw new Error(apiErrorMessage(data, 'Opération impossible.')); - return data; - } - /** Rend un contrôle, sa classe CSS dérivant de son niveau. */ function controlHtml(control) { const level = String(control.niveau || 'INFO').toLowerCase(); @@ -89,7 +78,7 @@ if (end) query.set('date_fin', end); message('Chargement des équipes…'); try { - const data = await api(`api/teams.php?${query.toString()}`); + const data = await apiJson(`api/teams.php?${query.toString()}`); el('team-list').innerHTML = (data.teams || []).map(teamHtml).join('') || '
Aucune équipe sur cette période.
'; message(`${(data.teams || []).length} équipe(s) affichée(s).`, 'success'); } catch (error) { message(error.message, 'error'); } @@ -100,7 +89,7 @@ event.preventDefault(); const box = el('team-create-message'); try { - const data = await api('api/teams.php', { + const data = await apiJson('api/teams.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, @@ -136,7 +125,7 @@ if (event.type === 'submit' && event.target.matches('.team-member-form')) { event.preventDefault(); try { - await api('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + await apiJson('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, team_id: teamId, agent_id: Number(card.querySelector('.team-member-agent').value), fonction_equipe: card.querySelector('.team-member-function').value, @@ -150,7 +139,7 @@ if (remove) { if (!window.confirm('Retirer cet agent de l’équipe ?')) return; try { - await api('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + await apiJson('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, action: 'REMOVE', team_id: teamId, agent_id: Number(remove.dataset.agentId), }) }); await load(); @@ -168,7 +157,7 @@ if (!select) return; const card = select.closest('.team-card'); try { - await api('api/team_status.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + await apiJson('api/team_status.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, team_id: Number(card.dataset.teamId), statut: select.value, }) }); await load(); diff --git a/database/install_complet.sql b/database/install_complet.sql new file mode 100644 index 0000000..86dc988 --- /dev/null +++ b/database/install_complet.sql @@ -0,0 +1,1832 @@ +-- ============================================================================ +-- PTA — INSTALLATION PRODUCTION COMPLÈTE, EN UN SEUL FICHIER +-- Version applicative cible : V7.30 +-- MariaDB 10.6+ / MySQL 8.0.16+ +-- ============================================================================ +-- Ce fichier remplace, à lui seul, l'enchaînement +-- database/pta_prod_schema_mariadb.sql (socle métier) +-- + database/migration_v7_8_securite_acces.sql +-- + database/migration_v7_9_tableau_de_bord.sql +-- + database/migration_v7_9b_droit_enveloppe_formation.sql +-- + database/migration_v7_9c_retrait_formation_repartition.sql +-- + database/migration_v7_9d_faq.sql +-- + database/migration_v7_9e_dsi_comptes_journal.sql +-- + database/migration_v7_9f_mot_de_passe_admin.sql +-- + database/migration_v7_9g_management.sql +-- + database/migration_v7_9h_jetons_service_enfance.sql +-- +-- Il contient TOUT ce que l'application exige pour démarrer : +-- * les tables du métier, leurs clés étrangères, index et contraintes ; +-- * la matrice des droits (rôle -> pages, rôle -> permissions) ; +-- * les comptes d'accès à jeton et le journal d'activité ; +-- * les dix vues de contrôle d'accès vérifiées au démarrage ; +-- * les vues de pilotage et les procédures SQL historiques ; +-- * les seuls référentiels techniques indispensables : postes et motifs. +-- +-- Il ne contient AUCUN agent, lieu, planning, quota ni jeu de démonstration. +-- La seule donnée nominative est le compte DSI d'installation (section 14.1). +-- +-- ---------------------------------------------------------------------------- +-- USAGE +-- ---------------------------------------------------------------------------- +-- Sur une base vide, en ligne de commande : +-- +-- mariadb -u pta -p pta < database/install_complet.sql +-- +-- Depuis un client SQL déjà connecté à la base : +-- +-- SOURCE database/install_complet.sql; +-- +-- Avec la pile Docker du projet, il n'y a RIEN à faire : le socle est joué à la +-- création du volume par docker-entrypoint-initdb.d et les migrations par le +-- service db-migrate. Ce fichier vise l'installation MANUELLE sur un serveur de +-- production existant, ou la reconstruction d'une base à l'identique. +-- +-- ---------------------------------------------------------------------------- +-- PORTABILITÉ MySQL / MariaDB +-- ---------------------------------------------------------------------------- +-- Ce fichier est joué tel quel sur les DEUX SGBD, et testé sur les deux : +-- MariaDB 11.4 (la pile Docker du projet) et MySQL 8.0 (le serveur de +-- production). Il n'utilise donc AUCUNE extension propre à l'un des deux. +-- +-- Le piège à connaître avant d'y toucher : `ALTER TABLE … ADD COLUMN +-- IF NOT EXISTS …` et `DROP COLUMN IF EXISTS` sont des extensions MARIADB, que +-- MySQL rejette par une erreur 1064 de syntaxe. Les migrations du dossier +-- `database/` les emploient — elles ne visent que la pile Docker — mais ici le +-- test d'existence passe par `information_schema` et une requête préparée (voir +-- section 14). Ne pas « simplifier » ce passage en revenant à IF NOT EXISTS. +-- +-- Tous les identifiants sont entourés de backticks : certains clients SQL les +-- exigent, et cela met le schéma à l'abri d'un mot devenu réservé dans une +-- version future (`role`, `action`, `source`, `zone`… sont des noms de colonnes +-- de ce schéma). +-- +-- Reste une DÉPRÉCIATION, pas une erreur : `VALUES(colonne)` dans les clauses +-- ON DUPLICATE KEY UPDATE fait émettre à MySQL 8.0.20+ l'avertissement 1287. +-- La forme de remplacement (`… VALUES (…) AS n … = n.colonne`) est refusée par +-- MySQL < 8.0.19 et MariaDB < 10.3.3 ; la forme historique est conservée, comme +-- dans le socle et les migrations. +-- +-- ---------------------------------------------------------------------------- +-- IDEMPOTENCE +-- ---------------------------------------------------------------------------- +-- Tout est écrit en CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE VIEW et +-- INSERT ... ON DUPLICATE KEY UPDATE : le rejouer sur une base déjà installée +-- ne détruit aucune donnée et ne fait que remettre les référentiels et les +-- droits dans leur état de référence. +-- +-- Corollaire : ce fichier ne renseigne PAS la table `schema_migration`. Si la +-- pile Docker démarre ensuite sur cette base, db-migrate rejouera les neuf +-- migrations — elles sont elles aussi idempotentes, l'opération est sans effet. +-- +-- ---------------------------------------------------------------------------- +-- APRÈS L'INSTALLATION — deux gestes obligatoires +-- ---------------------------------------------------------------------------- +-- 1. Se connecter avec le jeton d'installation PTA-INSTALLATION-DSI, puis le +-- RÉGÉNÉRER immédiatement : il est public, il est écrit en clair dans ce +-- fichier versionné (section 14.1). La page DSI alerte tant que c'est le +-- cas. +-- 2. Choisir le mot de passe du compte DSI à cette première connexion : les +-- rôles SERVICE_ENFANCE et DSI exigent ce second facteur, et un compte +-- privilégié sans mot de passe ne peut ouvrir aucune session. +-- +-- ---------------------------------------------------------------------------- +-- NOTE MySQL #1832 +-- ---------------------------------------------------------------------------- +-- creneau_horaire.id_motif est créé directement en INT UNSIGNED NOT NULL, AVANT +-- la création de fk_creneau_motif. Aucun ALTER TABLE ... MODIFY id_motif n'est +-- donc exécuté après coup sur une installation neuve. +-- ============================================================================ + +SET NAMES utf8mb4; +SET time_zone = '+00:00'; + +-- Décommenter si vous souhaitez que le script crée la base lui-même. +-- CREATE DATABASE IF NOT EXISTS pta +-- CHARACTER SET utf8mb4 +-- COLLATE utf8mb4_unicode_ci; +-- USE pta; + +-- ============================================================================ +-- 1. LIEUX D'AFFECTATION +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `structure` ( + `id_structure` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(50) NOT NULL, + `nom` VARCHAR(150) NOT NULL, + `adresse` VARCHAR(255) NULL, + `type_affectation` ENUM('PERISCOLAIRE', 'EXTRASCOLAIRE') NOT NULL DEFAULT 'PERISCOLAIRE', + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `uk_structure_code` UNIQUE (`code`), + INDEX `idx_structure_actif_nom` (`actif`, `nom`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 2. POSTES PTA - RÉFÉRENTIEL TECHNIQUE +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `poste_agent` ( + `id_poste` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(50) NOT NULL, + `libelle` VARCHAR(150) NOT NULL, + `famille` ENUM('ANIMATION', 'RESTAURATION_ENTRETIEN') NOT NULL, + `heures_mercredi_minutes` SMALLINT UNSIGNED NOT NULL, + `heures_extrascolaire_minutes` SMALLINT UNSIGNED NOT NULL, + `autorise_preparation` BOOLEAN NOT NULL DEFAULT TRUE, + `peut_assurer_animation` BOOLEAN NOT NULL DEFAULT TRUE, + `preparation_lundi_si_100` BOOLEAN NOT NULL DEFAULT FALSE, + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `ordre_affichage` SMALLINT UNSIGNED NOT NULL DEFAULT 100, + + CONSTRAINT `uk_poste_agent_code` UNIQUE (`code`), + INDEX `idx_poste_agent_actif_ordre` (`actif`, `ordre_affichage`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Référentiel indispensable au fonctionnement de l'application. +INSERT INTO `poste_agent` ( + `code`, `libelle`, `famille`, + `heures_mercredi_minutes`, `heures_extrascolaire_minutes`, + `autorise_preparation`, `peut_assurer_animation`, + `preparation_lundi_si_100`, `ordre_affichage` +) VALUES + ('RESPONSABLE', 'Responsable d’accueil', 'ANIMATION', 570, 570, TRUE, TRUE, TRUE, 10), + ('ANIMATION_RELAIS', 'Agent d’animation relais', 'ANIMATION', 570, 570, TRUE, TRUE, TRUE, 20), + ('ANIMATION', 'Agent d’animation', 'ANIMATION', 570, 570, TRUE, TRUE, TRUE, 30), + ('RESTAURATION_ENTRETIEN', 'Agent de restauration collective et d’entretien', 'RESTAURATION_ENTRETIEN', 420, 420, FALSE, FALSE, FALSE, 40) +ON DUPLICATE KEY UPDATE + `libelle` = VALUES(`libelle`), + `famille` = VALUES(`famille`), + `heures_mercredi_minutes` = VALUES(`heures_mercredi_minutes`), + `heures_extrascolaire_minutes` = VALUES(`heures_extrascolaire_minutes`), + `autorise_preparation` = VALUES(`autorise_preparation`), + `peut_assurer_animation` = VALUES(`peut_assurer_animation`), + `preparation_lundi_si_100` = VALUES(`preparation_lundi_si_100`), + `ordre_affichage` = VALUES(`ordre_affichage`), + `actif` = TRUE; + +-- ============================================================================ +-- 3. AGENTS +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `agent` ( + `id_agent` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `matricule` VARCHAR(50) NOT NULL, + `nom` VARCHAR(100) NOT NULL, + `prenom` VARCHAR(100) NOT NULL, + `email` VARCHAR(255) NULL, + `telephone` VARCHAR(30) NULL, + `adresse` VARCHAR(255) NULL, + `est_diplome` BOOLEAN NOT NULL DEFAULT FALSE, + `diplome_libelle` VARCHAR(150) NULL, + -- V7.9b : tous les agents ne bénéficient pas de l'enveloppe de 14 h. + `droit_enveloppe_formation` BOOLEAN NOT NULL DEFAULT TRUE + COMMENT 'Droit à l''enveloppe annuelle de 14 h de formation', + `date_debut_contrat` DATE NULL, + `id_poste` INT UNSIGNED NULL, + `type_contrat` ENUM('PERMANENT', 'TEMPORAIRE') NOT NULL DEFAULT 'PERMANENT', + `date_fin_contrat` DATE NULL, + `commentaire_pta` VARCHAR(1000) NULL, + `id_structure` INT UNSIGNED NULL COMMENT 'Lieu principal de rattachement', + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `uk_agent_matricule` UNIQUE (`matricule`), + + CONSTRAINT `fk_agent_poste` + FOREIGN KEY (`id_poste`) REFERENCES `poste_agent`(`id_poste`) + ON UPDATE CASCADE ON DELETE SET NULL, + + CONSTRAINT `fk_agent_structure_principale` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE SET NULL, + + CONSTRAINT `chk_agent_dates_contrat` + CHECK ( + `date_fin_contrat` IS NULL + OR `date_debut_contrat` IS NULL + OR `date_fin_contrat` >= `date_debut_contrat` + ), + + INDEX `idx_agent_poste` (`id_poste`), + INDEX `idx_agent_structure_principale` (`id_structure`), + INDEX `idx_agent_type_contrat` (`type_contrat`, `actif`), + INDEX `idx_agent_actif_nom` (`actif`, `nom`, `prenom`), + INDEX `idx_agent_date_debut_contrat` (`date_debut_contrat`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Historique des rattachements de l'agent. +CREATE TABLE IF NOT EXISTS `agent_structure` ( + `id_agent_structure` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_agent` INT UNSIGNED NOT NULL, + `id_structure` INT UNSIGNED NOT NULL, + `date_debut` DATE NULL, + `date_fin` DATE NULL, + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT `fk_agent_structure_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `fk_agent_structure_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `chk_agent_structure_dates` + CHECK (`date_fin` IS NULL OR `date_debut` IS NULL OR `date_fin` >= `date_debut`), + + CONSTRAINT `uk_agent_structure_periode` + UNIQUE (`id_agent`, `id_structure`, `date_debut`), + + INDEX `idx_agent_structure_agent_actif` (`id_agent`, `actif`), + INDEX `idx_agent_structure_structure_actif` (`id_structure`, `actif`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 4. CAPACITÉS / TRANCHES D'ÂGE +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `tranche_age` ( + `id_tranche_age` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `libelle` VARCHAR(100) NOT NULL, + `age_min_mois` INT UNSIGNED NULL, + `age_max_mois` INT UNSIGNED NULL, + + CONSTRAINT `chk_tranche_age` + CHECK ( + `age_min_mois` IS NULL + OR `age_max_mois` IS NULL + OR `age_max_mois` >= `age_min_mois` + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `structure_capacite` ( + `id_structure` INT UNSIGNED NOT NULL, + `id_tranche_age` INT UNSIGNED NOT NULL, + `nombre_places` INT UNSIGNED NOT NULL, + + PRIMARY KEY (`id_structure`, `id_tranche_age`), + + CONSTRAINT `fk_capacite_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `fk_capacite_tranche_age` + FOREIGN KEY (`id_tranche_age`) REFERENCES `tranche_age`(`id_tranche_age`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `chk_nombre_places` CHECK (`nombre_places` > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 5. RÈGLES DE COUVERTURE DES LIEUX +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `structure_couverture_horaire` ( + `id_couverture` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_structure` INT UNSIGNED NOT NULL, + `jour_semaine` TINYINT UNSIGNED NOT NULL COMMENT '1=lundi, 5=vendredi', + `heure_debut` TIME NOT NULL, + `heure_fin` TIME NOT NULL, + `minimum_agents` TINYINT UNSIGNED NOT NULL DEFAULT 1, + + CONSTRAINT `fk_couverture_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `chk_couverture_jour` CHECK (`jour_semaine` BETWEEN 1 AND 5), + CONSTRAINT `chk_couverture_ordre` CHECK (`heure_fin` > `heure_debut`), + CONSTRAINT `chk_couverture_minimum` CHECK (`minimum_agents` BETWEEN 1 AND 50), + CONSTRAINT `chk_couverture_quart_heure` CHECK ( + MOD(MINUTE(`heure_debut`), 15) = 0 + AND MOD(MINUTE(`heure_fin`), 15) = 0 + AND SECOND(`heure_debut`) = 0 + AND SECOND(`heure_fin`) = 0 + ), + + INDEX `idx_couverture_structure_jour` + (`id_structure`, `jour_semaine`, `heure_debut`, `heure_fin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Horaires de référence utilisés pour les automatismes de préparation. +CREATE TABLE IF NOT EXISTS `structure_creneau_reference` ( + `id_reference` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_structure` INT UNSIGNED NOT NULL, + `code_plage` ENUM('ALP_MATIN', 'RESTAURATION', 'ALP_SOIR') NOT NULL, + `jour_semaine` TINYINT UNSIGNED NOT NULL COMMENT '1=lundi ... 5=vendredi', + `heure_debut` TIME NOT NULL, + `heure_fin` TIME NOT NULL, + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT `fk_reference_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `chk_reference_jour` CHECK (`jour_semaine` BETWEEN 1 AND 5), + CONSTRAINT `chk_reference_ordre` CHECK (`heure_fin` > `heure_debut`), + CONSTRAINT `uk_reference_structure_plage_jour` + UNIQUE (`id_structure`, `code_plage`, `jour_semaine`), + + INDEX `idx_reference_structure_jour` (`id_structure`, `jour_semaine`, `actif`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 6. MOTIFS D'AFFECTATION - RÉFÉRENTIEL TECHNIQUE +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `motif_planning` ( + `id_motif` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(50) NOT NULL, + `libelle` VARCHAR(120) NOT NULL, + `compte_dans_quota` BOOLEAN NOT NULL DEFAULT FALSE, + `ordre_affichage` SMALLINT UNSIGNED NOT NULL DEFAULT 100, + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `uk_motif_planning_code` UNIQUE (`code`), + INDEX `idx_motif_planning_actif_ordre` (`actif`, `ordre_affichage`, `libelle`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Aucun jeu d'essai : uniquement les motifs nécessaires au fonctionnement. +INSERT INTO `motif_planning` (`code`, `libelle`, `compte_dans_quota`, `ordre_affichage`, `actif`) +VALUES + ('TRAVAIL', 'Heure de travail', TRUE, 5, TRUE), + ('ALP_MATIN', 'ALP matin', TRUE, 10, TRUE), + ('RESTAURATION', 'Restauration', TRUE, 20, TRUE), + ('ALP_SOIR', 'ALP soir', TRUE, 30, TRUE), + ('MERCREDI_PERISCOLAIRE', 'Mercredi périscolaire', TRUE, 40, TRUE), + ('EXTRASCOLAIRE', 'Temps extrascolaire', TRUE, 50, TRUE), + ('FORMATION', 'Formation', TRUE, 60, TRUE), + -- compte_dans_quota = FALSE : les 1 607 h sont un volume NET dont les 25 + -- jours de congés payés ont déjà été retranchés. Les imputer au quota les + -- décompterait une seconde fois. Idem pour les jours fériés. + ('CONGE', 'Congé annuel (non décompté)', FALSE, 70, TRUE), + ('JOUR_FRACTIONNEMENT', 'Journée de fractionnement', TRUE, 80, TRUE), + ('PREPARATION_PERISCOLAIRE', 'Préparation périscolaire', TRUE, 90, TRUE), + ('PREPARATION_EXTRASCOLAIRE', 'Préparation extrascolaire', TRUE, 100, TRUE), + ('PREPARATION_LUNDI', 'Préparation du lundi', TRUE, 110, TRUE), + ('MENAGE_FOND', 'Ménage de fond', TRUE, 120, TRUE), + ('ABSENCE', 'Absence', TRUE, 130, TRUE), + ('AUTRE_ABSENCE', 'Autre absence (non décomptée)', FALSE, 140, TRUE), + ('JNT', 'Journée non travaillée', FALSE, 150, TRUE) +ON DUPLICATE KEY UPDATE + `libelle` = VALUES(`libelle`), + `compte_dans_quota` = VALUES(`compte_dans_quota`), + `ordre_affichage` = VALUES(`ordre_affichage`), + `actif` = TRUE; + +-- ============================================================================ +-- 7. QUOTAS ANNUELS - ANNÉE CIVILE 01/01 -> 31/12 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `quota_agent_annuel` ( + `id_quota` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_agent` INT UNSIGNED NOT NULL, + `annee` SMALLINT UNSIGNED NOT NULL, + `quotite_travail` DECIMAL(5,2) NOT NULL DEFAULT 100.00, + `quota_reference_minutes` INT UNSIGNED NOT NULL DEFAULT 96420 COMMENT '1607 h à 100 %', + `quota_cible_minutes` INT UNSIGNED NOT NULL, + `commentaire` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `fk_quota_annuel_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `chk_quotite_travail` CHECK (`quotite_travail` > 0 AND `quotite_travail` <= 100), + CONSTRAINT `chk_quota_reference_positif` CHECK (`quota_reference_minutes` > 0), + CONSTRAINT `chk_quota_cible_positif` CHECK (`quota_cible_minutes` > 0), + CONSTRAINT `uk_quota_agent_annee` UNIQUE (`id_agent`, `annee`), + + INDEX `idx_quota_agent_annee` (`id_agent`, `annee`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `pta_annuel` ( + `id_pta` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_agent` INT UNSIGNED NOT NULL, + `date_debut` DATE NOT NULL, + `date_fin` DATE NOT NULL, + `quotite_travail` DECIMAL(5,2) NOT NULL, + `quota_reference_minutes` INT UNSIGNED NOT NULL DEFAULT 96420, + `quota_cible_minutes` INT UNSIGNED NOT NULL, + `enveloppe_formation_minutes` SMALLINT UNSIGNED NOT NULL DEFAULT 840, + `conges_annuels_jours_cible` DECIMAL(5,2) NOT NULL DEFAULT 25.00, + `jours_fractionnement_cible` TINYINT UNSIGNED NOT NULL DEFAULT 2, + `statut` ENUM('BROUILLON', 'A_CONTROLER', 'VALIDE') NOT NULL DEFAULT 'BROUILLON', + `commentaire` VARCHAR(1000) NULL, + `date_validation` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `fk_pta_annuel_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `chk_pta_annuel_dates` CHECK (`date_fin` >= `date_debut`), + CONSTRAINT `chk_pta_annuel_quotite` CHECK (`quotite_travail` > 0 AND `quotite_travail` <= 100), + CONSTRAINT `chk_pta_annuel_quota` CHECK (`quota_reference_minutes` > 0 AND `quota_cible_minutes` > 0), + CONSTRAINT `uk_pta_annuel_agent_periode` UNIQUE (`id_agent`, `date_debut`, `date_fin`), + + INDEX `idx_pta_annuel_agent_statut` (`id_agent`, `statut`), + INDEX `idx_pta_annuel_periode` (`date_debut`, `date_fin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 8. SEMAINES / PLANNINGS / CRÉNEAUX +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `semaine` ( + `id_semaine` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `annee` SMALLINT UNSIGNED NOT NULL COMMENT 'Année ISO de la semaine', + `numero_semaine` TINYINT UNSIGNED NOT NULL, + `date_debut` DATE NOT NULL COMMENT 'Lundi', + `date_fin` DATE NOT NULL COMMENT 'Dimanche', + `statut` ENUM('OUVERTE', 'VALIDEE', 'VERROUILLEE') NOT NULL DEFAULT 'OUVERTE', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT `uk_semaine_annee_numero` UNIQUE (`annee`, `numero_semaine`), + CONSTRAINT `uk_semaine_dates` UNIQUE (`date_debut`, `date_fin`), + CONSTRAINT `chk_numero_semaine` CHECK (`numero_semaine` BETWEEN 1 AND 53), + CONSTRAINT `chk_dates_semaine` CHECK (`date_fin` >= `date_debut`), + + INDEX `idx_semaine_dates` (`date_debut`, `date_fin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `planning` ( + `id_planning` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_agent` INT UNSIGNED NOT NULL, + `id_semaine` INT UNSIGNED NOT NULL, + `id_structure` INT UNSIGNED NOT NULL, + `statut` ENUM('BROUILLON', 'VALIDE') NOT NULL DEFAULT 'BROUILLON', + `commentaire` VARCHAR(500) NULL, + `id_planning_source` INT UNSIGNED NULL, + `date_creation` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date_modification` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `date_validation` DATETIME NULL, + + CONSTRAINT `fk_planning_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `fk_planning_semaine` + FOREIGN KEY (`id_semaine`) REFERENCES `semaine`(`id_semaine`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `fk_planning_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `fk_planning_source` + FOREIGN KEY (`id_planning_source`) REFERENCES `planning`(`id_planning`) + ON UPDATE CASCADE ON DELETE SET NULL, + + CONSTRAINT `uk_planning_agent_semaine_structure` + UNIQUE (`id_agent`, `id_semaine`, `id_structure`), + + INDEX `idx_planning_agent_statut` (`id_agent`, `statut`), + INDEX `idx_planning_semaine_structure` (`id_semaine`, `id_structure`, `statut`), + INDEX `idx_planning_modification` (`date_modification`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- CORRECTION #1832 : le type de id_motif est correct dès sa création. +CREATE TABLE IF NOT EXISTS `creneau_horaire` ( + `id_creneau` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_planning` INT UNSIGNED NOT NULL, + `id_motif` INT UNSIGNED NOT NULL, + `date_jour` DATE NOT NULL, + `heure_debut` TIME NOT NULL, + `heure_fin` TIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `fk_creneau_planning` + FOREIGN KEY (`id_planning`) REFERENCES `planning`(`id_planning`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `fk_creneau_motif` + FOREIGN KEY (`id_motif`) REFERENCES `motif_planning`(`id_motif`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `chk_creneau_ordre` CHECK (`heure_fin` > `heure_debut`), + CONSTRAINT `chk_creneau_quart_heure` CHECK ( + MOD(MINUTE(`heure_debut`), 15) = 0 + AND MOD(MINUTE(`heure_fin`), 15) = 0 + AND SECOND(`heure_debut`) = 0 + AND SECOND(`heure_fin`) = 0 + ), + + INDEX `idx_creneau_planning_date` (`id_planning`, `date_jour`, `heure_debut`, `heure_fin`), + INDEX `idx_creneau_motif` (`id_motif`), + INDEX `idx_creneau_date` (`date_jour`, `heure_debut`, `heure_fin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 9. SEMAINES TYPES +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `modele_semaine_agent` ( + `id_modele` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_agent` INT UNSIGNED NOT NULL, + `nom` VARCHAR(120) NOT NULL, + `date_creation` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date_modification` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `fk_modele_semaine_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `uk_modele_semaine_agent_nom` UNIQUE (`id_agent`, `nom`), + INDEX `idx_modele_semaine_agent` (`id_agent`, `date_modification`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `modele_semaine_creneau` ( + `id_modele_creneau` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_modele` INT UNSIGNED NOT NULL, + `jour_semaine` TINYINT UNSIGNED NOT NULL COMMENT '1=lundi, 5=vendredi', + `id_structure` INT UNSIGNED NOT NULL, + `id_motif` INT UNSIGNED NOT NULL, + `heure_debut` TIME NOT NULL, + `heure_fin` TIME NOT NULL, + + CONSTRAINT `fk_modele_creneau_modele` + FOREIGN KEY (`id_modele`) REFERENCES `modele_semaine_agent`(`id_modele`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `fk_modele_creneau_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `fk_modele_creneau_motif` + FOREIGN KEY (`id_motif`) REFERENCES `motif_planning`(`id_motif`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `chk_modele_creneau_jour` CHECK (`jour_semaine` BETWEEN 1 AND 5), + CONSTRAINT `chk_modele_creneau_ordre` CHECK (`heure_fin` > `heure_debut`), + CONSTRAINT `chk_modele_creneau_quart_heure` CHECK ( + MOD(MINUTE(`heure_debut`), 15) = 0 + AND MOD(MINUTE(`heure_fin`), 15) = 0 + AND SECOND(`heure_debut`) = 0 + AND SECOND(`heure_fin`) = 0 + ), + + INDEX `idx_modele_creneau_modele_jour` (`id_modele`, `jour_semaine`, `heure_debut`), + INDEX `idx_modele_creneau_structure` (`id_structure`), + INDEX `idx_modele_creneau_motif` (`id_motif`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 10. VACANCES SCOLAIRES +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `periode_vacance` ( + `id_periode` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `libelle` VARCHAR(150) NOT NULL, + `date_debut` DATE NOT NULL, + `date_fin` DATE NOT NULL COMMENT 'Date inclusive', + `annee_scolaire` VARCHAR(9) NOT NULL, + `academie` VARCHAR(100) NOT NULL DEFAULT '', + `zone` VARCHAR(50) NOT NULL DEFAULT '', + `source` ENUM('MANUEL', 'DATA_GOUV') NOT NULL DEFAULT 'MANUEL', + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `date_creation` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date_modification` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `chk_periode_vacance_dates` CHECK (`date_fin` >= `date_debut`), + CONSTRAINT `uk_periode_vacance` UNIQUE (`date_debut`, `date_fin`, `annee_scolaire`, `academie`), + + INDEX `idx_periode_vacance_dates` (`date_debut`, `date_fin`), + INDEX `idx_periode_vacance_annee` (`annee_scolaire`), + INDEX `idx_periode_vacance_actif_dates` (`actif`, `date_debut`, `date_fin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 11. PRÉFÉRENCES / INTERDICTIONS / CONTRAINTES AGENT +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `agent_structure_souhait` ( + `id_souhait` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_agent` INT UNSIGNED NOT NULL, + `id_structure` INT UNSIGNED NOT NULL, + `type_souhait` ENUM('PREFERENCE', 'INTERDICTION') NOT NULL, + `priorite` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `distance_km` DECIMAL(7,2) NULL, + `commentaire` VARCHAR(500) NULL, + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `fk_souhait_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `fk_souhait_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `chk_souhait_priorite` CHECK (`priorite` BETWEEN 1 AND 5), + CONSTRAINT `chk_souhait_distance` CHECK (`distance_km` IS NULL OR `distance_km` >= 0), + CONSTRAINT `uk_souhait_agent_structure` UNIQUE (`id_agent`, `id_structure`), + + INDEX `idx_souhait_agent_type` (`id_agent`, `type_souhait`, `actif`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `agent_contrainte` ( + `id_contrainte` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_agent` INT UNSIGNED NOT NULL, + `type_contrainte` ENUM('MEDICALE', 'TEMPS_PARTIEL_THERAPEUTIQUE') NOT NULL, + `date_debut` DATE NOT NULL, + `date_fin` DATE NULL, + `quotite_temporaire` DECIMAL(5,2) NULL, + `maximum_minutes_jour` SMALLINT UNSIGNED NULL, + `maximum_minutes_semaine` SMALLINT UNSIGNED NULL, + `interdit_matin` BOOLEAN NOT NULL DEFAULT FALSE, + `interdit_midi` BOOLEAN NOT NULL DEFAULT FALSE, + `interdit_soir` BOOLEAN NOT NULL DEFAULT FALSE, + `commentaire` VARCHAR(1000) NOT NULL, + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `fk_contrainte_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `chk_contrainte_dates` CHECK (`date_fin` IS NULL OR `date_fin` >= `date_debut`), + CONSTRAINT `chk_contrainte_quotite` CHECK ( + `quotite_temporaire` IS NULL OR (`quotite_temporaire` > 0 AND `quotite_temporaire` <= 100) + ), + CONSTRAINT `chk_contrainte_max_jour` CHECK (`maximum_minutes_jour` IS NULL OR `maximum_minutes_jour` > 0), + CONSTRAINT `chk_contrainte_max_semaine` CHECK (`maximum_minutes_semaine` IS NULL OR `maximum_minutes_semaine` > 0), + + INDEX `idx_contrainte_agent_dates` (`id_agent`, `actif`, `date_debut`, `date_fin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 12. ÉQUIPES PTA +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `equipe_pta` ( + `id_equipe` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_structure` INT UNSIGNED NOT NULL, + `type_equipe` ENUM('MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE', 'PERISCOLAIRE_SEMAINE') NOT NULL, + `libelle` VARCHAR(180) NOT NULL, + `date_debut` DATE NOT NULL, + `date_fin` DATE NOT NULL, + `nombre_enfants_moins_6` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `nombre_enfants_6_plus` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `ratio_moins_6` TINYINT UNSIGNED NOT NULL DEFAULT 8, + `ratio_6_plus` TINYINT UNSIGNED NOT NULL DEFAULT 12, + `minimum_agents` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `pourcentage_diplomes_min` DECIMAL(5,2) NOT NULL DEFAULT 50.00, + `statut` ENUM('BROUILLON', 'A_CONTROLER', 'VALIDEE') NOT NULL DEFAULT 'BROUILLON', + `commentaire` VARCHAR(1000) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT `fk_equipe_pta_structure` + FOREIGN KEY (`id_structure`) REFERENCES `structure`(`id_structure`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `chk_equipe_dates` CHECK (`date_fin` >= `date_debut`), + CONSTRAINT `chk_equipe_ratios` CHECK (`ratio_moins_6` > 0 AND `ratio_6_plus` > 0), + CONSTRAINT `chk_equipe_minimum` CHECK (`minimum_agents` > 0), + CONSTRAINT `chk_equipe_diplomes` CHECK (`pourcentage_diplomes_min` BETWEEN 0 AND 100), + + INDEX `idx_equipe_pta_type_dates` (`type_equipe`, `date_debut`, `date_fin`), + INDEX `idx_equipe_pta_structure_dates` (`id_structure`, `date_debut`, `date_fin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `equipe_pta_membre` ( + `id_equipe_membre` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `id_equipe` INT UNSIGNED NOT NULL, + `id_agent` INT UNSIGNED NOT NULL, + `fonction_equipe` ENUM('RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN') NOT NULL DEFAULT 'ANIMATION', + `date_debut` DATE NULL, + `date_fin` DATE NULL, + `commentaire` VARCHAR(500) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT `fk_equipe_membre_equipe` + FOREIGN KEY (`id_equipe`) REFERENCES `equipe_pta`(`id_equipe`) + ON UPDATE CASCADE ON DELETE CASCADE, + + CONSTRAINT `fk_equipe_membre_agent` + FOREIGN KEY (`id_agent`) REFERENCES `agent`(`id_agent`) + ON UPDATE CASCADE ON DELETE RESTRICT, + + CONSTRAINT `chk_equipe_membre_dates` + CHECK (`date_fin` IS NULL OR `date_debut` IS NULL OR `date_fin` >= `date_debut`), + + CONSTRAINT `uk_equipe_membre` UNIQUE (`id_equipe`, `id_agent`), + INDEX `idx_equipe_membre_agent` (`id_agent`, `id_equipe`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- 13. MATRICE DES DROITS — rôle -> pages, rôle -> permissions +-- ============================================================================ +-- Depuis la V7.8, AUCUNE règle de droit n'est écrite en PHP : le contrôleur +-- pose une question à la base, la base répond oui ou non. Modifier un droit se +-- fait ici, en SQL, sans redéploiement. Une permission absente vaut « refusé ». +-- +-- L'ENUM porte les quatre rôles dès la création : trois rôles métier plus la +-- DSI, administration technique sans aucun droit fonctionnel (V7.11). +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `acces_role_page` ( + `role` ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL, + `page` VARCHAR(50) NOT NULL, + `ordre_affichage` SMALLINT UNSIGNED NOT NULL DEFAULT 100, + + PRIMARY KEY (`role`, `page`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `acces_role_permission` ( + `role` ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL, + `permission` VARCHAR(50) NOT NULL, + `autorise` BOOLEAN NOT NULL DEFAULT FALSE, + + PRIMARY KEY (`role`, `permission`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Le MODIFY rattrape une base créée par une version antérieure de ce fichier, +-- où l'ENUM ne portait que les trois rôles métier. Sans effet sur une base +-- neuve, il rend le script rejouable sur l'existant. +ALTER TABLE `acces_role_page` + MODIFY COLUMN `role` ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL; + +ALTER TABLE `acces_role_permission` + MODIFY COLUMN `role` ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL; + +-- ---------------------------------------------------------------------------- +-- 13.1 PAGES AUTORISÉES PAR RÔLE +-- ---------------------------------------------------------------------------- +-- Une page absente de cette table est INATTEINTE : le bouton n'apparaît pas et +-- l'accès direct à l'URL renvoie vers l'accueil du rôle. Ajouter une page à +-- l'application impose donc d'ajouter sa ligne ici. +-- +-- « faq » (guide d'utilisation) est ouverte aux trois rôles métier : c'est un +-- secours, pas un droit métier. Son bouton est ancré à droite de la barre, d'où +-- un ordre d'affichage purement indicatif. +-- +-- La DSI n'a qu'une page, la sienne : ni saisie, ni référentiel métier. +INSERT INTO `acces_role_page` (`role`, `page`, `ordre_affichage`) VALUES + ('AGENT', 'agents', 10), + ('AGENT', 'structures', 20), + ('AGENT', 'faq', 200), + + ('RESPONSABLE_STRUCTURE', 'planning', 10), + ('RESPONSABLE_STRUCTURE', 'structures', 20), + ('RESPONSABLE_STRUCTURE', 'agents', 30), + ('RESPONSABLE_STRUCTURE', 'coverage', 40), + ('RESPONSABLE_STRUCTURE', 'faq', 200), + + ('SERVICE_ENFANCE', 'dashboard', 5), + ('SERVICE_ENFANCE', 'planning', 10), + ('SERVICE_ENFANCE', 'pending', 20), + ('SERVICE_ENFANCE', 'coverage', 30), + ('SERVICE_ENFANCE', 'structures', 40), + ('SERVICE_ENFANCE', 'agents', 50), + ('SERVICE_ENFANCE', 'annual', 60), + ('SERVICE_ENFANCE', 'pta', 70), + ('SERVICE_ENFANCE', 'teams', 80), + ('SERVICE_ENFANCE', 'vacations', 90), + -- 95 et 96 : les deux onglets du groupe « Management » (V7.14) se rangent + -- juste avant « Administration », dont ils reprennent l'essentiel. + ('SERVICE_ENFANCE', 'manage_agents', 95), + ('SERVICE_ENFANCE', 'manage_structures', 96), + ('SERVICE_ENFANCE', 'administration', 100), + ('SERVICE_ENFANCE', 'faq', 200), + + ('DSI', 'dsi', 10) +ON DUPLICATE KEY UPDATE `ordre_affichage` = VALUES(`ordre_affichage`); + +-- Filet : une page retirée de l'application resterait sinon dans la table et +-- finirait dans le groupe de repêchage « Autres » de la barre de navigation. +-- « management » était une page unique d'une version intermédiaire de la V7.14, +-- remplacée par les deux onglets ci-dessus. +DELETE FROM `acces_role_page` WHERE `page` = 'management'; + +-- ---------------------------------------------------------------------------- +-- 13.2 PERMISSIONS FONCTIONNELLES PAR RÔLE +-- ---------------------------------------------------------------------------- +-- Les refus sont écrits explicitement plutôt que laissés absents : une ligne à +-- FALSE documente une décision, une ligne absente laisse croire à un oubli. +-- +-- Deux permissions à ne pas confondre : +-- * can_manage_access : TOUS les comptes, privilégiés compris — DSI seule ; +-- * can_manage_field_access : les jetons DE TERRAIN, agents et responsables de +-- lieu uniquement — Service Enfance (V7.27). +-- Un compte SERVICE_ENFANCE créé par le Service Enfance serait une +-- auto-promotion, un compte DSI lui ouvrirait le journal de ses propres +-- actions : la borne est doublée en PHP (AccessAccountController::ROLES_DELEGUES) +-- et ne doit être élargie d'aucun côté. +INSERT INTO `acces_role_permission` (`role`, `permission`, `autorise`) VALUES + ('AGENT', 'read_only', TRUE), + ('AGENT', 'can_edit_draft', FALSE), + ('AGENT', 'can_edit_cross_structure', FALSE), + ('AGENT', 'can_validate', FALSE), + ('AGENT', 'can_reopen', FALSE), + ('AGENT', 'can_use_templates', FALSE), + ('AGENT', 'can_copy_full_week', FALSE), + ('AGENT', 'can_view_pending', FALSE), + ('AGENT', 'can_manage_coverage', FALSE), + ('AGENT', 'can_manage_agents', FALSE), + ('AGENT', 'can_manage_structures', FALSE), + ('AGENT', 'can_manage_vacations', FALSE), + ('AGENT', 'can_manage_teams', FALSE), + ('AGENT', 'can_manage_pta', FALSE), + ('AGENT', 'can_forecast_next_year', FALSE), + ('AGENT', 'can_manage_access', FALSE), + ('AGENT', 'can_manage_field_access', FALSE), + ('AGENT', 'can_read_journal', FALSE), + + ('RESPONSABLE_STRUCTURE', 'read_only', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_edit_draft', TRUE), + ('RESPONSABLE_STRUCTURE', 'can_edit_cross_structure', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_validate', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_reopen', TRUE), + ('RESPONSABLE_STRUCTURE', 'can_use_templates', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_copy_full_week', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_view_pending', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_coverage', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_agents', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_structures', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_vacations', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_teams', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_pta', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_forecast_next_year', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_access', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_field_access', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_read_journal', FALSE), + + ('SERVICE_ENFANCE', 'read_only', FALSE), + ('SERVICE_ENFANCE', 'can_edit_draft', TRUE), + ('SERVICE_ENFANCE', 'can_edit_cross_structure', TRUE), + ('SERVICE_ENFANCE', 'can_validate', TRUE), + ('SERVICE_ENFANCE', 'can_reopen', TRUE), + ('SERVICE_ENFANCE', 'can_use_templates', TRUE), + ('SERVICE_ENFANCE', 'can_copy_full_week', TRUE), + ('SERVICE_ENFANCE', 'can_view_pending', TRUE), + ('SERVICE_ENFANCE', 'can_manage_coverage', TRUE), + ('SERVICE_ENFANCE', 'can_manage_agents', TRUE), + ('SERVICE_ENFANCE', 'can_manage_structures', TRUE), + ('SERVICE_ENFANCE', 'can_manage_vacations', TRUE), + ('SERVICE_ENFANCE', 'can_manage_teams', TRUE), + ('SERVICE_ENFANCE', 'can_manage_pta', TRUE), + ('SERVICE_ENFANCE', 'can_forecast_next_year', TRUE), + ('SERVICE_ENFANCE', 'can_manage_access', FALSE), + ('SERVICE_ENFANCE', 'can_manage_field_access', TRUE), + ('SERVICE_ENFANCE', 'can_read_journal', FALSE), + + ('DSI', 'read_only', FALSE), + ('DSI', 'can_edit_draft', FALSE), + ('DSI', 'can_edit_cross_structure', FALSE), + ('DSI', 'can_validate', FALSE), + ('DSI', 'can_reopen', FALSE), + ('DSI', 'can_use_templates', FALSE), + ('DSI', 'can_copy_full_week', FALSE), + ('DSI', 'can_view_pending', FALSE), + ('DSI', 'can_manage_coverage', FALSE), + ('DSI', 'can_manage_agents', FALSE), + ('DSI', 'can_manage_structures', FALSE), + ('DSI', 'can_manage_vacations', FALSE), + ('DSI', 'can_manage_teams', FALSE), + ('DSI', 'can_manage_pta', FALSE), + ('DSI', 'can_forecast_next_year', FALSE), + ('DSI', 'can_manage_access', TRUE), + -- Inutile à la DSI : can_manage_access lui ouvre déjà tous les comptes. + ('DSI', 'can_manage_field_access', FALSE), + ('DSI', 'can_read_journal', TRUE) +ON DUPLICATE KEY UPDATE `autorise` = VALUES(`autorise`); + +-- ============================================================================ +-- 14. COMPTES D'ACCÈS ET JOURNAL D'ACTIVITÉ +-- ============================================================================ +-- Le jeton complet n'est JAMAIS stocké : la base ne garde qu'une empreinte +-- SHA-256 et les huit premiers caractères, qui servent uniquement à reconnaître +-- une ligne dans la liste. Un jeton perdu ne se retrouve pas, il se régénère. +-- Une fuite de ce dump ne donne donc aucun accès. +-- +-- L'empreinte n'est pas salée, et c'est délibéré : le jeton est tiré au sort sur +-- 120 bits par le serveur, il n'est pas devinable par force brute comme le +-- serait un mot de passe choisi par un humain. L'absence de sel est ce qui +-- permet de retrouver le compte en une lecture d'index à la connexion. +-- +-- Le MOT DE PASSE (V7.12), lui, est haché par password_hash() (bcrypt) : choisi +-- par un humain, donc attaquable par dictionnaire, et c'est précisément ce que +-- le hachage lent rend coûteux. Il n'est exigé que des comptes SERVICE_ENFANCE +-- et DSI — ceux qui valident et ceux qui distribuent les accès. NULL signifie +-- « pas encore défini » : c'est l'état d'un compte neuf comme d'un compte +-- réinitialisé par la DSI, et un compte privilégié dans cet état ne peut pas +-- ouvrir de session. +-- +-- id_agent / id_structure sont UNIQUE tout en étant NULLables : MariaDB tolère +-- autant de NULL que voulu dans un index unique. Un agent ne peut donc avoir +-- qu'un seul compte, un lieu qu'un seul compte responsable, tandis que les +-- comptes SERVICE_ENFANCE et DSI (les deux colonnes à NULL) sont libres en +-- nombre. +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `compte_acces` ( + `id_compte` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `role` ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL, + `libelle` VARCHAR(180) NOT NULL COMMENT 'Titulaire du jeton, tel qu''affiché dans la liste', + `id_agent` INT UNSIGNED NULL COMMENT 'Obligatoire pour un compte AGENT', + `id_structure` INT UNSIGNED NULL COMMENT 'Obligatoire pour un compte RESPONSABLE_STRUCTURE', + `jeton_empreinte` CHAR(64) NOT NULL COMMENT 'SHA-256 hexadécimal du jeton', + `jeton_prefixe` VARCHAR(12) NOT NULL COMMENT 'Début du jeton, pour le reconnaître sans le révéler', + `mot_de_passe_hash` VARCHAR(255) NULL + COMMENT 'password_hash() du mot de passe ; NULL = à définir à la prochaine connexion', + `date_mot_de_passe` DATETIME NULL + COMMENT 'Dernière définition ou modification du mot de passe par le titulaire', + `actif` BOOLEAN NOT NULL DEFAULT TRUE, + `commentaire` VARCHAR(255) NULL, + `date_creation` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date_rotation` DATETIME NULL COMMENT 'Dernière régénération du jeton', + `date_derniere_connexion` DATETIME NULL, + `nombre_connexions` INT UNSIGNED NOT NULL DEFAULT 0, + + CONSTRAINT `uk_compte_empreinte` UNIQUE (`jeton_empreinte`), + CONSTRAINT `uk_compte_agent` UNIQUE (`id_agent`), + CONSTRAINT `uk_compte_structure` UNIQUE (`id_structure`), + INDEX `idx_compte_role_actif` (`role`, `actif`), + + CONSTRAINT `fk_compte_agent` FOREIGN KEY (`id_agent`) + REFERENCES `agent` (`id_agent`) ON DELETE CASCADE, + CONSTRAINT `fk_compte_structure` FOREIGN KEY (`id_structure`) + REFERENCES `structure` (`id_structure`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Rattrapage d'une base créée avant la V7.12 : sans effet sur une base neuve, +-- où le CREATE TABLE ci-dessus porte déjà les deux colonnes. +-- +-- La forme évidente serait `ALTER TABLE … ADD COLUMN IF NOT EXISTS …`, mais +-- `IF NOT EXISTS` sur un ADD COLUMN est une EXTENSION MARIADB : MySQL la refuse +-- avec une erreur 1064 de syntaxe. Le test d'existence passe donc par +-- information_schema et une requête préparée, comprise par les deux SGBD. +-- `DO 0` est le non-opérant : contrairement à `SELECT 1`, il ne produit pas de +-- résultat parasite dans phpMyAdmin. +SET @ajout_mot_de_passe := IF( + (SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'compte_acces' + AND COLUMN_NAME = 'mot_de_passe_hash') > 0, + 'DO 0', + 'ALTER TABLE `compte_acces` + ADD COLUMN `mot_de_passe_hash` VARCHAR(255) NULL + COMMENT ''password_hash() du mot de passe ; NULL = à définir à la prochaine connexion'', + ADD COLUMN `date_mot_de_passe` DATETIME NULL + COMMENT ''Dernière définition ou modification du mot de passe par le titulaire''' +); +PREPARE requete_ajout_mot_de_passe FROM @ajout_mot_de_passe; +EXECUTE requete_ajout_mot_de_passe; +DEALLOCATE PREPARE requete_ajout_mot_de_passe; +SET @ajout_mot_de_passe := NULL; + +-- ---------------------------------------------------------------------------- +-- 14.1 COMPTE DSI D'INSTALLATION +-- ---------------------------------------------------------------------------- +-- Problème de l'œuf et de la poule : les jetons ne sont distribuables que par la +-- DSI, qui a elle-même besoin d'un jeton pour entrer. Cette ligne est la porte +-- d'entrée initiale. +-- +-- JETON D'INSTALLATION : PTA-INSTALLATION-DSI +-- +-- Il est PUBLIC — il est écrit ici, dans un fichier versionné. La page DSI +-- affiche un avertissement rouge tant que ce compte porte encore cette +-- empreinte : LE RÉGÉNÉRER EST LA PREMIÈRE ACTION À FAIRE APRÈS L'INSTALLATION. +-- +-- Son mot de passe est laissé à NULL : la DSI en choisit un à sa première +-- connexion. La DSI peut effacer un mot de passe, jamais en poser un — sans quoi +-- elle connaîtrait celui de tout le monde. +-- +-- L'identifiant 1 est forcé pour qu'un rejeu de ce script ne recrée pas un +-- second compte d'installation après régénération du jeton (l'empreinte, elle, +-- aura changé). Supprimer la ligne 1 puis rejouer est d'ailleurs la procédure de +-- secours documentée en cas de perte du jeton DSI. +INSERT INTO `compte_acces` + (`id_compte`, `role`, `libelle`, `jeton_empreinte`, `jeton_prefixe`, `commentaire`) +VALUES + (1, 'DSI', 'Compte DSI d''installation', + '7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d', + 'PTA-INSTALL', + 'Jeton d''installation public : à régénérer immédiatement.') +ON DUPLICATE KEY UPDATE `id_compte` = `id_compte`; + +-- ---------------------------------------------------------------------------- +-- 14.2 JOURNAL D'ACTIVITÉ +-- ---------------------------------------------------------------------------- +-- Alimenté exclusivement par App\Core\Journal, jamais par des déclencheurs SQL : +-- le journal doit nommer l'UTILISATEUR à l'origine de l'action, information que +-- la base n'a pas (la connexion MariaDB est la même pour tout le monde). +-- +-- Sont tracés : les connexions et déconnexions, toutes les écritures en base et +-- tous les refus d'accès. Les lectures ne le sont pas — c'est un choix de +-- volume : elles représenteraient l'essentiel des lignes pour une valeur +-- d'audit très faible. +-- +-- L'acteur est FIGÉ au moment de l'action (rôle, libellé) : renommer un agent ou +-- supprimer son compte ne doit pas réécrire l'histoire. C'est aussi pourquoi la +-- clé étrangère est en ON DELETE SET NULL et non en CASCADE. +-- +-- Cette table sert également de compteur au frein anti-force-brute de la +-- connexion (JournalModel::echecsRecents()) : les actions JETON_REFUSE et +-- MOT_DE_PASSE_REFUSE sont comptées par adresse IP. Les renommer désarmerait ce +-- frein silencieusement. +CREATE TABLE IF NOT EXISTS `journal_activite` ( + `id_journal` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `date_evenement` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `categorie` ENUM('CONNEXION', 'ECRITURE', 'ACCES_REFUSE', 'ADMINISTRATION', 'SYSTEME') NOT NULL, + `action` VARCHAR(60) NOT NULL COMMENT 'Code stable de l''action, ex. PLANNING_VALIDE', + `resultat` ENUM('SUCCES', 'REFUS', 'ERREUR') NOT NULL DEFAULT 'SUCCES', + `id_compte` INT UNSIGNED NULL, + `role` VARCHAR(30) NULL COMMENT 'Rôle au moment de l''action, figé', + `acteur` VARCHAR(180) NULL COMMENT 'Libellé de l''utilisateur au moment de l''action, figé', + `cible_table` VARCHAR(64) NULL COMMENT 'Table principale touchée par l''écriture', + `cible_id` INT UNSIGNED NULL, + `description` VARCHAR(500) NULL, + `contexte` JSON NULL COMMENT 'Détail structuré, ex. semaine et lieu concernés', + `adresse_ip` VARCHAR(45) NULL, + `agent_utilisateur` VARCHAR(255) NULL, + + INDEX `idx_journal_date` (`date_evenement`), + INDEX `idx_journal_categorie` (`categorie`, `date_evenement`), + INDEX `idx_journal_compte` (`id_compte`, `date_evenement`), + INDEX `idx_journal_cible` (`cible_table`, `cible_id`), + INDEX `idx_journal_ip` (`adresse_ip`, `date_evenement`), + + CONSTRAINT `fk_journal_compte` FOREIGN KEY (`id_compte`) + REFERENCES `compte_acces` (`id_compte`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================ +-- 15. VUES DE PILOTAGE MÉTIER +-- ============================================================================ + +CREATE OR REPLACE VIEW `v_planning_agent_semaine` AS +SELECT + `p`.`id_planning`, + `p`.`id_agent`, + `a`.`matricule`, + `a`.`nom`, + `a`.`prenom`, + CONCAT(`a`.`prenom`, ' ', `a`.`nom`) AS `agent`, + `p`.`id_semaine`, + `s`.`annee`, + `s`.`numero_semaine`, + `s`.`date_debut`, + `s`.`date_fin`, + `p`.`id_structure`, + `st`.`code` AS `code_structure`, + `st`.`nom` AS `lieu_affectation`, + `st`.`type_affectation`, + `p`.`statut`, + `p`.`date_creation`, + `p`.`date_modification`, + `p`.`date_validation`, + COUNT(`c`.`id_creneau`) AS `nombre_creneaux`, + COALESCE(SUM( + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ), 0) AS `minutes_planifiees`, + COALESCE(SUM( + CASE WHEN `m`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_decomptees_quota`, + ROUND(COALESCE(SUM( + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ), 0) / 60, 2) AS `heures_planifiees` +FROM `planning` `p` +INNER JOIN `agent` `a` ON `a`.`id_agent` = `p`.`id_agent` +INNER JOIN `semaine` `s` ON `s`.`id_semaine` = `p`.`id_semaine` +INNER JOIN `structure` `st` ON `st`.`id_structure` = `p`.`id_structure` +LEFT JOIN `creneau_horaire` `c` ON `c`.`id_planning` = `p`.`id_planning` +LEFT JOIN `motif_planning` `m` ON `m`.`id_motif` = `c`.`id_motif` +GROUP BY + `p`.`id_planning`, `p`.`id_agent`, `a`.`matricule`, `a`.`nom`, `a`.`prenom`, + `p`.`id_semaine`, `s`.`annee`, `s`.`numero_semaine`, `s`.`date_debut`, `s`.`date_fin`, + `p`.`id_structure`, `st`.`code`, `st`.`nom`, `st`.`type_affectation`, + `p`.`statut`, `p`.`date_creation`, `p`.`date_modification`, `p`.`date_validation`; + +-- Quota annuel = année CIVILE. +CREATE OR REPLACE VIEW `v_suivi_quota_annuel` AS +SELECT + `q`.`id_quota`, + `q`.`id_agent`, + `a`.`matricule`, + `a`.`nom`, + `a`.`prenom`, + `a`.`date_debut_contrat`, + `a`.`date_fin_contrat`, + `q`.`annee`, + `q`.`quotite_travail`, + `q`.`quota_reference_minutes`, + `q`.`quota_cible_minutes`, + STR_TO_DATE(CONCAT(`q`.`annee`, '-01-01'), '%Y-%m-%d') AS `date_debut_periode`, + STR_TO_DATE(CONCAT(`q`.`annee`, '-12-31'), '%Y-%m-%d') AS `date_fin_periode`, + ROUND(`q`.`quota_reference_minutes` / 60, 2) AS `quota_reference_heures`, + ROUND(`q`.`quota_cible_minutes` / 60, 2) AS `quota_cible_heures`, + + COALESCE(SUM(CASE + WHEN `p`.`statut` = 'VALIDE' AND `m`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_valides`, + + COALESCE(SUM(CASE + WHEN `p`.`statut` = 'BROUILLON' AND `m`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_brouillon`, + + COALESCE(SUM(CASE + WHEN `m`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_affectees`, + + `q`.`quota_cible_minutes` - COALESCE(SUM(CASE + WHEN `m`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_restantes` +FROM `quota_agent_annuel` `q` +INNER JOIN `agent` `a` ON `a`.`id_agent` = `q`.`id_agent` +LEFT JOIN `planning` `p` ON `p`.`id_agent` = `q`.`id_agent` +LEFT JOIN `creneau_horaire` `c` + ON `c`.`id_planning` = `p`.`id_planning` + AND `c`.`date_jour` BETWEEN + STR_TO_DATE(CONCAT(`q`.`annee`, '-01-01'), '%Y-%m-%d') + AND STR_TO_DATE(CONCAT(`q`.`annee`, '-12-31'), '%Y-%m-%d') +LEFT JOIN `motif_planning` `m` ON `m`.`id_motif` = `c`.`id_motif` +GROUP BY + `q`.`id_quota`, `q`.`id_agent`, `a`.`matricule`, `a`.`nom`, `a`.`prenom`, + `a`.`date_debut_contrat`, `a`.`date_fin_contrat`, + `q`.`annee`, `q`.`quotite_travail`, + `q`.`quota_reference_minutes`, `q`.`quota_cible_minutes`; + +CREATE OR REPLACE VIEW `v_plannings_en_attente_validation` AS +SELECT + `p`.`id_planning`, + `p`.`id_agent`, + `a`.`matricule`, + `a`.`nom`, + `a`.`prenom`, + CONCAT(`a`.`prenom`, ' ', `a`.`nom`) AS `agent`, + `p`.`id_structure`, + `st`.`code` AS `code_structure`, + `st`.`nom` AS `lieu_affectation`, + `p`.`id_semaine`, + `s`.`annee`, + `s`.`numero_semaine`, + `s`.`date_debut`, + `s`.`date_fin`, + `p`.`date_modification`, + COUNT(`c`.`id_creneau`) AS `nombre_creneaux`, + COALESCE(SUM( + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ), 0) AS `minutes_totales`, + COALESCE(SUM(CASE + WHEN `m`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_debut`), + CONCAT(`c`.`date_jour`, ' ', `c`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_decomptees` +FROM `planning` `p` +INNER JOIN `agent` `a` ON `a`.`id_agent` = `p`.`id_agent` +INNER JOIN `structure` `st` ON `st`.`id_structure` = `p`.`id_structure` +INNER JOIN `semaine` `s` ON `s`.`id_semaine` = `p`.`id_semaine` +INNER JOIN `creneau_horaire` `c` ON `c`.`id_planning` = `p`.`id_planning` +INNER JOIN `motif_planning` `m` ON `m`.`id_motif` = `c`.`id_motif` +WHERE `p`.`statut` = 'BROUILLON' +GROUP BY + `p`.`id_planning`, `p`.`id_agent`, `a`.`matricule`, `a`.`nom`, `a`.`prenom`, + `p`.`id_structure`, `st`.`code`, `st`.`nom`, + `p`.`id_semaine`, `s`.`annee`, `s`.`numero_semaine`, `s`.`date_debut`, `s`.`date_fin`, + `p`.`date_modification`; + +-- Uniquement les PTA correspondant à une année civile complète. +CREATE OR REPLACE VIEW `v_pta_annuel_synthese` AS +SELECT + `pa`.`id_pta`, + `pa`.`id_agent`, + `a`.`matricule`, + `a`.`nom`, + `a`.`prenom`, + `po`.`code` AS `poste_code`, + `po`.`libelle` AS `poste_libelle`, + `a`.`type_contrat`, + `pa`.`date_debut`, + `pa`.`date_fin`, + `pa`.`quotite_travail`, + `pa`.`quota_cible_minutes`, + COALESCE(SUM(CASE + WHEN `mp`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`ch`.`date_jour`, ' ', `ch`.`heure_debut`), + CONCAT(`ch`.`date_jour`, ' ', `ch`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_decomptees`, + `pa`.`quota_cible_minutes` - COALESCE(SUM(CASE + WHEN `mp`.`compte_dans_quota` = TRUE THEN + TIMESTAMPDIFF( + MINUTE, + CONCAT(`ch`.`date_jour`, ' ', `ch`.`heure_debut`), + CONCAT(`ch`.`date_jour`, ' ', `ch`.`heure_fin`) + ) + ELSE 0 END + ), 0) AS `minutes_restantes`, + `pa`.`statut` +FROM `pta_annuel` `pa` +INNER JOIN `agent` `a` ON `a`.`id_agent` = `pa`.`id_agent` +LEFT JOIN `poste_agent` `po` ON `po`.`id_poste` = `a`.`id_poste` +LEFT JOIN `planning` `p` ON `p`.`id_agent` = `pa`.`id_agent` +LEFT JOIN `creneau_horaire` `ch` + ON `ch`.`id_planning` = `p`.`id_planning` + AND `ch`.`date_jour` BETWEEN `pa`.`date_debut` AND `pa`.`date_fin` +LEFT JOIN `motif_planning` `mp` ON `mp`.`id_motif` = `ch`.`id_motif` +WHERE `pa`.`date_debut` = STR_TO_DATE(CONCAT(YEAR(`pa`.`date_debut`), '-01-01'), '%Y-%m-%d') + AND `pa`.`date_fin` = STR_TO_DATE(CONCAT(YEAR(`pa`.`date_debut`), '-12-31'), '%Y-%m-%d') +GROUP BY + `pa`.`id_pta`, `pa`.`id_agent`, `a`.`matricule`, `a`.`nom`, `a`.`prenom`, + `po`.`code`, `po`.`libelle`, `a`.`type_contrat`, + `pa`.`date_debut`, `pa`.`date_fin`, `pa`.`quotite_travail`, + `pa`.`quota_cible_minutes`, `pa`.`statut`; + +CREATE OR REPLACE VIEW `v_equipe_pta_controle` AS +SELECT + `e`.`id_equipe`, + `e`.`libelle`, + `e`.`type_equipe`, + `e`.`id_structure`, + `s`.`nom` AS `structure_nom`, + `e`.`date_debut`, + `e`.`date_fin`, + `e`.`nombre_enfants_moins_6`, + `e`.`nombre_enfants_6_plus`, + GREATEST( + `e`.`minimum_agents`, + CEIL(`e`.`nombre_enfants_moins_6` / `e`.`ratio_moins_6`) + + CEIL(`e`.`nombre_enfants_6_plus` / `e`.`ratio_6_plus`) + ) AS `agents_requis`, + COUNT(`em`.`id_agent`) AS `agents_affectes`, + COALESCE(SUM(CASE WHEN `a`.`est_diplome` = TRUE THEN 1 ELSE 0 END), 0) AS `agents_diplomes`, + CASE + WHEN COUNT(`em`.`id_agent`) = 0 THEN 0 + ELSE ROUND( + 100 * SUM(CASE WHEN `a`.`est_diplome` = TRUE THEN 1 ELSE 0 END) + / COUNT(`em`.`id_agent`), + 2 + ) + END AS `pourcentage_diplomes`, + `e`.`pourcentage_diplomes_min`, + COALESCE(SUM(CASE WHEN `em`.`fonction_equipe` = 'RESPONSABLE' THEN 1 ELSE 0 END), 0) AS `responsables`, + `e`.`statut` +FROM `equipe_pta` `e` +INNER JOIN `structure` `s` ON `s`.`id_structure` = `e`.`id_structure` +LEFT JOIN `equipe_pta_membre` `em` ON `em`.`id_equipe` = `e`.`id_equipe` +LEFT JOIN `agent` `a` ON `a`.`id_agent` = `em`.`id_agent` +GROUP BY + `e`.`id_equipe`, `e`.`libelle`, `e`.`type_equipe`, `e`.`id_structure`, `s`.`nom`, + `e`.`date_debut`, `e`.`date_fin`, + `e`.`nombre_enfants_moins_6`, `e`.`nombre_enfants_6_plus`, + `e`.`ratio_moins_6`, `e`.`ratio_6_plus`, `e`.`minimum_agents`, + `e`.`pourcentage_diplomes_min`, `e`.`statut`; + +-- ============================================================================ +-- 16. VUES DE CONTRÔLE D'ACCÈS +-- ============================================================================ +-- Les dix vues ci-dessous sont vérifiées au démarrage par SchemaModel et par +-- docker/db/apply-migrations.sh : si l'une manque, l'application REFUSE de +-- démarrer plutôt que de tourner sans contrôle de périmètre ni journal. Ce +-- fail-closed est volontaire, ne pas l'assouplir. +-- ---------------------------------------------------------------------------- + +-- 16.1 Matrice des droits ---------------------------------------------------- +CREATE OR REPLACE VIEW `v_acces_role_page` AS +SELECT `role`, `page`, `ordre_affichage` +FROM `acces_role_page`; + +CREATE OR REPLACE VIEW `v_acces_role_permission` AS +SELECT `role`, `permission`, `autorise` +FROM `acces_role_permission`; + +-- 16.2 Identités ------------------------------------------------------------- +-- Identité complète d'un agent actif : sert à construire le bandeau de profil. +-- Un agent inactif n'apparaît jamais ici, sa session se referme donc d'elle-même +-- à la requête suivante (Access relit le compte à CHAQUE requête). +CREATE OR REPLACE VIEW `v_acces_identite_agent` AS +SELECT + `a`.`id_agent`, + `a`.`matricule`, + `a`.`nom`, + `a`.`prenom`, + `a`.`id_structure`, + `s`.`nom` AS `structure_nom`, + `s`.`code` AS `structure_code` +FROM `agent` `a` +LEFT JOIN `structure` `s` + ON `s`.`id_structure` = `a`.`id_structure` + AND `s`.`actif` = TRUE +WHERE `a`.`actif` = TRUE; + +-- Lieux d'affectation portés par un compte responsable de structure. +CREATE OR REPLACE VIEW `v_acces_identite_structure` AS +SELECT + `s`.`id_structure`, + `s`.`code`, + `s`.`nom`, + `s`.`type_affectation` +FROM `structure` `s` +WHERE `s`.`actif` = TRUE; + +-- 16.3 Périmètres ------------------------------------------------------------ +-- Quels agents un lieu d'affectation a-t-il le droit de consulter ? +-- Deux origines : le rattachement principal, ou une planification effective sur +-- ce lieu — un agent peut être planifié ailleurs que sur son rattachement. +CREATE OR REPLACE VIEW `v_acces_agent_par_structure` AS +SELECT DISTINCT + `a`.`id_agent`, + `a`.`id_structure` AS `id_structure_autorisee`, + 'RATTACHEMENT' AS `origine` +FROM `agent` `a` +WHERE `a`.`actif` = TRUE + AND `a`.`id_structure` IS NOT NULL +UNION +SELECT DISTINCT + `a`.`id_agent`, + `p`.`id_structure` AS `id_structure_autorisee`, + 'PLANIFICATION' AS `origine` +FROM `agent` `a` +INNER JOIN `planning` `p` ON `p`.`id_agent` = `a`.`id_agent` +WHERE `a`.`actif` = TRUE; + +-- Périmètre d'un créneau : à quel lieu et à quel planning appartient-il ? +-- Utilisé avant toute modification ou suppression d'affectation. +CREATE OR REPLACE VIEW `v_acces_perimetre_creneau` AS +SELECT + `c`.`id_creneau`, + `c`.`id_planning`, + `p`.`id_structure`, + `p`.`id_agent`, + `p`.`statut` AS `statut_planning`, + `s`.`nom` AS `structure_nom` +FROM `creneau_horaire` `c` +INNER JOIN `planning` `p` ON `p`.`id_planning` = `c`.`id_planning` +INNER JOIN `structure` `s` ON `s`.`id_structure` = `p`.`id_structure`; + +-- Périmètre d'un planning : utilisé avant validation ou réouverture. +CREATE OR REPLACE VIEW `v_acces_perimetre_planning` AS +SELECT + `p`.`id_planning`, + `p`.`id_structure`, + `p`.`id_agent`, + `p`.`statut` AS `statut_planning`, + `s`.`nom` AS `structure_nom`, + `a`.`nom` AS `agent_nom`, + `a`.`prenom` AS `agent_prenom` +FROM `planning` `p` +INNER JOIN `structure` `s` ON `s`.`id_structure` = `p`.`id_structure` +INNER JOIN `agent` `a` ON `a`.`id_agent` = `p`.`id_agent`; + +-- 16.4 Comptes et journal ---------------------------------------------------- +-- Le PHP interroge cette vue et non la table : l'identité réelle derrière un +-- compte, son activité et la règle « qui doit avoir un mot de passe » sont +-- résolues en SQL, une seule fois, au même endroit. +CREATE OR REPLACE VIEW `v_acces_compte` AS +SELECT + `c`.`id_compte`, + `c`.`role`, + `c`.`libelle`, + `c`.`id_agent`, + `c`.`id_structure`, + `c`.`jeton_empreinte`, + `c`.`jeton_prefixe`, + `c`.`actif`, + `c`.`commentaire`, + `c`.`date_creation`, + `c`.`date_rotation`, + `c`.`date_derniere_connexion`, + `c`.`nombre_connexions`, + `c`.`mot_de_passe_hash`, + `c`.`date_mot_de_passe`, + -- Les deux rôles qui engagent l'application : celui qui valide les plannings + -- de toute la collectivité, et celui qui distribue les accès. + CASE WHEN `c`.`role` IN ('SERVICE_ENFANCE', 'DSI') THEN 1 ELSE 0 END AS `mot_de_passe_requis`, + CASE WHEN `c`.`mot_de_passe_hash` IS NULL THEN 0 ELSE 1 END AS `mot_de_passe_defini`, + `a`.`matricule`, + `a`.`nom` AS `agent_nom`, + `a`.`prenom` AS `agent_prenom`, + `s`.`code` AS `structure_code`, + `s`.`nom` AS `structure_nom`, + -- Libellé affiché : l'identité VIVE pour un agent ou un lieu (un agent + -- renommé doit apparaître sous son nouveau nom), le libellé saisi pour les + -- comptes Service Enfance et DSI, qui ne pointent vers rien. + CASE `c`.`role` + WHEN 'AGENT' THEN CONCAT(`a`.`prenom`, ' ', `a`.`nom`) + WHEN 'RESPONSABLE_STRUCTURE' THEN `s`.`nom` + ELSE `c`.`libelle` + END AS `identite_libelle`, + -- Un compte reste utilisable tant que l'identité qu'il porte est active : + -- désactiver un agent coupe son accès sans avoir à toucher à son compte. + CASE `c`.`role` + WHEN 'AGENT' THEN COALESCE(`a`.`actif`, 0) + WHEN 'RESPONSABLE_STRUCTURE' THEN COALESCE(`s`.`actif`, 0) + ELSE 1 + END AS `identite_active` +FROM `compte_acces` `c` +LEFT JOIN `agent` `a` ON `a`.`id_agent` = `c`.`id_agent` +LEFT JOIN `structure` `s` ON `s`.`id_structure` = `c`.`id_structure`; + +-- Identités actives encore dépourvues de compte : alimente le bouton +-- « générer les jetons manquants », qui permet d'équiper d'un coup tous les +-- agents et tous les lieux lors de la mise en service. +CREATE OR REPLACE VIEW `v_acces_compte_manquant` AS +SELECT + 'AGENT' AS `role`, + `a`.`id_agent` AS `id_agent`, + CAST(NULL AS UNSIGNED) AS `id_structure`, + CONCAT(`a`.`prenom`, ' ', `a`.`nom`) AS `libelle` +FROM `agent` `a` +LEFT JOIN `compte_acces` `c` ON `c`.`id_agent` = `a`.`id_agent` +WHERE `a`.`actif` = 1 AND `c`.`id_compte` IS NULL +UNION ALL +SELECT + 'RESPONSABLE_STRUCTURE', + CAST(NULL AS UNSIGNED), + `s`.`id_structure`, + `s`.`nom` +FROM `structure` `s` +LEFT JOIN `compte_acces` `c` ON `c`.`id_structure` = `s`.`id_structure` +WHERE `s`.`actif` = 1 AND `c`.`id_compte` IS NULL; + +-- Journal enrichi du compte encore existant, quand il existe. Le libellé figé +-- (`acteur`) reste la source d'affichage : la jointure ne sert qu'à retrouver le +-- préfixe du jeton et à filtrer par compte. +CREATE OR REPLACE VIEW `v_journal_activite` AS +SELECT + `j`.`id_journal`, + `j`.`date_evenement`, + `j`.`categorie`, + `j`.`action`, + `j`.`resultat`, + `j`.`id_compte`, + `j`.`role`, + `j`.`acteur`, + `j`.`cible_table`, + `j`.`cible_id`, + `j`.`description`, + `j`.`contexte`, + `j`.`adresse_ip`, + `j`.`agent_utilisateur`, + `c`.`jeton_prefixe`, + `c`.`actif` AS `compte_actif` +FROM `journal_activite` `j` +LEFT JOIN `compte_acces` `c` ON `c`.`id_compte` = `j`.`id_compte`; + +-- ============================================================================ +-- 17. PROCÉDURES DE CONTRÔLE / VALIDATION (PATRIMOINE SQL, NON APPELÉ PAR LE PHP) +-- ============================================================================ +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `sp_preparer_controles_planning`$$ +CREATE PROCEDURE `sp_preparer_controles_planning`( + IN p_id_planning INT UNSIGNED +) +controle: BEGIN + DECLARE v_planning_existe INT DEFAULT 0; + + DROP TEMPORARY TABLE IF EXISTS `tmp_controles_planning`; + + CREATE TEMPORARY TABLE `tmp_controles_planning` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `niveau` ENUM('ERREUR', 'ALERTE', 'INFO') NOT NULL, + `code` VARCHAR(100) NOT NULL, + `message` VARCHAR(1000) NOT NULL + ); + + SELECT COUNT(*) + INTO v_planning_existe + FROM `planning` + WHERE `id_planning` = p_id_planning; + + IF v_planning_existe = 0 THEN + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + VALUES ( + 'ERREUR', + 'PLANNING_INTROUVABLE', + CONCAT('Le planning #', p_id_planning, ' n''existe pas.') + ); + LEAVE controle; + END IF; + + -- Un planning validable doit contenir au moins un créneau. + IF NOT EXISTS ( + SELECT 1 FROM `creneau_horaire` WHERE `id_planning` = p_id_planning + ) THEN + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + VALUES ('ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucun créneau horaire.'); + END IF; + + -- Créneaux hors de la semaine du planning. + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + SELECT + 'ERREUR', + 'CRENEAU_HORS_SEMAINE', + CONCAT( + 'Le créneau #', `c`.`id_creneau`, + ' du ', DATE_FORMAT(`c`.`date_jour`, '%d/%m/%Y'), + ' est en dehors de la semaine ', `s`.`numero_semaine`, + ' (', DATE_FORMAT(`s`.`date_debut`, '%d/%m/%Y'), + ' au ', DATE_FORMAT(`s`.`date_fin`, '%d/%m/%Y'), ').' + ) + FROM `planning` `p` + INNER JOIN `semaine` `s` ON `s`.`id_semaine` = `p`.`id_semaine` + INNER JOIN `creneau_horaire` `c` ON `c`.`id_planning` = `p`.`id_planning` + WHERE `p`.`id_planning` = p_id_planning + AND `c`.`date_jour` NOT BETWEEN `s`.`date_debut` AND `s`.`date_fin`; + + -- Avant le début du contrat. + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + SELECT + 'ERREUR', + 'AVANT_DEBUT_CONTRAT', + CONCAT( + 'Le créneau #', `c`.`id_creneau`, + ' du ', DATE_FORMAT(`c`.`date_jour`, '%d/%m/%Y'), + ' est antérieur au début du contrat de ', + CONCAT(`a`.`prenom`, ' ', `a`.`nom`), + ' (', DATE_FORMAT(`a`.`date_debut_contrat`, '%d/%m/%Y'), ').' + ) + FROM `planning` `p` + INNER JOIN `agent` `a` ON `a`.`id_agent` = `p`.`id_agent` + INNER JOIN `creneau_horaire` `c` ON `c`.`id_planning` = `p`.`id_planning` + WHERE `p`.`id_planning` = p_id_planning + AND `a`.`date_debut_contrat` IS NOT NULL + AND `c`.`date_jour` < `a`.`date_debut_contrat`; + + -- Après la fin du contrat, si elle existe. + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + SELECT + 'ERREUR', + 'APRES_FIN_CONTRAT', + CONCAT( + 'Le créneau #', `c`.`id_creneau`, + ' du ', DATE_FORMAT(`c`.`date_jour`, '%d/%m/%Y'), + ' est postérieur à la fin du contrat de ', + CONCAT(`a`.`prenom`, ' ', `a`.`nom`), + ' (', DATE_FORMAT(`a`.`date_fin_contrat`, '%d/%m/%Y'), ').' + ) + FROM `planning` `p` + INNER JOIN `agent` `a` ON `a`.`id_agent` = `p`.`id_agent` + INNER JOIN `creneau_horaire` `c` ON `c`.`id_planning` = `p`.`id_planning` + WHERE `p`.`id_planning` = p_id_planning + AND `a`.`date_fin_contrat` IS NOT NULL + AND `c`.`date_jour` > `a`.`date_fin_contrat`; + + -- Chevauchements internes. + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + SELECT + 'ERREUR', + 'CHEVAUCHEMENT_INTERNE', + CONCAT( + 'Les créneaux #', `c1`.`id_creneau`, + ' et #', `c2`.`id_creneau`, + ' se chevauchent le ', DATE_FORMAT(`c1`.`date_jour`, '%d/%m/%Y'), + ' (', TIME_FORMAT(`c1`.`heure_debut`, '%H:%i'), '-', TIME_FORMAT(`c1`.`heure_fin`, '%H:%i'), + ' et ', TIME_FORMAT(`c2`.`heure_debut`, '%H:%i'), '-', TIME_FORMAT(`c2`.`heure_fin`, '%H:%i'), ').' + ) + FROM `creneau_horaire` `c1` + INNER JOIN `creneau_horaire` `c2` + ON `c2`.`id_planning` = `c1`.`id_planning` + AND `c2`.`date_jour` = `c1`.`date_jour` + AND `c2`.`id_creneau` > `c1`.`id_creneau` + AND `c1`.`heure_debut` < `c2`.`heure_fin` + AND `c1`.`heure_fin` > `c2`.`heure_debut` + WHERE `c1`.`id_planning` = p_id_planning; + + -- Double affectation de l'agent, tous lieux et tous statuts confondus. + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + SELECT DISTINCT + 'ERREUR', + 'CHEVAUCHEMENT_AUTRE_PLANNING', + CONCAT( + 'Le créneau #', `c1`.`id_creneau`, + ' chevauche le créneau #', `c2`.`id_creneau`, + ' du planning #', `p2`.`id_planning`, + ' au lieu « ', `st2`.`nom`, ' » le ', + DATE_FORMAT(`c1`.`date_jour`, '%d/%m/%Y'), + ' (', TIME_FORMAT(`c2`.`heure_debut`, '%H:%i'), '-', TIME_FORMAT(`c2`.`heure_fin`, '%H:%i'), ').' + ) + FROM `planning` `p1` + INNER JOIN `creneau_horaire` `c1` ON `c1`.`id_planning` = `p1`.`id_planning` + INNER JOIN `planning` `p2` + ON `p2`.`id_agent` = `p1`.`id_agent` + AND `p2`.`id_planning` <> `p1`.`id_planning` + INNER JOIN `structure` `st2` ON `st2`.`id_structure` = `p2`.`id_structure` + INNER JOIN `creneau_horaire` `c2` + ON `c2`.`id_planning` = `p2`.`id_planning` + AND `c2`.`date_jour` = `c1`.`date_jour` + AND `c1`.`heure_debut` < `c2`.`heure_fin` + AND `c1`.`heure_fin` > `c2`.`heure_debut` + WHERE `p1`.`id_planning` = p_id_planning; + + -- Dépassement du quota CIVIL de l'année concernée. + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + SELECT DISTINCT + 'ALERTE', + 'QUOTA_DEPASSE', + CONCAT( + 'Le quota de ', ROUND(`v`.`quota_cible_minutes` / 60, 2), ' h ', + 'pour l''année civile ', `v`.`annee`, + ' est dépassé. Total affecté : ', ROUND(`v`.`minutes_affectees` / 60, 2), ' h.' + ) + FROM `planning` `p` + INNER JOIN `creneau_horaire` `cp` ON `cp`.`id_planning` = `p`.`id_planning` + INNER JOIN `v_suivi_quota_annuel` `v` + ON `v`.`id_agent` = `p`.`id_agent` + AND YEAR(`cp`.`date_jour`) = `v`.`annee` + WHERE `p`.`id_planning` = p_id_planning + AND `v`.`minutes_restantes` < 0; + + -- Aucun quota paramétré pour l'année civile touchée. + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + SELECT DISTINCT + 'ALERTE', + 'QUOTA_NON_PARAMETRE', + CONCAT( + 'Aucun quota n''est paramétré pour l''agent sur l''année civile ', + YEAR(`c`.`date_jour`), '.' + ) + FROM `planning` `p` + INNER JOIN `creneau_horaire` `c` ON `c`.`id_planning` = `p`.`id_planning` + WHERE `p`.`id_planning` = p_id_planning + AND NOT EXISTS ( + SELECT 1 + FROM `quota_agent_annuel` `q` + WHERE `q`.`id_agent` = `p`.`id_agent` + AND `q`.`annee` = YEAR(`c`.`date_jour`) + ); + + IF NOT EXISTS (SELECT 1 FROM `tmp_controles_planning`) THEN + INSERT INTO `tmp_controles_planning`(`niveau`, `code`, `message`) + VALUES ('INFO', 'OK', 'Aucune anomalie détectée. Le planning peut être validé.'); + END IF; +END$$ + +DROP PROCEDURE IF EXISTS `sp_controler_planning`$$ +CREATE PROCEDURE `sp_controler_planning`( + IN p_id_planning INT UNSIGNED +) +BEGIN + CALL `sp_preparer_controles_planning`(p_id_planning); + + SELECT `niveau`, `code`, `message` + FROM `tmp_controles_planning` + ORDER BY FIELD(`niveau`, 'ERREUR', 'ALERTE', 'INFO'), `id`; +END$$ + +DROP PROCEDURE IF EXISTS `sp_valider_planning`$$ +CREATE PROCEDURE `sp_valider_planning`( + IN p_id_planning INT UNSIGNED, + IN p_forcer_depassement_quota BOOLEAN +) +BEGIN + DECLARE v_nb_erreurs INT DEFAULT 0; + DECLARE v_nb_alertes_quota INT DEFAULT 0; + DECLARE v_message VARCHAR(1000); + + CALL `sp_preparer_controles_planning`(p_id_planning); + + SELECT COUNT(*) INTO v_nb_erreurs + FROM `tmp_controles_planning` + WHERE `niveau` = 'ERREUR'; + + IF v_nb_erreurs > 0 THEN + SELECT `message` INTO v_message + FROM `tmp_controles_planning` + WHERE `niveau` = 'ERREUR' + ORDER BY `id` + LIMIT 1; + + SET v_message = CONCAT( + 'Validation impossible. ', v_nb_erreurs, + ' erreur(s) détectée(s). Première erreur : ', v_message + ); + + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = v_message; + END IF; + + SELECT COUNT(*) INTO v_nb_alertes_quota + FROM `tmp_controles_planning` + WHERE `code` = 'QUOTA_DEPASSE'; + + IF v_nb_alertes_quota > 0 + AND COALESCE(p_forcer_depassement_quota, FALSE) = FALSE THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Validation suspendue : le quota annuel serait dépassé. Relancer en forçant le dépassement après confirmation.'; + END IF; + + UPDATE `planning` + SET `statut` = 'VALIDE', + `date_validation` = CURRENT_TIMESTAMP, + `date_modification` = CURRENT_TIMESTAMP + WHERE `id_planning` = p_id_planning; + + SELECT + `p`.`id_planning`, + `p`.`statut`, + `p`.`date_validation`, + 'Planning validé avec succès.' AS `message` + FROM `planning` `p` + WHERE `p`.`id_planning` = p_id_planning; +END$$ + +DELIMITER ; + +-- ============================================================================ +-- 18. REGISTRE DES MIGRATIONS +-- ============================================================================ +-- Table tenue par docker/db/apply-migrations.sh : un fichier de migration déjà +-- appliqué et inchangé est ignoré, un fichier modifié est rejoué. Elle est +-- créée ici pour qu'une base installée à la main soit strictement identique à +-- une base montée par la pile Docker. +-- +-- Elle est laissée VIDE volontairement : les empreintes MD5 des fichiers de +-- migration ne peuvent pas être calculées en SQL, et les inscrire en dur ici +-- se périmerait à la première retouche d'une migration. db-migrate les rejouera +-- donc une fois, sans effet — elles sont idempotentes. +CREATE TABLE IF NOT EXISTS `schema_migration` ( + `fichier` VARCHAR(190) NOT NULL, + `empreinte` CHAR(32) NOT NULL, + `applique_le` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (`fichier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================ +-- FIN DE L'INSTALLATION — PTA V7.30 +-- ============================================================================ +-- Contrôles recommandés après import : +-- +-- -- 21 tables métier + 2 tables de droits + compte_acces + journal_activite +-- SELECT COUNT(*) FROM information_schema.TABLES +-- WHERE TABLE_SCHEMA = DATABASE(); +-- +-- -- Les dix vues exigées au démarrage doivent toutes répondre présentes. +-- SELECT TABLE_NAME FROM information_schema.VIEWS +-- WHERE TABLE_SCHEMA = DATABASE() +-- AND TABLE_NAME IN ( +-- 'v_acces_role_page', 'v_acces_role_permission', +-- 'v_acces_identite_agent', 'v_acces_identite_structure', +-- 'v_acces_agent_par_structure', 'v_acces_perimetre_creneau', +-- 'v_acces_perimetre_planning', 'v_acces_compte', +-- 'v_acces_compte_manquant', 'v_journal_activite') +-- ORDER BY TABLE_NAME; +-- +-- -- Droits en place : 4 rôles côté pages, 4 rôles côté permissions. +-- SELECT role, COUNT(*) FROM acces_role_page GROUP BY role; +-- SELECT role, COUNT(*) FROM acces_role_permission GROUP BY role; +-- +-- -- Référentiels techniques : 4 postes, 16 motifs. +-- SELECT COUNT(*) FROM poste_agent; +-- SELECT COUNT(*) FROM motif_planning; +-- +-- -- La correction #1832 : id_motif doit être int unsigned NOT NULL. +-- SHOW CREATE TABLE creneau_horaire; +-- +-- -- La porte d'entrée initiale, à régénérer AVANT toute mise en service. +-- SELECT id_compte, role, libelle, jeton_prefixe FROM compte_acces; +-- ============================================================================