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
+868
View File
@@ -0,0 +1,868 @@
/* Fiche établissement plein écran — overlay superposé à la carte.
*
* L'overlay est simplement affiché/masqué : la carte Leaflet reste montée
* dessous, donc « retour à la carte » ne provoque aucun rechargement.
*
* Contenu servi par l'API PHP/MySQL :
* - arborescence globale de dossiers (api/folders.php) ;
* - éléments validés de l'établissement (api/items.php) : fichiers, photos, liens ;
* - soumission par tout internaute (statut « en attente » jusqu'à validation).
*/
(function () {
'use strict';
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
));
const fmtSize = (n) => {
if (n == null) return '';
if (n < 1024) return n + ' o';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' Ko';
return (n / 1024 / 1024).toFixed(1) + ' Mo';
};
// --- API ---
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;
}
let foldersCache = null; // arbre global, chargé une fois
const getFolders = async () => (foldersCache ??= await apiJson('api/folders.php'));
const getItems = (uai) => apiJson(`api/items.php?uai=${encodeURIComponent(uai)}`);
// --- État ---
let current = null; // établissement affiché
let items = []; // éléments validés de l'établissement
let selectedFolder = 'all';
let onLocate = null;
// Contexte de l'utilisateur connecté (déduit de api/comments.php?uai=).
let viewer = { auth: false, can_post: false, can_validate: false, role: null, username: null };
let estComments = null; // dernière réponse des commentaires d'établissement
const els = {};
const $ = (id) => document.getElementById(id);
function cacheEls() {
els.root = $('fiche');
els.back = $('fiche-back');
els.nom = $('fiche-nom');
els.sub = $('fiche-sub');
els.tabs = els.root.querySelectorAll('.fiche-tab');
els.panels = {
infos: $('tab-infos'),
docs: $('tab-docs'),
photos: $('tab-photos'),
propose: $('tab-propose'),
};
els.infoMain = $('info-main'); // colonne gauche : identité, coordonnées, effectifs, DPE
els.infoSide = $('info-side'); // colonne droite : commentaires
els.tree = $('folder-tree');
els.items = $('folder-items');
els.photoGrid = $('photo-grid');
els.locate = $('fiche-locate');
els.form = $('propose-form');
els.pFolder = $('p-folder');
els.proposeMsg = $('propose-msg');
}
// --- Onglets ---
function selectTab(name) {
els.tabs.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
Object.entries(els.panels).forEach(([k, p]) => p.classList.toggle('active', k === name));
}
// --- Informations ---
const OPTIONS = [
{ key: 'rest', label: 'Restauration' },
{ key: 'heb', label: 'Internat (hébergement)' },
{ key: 'ulis', label: 'ULIS' },
{ key: 'segp', label: 'SEGPA' },
];
function infoRow(label, value) {
if (value === null || value === undefined || value === '') return '';
return `<div class="info-row"><dt>${esc(label)}</dt><dd>${value}</dd></div>`;
}
function renderInfos(e) {
const badgeCls = e.etat === 'OUVERT' ? 'ouvert' : 'fermer';
const ville = [e.cp, e.com].filter(Boolean).join(' ');
const opts = OPTIONS.filter((o) => e[o.key] === 1).map((o) => o.label);
let webHtml = '';
if (e.web) {
const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web;
webHtml = `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(e.web)}</a>`;
}
const mailHtml = e.mail ? `<a href="mailto:${esc(e.mail)}">${esc(e.mail)}</a>` : '';
const telHtml = e.tel ? `<a href="tel:${esc(e.tel)}">${esc(e.tel)}</a>` : '';
const dep = [e.dep, e.depc ? `(${e.depc})` : ''].filter(Boolean).join(' ');
els.infoMain.querySelectorAll('.info-grid').forEach((n) => n.remove());
els.infoMain.insertAdjacentHTML('afterbegin', `
<div class="info-grid">
<section class="info-card"><h3>Identité</h3><dl>
${infoRow('Type', esc(e.type))}
${infoRow('Statut', esc(e.stat))}
${infoRow('État', e.etat ? `<span class="badge ${badgeCls}">${esc(e.etat)}</span>` : '')}
${infoRow('UAI', esc(e.id))}
</dl></section>
<section class="info-card"><h3>Coordonnées</h3><dl>
${infoRow('Adresse', esc(e.adr))}
${infoRow('Commune', esc(ville))}
${infoRow('Téléphone', telHtml)}
${infoRow('Courriel', mailHtml)}
${infoRow('Site web', webHtml)}
</dl></section>
<section class="info-card"><h3>Rattachement</h3><dl>
${infoRow('Région', esc(e.reg))}
${infoRow('Académie', esc(e.aca))}
${infoRow('Département', esc(dep))}
${infoRow('Circonscription', esc(e.circ))}
</dl></section>
<section class="info-card"><h3>Options &amp; services</h3><dl>
${infoRow('Options proposées', opts.length ? esc(opts.join(' · ')) : '<em>Aucune renseignée</em>')}
${infoRow('Coordonnées GPS', e.lat != null ? `${e.lat}, ${e.lon}` : '')}
</dl></section>
</div>`);
}
// --- Effectifs (écoles 1er degré) : derniers chiffres + évolution ---
function sparkline(series, key, color) {
// viewBox proportionné ; le SVG conserve son ratio (pas d'étirement).
const W = 640, H = 200, padX = 34, padT = 16, padB = 26;
const vals = series.map((s) => s[key]);
const minV = Math.min(...vals), maxV = Math.max(...vals);
const x = (i) => padX + (W - 2 * padX) * (series.length === 1 ? 0.5 : i / (series.length - 1));
const y = (v) => (H - padB) - (H - padT - padB) * (maxV === minV ? 0.5 : (v - minV) / (maxV - minV));
const pts = series.map((s, i) => `${x(i).toFixed(1)},${y(s[key]).toFixed(1)}`);
const area = `${padX},${H - padB} ${pts.join(' ')} ${(W - padX).toFixed(1)},${H - padB}`;
const last = series.length - 1;
const dots = series.map((s, i) =>
`<circle cx="${x(i).toFixed(1)}" cy="${y(s[key]).toFixed(1)}" r="3" fill="${color}"><title>${s.annee} : ${s[key]}</title></circle>`
).join('');
return `<svg class="eff-spark" viewBox="0 0 ${W} ${H}" role="img">
<polygon points="${area}" fill="${color}" opacity="0.12"/>
<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="2.5"/>
${dots}
<circle cx="${x(last).toFixed(1)}" cy="${y(series[last][key]).toFixed(1)}" r="5" fill="${color}"/>
<text x="${padX}" y="${H - 6}" class="eff-axis">${series[0].annee}</text>
<text x="${W - padX}" y="${H - 6}" class="eff-axis" text-anchor="end">${series[last].annee}</text>
<text x="4" y="${padT + 6}" class="eff-axis">${maxV}</text>
<text x="4" y="${H - padB}" class="eff-axis">${minV}</text>
</svg>`;
}
const EFF_LABEL = {
'Ecole': 'école (1<sup>er</sup> degré)',
'Collège': 'collège',
'Lycée': 'lycée',
};
// Graphique IPS : courbe de l'établissement + référence départementale (même échelle).
function ipsChart(series) {
const W = 640, H = 200, padX = 34, padT = 16, padB = 26;
const depVals = series.map((s) => s.ips_dep).filter((v) => v != null);
const hasDep = depVals.length > 0;
const vals = series.map((s) => s.ips).concat(depVals);
const minV = Math.min(...vals), maxV = Math.max(...vals);
const x = (i) => padX + (W - 2 * padX) * (series.length === 1 ? 0.5 : i / (series.length - 1));
const y = (v) => (H - padB) - (H - padT - padB) * (maxV === minV ? 0.5 : (v - minV) / (maxV - minV));
const poly = (key, color, extra = '') => {
const pts = series.map((s, i) => (s[key] != null ? `${x(i).toFixed(1)},${y(s[key]).toFixed(1)}` : null)).filter(Boolean);
return `<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="2.5" ${extra}/>`;
};
const dots = (key, color) => series.map((s, i) =>
s[key] != null ? `<circle cx="${x(i).toFixed(1)}" cy="${y(s[key]).toFixed(1)}" r="3" fill="${color}"><title>${s.annee} : ${s[key]}</title></circle>` : ''
).join('');
return `<svg class="eff-spark" viewBox="0 0 ${W} ${H}" role="img">
${hasDep ? poly('ips_dep', '#b08968', 'stroke-dasharray="5 4" opacity="0.85"') : ''}
${poly('ips', '#9d4edd')}
${hasDep ? dots('ips_dep', '#b08968') : ''}
${dots('ips', '#9d4edd')}
<text x="${padX}" y="${H - 6}" class="eff-axis">${series[0].annee}</text>
<text x="${W - padX}" y="${H - 6}" class="eff-axis" text-anchor="end">${series[series.length - 1].annee}</text>
<text x="4" y="${padT + 6}" class="eff-axis">${maxV}</text>
<text x="4" y="${H - padB}" class="eff-axis">${minV}</text>
</svg>`;
}
function ipsCardHtml(ips) {
const last = ips[ips.length - 1];
const first = ips[0];
const depTxt = last.ips_dep != null ? ` · département ${last.ips_dep}` : '';
const legend = last.ips_dep != null
? `<div class="ips-legend"><span class="ips-k ips-k-etab">Établissement</span><span class="ips-k ips-k-dep">Département</span></div>` : '';
// Ligne d'évolution seulement s'il y a au moins deux rentrées.
let evolHtml = '';
if (ips.length >= 2) {
const delta = +(last.ips - first.ips).toFixed(1);
const sign = delta > 0 ? '+' : '';
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
evolHtml = `<div class="eff-evol ${trend}">${first.annee}${last.annee} : ${sign}${delta} pts</div>`;
}
return `<section class="info-card ips-block">
<h3>IPS — indice de position sociale</h3>
<div class="eff-latest">
<span class="eff-num">${last.ips}</span>
<span class="eff-year">(rentrée ${last.annee}${depTxt})</span>
</div>
${legend}
${ipsChart(ips)}
${evolHtml}
<p class="ips-note">IPS élevé = milieu social plus favorisé (réf. nationale ≈ 100).</p>
</section>`;
}
// Carte « Effectifs » de l'établissement (sa propre évolution).
function effectifsCardHtml(serie, e) {
if (!Array.isArray(serie) || !serie.length) return '';
const last = serie[serie.length - 1];
const first = serie[0];
const delta = last.eleves - first.eleves;
const pct = first.eleves ? Math.round(delta / first.eleves * 100) : 0;
const sign = delta > 0 ? '+' : '';
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
const label = EFF_LABEL[e.type] || (e.type ? esc(e.type.toLowerCase()) : '');
const classesHtml = last.classes > 0
? ` · ${last.classes} classe${last.classes > 1 ? 's' : ''}` : '';
return `<section class="info-card effectifs-block">
<h3>Effectifs${label ? ' — ' + label : ''}</h3>
<div class="eff-latest">
<span class="eff-num">${last.eleves}</span> élèves${classesHtml}
<span class="eff-year">(rentrée ${last.annee})</span>
</div>
${sparkline(serie, 'eleves', '#1d3557')}
<div class="eff-evol ${trend}">
${first.annee}${last.annee} :
${sign}${delta} élève${Math.abs(delta) > 1 ? 's' : ''} (${sign}${pct} %)
</div>
</section>`;
}
// Carte « Effectifs cumulés des écoles PUBLIQUES de la commune ».
function communeCardHtml(commune, e) {
if (!Array.isArray(commune) || !commune.length) return '';
const last = commune[commune.length - 1];
const first = commune[0];
let evol = '';
if (commune.length >= 2) {
const delta = last.eleves - first.eleves;
const sign = delta > 0 ? '+' : '';
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
evol = `<div class="eff-evol ${trend}">${first.annee}${last.annee} :
${sign}${delta.toLocaleString('fr')} élève${Math.abs(delta) > 1 ? 's' : ''}</div>`;
}
return `<section class="info-card effectifs-block">
<h3>Écoles publiques de la commune${e.com ? ' — ' + esc(e.com) : ''}</h3>
<div class="eff-latest">
<span class="eff-num">${last.eleves.toLocaleString('fr')}</span> élèves
<span class="eff-year">(rentrée ${last.annee}, cumul public)</span>
</div>
${sparkline(commune, 'eleves', '#2a9d8f')}
${evol}
</section>`;
}
// Bloc d'une série de population : dernier chiffre + courbe + évolution.
function popSeriesHtml(serie, color) {
const last = serie[serie.length - 1];
const first = serie[0];
let evol = '';
if (serie.length >= 2) {
const delta = last.pop - first.pop;
const pct = first.pop ? Math.round(delta / first.pop * 100) : 0;
const sign = delta > 0 ? '+' : '';
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
evol = `<div class="eff-evol ${trend}">${first.annee}${last.annee} :
${sign}${delta.toLocaleString('fr')} hab. (${sign}${pct} %)</div>`;
}
return `<div class="eff-latest">
<span class="eff-num">${last.pop.toLocaleString('fr')}</span> habitants
<span class="eff-year">(${last.annee})</span>
</div>
${sparkline(serie, 'pop', color)}
${evol}`;
}
// Carte « Population » : zone (commune/arrondissement) + ville entière pour les arrondissements.
function populationCardHtml(data, e) {
const zone = (data && Array.isArray(data.zone)) ? data.zone : [];
if (!zone.length) return '';
const estimated = !!(data && data.zone_estimated);
const ville = (data && Array.isArray(data.ville)) ? data.ville : [];
const estNote = estimated
? '<p class="pop-note">Estimation : population de la ville × part de larrondissement dans les IRIS (recensement 2022).</p>'
: '';
let villeHtml = '';
if (ville.length) {
villeHtml = `<h4 class="eff-sub pop-sub">Ville entière${data.ville_nom ? ' — ' + esc(data.ville_nom) : ''}</h4>
${popSeriesHtml(ville, '#577590')}`;
}
return `<section class="info-card pop-block">
<h3>Population${e.com ? ' — ' + esc(e.com) : ''} ${estimated ? '(estimation)' : '(INSEE)'}</h3>
${popSeriesHtml(zone, estimated ? '#9aa7b0' : '#577590')}
${estNote}
${villeHtml}
</section>`;
}
// Établissement (effectifs · IPS) puis commune (écoles publiques · population), côte à côte.
async function renderEffectifs(e) {
els.infoMain.querySelectorAll('.eff-ips-row').forEach((n) => n.remove());
const [eff, communeEff, ips, popData] = await Promise.all([
apiJson(`api/effectifs.php?uai=${encodeURIComponent(e.id)}`).catch(() => []),
apiJson(`api/effectifs.php?action=commune&uai=${encodeURIComponent(e.id)}`).catch(() => []),
apiJson(`api/ips.php?uai=${encodeURIComponent(e.id)}`).catch(() => []),
apiJson(`api/population.php?uai=${encodeURIComponent(e.id)}`).catch(() => null),
]);
const cards = [
effectifsCardHtml(Array.isArray(eff) ? eff : [], e),
(Array.isArray(ips) && ips.length) ? ipsCardHtml(ips) : '',
communeCardHtml(Array.isArray(communeEff) ? communeEff : [], e),
populationCardHtml(popData, e),
].filter(Boolean);
if (!cards.length) return;
const row = document.createElement('div');
row.className = 'eff-ips-row';
row.innerHTML = cards.join('');
els.infoMain.appendChild(row);
}
// --- DPE (ADEME, bâtiments tertiaires) — rattaché par proximité ---
const DPE_NOTE = 'Rattaché au(x) bâtiment(s) tertiaire(s) le(s) plus proche(s) ' +
'(données ADEME). Le dataset na pas didentifiant école : le lien est ' +
'géographique, la distance indique sa fiabilité.';
function dpeBadge(letter, kind) {
const l = String(letter || '').toUpperCase();
const cls = /^[A-G]$/.test(l) ? 'dpe-' + l.toLowerCase() : 'dpe-na';
const k = kind === 'ges' ? 'GES' : 'Énergie';
return `<span class="dpe-badge ${cls}"><span class="dpe-k">${k}</span>${esc(l || '—')}</span>`;
}
async function renderDpe(e) {
els.infoMain.querySelectorAll('.dpe-block').forEach((n) => n.remove());
let list;
try {
list = await apiJson(`api/dpe.php?uai=${encodeURIComponent(e.id)}`);
} catch {
return;
}
if (!Array.isArray(list) || !list.length) return; // aucun DPE rattaché
const rows = list.map((d) => {
const meta = [];
if (d.conso != null) meta.push(`${d.conso} kWh<sub>EP</sub>/m²/an`);
if (d.ges != null) meta.push(`${d.ges} kg CO₂/m²/an`);
if (d.surface != null) meta.push(`${d.surface}`);
const dat = d.date_dpe ? `établi le ${esc(d.date_dpe)}` : '';
const fin = d.date_fin ? ` · valide jusquau ${esc(d.date_fin)}` : '';
return `<li class="dpe-item">
<div class="dpe-badges">${dpeBadge(d.etiquette_dpe, 'dpe')} ${dpeBadge(d.etiquette_ges, 'ges')}</div>
${meta.length ? `<div class="dpe-metaline">${meta.join(' · ')}</div>` : ''}
<div class="dpe-addr">${esc(d.adresse || '')} <span class="dpe-dist">≈ ${d.distance_m} m</span></div>
${dat || fin ? `<div class="dpe-dates">${dat}${fin}</div>` : ''}
</li>`;
}).join('');
const block = document.createElement('section');
block.className = 'info-card dpe-block';
block.innerHTML = `
<h3>Performance énergétique (DPE)</h3>
<ul class="dpe-list">${rows}</ul>
<p class="dpe-note">${DPE_NOTE}</p>`;
els.infoMain.appendChild(block);
}
// --- Commentaires (réservés aux utilisateurs, visibles par groupe) ---
const postJson = (url, body) => apiJson(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
function fmtDateTime(s) {
const d = new Date(String(s || '').replace(' ', 'T'));
return isNaN(d) ? esc(s) : d.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
}
function fmtDate(s) {
const d = new Date(String(s || '').replace(' ', 'T'));
return isNaN(d) ? '' : d.toLocaleDateString('fr-FR');
}
function commentItemHtml(c, canModerate) {
const del = c.mine || canModerate
? `<button class="comment-del" data-id="${c.id}" title="Supprimer">🗑</button>` : '';
return `<li class="comment-item">
<div class="comment-head">
<span class="comment-author">${esc(c.username)}</span>
<span class="comment-date">${fmtDateTime(c.created_at)}</span>
${del}
</div>
<div class="comment-body">${esc(c.body)}</div>
</li>`;
}
// Contenu interne d'un fil (connexion / lecture seule / liste + saisie).
// « intro » = true pour le fil d'établissement (textes plus explicites).
function threadInnerHtml(data, intro) {
if (!data.auth) {
return `
${intro ? '<p class="comment-intro">Les commentaires sont réservés aux utilisateurs. Connectez-vous pour les consulter et en déposer.</p>' : ''}
<form class="comment-login" autocomplete="off">
<input class="cl-user" type="text" placeholder="Identifiant" required>
<input class="cl-pass" type="password" placeholder="Mot de passe" required>
<button type="submit" class="btn-primary btn-sm">Se connecter</button>
<span class="comment-msg err"></span>
</form>
<p class="comment-forgot"><a href="forgot.html" target="_blank" rel="noopener">Mot de passe oublié ?</a></p>`;
}
const canModerate = data.role === 'super';
const list = data.comments.map((c) => commentItemHtml(c, canModerate));
const listHtml = list.length
? `<ul class="comment-list">${list.join('')}</ul>`
: '<p class="empty">Aucun commentaire pour le moment.</p>';
let form;
if (data.can_post) {
form = `<form class="comment-form" autocomplete="off">
<textarea class="cf-body" rows="3" placeholder="Votre commentaire…" required></textarea>
<div class="comment-form-foot">
<span class="comment-who">Connecté : <strong>${esc(data.username)}</strong></span>
<button type="submit" class="btn-primary btn-sm">Publier</button>
</div>
<span class="comment-msg"></span>
</form>`;
} else {
form = '<p class="comment-intro">Vous nappartenez à aucun groupe : lecture seule. Demandez à un administrateur de vous ajouter à un groupe pour commenter.</p>';
}
return form + listHtml;
}
// Charge le contexte visiteur + les commentaires d'établissement (une fois par fiche).
async function loadViewer(uai) {
try {
estComments = await apiJson(`api/comments.php?uai=${encodeURIComponent(uai)}`);
} catch {
estComments = { auth: false, can_post: false, can_validate: false, comments: [] };
}
viewer = {
auth: !!estComments.auth,
can_post: !!estComments.can_post,
can_validate: !!estComments.can_validate,
role: estComments.role || null,
username: estComments.username || null,
};
}
// Rendu du fil d'établissement, dans la colonne droite de l'onglet Informations.
function renderComments() {
els.infoSide.querySelectorAll('.comments-block').forEach((n) => n.remove());
if (!estComments) return;
const block = document.createElement('section');
block.className = 'info-card comments-block';
block.innerHTML = `<h3>💬 Commentaires</h3>
<div class="comment-thread" data-uai="${esc(current.id)}">${threadInnerHtml(estComments, true)}</div>`;
els.infoSide.appendChild(block);
}
// Portée d'un fil (établissement ou document) → URL d'API.
function threadUrl(threadEl) {
return threadEl.dataset.item
? `api/comments.php?item_id=${encodeURIComponent(threadEl.dataset.item)}`
: `api/comments.php?uai=${encodeURIComponent(threadEl.dataset.uai)}`;
}
// Recharge un fil donné (sans toucher au reste de la page).
async function refreshThread(threadEl) {
let data;
try { data = await apiJson(threadUrl(threadEl)); }
catch (err) { threadEl.innerHTML = `<p class="empty">Erreur : ${esc(err.message)}</p>`; return; }
threadEl.innerHTML = threadInnerHtml(data, !threadEl.dataset.item);
}
// Gestion commune (envoi connexion / commentaire, suppression) sur tout .comment-thread.
async function onThreadSubmit(ev) {
const loginForm = ev.target.closest('.comment-login');
const commentForm = ev.target.closest('.comment-form');
if (!loginForm && !commentForm) return;
ev.preventDefault();
const threadEl = ev.target.closest('.comment-thread');
try {
if (loginForm) {
const res = await postJson('api/auth.php?action=login', {
username: loginForm.querySelector('.cl-user').value,
password: loginForm.querySelector('.cl-pass').value,
});
if (res.user && res.user.must_change) {
threadEl.innerHTML = '<p class="comment-intro">Vous devez définir un nouveau mot de passe avant de continuer. <a href="account.html?forced=1" target="_blank" rel="noopener">Changer mon mot de passe</a>, puis rouvrez la fiche.</p>';
return;
}
await loadViewer(current.id); // met à jour droits (édition) + identité
renderComments();
renderDocs(); // réaffiche les documents (boutons d'édition)
} else {
const body = commentForm.querySelector('.cf-body').value.trim();
if (!body) return;
await postJson(threadUrl(threadEl), { body });
await refreshThread(threadEl);
}
} catch (err) {
const msg = ev.target.querySelector('.comment-msg');
if (msg) { msg.textContent = 'Erreur : ' + err.message; msg.className = 'comment-msg err'; }
}
}
async function onThreadClick(ev) {
const del = ev.target.closest('.comment-del');
if (!del) return;
const threadEl = ev.target.closest('.comment-thread');
if (!threadEl || !confirm('Supprimer ce commentaire ?')) return;
try {
await apiJson(`api/comments.php?id=${del.dataset.id}`, { method: 'DELETE' });
await refreshThread(threadEl);
} catch (err) { alert('Erreur : ' + err.message); }
}
// Interactions d'une carte document : déplier le fil, éditer le libellé/description.
async function onDocClick(ev) {
const toggle = ev.target.closest('.comment-toggle');
const edit = ev.target.closest('.doc-edit');
const cancel = ev.target.closest('.doc-edit-cancel');
if (toggle) {
const card = toggle.closest('.doc-card');
const box = card.querySelector('.doc-thread');
box.hidden = !box.hidden;
toggle.classList.toggle('open', !box.hidden);
if (!box.hidden && !box.dataset.loaded) {
box.dataset.loaded = '1';
box.innerHTML = `<div class="comment-thread" data-item="${card.dataset.id}"><p class="empty">Chargement…</p></div>`;
await refreshThread(box.querySelector('.comment-thread'));
}
return;
}
if (edit) {
const card = edit.closest('.doc-card');
const box = card.querySelector('.doc-edit-box');
if (!box.hidden) { box.hidden = true; box.innerHTML = ''; return; }
const it = items.find((x) => String(x.id) === String(card.dataset.id));
if (!it) return;
box.innerHTML = `<form class="doc-edit-form" autocomplete="off">
<input class="de-title" type="text" value="${esc(it.title)}" placeholder="Libellé" required>
<textarea class="de-desc" rows="2" placeholder="Description (facultatif)">${esc(it.description || '')}</textarea>
<div class="doc-edit-actions">
<button type="submit" class="btn-primary btn-sm">Enregistrer</button>
<button type="button" class="btn-secondary btn-sm doc-edit-cancel">Annuler</button>
<span class="comment-msg"></span>
</div>
</form>`;
box.hidden = false;
return;
}
if (cancel) {
const box = cancel.closest('.doc-edit-box');
box.hidden = true;
box.innerHTML = '';
}
}
async function onDocEditSubmit(ev) {
const form = ev.target.closest('.doc-edit-form');
if (!form) return false;
ev.preventDefault();
const card = form.closest('.doc-card');
const msg = form.querySelector('.comment-msg');
const title = form.querySelector('.de-title').value.trim();
const description = form.querySelector('.de-desc').value.trim();
if (!title) { msg.textContent = 'Le libellé est requis.'; msg.className = 'comment-msg err'; return true; }
try {
await postJson('api/items.php?action=update', { id: Number(card.dataset.id), title, description });
items = await getItems(current.id);
renderDocs();
} catch (err) { msg.textContent = 'Erreur : ' + err.message; msg.className = 'comment-msg err'; }
return true;
}
// --- Arborescence de dossiers ---
function childrenOf(parentId) {
return foldersCache
.filter((f) => f.parent_id === parentId)
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, 'fr'));
}
function countInFolder(folderId) {
return items.filter((it) => it.folder_id === folderId).length;
}
function treeNodeHtml(folder) {
const kids = childrenOf(folder.id);
const n = countInFolder(folder.id);
const active = String(selectedFolder) === String(folder.id) ? ' active' : '';
let html = `<li><span class="tree-folder${active}" data-folder="${folder.id}">`
+ `📁 ${esc(folder.name)}${n ? ` <span class="tree-count">${n}</span>` : ''}</span>`;
if (kids.length) {
html += '<ul>' + kids.map(treeNodeHtml).join('') + '</ul>';
}
return html + '</li>';
}
function renderTree() {
const noneCount = countInFolder(null);
let html = '<ul class="tree">';
html += `<li><span class="tree-folder${selectedFolder === 'all' ? ' active' : ''}" data-folder="all">`
+ `🗂 Tous les éléments <span class="tree-count">${items.length}</span></span></li>`;
html += childrenOf(null).map(treeNodeHtml).join('');
if (noneCount) {
html += `<li><span class="tree-folder${selectedFolder === 'none' ? ' active' : ''}" data-folder="none">`
+ `📂 Sans dossier <span class="tree-count">${noneCount}</span></span></li>`;
}
html += '</ul>';
els.tree.innerHTML = html;
}
// Ligne principale d'un élément (selon son type).
function itemMainHtml(it) {
if (it.kind === 'photo') {
return `<figure class="item-photo doc-thumb" data-url="${esc(it.url)}" data-title="${esc(it.title)}">
<img src="${esc(it.url)}" alt="${esc(it.title)}" loading="lazy">
</figure>
<div class="doc-name">🖼 ${esc(it.title)}</div>`;
}
if (it.kind === 'link') {
return `<div class="doc-name"><span class="item-icon">🔗</span>
<a class="item-name" href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.title)}</a></div>`;
}
return `<div class="doc-name"><span class="item-icon">📄</span>
<a class="item-name" href="${esc(it.url)}" download="${esc(it.title)}">${esc(it.title)}</a>
<span class="item-size">${fmtSize(it.size)}</span></div>`;
}
// Carte d'un élément : libellé, date de dépôt, description, édition, commentaires.
function docCardHtml(it) {
const date = it.submitted_at ? `<span class="item-date">📅 déposé le ${fmtDate(it.submitted_at)}</span>` : '';
const desc = it.description ? `<div class="item-desc">${esc(it.description)}</div>` : '';
const editBtn = viewer.can_validate ? `<button class="doc-edit" type="button" data-id="${it.id}">✏️ Éditer</button>` : '';
return `<div class="doc-card" data-id="${it.id}">
${itemMainHtml(it)}
${desc}
<div class="doc-meta">
${date}
<span class="doc-actions">${editBtn}<button class="comment-toggle" type="button" data-id="${it.id}">💬 Commentaires</button></span>
</div>
<div class="doc-edit-box" hidden></div>
<div class="doc-thread" hidden></div>
</div>`;
}
function renderItemsPanel() {
let list;
if (selectedFolder === 'all') list = items;
else if (selectedFolder === 'none') list = items.filter((it) => it.folder_id === null);
else list = items.filter((it) => String(it.folder_id) === String(selectedFolder));
if (!list.length) {
els.items.innerHTML = '<p class="empty">Aucun élément validé dans ce dossier.</p>';
return;
}
els.items.innerHTML = `<div class="doc-list">${list.map(docCardHtml).join('')}</div>`;
}
function renderDocs() {
renderTree();
renderItemsPanel();
}
// --- Photothèque (toutes les photos validées) ---
function photoFigureHtml(it) {
const date = it.submitted_at ? `<span class="photo-date">${fmtDate(it.submitted_at)}</span>` : '';
return `<figure class="item-photo" data-url="${esc(it.url)}" data-title="${esc(it.title)}">
<img src="${esc(it.url)}" alt="${esc(it.title)}" loading="lazy">
<figcaption>${esc(it.title)} ${date}</figcaption>
</figure>`;
}
function renderPhotos() {
const photos = items.filter((it) => it.kind === 'photo');
if (!photos.length) {
els.photoGrid.innerHTML = '<p class="empty">Aucune photo validée pour le moment.</p>';
return;
}
els.photoGrid.innerHTML = photos.map(photoFigureHtml).join('');
}
// --- Lightbox ---
function openLightbox(url, caption) {
const box = document.createElement('div');
box.className = 'lightbox';
box.innerHTML = `<img src="${esc(url)}" alt="${esc(caption)}"><span class="lb-cap">${esc(caption)}</span>`;
box.addEventListener('click', () => box.remove());
document.body.appendChild(box);
}
// --- Formulaire de proposition ---
function fillFolderSelect() {
const opts = ['<option value="">— Aucun dossier —</option>'];
const walk = (parentId, depth) => {
childrenOf(parentId).forEach((f) => {
opts.push(`<option value="${f.id}">${'  '.repeat(depth)}${esc(f.name)}</option>`);
walk(f.id, depth + 1);
});
};
walk(null, 0);
els.pFolder.innerHTML = opts.join('');
}
function syncProposeFields() {
const kind = els.form.querySelector('input[name="kind"]:checked').value;
els.form.querySelectorAll('[data-when]').forEach((el) => {
el.hidden = !el.dataset.when.split(' ').includes(kind);
});
}
async function submitProposal(ev) {
ev.preventDefault();
const kind = els.form.querySelector('input[name="kind"]:checked').value;
const fd = new FormData();
fd.append('kind', kind);
fd.append('folder_id', els.pFolder.value);
fd.append('title', $('p-title').value.trim());
fd.append('description', $('p-desc').value.trim());
fd.append('submitter_name', $('p-name').value.trim());
fd.append('submitter_email', $('p-mail').value.trim());
if (kind === 'link') {
const url = $('p-url').value.trim();
if (!/^https?:\/\//i.test(url)) {
return showProposeMsg('Veuillez saisir une URL valide (http(s)://…).', false);
}
fd.append('url', url);
} else {
const file = $('p-file').files[0];
if (!file) return showProposeMsg('Veuillez choisir un fichier.', false);
fd.append('file', file);
}
try {
await apiJson(`api/items.php?uai=${encodeURIComponent(current.id)}`, { method: 'POST', body: fd });
els.form.reset();
syncProposeFields();
showProposeMsg('✅ Merci ! Votre proposition a été enregistrée et sera publiée après validation.', true);
} catch (err) {
showProposeMsg('Erreur : ' + err.message, false);
}
}
function showProposeMsg(text, ok) {
els.proposeMsg.textContent = text;
els.proposeMsg.className = 'propose-msg ' + (ok ? 'ok' : 'err');
}
// --- Ouverture / fermeture ---
async function open(e) {
current = e;
items = [];
selectedFolder = 'all';
els.nom.textContent = e.nom || 'Établissement';
els.sub.textContent = [e.type, e.stat, [e.cp, e.com].filter(Boolean).join(' ')]
.filter(Boolean).join(' · ');
renderInfos(e);
renderEffectifs(e);
renderDpe(e);
estComments = null;
viewer = { auth: false, can_post: false, can_validate: false, role: null, username: null };
showProposeMsg('', true);
selectTab('infos');
els.root.hidden = false;
document.body.classList.add('fiche-open');
try {
await getFolders();
items = await getItems(e.id);
} catch (err) {
els.items.innerHTML = `<p class="empty">Erreur de chargement : ${esc(err.message)}</p>`;
}
await loadViewer(e.id); // contexte connecté (droits d'édition) + commentaires d'établissement
fillFolderSelect();
syncProposeFields();
renderDocs();
renderPhotos();
renderComments();
}
function close() {
els.root.hidden = true;
document.body.classList.remove('fiche-open');
current = null;
// La carte n'est pas touchée : aucun rechargement.
}
// --- Initialisation (appelée par app.js) ---
function init(opts) {
onLocate = (opts || {}).onLocate || null;
cacheEls();
els.back.addEventListener('click', close);
els.tabs.forEach((t) => t.addEventListener('click', () => selectTab(t.dataset.tab)));
// Sélection d'un dossier dans l'arbre.
els.tree.addEventListener('click', (ev) => {
const node = ev.target.closest('.tree-folder');
if (!node) return;
selectedFolder = node.dataset.folder === 'all' || node.dataset.folder === 'none'
? node.dataset.folder : Number(node.dataset.folder);
renderDocs();
});
// Agrandissement des photos (onglets Documents et Photothèque).
const onPhotoClick = (ev) => {
const fig = ev.target.closest('.item-photo');
if (fig) openLightbox(fig.dataset.url, fig.dataset.title);
};
els.photoGrid.addEventListener('click', onPhotoClick);
// Onglet Documents : photo (lightbox), déplier/éditer une carte, et fils de commentaires.
els.items.addEventListener('click', (ev) => {
if (!current) return;
if (ev.target.closest('.item-photo')) { onPhotoClick(ev); return; }
if (ev.target.closest('.comment-toggle, .doc-edit, .doc-edit-cancel')) { onDocClick(ev); return; }
onThreadClick(ev); // suppression d'un commentaire dans un fil de document
});
els.items.addEventListener('submit', (ev) => {
if (!current) return;
// Routage SYNCHRONE : chaque handler appelle ev.preventDefault() avant tout await.
if (ev.target.closest('.doc-edit-form')) onDocEditSubmit(ev); // édition libellé/description
else onThreadSubmit(ev); // connexion / commentaire (fil document)
});
// Fil de commentaires d'établissement (carte dans l'onglet Informations).
els.panels.infos.addEventListener('submit', (ev) => { if (current) onThreadSubmit(ev); });
els.panels.infos.addEventListener('click', (ev) => { if (current) onThreadClick(ev); });
// Formulaire de proposition.
els.form.addEventListener('submit', submitProposal);
els.form.querySelectorAll('input[name="kind"]').forEach((r) =>
r.addEventListener('change', syncProposeFields));
els.locate.addEventListener('click', () => {
const e = current;
close();
if (e && onLocate) onLocate(e);
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && !els.root.hidden) {
const lb = document.querySelector('.lightbox');
if (lb) lb.remove();
else close();
}
});
}
window.Fiche = { init, open, close };
})();