last update
This commit is contained in:
@@ -264,7 +264,10 @@ final class PageController extends Controller
|
|||||||
// V7.27 : la distribution des jetons de terrain rejoint cet écran.
|
// V7.27 : la distribution des jetons de terrain rejoint cet écran.
|
||||||
// Le contenu de la section vient entièrement de l'API — le HTML
|
// Le contenu de la section vient entièrement de l'API — le HTML
|
||||||
// servi ne porte aucune information sur les comptes.
|
// 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'],
|
'modules' => ['coverage', 'accessTokens'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -332,7 +335,9 @@ final class PageController extends Controller
|
|||||||
$this->access->requirePage('dsi');
|
$this->access->requirePage('dsi');
|
||||||
$this->renderPage('dsi', 'Espace DSI', 'dsi', [
|
$this->renderPage('dsi', 'Espace DSI', 'dsi', [
|
||||||
'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.',
|
'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.',
|
||||||
'scripts' => ['dsi.js'],
|
// 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'],
|
'modules' => ['dsi'],
|
||||||
'pageConfig' => [
|
'pageConfig' => [
|
||||||
// Lien profond « journal de ce compte » : filtre d'emblée le
|
// Lien profond « journal de ce compte » : filtre d'emblée le
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
|
|||||||
'params' => $pageConfig,
|
'params' => $pageConfig,
|
||||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/core.js?v=7.29"></script>
|
<script src="assets/js/core.js?v=7.30"></script>
|
||||||
<?php foreach ($scripts as $script): ?>
|
<?php foreach ($scripts as $script): ?>
|
||||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.29"></script>
|
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.30"></script>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<script src="assets/js/page-bootstrap.js?v=7.29"></script>
|
<script src="assets/js/page-bootstrap.js?v=7.30"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true);
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
||||||
<link rel="stylesheet" href="assets/style.css?v=7.29">
|
<link rel="stylesheet" href="assets/style.css?v=7.30">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ $titre = $titres[$etape] ?? 'Connexion';
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= htmlspecialchars($titre) ?> — PTA</title>
|
<title><?= htmlspecialchars($titre) ?> — PTA</title>
|
||||||
<link rel="stylesheet" href="assets/style.css?v=7.29">
|
<link rel="stylesheet" href="assets/style.css?v=7.30">
|
||||||
</head>
|
</head>
|
||||||
<body class="role-page-body">
|
<body class="role-page-body">
|
||||||
<main class="role-page-shell">
|
<main class="role-page-shell">
|
||||||
|
|||||||
@@ -11,335 +11,35 @@
|
|||||||
* le serveur les refuse en 403 (AccessAccountController) : l'écran ne fait que
|
* le serveur les refuse en 403 (AccessAccountController) : l'écran ne fait que
|
||||||
* refléter une frontière qu'il ne décide pas.
|
* 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
|
* Tout le comportement vient de `comptes-acces.js`, partagé avec l'espace DSI :
|
||||||
* la réponse qui vient de le produire. Il est affiché dans un panneau dédié, qui
|
* ce fichier ne fait que le brancher sur les ancres HTML `access-*` et sur les
|
||||||
* reste ouvert tant qu'il n'a pas été refermé, et n'est jamais rechargé depuis
|
* endpoints délégués `api/acces_*.php`. La version DSI du même écran ajoute, en
|
||||||
* le serveur — la base n'en garde qu'une empreinte.
|
* 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';
|
'use strict';
|
||||||
const PTA = window.PTA;
|
const PTA = window.PTA;
|
||||||
// core.js absent : la page reste lisible, simplement non interactive.
|
// core.js ou comptes-acces.js absent : la page reste lisible, simplement non
|
||||||
if (!PTA) return;
|
// 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 ecran = PTA.shared.ecranComptesAcces({
|
||||||
const el = (id) => document.getElementById(id);
|
prefixe: 'access',
|
||||||
|
endpoints: {
|
||||||
const LIBELLES_ROLE = {
|
liste: 'api/acces_comptes.php',
|
||||||
RESPONSABLE_STRUCTURE: 'Responsable de structure',
|
creer: 'api/acces_compte_creer.php',
|
||||||
AGENT: 'Agent',
|
jeton: 'api/acces_compte_jeton.php',
|
||||||
};
|
statut: 'api/acces_compte_statut.php',
|
||||||
|
manquants: 'api/acces_comptes_manquants.php',
|
||||||
/**
|
},
|
||||||
* Ce qu'il faut renseigner change selon le type de compte : l'écran
|
// Ni champ « Nom du titulaire » ni aide de saisie propres : les deux
|
||||||
* l'explique plutôt que de laisser deviner pourquoi un champ change.
|
// rôles distribués ici pointent vers une identité déjà en base, et leurs
|
||||||
*/
|
// aides sont celles, communes, de la fabrique.
|
||||||
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 ». */
|
// La section n'existe que sur l'écran Administration : ailleurs, `attacher()`
|
||||||
function formatDateHeure(valeur) {
|
// se retire sans rien tenter.
|
||||||
if (!valeur) return '—';
|
PTA.modules.accessTokens = { init: ecran.attacher, refresh: ecran.chargerComptes };
|
||||||
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) => `
|
|
||||||
<div class="dsi-token-row">
|
|
||||||
<div class="dsi-token-identity">
|
|
||||||
<strong>${escapeHtml(entree.libelle)}</strong>
|
|
||||||
<small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)}</small>
|
|
||||||
</div>
|
|
||||||
<code class="dsi-token-value">${escapeHtml(entree.jeton)}</code>
|
|
||||||
</div>
|
|
||||||
`).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 = [
|
|
||||||
`<strong>${escapeHtml(compte.identite_libelle)}</strong>`,
|
|
||||||
compte.matricule ? `<br><small>Matricule ${escapeHtml(compte.matricule)}</small>` : '',
|
|
||||||
compte.commentaire ? `<br><small>${escapeHtml(compte.commentaire)}</small>` : '',
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
const etats = [];
|
|
||||||
if (!compte.actif) etats.push('<span class="dsi-state dsi-state-off">Désactivé</span>');
|
|
||||||
else if (!compte.identite_active) etats.push('<span class="dsi-state dsi-state-off">Identité inactive</span>');
|
|
||||||
else etats.push('<span class="dsi-state dsi-state-on">Actif</span>');
|
|
||||||
|
|
||||||
const connexions = compte.nombre_connexions > 0
|
|
||||||
? `<br><small>${compte.nombre_connexions} connexion(s)</small>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr data-compte-id="${Number(compte.id_compte)}">
|
|
||||||
<td>${identite}</td>
|
|
||||||
<td>${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)}</td>
|
|
||||||
<td><code>${escapeHtml(compte.jeton_prefixe)}…</code></td>
|
|
||||||
<td>${formatDateHeure(compte.date_derniere_connexion)}${connexions}</td>
|
|
||||||
<td>${etats.join(' ')}</td>
|
|
||||||
<td class="dsi-actions-cell">
|
|
||||||
<button type="button" class="button button-secondary access-regenerate">Régénérer</button>
|
|
||||||
<button type="button" class="button button-secondary access-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
|
|
||||||
<button type="button" class="button button-danger access-delete">Supprimer</button>
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = '<option value="">Choisir un agent sans compte</option>'
|
|
||||||
+ agents.map((agent) => `<option value="${Number(agent.id_agent)}">${escapeHtml(agent.libelle)}</option>`).join('');
|
|
||||||
}
|
|
||||||
if (selectLieu) {
|
|
||||||
selectLieu.innerHTML = '<option value="">Choisir un lieu sans compte</option>'
|
|
||||||
+ lieux.map((lieu) => `<option value="${Number(lieu.id_structure)}">${escapeHtml(lieu.libelle)}</option>`).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('')
|
|
||||||
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
|
|
||||||
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 };
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
const PTA = window.PTA;
|
const PTA = window.PTA;
|
||||||
if (!PTA) return;
|
if (!PTA) return;
|
||||||
|
|
||||||
const { readJsonResponse, apiErrorMessage } = PTA.utils;
|
const { apiJson } = PTA.utils;
|
||||||
|
|
||||||
const corps = () => document.querySelector('#agent-assignment-body');
|
const corps = () => document.querySelector('#agent-assignment-body');
|
||||||
|
|
||||||
@@ -41,14 +41,6 @@
|
|||||||
boite.textContent = texte;
|
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.
|
* Met un bouton de ligne à l'état du compte correspondant.
|
||||||
*
|
*
|
||||||
@@ -100,7 +92,7 @@
|
|||||||
if (!corps() || chargement) return;
|
if (!corps() || chargement) return;
|
||||||
chargement = true;
|
chargement = true;
|
||||||
try {
|
try {
|
||||||
const donnees = await api('api/acces_comptes.php?role=AGENT');
|
const donnees = await apiJson('api/acces_comptes.php?role=AGENT');
|
||||||
comptesParAgent = new Map(
|
comptesParAgent = new Map(
|
||||||
(donnees.comptes || [])
|
(donnees.comptes || [])
|
||||||
.filter((compte) => compte.id_agent !== null)
|
.filter((compte) => compte.id_agent !== null)
|
||||||
@@ -130,7 +122,7 @@
|
|||||||
|
|
||||||
bouton.disabled = true;
|
bouton.disabled = true;
|
||||||
try {
|
try {
|
||||||
const donnees = await api('api/acces_compte_statut.php', {
|
const donnees = await apiJson('api/acces_compte_statut.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
402
assets/js/comptes-acces.js
Normal file
402
assets/js/comptes-acces.js
Normal file
@@ -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) => `
|
||||||
|
<div class="dsi-token-row">
|
||||||
|
<div class="dsi-token-identity">
|
||||||
|
<strong>${escapeHtml(entree.libelle)}</strong>
|
||||||
|
<small>${escapeHtml(libelleRole(entree.role))}</small>
|
||||||
|
</div>
|
||||||
|
<code class="dsi-token-value">${escapeHtml(entree.jeton)}</code>
|
||||||
|
</div>
|
||||||
|
`).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 = [
|
||||||
|
`<strong>${escapeHtml(compte.identite_libelle)}</strong>`,
|
||||||
|
compte.matricule ? `<br><small>Matricule ${escapeHtml(compte.matricule)}</small>` : '',
|
||||||
|
compte.commentaire ? `<br><small>${escapeHtml(compte.commentaire)}</small>` : '',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const etats = [];
|
||||||
|
if (!compte.actif) etats.push('<span class="dsi-state dsi-state-off">Désactivé</span>');
|
||||||
|
else if (!compte.identite_active) etats.push('<span class="dsi-state dsi-state-off">Identité inactive</span>');
|
||||||
|
else etats.push('<span class="dsi-state dsi-state-on">Actif</span>');
|
||||||
|
etats.push(...etatsSupplementaires(compte));
|
||||||
|
|
||||||
|
const connexions = compte.nombre_connexions > 0
|
||||||
|
? `<br><small>${compte.nombre_connexions} connexion(s)</small>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr data-compte-id="${Number(compte.id_compte)}">
|
||||||
|
<td>${identite}</td>
|
||||||
|
<td>${escapeHtml(libelleRole(compte.role))}</td>
|
||||||
|
<td><code>${escapeHtml(compte.jeton_prefixe)}…</code></td>
|
||||||
|
<td>${formatDateTime(compte.date_derniere_connexion)}${connexions}</td>
|
||||||
|
<td>${etats.join(' ')}</td>
|
||||||
|
<td class="dsi-actions-cell">
|
||||||
|
<button type="button" class="button button-secondary compte-regenerate">Régénérer</button>
|
||||||
|
<button type="button" class="button button-secondary compte-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
|
||||||
|
${actionsSupplementaires(compte)}
|
||||||
|
<button type="button" class="button button-danger compte-delete">Supprimer</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = '<option value="">Choisir un agent sans compte</option>'
|
||||||
|
+ agents.map((agent) => `<option value="${Number(agent.id_agent)}">${escapeHtml(agent.libelle)}</option>`).join('');
|
||||||
|
}
|
||||||
|
if (selectLieu) {
|
||||||
|
selectLieu.innerHTML = '<option value="">Choisir un lieu sans compte</option>'
|
||||||
|
+ lieux.map((lieu) => `<option value="${Number(lieu.id_structure)}">${escapeHtml(lieu.libelle)}</option>`).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('')
|
||||||
|
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
|
||||||
|
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;
|
||||||
|
})();
|
||||||
@@ -79,8 +79,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache de données brutes qu'un module expose aux autres, pour éviter de
|
* Ce qu'un module expose aux autres : des données brutes déjà chargées (pour
|
||||||
* refaire un appel réseau déjà effectué.
|
* é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 = {
|
const shared = {
|
||||||
lastAgentOverviewData: null,
|
lastAgentOverviewData: null,
|
||||||
@@ -177,6 +179,58 @@
|
|||||||
return data?.details ? `${base} Détail : ${data.details}` : base;
|
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.
|
* Date → « AAAA-MM-JJ » en heure LOCALE.
|
||||||
* `toISOString()` convertirait en UTC et décalerait la date d'un jour selon
|
* `toISOString()` convertirait en UTC et décalerait la date d'un jour selon
|
||||||
@@ -284,6 +338,10 @@
|
|||||||
clearMessage,
|
clearMessage,
|
||||||
readJsonResponse,
|
readJsonResponse,
|
||||||
apiErrorMessage,
|
apiErrorMessage,
|
||||||
|
apiJson,
|
||||||
|
postJson,
|
||||||
|
showMessageIn,
|
||||||
|
formatDateTime,
|
||||||
parseIsoWeek,
|
parseIsoWeek,
|
||||||
timeToMinutes,
|
timeToMinutes,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
|
|||||||
413
assets/js/dsi.js
413
assets/js/dsi.js
@@ -8,6 +8,13 @@
|
|||||||
* - le JOURNAL D'ACTIVITÉ : recherche filtrée et paginée, statistiques de tête,
|
* - le JOURNAL D'ACTIVITÉ : recherche filtrée et paginée, statistiques de tête,
|
||||||
* purge des entrées anciennes.
|
* 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
|
* 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
|
* 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
|
* ouvert tant que la DSI ne l'a pas refermé, et n'est jamais rechargé depuis le
|
||||||
@@ -17,11 +24,13 @@
|
|||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'use strict';
|
||||||
const PTA = window.PTA;
|
const PTA = window.PTA;
|
||||||
// core.js absent : la page reste lisible, simplement non interactive.
|
// core.js ou comptes-acces.js absent : la page reste lisible, simplement non
|
||||||
if (!PTA) return;
|
// 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 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. */
|
/** Page courante du journal, seul état conservé par le module. */
|
||||||
let pageJournal = 1;
|
let pageJournal = 1;
|
||||||
@@ -41,352 +50,89 @@
|
|||||||
SYSTEME: 'Système',
|
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) => `
|
|
||||||
<div class="dsi-token-row">
|
|
||||||
<div class="dsi-token-identity">
|
|
||||||
<strong>${escapeHtml(entree.libelle)}</strong>
|
|
||||||
<small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)}</small>
|
|
||||||
</div>
|
|
||||||
<code class="dsi-token-value">${escapeHtml(entree.jeton)}</code>
|
|
||||||
</div>
|
|
||||||
`).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
|
// ----------------------------------------------------------------- comptes
|
||||||
|
|
||||||
/** Une ligne du tableau des comptes. */
|
/**
|
||||||
function ligneCompte(compte) {
|
* Badges d'état propres à la DSI, ajoutés à « Actif / Désactivé ».
|
||||||
const identite = [
|
*
|
||||||
`<strong>${escapeHtml(compte.identite_libelle)}</strong>`,
|
* Second facteur (V7.12) : seuls les comptes Service Enfance et DSI en
|
||||||
compte.matricule ? `<br><small>Matricule ${escapeHtml(compte.matricule)}</small>` : '',
|
* portent un. « À définir » n'est pas une anomalie — c'est l'état normal
|
||||||
compte.commentaire ? `<br><small>${escapeHtml(compte.commentaire)}</small>` : '',
|
* d'un compte neuf ou fraîchement réinitialisé.
|
||||||
].join('');
|
*/
|
||||||
|
function etatsCompte(compte) {
|
||||||
const etats = [];
|
const etats = [];
|
||||||
if (!compte.actif) etats.push('<span class="dsi-state dsi-state-off">Désactivé</span>');
|
|
||||||
else if (!compte.identite_active) etats.push('<span class="dsi-state dsi-state-off">Identité inactive</span>');
|
|
||||||
else etats.push('<span class="dsi-state dsi-state-on">Actif</span>');
|
|
||||||
if (compte.jeton_installation) etats.push('<span class="dsi-state dsi-state-alert">Jeton d’installation</span>');
|
if (compte.jeton_installation) etats.push('<span class="dsi-state dsi-state-alert">Jeton d’installation</span>');
|
||||||
// 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) {
|
if (compte.mot_de_passe_requis) {
|
||||||
etats.push(compte.mot_de_passe_defini
|
etats.push(compte.mot_de_passe_defini
|
||||||
? '<span class="dsi-state dsi-state-on">Mot de passe défini</span>'
|
? '<span class="dsi-state dsi-state-on">Mot de passe défini</span>'
|
||||||
: '<span class="dsi-state dsi-state-wait">Mot de passe à définir</span>');
|
: '<span class="dsi-state dsi-state-wait">Mot de passe à définir</span>');
|
||||||
}
|
}
|
||||||
|
return etats;
|
||||||
const connexions = compte.nombre_connexions > 0
|
|
||||||
? `<br><small>${compte.nombre_connexions} connexion(s)</small>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr data-compte-id="${Number(compte.id_compte)}">
|
|
||||||
<td>${identite}</td>
|
|
||||||
<td>${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)}</td>
|
|
||||||
<td><code>${escapeHtml(compte.jeton_prefixe)}…</code></td>
|
|
||||||
<td>${formatDateHeure(compte.date_derniere_connexion)}${connexions}</td>
|
|
||||||
<td>${etats.join(' ')}</td>
|
|
||||||
<td class="dsi-actions-cell">
|
|
||||||
<button type="button" class="button button-secondary dsi-regenerate">Régénérer</button>
|
|
||||||
<button type="button" class="button button-secondary dsi-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
|
|
||||||
${compte.mot_de_passe_requis && compte.mot_de_passe_defini
|
|
||||||
? '<button type="button" class="button button-secondary dsi-reset-password">Effacer le mot de passe</button>'
|
|
||||||
: ''}
|
|
||||||
<button type="button" class="button button-danger dsi-delete">Supprimer</button>
|
|
||||||
<button type="button" class="button button-secondary dsi-see-journal">Journal</button>
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remplit les listes déroulantes de création à partir des identités SANS
|
* Boutons propres à la DSI en fin de ligne : effacement du mot de passe (la
|
||||||
* compte : proposer un agent qui en a déjà un ne mènerait qu'à un refus du
|
* DSI peut l'effacer, jamais en poser un) et accès au journal du compte.
|
||||||
* serveur (un seul compte par identité).
|
|
||||||
*/
|
*/
|
||||||
function remplirIdentites(manquants) {
|
function actionsCompte(compte) {
|
||||||
const agents = manquants.filter((identite) => identite.role === 'AGENT');
|
return [
|
||||||
const lieux = manquants.filter((identite) => identite.role === 'RESPONSABLE_STRUCTURE');
|
compte.mot_de_passe_requis && compte.mot_de_passe_defini
|
||||||
|
? '<button type="button" class="button button-secondary compte-reset-password">Effacer le mot de passe</button>'
|
||||||
const selectAgent = el('dsi-account-agent');
|
: '',
|
||||||
const selectLieu = el('dsi-account-structure');
|
'<button type="button" class="button button-secondary compte-journal">Journal</button>',
|
||||||
if (selectAgent) {
|
].join('');
|
||||||
selectAgent.innerHTML = '<option value="">Choisir un agent sans compte</option>'
|
|
||||||
+ agents.map((agent) => `<option value="${Number(agent.id_agent)}">${escapeHtml(agent.libelle)}</option>`).join('');
|
|
||||||
}
|
|
||||||
if (selectLieu) {
|
|
||||||
selectLieu.innerHTML = '<option value="">Choisir un lieu sans compte</option>'
|
|
||||||
+ lieux.map((lieu) => `<option value="${Number(lieu.id_structure)}">${escapeHtml(lieu.libelle)}</option>`).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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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);
|
|
||||||
|
|
||||||
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('')
|
|
||||||
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
|
|
||||||
remplirIdentites(donnees.manquants || []);
|
|
||||||
|
|
||||||
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 :
|
* Traite les deux boutons que la fabrique ne connaît pas.
|
||||||
* l'écran l'explique plutôt que de laisser deviner pourquoi un champ
|
* @returns {boolean} Vrai s'il faut recharger la liste des comptes derrière.
|
||||||
* apparaît ou disparaît.
|
|
||||||
*/
|
*/
|
||||||
const AIDES_ROLE = {
|
async function actionCompte(evenement, { bouton, compteId, titulaire }) {
|
||||||
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.',
|
if (bouton.classList.contains('compte-reset-password')) {
|
||||||
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.',
|
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;
|
||||||
AGENT: 'Compte en lecture seule sur son propre planning. Seuls les agents actifs qui n’ont pas encore de compte sont proposés.',
|
const donnees = await postJson('api/dsi_compte_mot_de_passe.php', { compte_id: compteId });
|
||||||
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');
|
message('dsi-accounts-message', donnees.message, 'success');
|
||||||
await chargerComptes();
|
return true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evenement.target.closest('.dsi-reset-password')) {
|
if (bouton.classList.contains('compte-journal')) {
|
||||||
if (!window.confirm(`Effacer le mot de passe de ${titulaire} ? Son jeton reste valable, et il en choisira un nouveau à sa prochaine connexion.`)) return;
|
// Filtre le journal sur ce compte, sans quitter la page ni toucher
|
||||||
const donnees = await poster('api/dsi_compte_mot_de_passe.php', { compte_id: compteId });
|
// à la liste des comptes.
|
||||||
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 = '';
|
el('dsi-journal-search').value = '';
|
||||||
pageJournal = 1;
|
pageJournal = 1;
|
||||||
await chargerJournal({ id_compte: compteId });
|
await chargerJournal({ id_compte: compteId });
|
||||||
el('dsi-journal-body')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el('dsi-journal-body')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
} catch (erreur) {
|
return false;
|
||||||
message('dsi-accounts-message', erreur.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Équipe d'un coup toutes les identités actives sans compte. */
|
const ecranComptes = PTA.shared.ecranComptesAcces({
|
||||||
async function genererManquants() {
|
prefixe: 'dsi',
|
||||||
const attendus = Number(el('dsi-missing-count')?.textContent || 0);
|
endpoints: {
|
||||||
if (!attendus) return;
|
liste: 'api/dsi_comptes.php',
|
||||||
if (!window.confirm(`Générer ${attendus} jeton(s) ? Ils ne seront affichés qu’une seule fois, tout de suite après.`)) return;
|
creer: 'api/dsi_compte_creer.php',
|
||||||
|
jeton: 'api/dsi_compte_jeton.php',
|
||||||
try {
|
statut: 'api/dsi_compte_statut.php',
|
||||||
const donnees = await poster('api/dsi_comptes_manquants.php', {});
|
manquants: 'api/dsi_comptes_manquants.php',
|
||||||
message('dsi-accounts-message', donnees.message, 'success');
|
},
|
||||||
afficherJetons(donnees.jetons || []);
|
// Seul l'espace DSI crée des comptes sans identité en base : eux seuls
|
||||||
await chargerComptes();
|
// ont besoin du champ libre « Nom du titulaire », et de ces deux aides.
|
||||||
} catch (erreur) {
|
avecLibelle: true,
|
||||||
message('dsi-accounts-message', erreur.message, 'error');
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------------------- journal
|
// ----------------------------------------------------------------- journal
|
||||||
|
|
||||||
@@ -406,7 +152,7 @@
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="dsi-journal-row${classeResultat}">
|
<tr class="dsi-journal-row${classeResultat}">
|
||||||
<td>${formatDateHeure(entree.date_evenement)}</td>
|
<td>${formatDateTime(entree.date_evenement)}</td>
|
||||||
<td>${escapeHtml(LIBELLES_CATEGORIE[entree.categorie] || entree.categorie)}</td>
|
<td>${escapeHtml(LIBELLES_CATEGORIE[entree.categorie] || entree.categorie)}</td>
|
||||||
<td>${escapeHtml(entree.acteur || 'Anonyme')}<br><small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role || '—')}</small></td>
|
<td>${escapeHtml(entree.acteur || 'Anonyme')}<br><small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role || '—')}</small></td>
|
||||||
<td><code>${escapeHtml(entree.action)}</code></td>
|
<td><code>${escapeHtml(entree.action)}</code></td>
|
||||||
@@ -454,7 +200,7 @@
|
|||||||
|
|
||||||
message('dsi-journal-message', 'Chargement du journal…');
|
message('dsi-journal-message', 'Chargement du journal…');
|
||||||
try {
|
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 || [];
|
const entrees = donnees.entrees || [];
|
||||||
el('dsi-journal-body').innerHTML = entrees.length
|
el('dsi-journal-body').innerHTML = entrees.length
|
||||||
? entrees.map(ligneJournal).join('')
|
? entrees.map(ligneJournal).join('')
|
||||||
@@ -480,7 +226,7 @@
|
|||||||
if (!window.confirm(`Supprimer définitivement les entrées de plus de ${jours} jours ?`)) return;
|
if (!window.confirm(`Supprimer définitivement les entrées de plus de ${jours} jours ?`)) return;
|
||||||
|
|
||||||
try {
|
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');
|
message('dsi-journal-message', donnees.message, 'success');
|
||||||
pageJournal = 1;
|
pageJournal = 1;
|
||||||
await chargerJournal();
|
await chargerJournal();
|
||||||
@@ -492,16 +238,9 @@
|
|||||||
function init() {
|
function init() {
|
||||||
const params = window.PTA_PAGE_CONFIG?.params || {};
|
const params = window.PTA_PAGE_CONFIG?.params || {};
|
||||||
|
|
||||||
el('dsi-account-role')?.addEventListener('change', ajusterFormulaireCompte);
|
// Comptes, jetons et formulaire de création : branchés et chargés par la
|
||||||
el('dsi-account-form')?.addEventListener('submit', creerCompte);
|
// fabrique partagée.
|
||||||
el('dsi-accounts-body')?.addEventListener('click', agirSurCompte);
|
ecranComptes.attacher();
|
||||||
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);
|
|
||||||
|
|
||||||
['dsi-journal-category', 'dsi-journal-result', 'dsi-journal-role', 'dsi-journal-from', 'dsi-journal-to', 'dsi-journal-search']
|
['dsi-journal-category', 'dsi-journal-result', 'dsi-journal-role', 'dsi-journal-from', 'dsi-journal-to', 'dsi-journal-search']
|
||||||
.forEach((identifiant) => el(identifiant)?.addEventListener('change', () => {
|
.forEach((identifiant) => el(identifiant)?.addEventListener('change', () => {
|
||||||
@@ -519,11 +258,9 @@
|
|||||||
});
|
});
|
||||||
el('dsi-purge-form')?.addEventListener('submit', purgerJournal);
|
el('dsi-purge-form')?.addEventListener('submit', purgerJournal);
|
||||||
|
|
||||||
ajusterFormulaireCompte();
|
|
||||||
chargerComptes();
|
|
||||||
// Lien profond « journal de ce compte » venu d'une autre page.
|
// Lien profond « journal de ce compte » venu d'une autre page.
|
||||||
chargerJournal(params.compteId ? { id_compte: params.compteId } : {});
|
chargerJournal(params.compteId ? { id_compte: params.compteId } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
PTA.modules.dsi = { init, chargerComptes, chargerJournal };
|
PTA.modules.dsi = { init, chargerComptes: ecranComptes.chargerComptes, chargerJournal };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
const PTA = window.PTA;
|
const PTA = window.PTA;
|
||||||
// core.js absent : la page reste lisible, simplement non interactive.
|
// core.js absent : la page reste lisible, simplement non interactive.
|
||||||
if (!PTA) return;
|
if (!PTA) return;
|
||||||
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
|
const { apiJson, escapeHtml, formatDate } = PTA.utils;
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
/** Message dans le bandeau de la page. */
|
/** Message dans le bandeau de la page. */
|
||||||
@@ -28,17 +28,6 @@
|
|||||||
box.textContent = text;
|
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. */
|
/** Rend un contrôle, sa classe CSS dérivant de son niveau. */
|
||||||
function controlHtml(control) {
|
function controlHtml(control) {
|
||||||
const level = String(control.niveau || 'INFO').toLowerCase();
|
const level = String(control.niveau || 'INFO').toLowerCase();
|
||||||
@@ -89,7 +78,7 @@
|
|||||||
if (end) query.set('date_fin', end);
|
if (end) query.set('date_fin', end);
|
||||||
message('Chargement des équipes…');
|
message('Chargement des équipes…');
|
||||||
try {
|
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('') || '<div class="empty-state">Aucune équipe sur cette période.</div>';
|
el('team-list').innerHTML = (data.teams || []).map(teamHtml).join('') || '<div class="empty-state">Aucune équipe sur cette période.</div>';
|
||||||
message(`${(data.teams || []).length} équipe(s) affichée(s).`, 'success');
|
message(`${(data.teams || []).length} équipe(s) affichée(s).`, 'success');
|
||||||
} catch (error) { message(error.message, 'error'); }
|
} catch (error) { message(error.message, 'error'); }
|
||||||
@@ -100,7 +89,7 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const box = el('team-create-message');
|
const box = el('team-create-message');
|
||||||
try {
|
try {
|
||||||
const data = await api('api/teams.php', {
|
const data = await apiJson('api/teams.php', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
csrf_token: window.PTA_CONFIG.csrfToken,
|
csrf_token: window.PTA_CONFIG.csrfToken,
|
||||||
@@ -136,7 +125,7 @@
|
|||||||
if (event.type === 'submit' && event.target.matches('.team-member-form')) {
|
if (event.type === 'submit' && event.target.matches('.team-member-form')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
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,
|
csrf_token: window.PTA_CONFIG.csrfToken, team_id: teamId,
|
||||||
agent_id: Number(card.querySelector('.team-member-agent').value),
|
agent_id: Number(card.querySelector('.team-member-agent').value),
|
||||||
fonction_equipe: card.querySelector('.team-member-function').value,
|
fonction_equipe: card.querySelector('.team-member-function').value,
|
||||||
@@ -150,7 +139,7 @@
|
|||||||
if (remove) {
|
if (remove) {
|
||||||
if (!window.confirm('Retirer cet agent de l’équipe ?')) return;
|
if (!window.confirm('Retirer cet agent de l’équipe ?')) return;
|
||||||
try {
|
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),
|
csrf_token: window.PTA_CONFIG.csrfToken, action: 'REMOVE', team_id: teamId, agent_id: Number(remove.dataset.agentId),
|
||||||
}) });
|
}) });
|
||||||
await load();
|
await load();
|
||||||
@@ -168,7 +157,7 @@
|
|||||||
if (!select) return;
|
if (!select) return;
|
||||||
const card = select.closest('.team-card');
|
const card = select.closest('.team-card');
|
||||||
try {
|
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,
|
csrf_token: window.PTA_CONFIG.csrfToken, team_id: Number(card.dataset.teamId), statut: select.value,
|
||||||
}) });
|
}) });
|
||||||
await load();
|
await load();
|
||||||
|
|||||||
1832
database/install_complet.sql
Normal file
1832
database/install_complet.sql
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user