5633969d98
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>
461 lines
20 KiB
JavaScript
461 lines
20 KiB
JavaScript
/* Espace d'administration : connexion, modération, arborescence, admins, import. */
|
||
'use strict';
|
||
|
||
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"' }[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 d’e-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 d’e-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. L’utilisateur 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();
|