Files
ecoles/fiche.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

869 lines
40 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/* 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 };
})();