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>
869 lines
40 KiB
JavaScript
869 lines
40 KiB
JavaScript
/* 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) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"' }[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 & 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 l’arrondissement 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 n’a pas d’identifiant é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} m²`);
|
||
const dat = d.date_dpe ? `établi le ${esc(d.date_dpe)}` : '';
|
||
const fin = d.date_fin ? ` · valide jusqu’au ${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 n’appartenez à 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 };
|
||
})();
|