Files
ecoles/admin.js
T
Arnaud 5633969d98 Version initiale — carte des établissements scolaires
Carte Leaflet (annuaire Éducation nationale) + API PHP/MySQL :
fiche établissement (effectifs, IPS, population INSEE, DPE ADEME),
GED documents/photos, commentaires par groupe, gestion utilisateurs
et politique de mots de passe. Imports opendata (effectifs, IPS,
population, DPE, IRIS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:35:41 +02:00

461 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Espace d'administration : connexion, modération, arborescence, admins, import. */
'use strict';
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
));
const $ = (id) => document.getElementById(id);
async function apiJson(url, opts) {
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
return data;
}
const postJson = (url, body) =>
apiJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
let me = null;
let folders = [];
let allUsers = []; // liste des utilisateurs (pour l'ajout de membres aux groupes)
// --- Démarrage / session ---
async function boot() {
try {
me = (await apiJson('api/auth.php?action=me')).user;
} catch { me = null; }
me ? showAdmin() : showLogin();
}
function showLogin() {
$('login').hidden = false;
$('admin').hidden = true;
$('logout').hidden = true;
$('who').textContent = '';
}
const ROLE_LABEL = { super: 'admin plateforme', perimeter: 'admin périmètre', member: 'commentateur' };
async function showAdmin() {
// Changement de mot de passe forcé : on redirige avant tout.
if (me.must_change) {
location.href = 'account.html?forced=1';
return;
}
$('login').hidden = true;
$('admin').hidden = false;
$('logout').hidden = false;
$('account-link').hidden = false;
$('who').textContent = `${me.username}${ROLE_LABEL[me.role] || me.role}`;
$('super-only').hidden = me.role !== 'super';
// Commentateur : pas de file de modération ; il commente depuis les fiches.
if (me.role === 'member') {
$('queue-count').textContent = '';
$('queue').innerHTML = '<p class="empty">Compte commentateur : rendez-vous sur une fiche d’établissement (onglet 💬 Commentaires) pour participer.</p>';
return;
}
await loadQueue();
if (me.role === 'super') {
await loadFolders();
await loadUsers();
await loadGroups();
}
}
$('login-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
me = (await postJson('api/auth.php?action=login',
{ username: $('l-user').value, password: $('l-pass').value })).user;
$('login-msg').textContent = '';
await showAdmin();
} catch (err) {
$('login-msg').textContent = err.message;
}
});
$('logout').addEventListener('click', async () => {
await apiJson('api/auth.php?action=logout', { method: 'POST' });
me = null;
showLogin();
});
// --- File de validation ---
async function loadQueue() {
let q;
try { q = await apiJson('api/items.php?action=queue'); }
catch (err) { $('queue').innerHTML = `<p class="empty">${esc(err.message)}</p>`; return; }
$('queue-count').textContent = q.length || '';
if (!q.length) { $('queue').innerHTML = '<p class="empty">Aucun élément en attente. 🎉</p>'; return; }
$('queue').innerHTML = q.map((it) => {
const icon = it.kind === 'photo' ? '🖼' : it.kind === 'link' ? '🔗' : '📄';
let preview;
if (it.kind === 'photo') preview = `<a href="${esc(it.url)}" target="_blank"><img class="q-thumb" src="${esc(it.url)}" alt=""></a>`;
else if (it.kind === 'link') preview = `<a href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.url)}</a>`;
else preview = `<a href="${esc(it.url)}" target="_blank">Télécharger</a>`;
const who = [it.submitter_name, it.submitter_email].filter(Boolean).join(' · ') || 'Anonyme';
const descHtml = it.description ? `<div class="q-desc">${esc(it.description)}</div>` : '';
return `<div class="q-item" data-id="${it.id}">
<div class="q-main">
<div class="q-title">${icon} ${esc(it.title)}</div>
${descHtml}
<div class="q-meta">${esc(it.etab)}</div>
<div class="q-meta">Proposé par : ${esc(who)} · ${esc(it.submitted_at || '')}</div>
<div class="q-preview">${preview}</div>
</div>
<div class="q-actions">
<button class="btn-primary btn-sm q-approve">✓ Valider</button>
<button class="btn-danger btn-sm q-reject">✗ Rejeter</button>
</div>
</div>`;
}).join('');
}
$('queue').addEventListener('click', async (ev) => {
const card = ev.target.closest('.q-item');
if (!card) return;
const id = Number(card.dataset.id);
try {
if (ev.target.closest('.q-approve')) {
await postJson('api/items.php?action=validate', { id, decision: 'approve' });
} else if (ev.target.closest('.q-reject')) {
const reason = prompt('Motif du rejet (facultatif) :') ?? '';
await postJson('api/items.php?action=validate', { id, decision: 'reject', reason });
} else return;
await loadQueue();
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Arborescence (super-admin) ---
async function loadFolders() {
folders = await apiJson('api/folders.php');
renderFolderParentSelect();
renderFoldersAdmin();
}
const childrenOf = (pid) => folders
.filter((f) => f.parent_id === pid)
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, 'fr'));
function renderFolderParentSelect() {
const opts = ['<option value="">(Racine)</option>'];
const walk = (pid, d) => childrenOf(pid).forEach((f) => {
opts.push(`<option value="${f.id}">${' '.repeat(d)}${esc(f.name)}</option>`);
walk(f.id, d + 1);
});
walk(null, 0);
$('nf-parent').innerHTML = opts.join('');
}
function folderNodeHtml(f) {
const kids = childrenOf(f.id);
let html = `<li><span class="fa-row" data-id="${f.id}">
<span class="fa-name">📁 ${esc(f.name)}</span>
<button class="fa-rename" title="Renommer">✎</button>
<button class="fa-del" title="Supprimer">🗑</button>
</span>`;
if (kids.length) html += '<ul>' + kids.map(folderNodeHtml).join('') + '</ul>';
return html + '</li>';
}
function renderFoldersAdmin() {
const roots = childrenOf(null);
$('folders-admin').innerHTML = roots.length
? '<ul class="tree">' + roots.map(folderNodeHtml).join('') + '</ul>'
: '<p class="empty">Aucun dossier.</p>';
}
$('folder-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
await postJson('api/folders.php', { name: $('nf-name').value.trim(), parent_id: $('nf-parent').value || null });
$('nf-name').value = '';
await loadFolders();
} catch (err) { alert('Erreur : ' + err.message); }
});
$('folders-admin').addEventListener('click', async (ev) => {
const row = ev.target.closest('.fa-row');
if (!row) return;
const id = Number(row.dataset.id);
const name = row.querySelector('.fa-name').textContent.replace(/^📁\s*/, '');
try {
if (ev.target.closest('.fa-rename')) {
const nv = prompt('Nouveau nom :', name);
if (nv && nv.trim()) {
await apiJson('api/folders.php', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name: nv.trim() }) });
await loadFolders();
}
} else if (ev.target.closest('.fa-del')) {
if (confirm(`Supprimer « ${name} » et ses sous-dossiers ?\nLes éléments associés seront déplacés vers « Sans dossier ».`)) {
await apiJson(`api/folders.php?id=${id}`, { method: 'DELETE' });
await loadFolders();
}
}
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Administrateurs de périmètre (super-admin) ---
async function loadUsers() {
const users = await apiJson('api/users.php');
$('users-admin').innerHTML = users.map((u) => {
const roleBadge = u.role === 'super'
? '<span class="badge ouvert">plateforme</span>'
: u.role === 'member'
? '<span class="badge">commentateur</span>'
: '<span class="badge">périmètre</span>';
let scopes;
if (u.role === 'super') scopes = '<em>Tous les établissements</em>';
else if (u.role === 'member') scopes = '<em>Commente via ses groupes (aucune validation)</em>';
else scopes = u.scopes.map((s) =>
`<span class="chip">${esc(s.com)} (${esc(s.depc)}) <button class="chip-x" data-scope="${s.id}">×</button></span>`
).join(' ') || '<em>Aucune commune</em>';
// Périmètre personnel réservé aux admins de périmètre.
const scopeEditor = u.role === 'perimeter' ? `
<div class="scope-editor">
<input class="scope-search" data-uid="${u.id}" placeholder="Ajouter une commune…">
<div class="scope-results" data-uid="${u.id}"></div>
</div>` : '';
const mustBadge = u.must_change ? '<span class="badge warn">⚠ doit changer</span>' : '';
const emailHtml = u.email ? esc(u.email) : '<em>pas de-mail</em>';
return `<div class="user-card" data-uid="${u.id}">
<div class="user-head">
<strong>${esc(u.username)}</strong> ${roleBadge} ${mustBadge}
<span class="user-actions">
<button class="btn-secondary btn-sm u-mail">✉ E-mail</button>
<button class="btn-secondary btn-sm u-pass">Mot de passe</button>
${u.id === me.id ? '' : '<button class="btn-danger btn-sm u-del">Supprimer</button>'}
</span>
</div>
<div class="user-email muted">📧 ${emailHtml}</div>
<div class="user-scopes">${scopes}</div>
${scopeEditor}
</div>`;
}).join('');
}
if (window.PWPolicy) PWPolicy.attach($('nu-pass'), $('nu-pw-meter'));
$('user-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const pw = $('nu-pass').value;
if (window.PWPolicy) {
const r = PWPolicy.check(pw);
if (!r.ok) { alert('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; }
}
try {
await postJson('api/users.php', {
username: $('nu-user').value.trim(),
email: $('nu-mail').value.trim(),
password: pw,
role: $('nu-role').value,
});
$('nu-user').value = ''; $('nu-mail').value = ''; $('nu-pass').value = '';
$('nu-pw-meter').innerHTML = '';
await loadUsers();
} catch (err) { alert('Erreur : ' + err.message); }
});
const searchCommunes = debounce(async (input) => {
const q = input.value.trim();
const box = input.parentElement.querySelector('.scope-results');
if (q.length < 2) { box.innerHTML = ''; return; }
try {
const rows = await apiJson('api/communes.php?q=' + encodeURIComponent(q));
box.innerHTML = rows.map((r) =>
`<button class="scope-result" data-com="${esc(r.com)}" data-depc="${esc(r.depc)}">
${esc(r.com)} <span class="muted">${esc(r.dep)} (${esc(r.depc)}) · ${r.nb}</span>
</button>`).join('') || '<div class="muted" style="padding:4px 8px">Aucune commune</div>';
} catch { box.innerHTML = ''; }
}, 250);
$('users-admin').addEventListener('input', (ev) => {
if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target);
});
$('users-admin').addEventListener('click', async (ev) => {
const card = ev.target.closest('.user-card');
if (!card) return;
const uid = Number(card.dataset.uid);
try {
if (ev.target.closest('.scope-result')) {
const b = ev.target.closest('.scope-result');
await postJson('api/users.php?action=scope', { user_id: uid, com: b.dataset.com, depc: b.dataset.depc });
await loadUsers();
} else if (ev.target.closest('.chip-x')) {
await apiJson(`api/users.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' });
await loadUsers();
} else if (ev.target.closest('.u-mail')) {
const cur = card.querySelector('.user-email').textContent.replace(/^📧\s*/, '').trim();
const email = prompt('Adresse e-mail (vide pour retirer) :', cur === 'pas de-mail' ? '' : cur);
if (email !== null) { await postJson('api/users.php?action=email', { id: uid, email: email.trim() }); await loadUsers(); }
} else if (ev.target.closest('.u-pass')) {
const pwd = prompt('Nouveau mot de passe (≥ 8 caractères, ≥ 3 types) :');
if (pwd) {
if (window.PWPolicy && !PWPolicy.check(pwd).ok) { alert('Mot de passe trop faible : ' + PWPolicy.check(pwd).unmet.join(' ; ') + '.'); return; }
await postJson('api/users.php?action=password', { id: uid, password: pwd });
alert('Mot de passe mis à jour. Lutilisateur devra le changer à sa prochaine connexion.');
await loadUsers();
}
} else if (ev.target.closest('.u-del')) {
if (confirm('Supprimer cet administrateur ?')) {
await apiJson(`api/users.php?id=${uid}`, { method: 'DELETE' });
await loadUsers();
}
}
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Groupes d'utilisateurs (super-admin) ---
async function loadGroups() {
const [groups, users] = await Promise.all([
apiJson('api/groups.php'),
apiJson('api/users.php'),
]);
allUsers = users;
$('groups-admin').innerHTML = groups.map((g) => {
const memberIds = new Set(g.members.map((m) => m.id));
const memberChips = g.members.map((m) =>
`<span class="chip">${esc(m.username)} <button class="chip-x" data-member="${m.id}">×</button></span>`
).join(' ') || '<em>Aucun membre</em>';
const addable = allUsers.filter((u) => !memberIds.has(u.id));
const memberSelect = addable.length
? `<select class="g-member-add"><option value="">+ Ajouter un membre…</option>`
+ addable.map((u) => `<option value="${u.id}">${esc(u.username)}</option>`).join('')
+ `</select>`
: '';
const valBadge = g.can_validate ? '<span class="badge ouvert">valideur</span>' : '';
let scopesHtml = '';
if (g.can_validate) {
const chips = g.scopes.map((s) =>
`<span class="chip">${esc(s.com)} (${esc(s.depc)}) <button class="chip-x" data-scope="${s.id}">×</button></span>`
).join(' ') || '<em>Aucune commune</em>';
scopesHtml = `
<div class="user-scopes">${chips}</div>
<div class="scope-editor">
<input class="scope-search" placeholder="Ajouter une commune…">
<div class="scope-results"></div>
</div>`;
}
return `<div class="user-card group-card" data-gid="${g.id}">
<div class="user-head">
<strong>${esc(g.name)}</strong> ${valBadge}
<span class="user-actions">
<label class="inline-check"><input type="checkbox" class="g-validate" ${g.can_validate ? 'checked' : ''}> valideur</label>
<button class="btn-danger btn-sm g-del">Supprimer</button>
</span>
</div>
<div class="group-members">${memberChips} ${memberSelect}</div>
${scopesHtml}
</div>`;
}).join('') || '<p class="empty">Aucun groupe.</p>';
}
$('group-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
await postJson('api/groups.php', { name: $('ng-name').value.trim(), can_validate: $('ng-validate').checked });
$('ng-name').value = ''; $('ng-validate').checked = false;
await loadGroups();
} catch (err) { alert('Erreur : ' + err.message); }
});
$('groups-admin').addEventListener('input', (ev) => {
if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target);
});
$('groups-admin').addEventListener('change', async (ev) => {
const card = ev.target.closest('.group-card');
if (!card) return;
const gid = Number(card.dataset.gid);
try {
if (ev.target.classList.contains('g-validate')) {
await postJson('api/groups.php?action=toggle', { id: gid, can_validate: ev.target.checked });
await loadGroups();
} else if (ev.target.classList.contains('g-member-add') && ev.target.value) {
await postJson('api/groups.php?action=member', { group_id: gid, user_id: Number(ev.target.value) });
await loadGroups();
}
} catch (err) { alert('Erreur : ' + err.message); }
});
$('groups-admin').addEventListener('click', async (ev) => {
const card = ev.target.closest('.group-card');
if (!card) return;
const gid = Number(card.dataset.gid);
try {
if (ev.target.closest('.scope-result')) {
const b = ev.target.closest('.scope-result');
await postJson('api/groups.php?action=scope', { group_id: gid, com: b.dataset.com, depc: b.dataset.depc });
await loadGroups();
} else if (ev.target.closest('.chip-x') && ev.target.dataset.member) {
await apiJson(`api/groups.php?action=member&group_id=${gid}&user_id=${ev.target.dataset.member}`, { method: 'DELETE' });
await loadGroups();
} else if (ev.target.closest('.chip-x') && ev.target.dataset.scope) {
await apiJson(`api/groups.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' });
await loadGroups();
} else if (ev.target.closest('.g-del')) {
if (confirm('Supprimer ce groupe ? Les membres et le périmètre associés seront retirés.')) {
await apiJson(`api/groups.php?id=${gid}`, { method: 'DELETE' });
await loadGroups();
}
}
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Import opendata (super-admin) ---
$('import-btn').addEventListener('click', async () => {
if (!confirm('Télécharger et importer la dernière version de l\'opendata ?\nCela peut prendre quelques minutes.')) return;
const btn = $('import-btn');
btn.disabled = true;
$('import-msg').className = 'propose-msg';
$('import-msg').textContent = '⏳ Téléchargement et import en cours…';
try {
const r = await apiJson('api/import.php', { method: 'POST' });
$('import-msg').className = 'propose-msg ok';
$('import-msg').textContent = `✅ Import terminé : ${r.imported} établissements traités, ${r.skipped} ignorés. Total en base : ${r.total}.`;
} catch (err) {
$('import-msg').className = 'propose-msg err';
$('import-msg').textContent = 'Erreur : ' + err.message;
} finally {
btn.disabled = false;
}
});
// --- Import DPE ADEME + rattachement (super-admin) ---
$('dpe-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const dep = $('dpe-dep').value.trim();
const radius = Number($('dpe-radius').value) || 80;
const scope = dep ? `du département ${dep}` : 'COMPLET (~532 000 DPE)';
if (!confirm(`Importer le dataset DPE ${scope} et rattacher les bâtiments aux écoles (rayon ${radius} m) ?\nCela peut prendre plusieurs minutes.`)) return;
const btn = $('dpe-btn');
btn.disabled = true;
$('dpe-msg').className = 'propose-msg';
$('dpe-msg').textContent = '⏳ Import et rattachement en cours… (ne fermez pas cette page)';
try {
const r = await postJson('api/import_dpe.php', { dep, radius });
$('dpe-msg').className = 'propose-msg ok';
$('dpe-msg').textContent = `✅ Terminé : ${r.imported} DPE importés (${r.geocoded} géolocalisés) · ${r.links} rattachements pour ${r.etabs} établissements (≤ ${r.radius} m).`;
} catch (err) {
$('dpe-msg').className = 'propose-msg err';
$('dpe-msg').textContent = 'Erreur : ' + err.message;
} finally {
btn.disabled = false;
}
});
boot();