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>
This commit is contained in:
2026-06-03 21:35:41 +02:00
commit 5633969d98
40 changed files with 7242 additions and 0 deletions
+460
View File
@@ -0,0 +1,460 @@
/* 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();