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
+20
View File
@@ -0,0 +1,20 @@
# --- Données volumineuses (régénérables, voir README) ---
/raw_export.json
/data/etablissements.json
/data/insee/
# --- Téléversements des internautes ---
/uploads/
# --- Boîte d'envoi du mailer (transport « log ») ---
/private/
# --- Configuration locale / IDE / outils ---
/.idea/
/.claude/
/.vscode/
# --- Divers ---
*.log
.DS_Store
Thumbs.db
+296
View File
@@ -0,0 +1,296 @@
# Carte des établissements scolaires (France)
Carte interactive OpenStreetMap / Leaflet des ~68 500 établissements de
l'annuaire de l'Éducation nationale, avec filtres côté client.
Source des données : [data.education.gouv.fr — annuaire-education](https://data.education.gouv.fr/explore/dataset/fr-en-annuaire-education/).
## Lancer
L'application nécessite **PHP + MySQL** (la carte et la GED sont servies par une
API PHP qui lit la base ; pas de fichier `file://`).
### 1. Initialiser la base (une seule fois)
MySQL doit tourner (Laragon le démarre). Puis :
```sh
php setup.php # crée la base « ecoles », les tables, importe les établissements
php setup.php --reset # réimporte les établissements depuis data/etablissements.json
```
> Identifiants par défaut (Laragon : `root` / mot de passe vide) dans `api/db.php`.
### 2. Servir l'application
**Avec Laragon** : place le projet dans `www/`, puis ouvre `http://ecoles.test/`
(ou `http://localhost/ecoles/`).
**Sans Laragon**, serveur PHP intégré :
```sh
php -S 127.0.0.1:8000
```
puis ouvre http://127.0.0.1:8000/.
## Fiche établissement
Un clic sur un point → **« Voir la fiche complète »** ouvre une fiche plein écran
(overlay superposé à la carte, donc le retour ne recharge pas la carte) avec
quatre onglets :
- **Informations** — toutes les données de l'établissement + bouton « Localiser sur la carte »,
les effectifs, et le **fil de commentaires** de l'établissement (voir *Groupes & commentaires*)
- **Documents** — arborescence de dossiers + fichiers / liens / photos **validés**
- **Photothèque** — galerie des photos validées
- **Proposer un élément** — tout internaute peut soumettre un fichier, une photo ou un lien
Les éléments sont stockés **sur disque** dans `uploads/`, leurs métadonnées en base
(table `items`, type `file` / `photo` / `link`), indexés par UAI et rangés dans
l'arborescence globale (`folders`).
Chaque document/photo de l'onglet *Documents* affiche, en plus de son libellé, sa
**date de dépôt** et une **description** (texte explicatif). La description est saisie par
le proposant au dépôt et **modifiable** ensuite par les **valideurs** (bouton ✏️ visible
sur la fiche quand on est connecté avec un droit de validation sur l'établissement). Chaque
document a son **fil de commentaires** dépliable (bouton 💬), soumis aux **mêmes règles**
que les commentaires d'établissement (visibilité par groupe, dépôt réservé aux membres d'un
groupe, suppression par l'auteur ou le super-admin) — voir *Groupes & commentaires*. Les
commentaires d'un document et ceux de l'établissement restent distincts
(`comments.item_id`).
## Droits & modération
Trois niveaux :
| Rôle | Droits |
|---|---|
| **Internaute** (anonyme) | Proposer des éléments (statut « en attente ») |
| **Commentateur** | Compte sans droit de modération : participe aux **commentaires** des fiches via ses groupes |
| **Admin de périmètre** | Valider/rejeter les éléments des communes de son périmètre (commune + département) ou de ses groupes valideurs ; commente aussi |
| **Super-admin (plateforme)** | Tout valider, gérer l'arborescence, créer les utilisateurs et les groupes, importer l'opendata |
- Seuls les éléments **validés** sont visibles du public.
- Espace d'administration : **`admin.html`** (lien en bas de la barre latérale).
- Super-admin par défaut créé par `setup.php` : **`admin` / `admin`** — *à changer*.
- L'import opendata (bouton dans l'admin) télécharge la dernière version depuis
data.education.gouv.fr et met la base à jour (accès Internet requis côté serveur).
## Groupes d'utilisateurs & commentaires
Le super-admin organise les utilisateurs en **groupes** (section *Groupes
d'utilisateurs* de l'admin) :
- Un groupe marqué **valideur** donne à ses membres le droit de **valider les
documents** des établissements de **ses communes** (même modèle de périmètre que les
admins de périmètre, mais porté au niveau du groupe). Un utilisateur peut appartenir à
plusieurs groupes ; ses droits de validation sont l'union de ceux de ses groupes
valideurs.
- Tables : `user_groups` (nom + `can_validate`), `group_members` (appartenance n..n),
`group_scopes` (communes du groupe valideur). Le contrôle est centralisé dans
`can_validate_uai()` (`api/db.php`), qui accepte super-admin, périmètre personnel
*(legacy)* **ou** groupe valideur. API : `api/groups.php` (super-admin).
**Commentaires de fiche** — affichés directement dans l'onglet *Informations* :
- Un commentaire est **horodaté** (date + heure), porte un **texte** et le **nom** de son
auteur (table `comments`).
- Il n'est **visible qu'entre membres d'un même groupe** que l'auteur ; le super-admin
voit tout. Déposer un commentaire nécessite d'**appartenir à au moins un groupe**.
- L'**auteur** (et le super-admin) peuvent supprimer un commentaire ; pas d'édition
(horodatage figé). API : `api/comments.php`.
- La connexion se fait **directement dans la carte commentaires** (mini-formulaire), la session étant
partagée avec l'espace d'administration.
- Pour les participants qui ne doivent **pas** modérer, le super-admin crée des comptes
de rôle **Commentateur** (section *Utilisateurs* de l'admin) : ils se connectent et
commentent via leurs groupes, sans accès à la file de validation.
## Sécurité des mots de passe
- **Hachage** : bcrypt (`password_hash` / `PASSWORD_DEFAULT`), vérifié par
`password_verify`. Aucun mot de passe n'est stocké ni transmis en clair.
- **Politique de complexité** (`password_policy_check`, `api/db.php`) : **≥ 8 caractères**
et **au moins 3 types** parmi minuscule / majuscule / chiffre / caractère spécial.
Appliquée à la création, à la réinitialisation et au changement. Côté navigateur, une
**jauge de robustesse** (`pwpolicy.js`) guide la saisie.
- **Changement forcé** : les comptes créés par un admin (et `admin`/`admin` par défaut)
sont marqués `must_change` → l'utilisateur doit choisir un nouveau mot de passe à sa
première connexion (`account.html?forced=1`).
- **Self-service** : tout utilisateur connecté peut changer son mot de passe via
**`account.html`** (saisie du mot de passe actuel).
- **Mot de passe oublié** : depuis l'écran de connexion → **`forgot.html`** (identifiant
ou e-mail). Un lien à **usage unique** (valable 1 h) est envoyé par e-mail et consommé
sur **`reset.html`**. Table `password_resets` (jeton stocké **haché** en SHA-256).
- **Anti-bruteforce** : après **5 échecs** de connexion, le compte est **verrouillé 15 min**
(`users.failed_attempts` / `locked_until`).
- **Envoi d'e-mails** — transport configurable dans `api/db.php` (`MAIL_TRANSPORT`) :
- `log` (par défaut) : écrit l'e-mail (dont le lien) dans `private/mail_outbox.log`
(dossier protégé par `.htaccess`) — pratique en local sans serveur de mail ;
- `mail` : fonction PHP `mail()` ;
- `smtp` : client SMTP intégré (renseigner `SMTP_HOST/PORT/USER/PASS/SECURE`).
Pensez à ajuster `APP_BASE_URL` pour que les liens de réinitialisation soient corrects.
## Couche IRIS & population
- Fond clair (CartoDB Positron) par défaut ; sélecteur de couches en haut à droite.
- Couche **Contours IRIS** (WFS Géoplateforme `STATISTICALUNITS.IRIS:contours_iris`), chargée par zone visible (zoom ≥ 11).
- **IRIS cliquables** : un clic sur une zone affiche son nom, sa commune, son type, et la
**population du recensement INSEE 2022** (de l'IRIS et de la commune).
- Populations importées via `php import_pop.php` dans la table `iris_pop`
(population communale = somme des IRIS de la commune).
- Bouton **« 📍 Me localiser »** + centrage automatique sur la position de l'internaute
(nécessite HTTPS ou localhost).
- **Coloration par densité** : chaque IRIS est colorié selon sa densité (population ÷ surface),
avec une légende ; un clic affiche population de l'IRIS, densité, et population de la commune.
## Effectifs des écoles (1er degré)
- Dataset `fr-en-ecoles-effectifs-nb_classes` importé via `php import_effectifs.php`
dans la table `effectifs` (uai, année → élèves, classes ; ~809 000 lignes, 55 360 écoles).
- La fiche d'une **école / collège / lycée** affiche, dans l'onglet *Informations*, ses
**derniers effectifs** (élèves ; classes pour le 1er degré) et un **graphique d'évolution**
sur tout l'historique connu. (1er degré depuis 2009 ; collèges/lycées via leurs datasets dédiés.)
- Une carte voisine montre les **effectifs cumulés des écoles publiques de la commune**
(somme des élèves des établissements **publics** de la commune, par année) pour situer
l'établissement dans son contexte local. API : `api/effectifs.php?action=commune&uai=…`.
- **À côté**, un graphique de l'**IPS** (indice de position sociale) de l'établissement.
Trois datasets importés via `php import_ips.php` dans la table `ips` (clé `uai` + année) :
`fr-en-ips-ecoles-ap2022` (écoles, rentrées 2022→2024, **+ référence départementale**),
`fr-en-ips-colleges-ap2022` (collèges) et `fr-en-ips-lycees-ap2022` (lycées, IPS
ensemble GT+Pro). Pour les écoles, la courbe de l'établissement est tracée avec la
**référence départementale** (même échelle) ; collèges et lycées n'ont qu'une rentrée
(2022-2023) pour l'instant, donc un point. API : `api/ips.php?uai=…`. Un IPS élevé reflète
un milieu social plus favorisé (référence nationale ≈ 100).
`php import_ips.php --niveau=ecoles|colleges|lycees` permet d'importer un seul niveau.
- À côté de cette courbe, un graphique d'**évolution de la population** (INSEE) sur les
recensements successifs (**1968 → 2022**, 9 dates) au niveau de la **commune** — ou de
l'**arrondissement** pour Paris/Lyon/Marseille (déduit du code INSEE `comc`). Pour un
arrondissement, une **2ᵉ courbe « ville entière »** donne en plus l'évolution de la ville
complète. Source : INSEE, *base communale séries historiques du recensement*
(<https://www.insee.fr/fr/statistiques/fichier/8582555/base-cc-serie-historique-2022_csv.zip>),
qui contient le **vrai historique par arrondissement** (Paris 75101…, Lyon 69381…,
Marseille 13201…) ainsi que les totaux communes, importée via `php import_pop_insee.php`
dans la table `pop_commune`. API : `api/population.php?uai=…`
`{zone, zone_estimated, ville, ville_nom}`.
- L'importeur lit le CSV dans le zip sans l'extension PHP `zip` (mini-lecteur ZIP +
`gzinflate`). `zone_estimated` n'est utilisé qu'en **repli** (zone sans historique
propre) : population de la ville × part de l'arrondissement dans les IRIS 2022.
- `import_pop_hist.php` reste une **alternative** (CSV Opendatasoft, s'arrête ~2014, sans
arrondissements).
## Performance énergétique (DPE — ADEME)
- Dataset `dpe01tertiaire` (DPE des **bâtiments tertiaires** depuis juillet 2021,
~532 000 diagnostics), importé via l'API data-fair de l'ADEME.
- ⚠ Ce jeu de données **n'a pas d'identifiant école (UAI)** : les bâtiments sont
repérés par adresse BAN + géolocalisation. Le rattachement école↔DPE est donc
**géographique** (plus proche bâtiment de la même commune, sous **5 m** par
défaut — soit le même bâtiment / la même adresse), comme le rattachement
école→IRIS. La distance affichée mesure la fiabilité du lien ; `--radius=N`
permet d'élargir.
- Import en deux temps :
```sh
php import_dpe.php # remplit la table « dpe » (long ; --dep=75 pour tester)
php import_comc.php # (si pas déjà fait) code INSEE commune des écoles
php match_dpe.php # rattache les DPE aux écoles (table « etab_dpe » ; --radius=N)
```
- La fiche d'un établissement affiche, dans l'onglet *Informations*, le ou les
**DPE rattachés** : étiquettes **Énergie** et **GES** (A→G, palette officielle),
consommation, émissions, surface, dates et adresse du bâtiment.
- **Coloration de la carte** : un sélecteur dans la légende (en bas à droite)
permet de colorer les marqueurs par **type d'établissement** (défaut) ou par
**étiquette DPE** (A→G ; gris = aucun DPE rattaché). Les étiquettes du
sous-ensemble affiché sont chargées à la demande via `api/dpe.php?action=latest`.
- **Import depuis l'admin** : un super-admin peut lancer l'import + le rattachement
depuis `admin.html` (section *Performance énergétique (DPE)*), avec un département
optionnel et un rayon réglable. Opération longue ⇒ pour le dataset complet,
préférez les scripts CLI ci-dessus.
- API : `api/dpe.php?uai=…` (liste triée par distance) et `?action=latest` (batch,
meilleur match par UAI). Logique d'import/rattachement partagée dans `api/dpe_lib.php`.
## Coloration des IRIS (2 modes)
Quand la couche IRIS est active, un sélecteur (en bas à gauche) permet de choisir :
1. **Densité de population** — population 2022 ÷ surface de l'IRIS (palette jaune→rouge).
2. **Taux d'écoliers publics** — **à l'échelle de l'IRIS** : élèves des écoles publiques
(1er degré, dernière rentrée) **situées dans l'IRIS** ÷ population de l'IRIS,
en palette **verte** (vert pâle = faible taux → vert foncé = fort taux). Chaque école
est rattachée à l'IRIS qui la contient (point-dans-polygone, côté navigateur).
Chaque mode a sa propre légende. Données : `api/iris.php` (population), `api/effectifs.php?action=latest`
(effectifs des écoles). Le rattachement école→IRIS et le calcul du taux se font côté client sur la zone visible.
## Filtres disponibles
- Recherche texte (nom d'établissement / commune), insensible aux accents
- Type d'établissement (École, Collège, Lycée, Médico-social, EREA…)
- Statut (Public / Privé)
- État (OUVERT / À FERMER)
- Région → Académie → Département → Circonscription (sélecteurs en cascade)
- Options proposées : Restauration, Internat, ULIS, SEGPA (logique ET ; coché = établissements proposant l'option)
> Note : la colonne `apprentissage` existe dans la source mais n'y est jamais renseignée (0 établissement), elle n'est donc pas proposée comme filtre.
Les points sont regroupés (clustering) pour rester fluides à l'échelle nationale
et colorés par type d'établissement (voir la légende).
## Mettre à jour les données
1. Télécharger l'export à jour (champs réduits) :
```sh
curl "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-annuaire-education/exports/json?select=identifiant_de_l_etablissement,nom_etablissement,type_etablissement,statut_public_prive,libelle_region,libelle_academie,libelle_departement,code_departement,nom_commune,code_postal,adresse_1,telephone,mail,web,etat,nom_circonscription,restauration,hebergement,ulis,segpa,apprentissage,latitude,longitude" -o raw_export.json
```
2. Recompacter vers `data/etablissements.json` :
```sh
php build_data.php
```
## Fichiers
| Fichier | Rôle |
|---|---|
| `index.html` | Page carte (sidebar + overlay fiche) |
| `admin.html` · `admin.js` | Espace d'administration (connexion, modération, arbre, admins, import) |
| `style.css` | Mise en forme (carte, fiche, galeries, admin, pages d'auth) |
| `app.js` | Chargement (API), filtres en cascade, clustering, popups |
| `account.html` · `forgot.html` · `reset.html` | Parcours mot de passe (changer / oublié / réinitialiser) |
| `pwpolicy.js` | Politique de mot de passe côté client (validation + jauge) |
| `fiche.js` | Fiche plein écran : infos, documents (arbre), photothèque, proposition |
| `setup.php` | Crée base + tables, super-admin, arbre d'exemple, importe les établissements |
| `api/db.php` | PDO MySQL + JSON + session/auth + import partagé |
| `api/auth.php` | Connexion / déconnexion / utilisateur courant |
| `api/etablissements.php` | GET : tous les établissements (sert la carte) |
| `api/items.php` | Éléments (fichiers/photos/liens) : liste, soumission, validation, suppression |
| `api/folders.php` | Arborescence globale (CRUD, super-admin) |
| `api/users.php` | Admins de périmètre + leurs communes (super-admin) |
| `api/groups.php` | Groupes d'utilisateurs : membres, communes, droit de validation (super-admin) |
| `api/comments.php` | Commentaires de fiche (horodatés, visibles par groupe) |
| `api/communes.php` | Autocomplétion des communes (affectation de périmètre) |
| `api/import.php` | Import de la dernière version de l'opendata (super-admin) |
| `api/iris.php` | Population INSEE 2022 d'un IRIS et de sa commune (+ densité par batch) |
| `api/effectifs.php` | Historique des effectifs d'un établissement + `?action=latest` (batch) + `?action=commune` (cumul commune) |
| `api/ips.php` | Historique de l'IPS (indice de position sociale) d'une école |
| `api/population.php` | Historique de la population municipale (INSEE) de la commune |
| `api/dpe.php` | DPE rattachés à un établissement (`?uai=`) + `?action=latest` (batch, meilleur match) |
| `api/dpe_lib.php` | Logique partagée DPE (création tables, import data-fair, rattachement géo) |
| `api/import_dpe.php` | Import DPE + rattachement déclenché depuis l'admin (super-admin) |
| `import_dpe.php` | Importe les DPE tertiaires de l'ADEME dans `dpe` (CLI ; appelle `dpe_lib`) |
| `match_dpe.php` | Rattache les DPE aux écoles par proximité géographique (CLI ; table `etab_dpe`) |
| `import_pop.php` | Importe les populations INSEE 2022 dans `iris_pop` (voir en-tête du script) |
| `import_effectifs.php` | Importe les effectifs des écoles (1er degré) dans `effectifs` |
| `import_ips.php` | Importe l'IPS (indice de position sociale) des écoles, collèges et lycées dans `ips` |
| `import_pop_insee.php` | Importe l'historique des populations INSEE (base séries historiques, communes **et arrondissements**, 1968→2022) dans `pop_commune` |
| `import_pop_hist.php` | Variante : historique des populations via CSV Opendatasoft (~2014) → `pop_commune` |
| `import_effectifs_2d.php` | Importe les effectifs des collèges et lycées dans `effectifs` |
| `import_comc.php` | Renseigne le code INSEE commune (`comc`) des établissements |
| `uploads/files/` · `uploads/photos/` | Fichiers et photos stockés sur disque |
| `build_data.php` | Convertit `raw_export.json` → `data/etablissements.json` (source du premier import) |
| `data/etablissements.json` | Jeu de données importé en base par `setup.php` |
| `raw_export.json` | Export brut temporaire (supprimable après build) |
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mon mot de passe</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card">
<h1>Mon mot de passe</h1>
<div id="forced-banner" class="forced-banner" hidden>
🔒 Pour des raisons de sécurité, vous devez choisir un nouveau mot de passe avant de continuer.
</div>
<p id="who" class="hint"></p>
<div id="not-auth" hidden>
<p class="propose-msg err">Vous n'êtes pas connecté.</p>
<p class="auth-foot"><a href="admin.html">Se connecter</a></p>
</div>
<form id="change-form" autocomplete="off" hidden>
<div class="field">
<label for="cur">Mot de passe actuel</label>
<input id="cur" type="password" required>
</div>
<div class="field">
<label for="pw">Nouveau mot de passe</label>
<input id="pw" type="password" required>
<div id="pw-meter" class="pw-meter"></div>
</div>
<div class="field">
<label for="pw2">Confirmer</label>
<input id="pw2" type="password" required>
</div>
<button type="submit" class="btn-primary">Changer le mot de passe</button>
<p id="msg" class="propose-msg"></p>
</form>
<p id="nav-foot" class="auth-foot" hidden><a href="admin.html">← Espace administration</a> · <a href="index.html">Carte</a></p>
</div>
</div>
<script src="pwpolicy.js"></script>
<script>
const $ = (id) => document.getElementById(id);
const forced = new URLSearchParams(location.search).get('forced') === '1';
PWPolicy.attach($('pw'), $('pw-meter'));
(async function boot() {
let user = null;
try { user = (await (await fetch('api/auth.php?action=me')).json()).user; } catch {}
if (!user) { $('not-auth').hidden = false; return; }
$('who').textContent = `Connecté : ${user.username}`;
$('change-form').hidden = false;
if (forced || user.must_change) {
$('forced-banner').hidden = false;
} else {
$('nav-foot').hidden = false;
}
})();
$('change-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const cur = $('cur').value, pw = $('pw').value, pw2 = $('pw2').value;
const r = PWPolicy.check(pw);
if (!r.ok) { showErr('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; }
if (pw !== pw2) { showErr('Les deux mots de passe ne correspondent pas.'); return; }
try {
const res = await fetch('api/auth.php?action=change_password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: cur, new: pw }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) { showErr(data.error || ('HTTP ' + res.status)); return; }
$('change-form').reset();
$('msg').className = 'propose-msg ok';
$('msg').innerHTML = '✅ Mot de passe changé. <a href="admin.html">Continuer</a>.';
$('forced-banner').hidden = true;
} catch (err) { showErr('Erreur : ' + err.message); }
});
function showErr(t) { $('msg').className = 'propose-msg err'; $('msg').textContent = t; }
</script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Administration — Établissements scolaires</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body class="admin-page">
<header class="admin-bar">
<h1>🔐 Administration</h1>
<div class="admin-user">
<span id="who"></span>
<a id="account-link" href="account.html" class="btn-secondary btn-sm" hidden>🔑 Mon mot de passe</a>
<a href="index.html" class="btn-secondary btn-sm">← Carte</a>
<button id="logout" type="button" class="btn-secondary btn-sm" hidden>Déconnexion</button>
</div>
</header>
<!-- Connexion -->
<section id="login" class="admin-section login-card" hidden>
<h2>Connexion administrateur</h2>
<form id="login-form" autocomplete="off">
<div class="field">
<label for="l-user">Identifiant</label>
<input id="l-user" type="text" required>
</div>
<div class="field">
<label for="l-pass">Mot de passe</label>
<input id="l-pass" type="password" required>
</div>
<button type="submit" class="btn-primary">Se connecter</button>
<p id="login-msg" class="propose-msg err"></p>
<p class="auth-foot"><a href="forgot.html">Mot de passe oublié ?</a></p>
</form>
</section>
<!-- Espace connecté -->
<main id="admin" class="admin-main" hidden>
<!-- File de validation -->
<section class="admin-section">
<h2>File d'attente de validation <span id="queue-count" class="tree-count"></span></h2>
<p class="hint">Éléments proposés par les internautes, en attente dans votre périmètre.</p>
<div id="queue" class="queue"></div>
</section>
<!-- Réservé au super-admin -->
<div id="super-only" hidden>
<section class="admin-section">
<h2>Arborescence des dossiers</h2>
<p class="hint">Arbre global appliqué à tous les établissements.</p>
<form id="folder-form" class="inline-form">
<input id="nf-name" type="text" placeholder="Nom du dossier" required>
<select id="nf-parent"></select>
<button type="submit" class="btn-primary btn-sm">Ajouter</button>
</form>
<div id="folders-admin" class="folders-admin"></div>
</section>
<section class="admin-section">
<h2>Utilisateurs</h2>
<p class="hint"><strong>Admin de périmètre</strong> : valide les documents de ses communes / groupes valideurs. <strong>Commentateur</strong> : participe seulement aux commentaires de fiche (via ses groupes), sans accès à la modération.</p>
<form id="user-form" class="inline-form">
<input id="nu-user" type="text" placeholder="Identifiant" required>
<input id="nu-mail" type="email" placeholder="E-mail (pour la récupération)">
<input id="nu-pass" type="text" placeholder="Mot de passe" required>
<select id="nu-role" title="Rôle de l'utilisateur">
<option value="perimeter">Admin de périmètre</option>
<option value="member">Commentateur</option>
</select>
<button type="submit" class="btn-primary btn-sm">Créer l'utilisateur</button>
</form>
<div id="nu-pw-meter" class="pw-meter"></div>
<p class="hint">Le mot de passe doit faire ≥ 8 caractères et combiner ≥ 3 types (minuscule, majuscule, chiffre, spécial). L'utilisateur devra le changer à sa 1<sup>re</sup> connexion.</p>
<div id="users-admin" class="users-admin"></div>
</section>
<section class="admin-section">
<h2>Groupes d'utilisateurs</h2>
<p class="hint">Un groupe rassemble des utilisateurs. Un groupe <strong>valideur</strong> permet à ses membres de valider les documents des établissements de ses communes. Les commentaires de fiche sont visibles entre membres d'un même groupe.</p>
<form id="group-form" class="inline-form">
<input id="ng-name" type="text" placeholder="Nom du groupe" required>
<label class="inline-check"><input id="ng-validate" type="checkbox"> peut valider les documents</label>
<button type="submit" class="btn-primary btn-sm">Créer un groupe</button>
</form>
<div id="groups-admin" class="users-admin"></div>
</section>
<section class="admin-section">
<h2>Import de l'opendata</h2>
<p class="hint">Télécharge la dernière version de l'annuaire de l'Éducation nationale et met à jour la base (accès Internet requis côté serveur).</p>
<button id="import-btn" type="button" class="btn-primary">⤓ Importer la dernière version</button>
<p id="import-msg" class="propose-msg"></p>
</section>
<section class="admin-section">
<h2>Performance énergétique (DPE — ADEME)</h2>
<p class="hint">Télécharge les DPE des bâtiments tertiaires de l'ADEME et les rattache aux établissements par proximité géographique. <strong>Opération longue</strong> (le dataset complet ≈ 532 000 DPE) ; laissez un département vide pour tout importer, ou indiquez un code pour un import ciblé. Accès Internet requis côté serveur.</p>
<form id="dpe-form" class="inline-form">
<input id="dpe-dep" type="text" placeholder="Département (ex. 75) — vide = tout" maxlength="3">
<input id="dpe-radius" type="number" min="1" max="2000" value="5" title="Rayon de rattachement (m) — 5 m = même bâtiment / adresse">
<button id="dpe-btn" type="submit" class="btn-primary btn-sm">⤓ Importer &amp; rattacher</button>
</form>
<p id="dpe-msg" class="propose-msg"></p>
</section>
</div>
</main>
<script src="pwpolicy.js"></script>
<script src="admin.js"></script>
</body>
</html>
+460
View File
@@ -0,0 +1,460 @@
/* Espace d'administration : connexion, modération, arborescence, admins, import. */
'use strict';
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
));
const $ = (id) => document.getElementById(id);
async function apiJson(url, opts) {
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
return data;
}
const postJson = (url, body) =>
apiJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
let me = null;
let folders = [];
let allUsers = []; // liste des utilisateurs (pour l'ajout de membres aux groupes)
// --- Démarrage / session ---
async function boot() {
try {
me = (await apiJson('api/auth.php?action=me')).user;
} catch { me = null; }
me ? showAdmin() : showLogin();
}
function showLogin() {
$('login').hidden = false;
$('admin').hidden = true;
$('logout').hidden = true;
$('who').textContent = '';
}
const ROLE_LABEL = { super: 'admin plateforme', perimeter: 'admin périmètre', member: 'commentateur' };
async function showAdmin() {
// Changement de mot de passe forcé : on redirige avant tout.
if (me.must_change) {
location.href = 'account.html?forced=1';
return;
}
$('login').hidden = true;
$('admin').hidden = false;
$('logout').hidden = false;
$('account-link').hidden = false;
$('who').textContent = `${me.username}${ROLE_LABEL[me.role] || me.role}`;
$('super-only').hidden = me.role !== 'super';
// Commentateur : pas de file de modération ; il commente depuis les fiches.
if (me.role === 'member') {
$('queue-count').textContent = '';
$('queue').innerHTML = '<p class="empty">Compte commentateur : rendez-vous sur une fiche d’établissement (onglet 💬 Commentaires) pour participer.</p>';
return;
}
await loadQueue();
if (me.role === 'super') {
await loadFolders();
await loadUsers();
await loadGroups();
}
}
$('login-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
me = (await postJson('api/auth.php?action=login',
{ username: $('l-user').value, password: $('l-pass').value })).user;
$('login-msg').textContent = '';
await showAdmin();
} catch (err) {
$('login-msg').textContent = err.message;
}
});
$('logout').addEventListener('click', async () => {
await apiJson('api/auth.php?action=logout', { method: 'POST' });
me = null;
showLogin();
});
// --- File de validation ---
async function loadQueue() {
let q;
try { q = await apiJson('api/items.php?action=queue'); }
catch (err) { $('queue').innerHTML = `<p class="empty">${esc(err.message)}</p>`; return; }
$('queue-count').textContent = q.length || '';
if (!q.length) { $('queue').innerHTML = '<p class="empty">Aucun élément en attente. 🎉</p>'; return; }
$('queue').innerHTML = q.map((it) => {
const icon = it.kind === 'photo' ? '🖼' : it.kind === 'link' ? '🔗' : '📄';
let preview;
if (it.kind === 'photo') preview = `<a href="${esc(it.url)}" target="_blank"><img class="q-thumb" src="${esc(it.url)}" alt=""></a>`;
else if (it.kind === 'link') preview = `<a href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.url)}</a>`;
else preview = `<a href="${esc(it.url)}" target="_blank">Télécharger</a>`;
const who = [it.submitter_name, it.submitter_email].filter(Boolean).join(' · ') || 'Anonyme';
const descHtml = it.description ? `<div class="q-desc">${esc(it.description)}</div>` : '';
return `<div class="q-item" data-id="${it.id}">
<div class="q-main">
<div class="q-title">${icon} ${esc(it.title)}</div>
${descHtml}
<div class="q-meta">${esc(it.etab)}</div>
<div class="q-meta">Proposé par : ${esc(who)} · ${esc(it.submitted_at || '')}</div>
<div class="q-preview">${preview}</div>
</div>
<div class="q-actions">
<button class="btn-primary btn-sm q-approve">✓ Valider</button>
<button class="btn-danger btn-sm q-reject">✗ Rejeter</button>
</div>
</div>`;
}).join('');
}
$('queue').addEventListener('click', async (ev) => {
const card = ev.target.closest('.q-item');
if (!card) return;
const id = Number(card.dataset.id);
try {
if (ev.target.closest('.q-approve')) {
await postJson('api/items.php?action=validate', { id, decision: 'approve' });
} else if (ev.target.closest('.q-reject')) {
const reason = prompt('Motif du rejet (facultatif) :') ?? '';
await postJson('api/items.php?action=validate', { id, decision: 'reject', reason });
} else return;
await loadQueue();
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Arborescence (super-admin) ---
async function loadFolders() {
folders = await apiJson('api/folders.php');
renderFolderParentSelect();
renderFoldersAdmin();
}
const childrenOf = (pid) => folders
.filter((f) => f.parent_id === pid)
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, 'fr'));
function renderFolderParentSelect() {
const opts = ['<option value="">(Racine)</option>'];
const walk = (pid, d) => childrenOf(pid).forEach((f) => {
opts.push(`<option value="${f.id}">${' '.repeat(d)}${esc(f.name)}</option>`);
walk(f.id, d + 1);
});
walk(null, 0);
$('nf-parent').innerHTML = opts.join('');
}
function folderNodeHtml(f) {
const kids = childrenOf(f.id);
let html = `<li><span class="fa-row" data-id="${f.id}">
<span class="fa-name">📁 ${esc(f.name)}</span>
<button class="fa-rename" title="Renommer">✎</button>
<button class="fa-del" title="Supprimer">🗑</button>
</span>`;
if (kids.length) html += '<ul>' + kids.map(folderNodeHtml).join('') + '</ul>';
return html + '</li>';
}
function renderFoldersAdmin() {
const roots = childrenOf(null);
$('folders-admin').innerHTML = roots.length
? '<ul class="tree">' + roots.map(folderNodeHtml).join('') + '</ul>'
: '<p class="empty">Aucun dossier.</p>';
}
$('folder-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
await postJson('api/folders.php', { name: $('nf-name').value.trim(), parent_id: $('nf-parent').value || null });
$('nf-name').value = '';
await loadFolders();
} catch (err) { alert('Erreur : ' + err.message); }
});
$('folders-admin').addEventListener('click', async (ev) => {
const row = ev.target.closest('.fa-row');
if (!row) return;
const id = Number(row.dataset.id);
const name = row.querySelector('.fa-name').textContent.replace(/^📁\s*/, '');
try {
if (ev.target.closest('.fa-rename')) {
const nv = prompt('Nouveau nom :', name);
if (nv && nv.trim()) {
await apiJson('api/folders.php', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name: nv.trim() }) });
await loadFolders();
}
} else if (ev.target.closest('.fa-del')) {
if (confirm(`Supprimer « ${name} » et ses sous-dossiers ?\nLes éléments associés seront déplacés vers « Sans dossier ».`)) {
await apiJson(`api/folders.php?id=${id}`, { method: 'DELETE' });
await loadFolders();
}
}
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Administrateurs de périmètre (super-admin) ---
async function loadUsers() {
const users = await apiJson('api/users.php');
$('users-admin').innerHTML = users.map((u) => {
const roleBadge = u.role === 'super'
? '<span class="badge ouvert">plateforme</span>'
: u.role === 'member'
? '<span class="badge">commentateur</span>'
: '<span class="badge">périmètre</span>';
let scopes;
if (u.role === 'super') scopes = '<em>Tous les établissements</em>';
else if (u.role === 'member') scopes = '<em>Commente via ses groupes (aucune validation)</em>';
else scopes = u.scopes.map((s) =>
`<span class="chip">${esc(s.com)} (${esc(s.depc)}) <button class="chip-x" data-scope="${s.id}">×</button></span>`
).join(' ') || '<em>Aucune commune</em>';
// Périmètre personnel réservé aux admins de périmètre.
const scopeEditor = u.role === 'perimeter' ? `
<div class="scope-editor">
<input class="scope-search" data-uid="${u.id}" placeholder="Ajouter une commune…">
<div class="scope-results" data-uid="${u.id}"></div>
</div>` : '';
const mustBadge = u.must_change ? '<span class="badge warn">⚠ doit changer</span>' : '';
const emailHtml = u.email ? esc(u.email) : '<em>pas de-mail</em>';
return `<div class="user-card" data-uid="${u.id}">
<div class="user-head">
<strong>${esc(u.username)}</strong> ${roleBadge} ${mustBadge}
<span class="user-actions">
<button class="btn-secondary btn-sm u-mail">✉ E-mail</button>
<button class="btn-secondary btn-sm u-pass">Mot de passe</button>
${u.id === me.id ? '' : '<button class="btn-danger btn-sm u-del">Supprimer</button>'}
</span>
</div>
<div class="user-email muted">📧 ${emailHtml}</div>
<div class="user-scopes">${scopes}</div>
${scopeEditor}
</div>`;
}).join('');
}
if (window.PWPolicy) PWPolicy.attach($('nu-pass'), $('nu-pw-meter'));
$('user-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const pw = $('nu-pass').value;
if (window.PWPolicy) {
const r = PWPolicy.check(pw);
if (!r.ok) { alert('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; }
}
try {
await postJson('api/users.php', {
username: $('nu-user').value.trim(),
email: $('nu-mail').value.trim(),
password: pw,
role: $('nu-role').value,
});
$('nu-user').value = ''; $('nu-mail').value = ''; $('nu-pass').value = '';
$('nu-pw-meter').innerHTML = '';
await loadUsers();
} catch (err) { alert('Erreur : ' + err.message); }
});
const searchCommunes = debounce(async (input) => {
const q = input.value.trim();
const box = input.parentElement.querySelector('.scope-results');
if (q.length < 2) { box.innerHTML = ''; return; }
try {
const rows = await apiJson('api/communes.php?q=' + encodeURIComponent(q));
box.innerHTML = rows.map((r) =>
`<button class="scope-result" data-com="${esc(r.com)}" data-depc="${esc(r.depc)}">
${esc(r.com)} <span class="muted">${esc(r.dep)} (${esc(r.depc)}) · ${r.nb}</span>
</button>`).join('') || '<div class="muted" style="padding:4px 8px">Aucune commune</div>';
} catch { box.innerHTML = ''; }
}, 250);
$('users-admin').addEventListener('input', (ev) => {
if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target);
});
$('users-admin').addEventListener('click', async (ev) => {
const card = ev.target.closest('.user-card');
if (!card) return;
const uid = Number(card.dataset.uid);
try {
if (ev.target.closest('.scope-result')) {
const b = ev.target.closest('.scope-result');
await postJson('api/users.php?action=scope', { user_id: uid, com: b.dataset.com, depc: b.dataset.depc });
await loadUsers();
} else if (ev.target.closest('.chip-x')) {
await apiJson(`api/users.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' });
await loadUsers();
} else if (ev.target.closest('.u-mail')) {
const cur = card.querySelector('.user-email').textContent.replace(/^📧\s*/, '').trim();
const email = prompt('Adresse e-mail (vide pour retirer) :', cur === 'pas de-mail' ? '' : cur);
if (email !== null) { await postJson('api/users.php?action=email', { id: uid, email: email.trim() }); await loadUsers(); }
} else if (ev.target.closest('.u-pass')) {
const pwd = prompt('Nouveau mot de passe (≥ 8 caractères, ≥ 3 types) :');
if (pwd) {
if (window.PWPolicy && !PWPolicy.check(pwd).ok) { alert('Mot de passe trop faible : ' + PWPolicy.check(pwd).unmet.join(' ; ') + '.'); return; }
await postJson('api/users.php?action=password', { id: uid, password: pwd });
alert('Mot de passe mis à jour. Lutilisateur devra le changer à sa prochaine connexion.');
await loadUsers();
}
} else if (ev.target.closest('.u-del')) {
if (confirm('Supprimer cet administrateur ?')) {
await apiJson(`api/users.php?id=${uid}`, { method: 'DELETE' });
await loadUsers();
}
}
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Groupes d'utilisateurs (super-admin) ---
async function loadGroups() {
const [groups, users] = await Promise.all([
apiJson('api/groups.php'),
apiJson('api/users.php'),
]);
allUsers = users;
$('groups-admin').innerHTML = groups.map((g) => {
const memberIds = new Set(g.members.map((m) => m.id));
const memberChips = g.members.map((m) =>
`<span class="chip">${esc(m.username)} <button class="chip-x" data-member="${m.id}">×</button></span>`
).join(' ') || '<em>Aucun membre</em>';
const addable = allUsers.filter((u) => !memberIds.has(u.id));
const memberSelect = addable.length
? `<select class="g-member-add"><option value="">+ Ajouter un membre…</option>`
+ addable.map((u) => `<option value="${u.id}">${esc(u.username)}</option>`).join('')
+ `</select>`
: '';
const valBadge = g.can_validate ? '<span class="badge ouvert">valideur</span>' : '';
let scopesHtml = '';
if (g.can_validate) {
const chips = g.scopes.map((s) =>
`<span class="chip">${esc(s.com)} (${esc(s.depc)}) <button class="chip-x" data-scope="${s.id}">×</button></span>`
).join(' ') || '<em>Aucune commune</em>';
scopesHtml = `
<div class="user-scopes">${chips}</div>
<div class="scope-editor">
<input class="scope-search" placeholder="Ajouter une commune…">
<div class="scope-results"></div>
</div>`;
}
return `<div class="user-card group-card" data-gid="${g.id}">
<div class="user-head">
<strong>${esc(g.name)}</strong> ${valBadge}
<span class="user-actions">
<label class="inline-check"><input type="checkbox" class="g-validate" ${g.can_validate ? 'checked' : ''}> valideur</label>
<button class="btn-danger btn-sm g-del">Supprimer</button>
</span>
</div>
<div class="group-members">${memberChips} ${memberSelect}</div>
${scopesHtml}
</div>`;
}).join('') || '<p class="empty">Aucun groupe.</p>';
}
$('group-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
await postJson('api/groups.php', { name: $('ng-name').value.trim(), can_validate: $('ng-validate').checked });
$('ng-name').value = ''; $('ng-validate').checked = false;
await loadGroups();
} catch (err) { alert('Erreur : ' + err.message); }
});
$('groups-admin').addEventListener('input', (ev) => {
if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target);
});
$('groups-admin').addEventListener('change', async (ev) => {
const card = ev.target.closest('.group-card');
if (!card) return;
const gid = Number(card.dataset.gid);
try {
if (ev.target.classList.contains('g-validate')) {
await postJson('api/groups.php?action=toggle', { id: gid, can_validate: ev.target.checked });
await loadGroups();
} else if (ev.target.classList.contains('g-member-add') && ev.target.value) {
await postJson('api/groups.php?action=member', { group_id: gid, user_id: Number(ev.target.value) });
await loadGroups();
}
} catch (err) { alert('Erreur : ' + err.message); }
});
$('groups-admin').addEventListener('click', async (ev) => {
const card = ev.target.closest('.group-card');
if (!card) return;
const gid = Number(card.dataset.gid);
try {
if (ev.target.closest('.scope-result')) {
const b = ev.target.closest('.scope-result');
await postJson('api/groups.php?action=scope', { group_id: gid, com: b.dataset.com, depc: b.dataset.depc });
await loadGroups();
} else if (ev.target.closest('.chip-x') && ev.target.dataset.member) {
await apiJson(`api/groups.php?action=member&group_id=${gid}&user_id=${ev.target.dataset.member}`, { method: 'DELETE' });
await loadGroups();
} else if (ev.target.closest('.chip-x') && ev.target.dataset.scope) {
await apiJson(`api/groups.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' });
await loadGroups();
} else if (ev.target.closest('.g-del')) {
if (confirm('Supprimer ce groupe ? Les membres et le périmètre associés seront retirés.')) {
await apiJson(`api/groups.php?id=${gid}`, { method: 'DELETE' });
await loadGroups();
}
}
} catch (err) { alert('Erreur : ' + err.message); }
});
// --- Import opendata (super-admin) ---
$('import-btn').addEventListener('click', async () => {
if (!confirm('Télécharger et importer la dernière version de l\'opendata ?\nCela peut prendre quelques minutes.')) return;
const btn = $('import-btn');
btn.disabled = true;
$('import-msg').className = 'propose-msg';
$('import-msg').textContent = '⏳ Téléchargement et import en cours…';
try {
const r = await apiJson('api/import.php', { method: 'POST' });
$('import-msg').className = 'propose-msg ok';
$('import-msg').textContent = `✅ Import terminé : ${r.imported} établissements traités, ${r.skipped} ignorés. Total en base : ${r.total}.`;
} catch (err) {
$('import-msg').className = 'propose-msg err';
$('import-msg').textContent = 'Erreur : ' + err.message;
} finally {
btn.disabled = false;
}
});
// --- Import DPE ADEME + rattachement (super-admin) ---
$('dpe-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const dep = $('dpe-dep').value.trim();
const radius = Number($('dpe-radius').value) || 80;
const scope = dep ? `du département ${dep}` : 'COMPLET (~532 000 DPE)';
if (!confirm(`Importer le dataset DPE ${scope} et rattacher les bâtiments aux écoles (rayon ${radius} m) ?\nCela peut prendre plusieurs minutes.`)) return;
const btn = $('dpe-btn');
btn.disabled = true;
$('dpe-msg').className = 'propose-msg';
$('dpe-msg').textContent = '⏳ Import et rattachement en cours… (ne fermez pas cette page)';
try {
const r = await postJson('api/import_dpe.php', { dep, radius });
$('dpe-msg').className = 'propose-msg ok';
$('dpe-msg').textContent = `✅ Terminé : ${r.imported} DPE importés (${r.geocoded} géolocalisés) · ${r.links} rattachements pour ${r.etabs} établissements (≤ ${r.radius} m).`;
} catch (err) {
$('dpe-msg').className = 'propose-msg err';
$('dpe-msg').textContent = 'Erreur : ' + err.message;
} finally {
btn.disabled = false;
}
});
boot();
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Authentification et gestion du mot de passe.
* GET ?action=me → utilisateur courant (ou null)
* POST ?action=login → {username, password}
* POST ?action=logout
* POST ?action=change_password → {current, new} (utilisateur connecté)
* POST ?action=forgot → {login} (identifiant ou e-mail)
* POST ?action=reset → {token, new} (lien de réinitialisation)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
const LOGIN_MAX_ATTEMPTS = 5; // échecs avant verrouillage
const LOGIN_LOCK_MINUTES = 15; // durée du verrou
const RESET_TTL_MINUTES = 60; // validité d'un lien de réinitialisation
try {
$action = $_GET['action'] ?? 'me';
if ($action === 'me') {
json_out(['user' => current_user()]);
}
// ---- Connexion --------------------------------------------------------
if ($action === 'login') {
$b = body_json();
$username = trim((string) ($b['username'] ?? ''));
$password = (string) ($b['password'] ?? '');
if ($username === '' || $password === '') {
json_error('Identifiant et mot de passe requis.');
}
$st = db()->prepare('SELECT id, username, password_hash, role, must_change, failed_attempts, locked_until FROM users WHERE username = ?');
$st->execute([$username]);
$u = $st->fetch();
// Verrou anti-bruteforce.
if ($u && $u['locked_until'] !== null && strtotime($u['locked_until']) > time()) {
json_error('Compte temporairement verrouillé suite à trop dessais. Réessayez plus tard.', 429);
}
if (!$u || !password_verify($password, $u['password_hash'])) {
if ($u) {
$attempts = (int) $u['failed_attempts'] + 1;
if ($attempts >= LOGIN_MAX_ATTEMPTS) {
db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = DATE_ADD(NOW(), INTERVAL ? MINUTE) WHERE id = ?')
->execute([LOGIN_LOCK_MINUTES, (int) $u['id']]);
} else {
db()->prepare('UPDATE users SET failed_attempts = ? WHERE id = ?')
->execute([$attempts, (int) $u['id']]);
}
}
json_error('Identifiants invalides.', 401);
}
// Succès : réinitialise les compteurs et régénère la session.
db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = ?')
->execute([(int) $u['id']]);
session_boot();
session_regenerate_id(true);
$_SESSION['uid'] = (int) $u['id'];
json_out(['user' => [
'id' => (int) $u['id'],
'username' => $u['username'],
'role' => $u['role'],
'must_change' => (int) $u['must_change'],
]]);
}
if ($action === 'logout') {
session_boot();
$_SESSION = [];
session_destroy();
json_out(['ok' => true]);
}
// ---- Changement de mot de passe (connecté) ----------------------------
if ($action === 'change_password') {
$me = require_login();
$b = body_json();
$current = (string) ($b['current'] ?? '');
$new = (string) ($b['new'] ?? '');
$st = db()->prepare('SELECT password_hash FROM users WHERE id = ?');
$st->execute([(int) $me['id']]);
$hash = $st->fetchColumn();
if (!$hash || !password_verify($current, $hash)) {
json_error('Mot de passe actuel incorrect.', 403);
}
if ($new === $current) {
json_error('Le nouveau mot de passe doit être différent de lactuel.');
}
if ($err = password_policy_check($new)) {
json_error($err);
}
db()->prepare('UPDATE users SET password_hash = ?, must_change = 0 WHERE id = ?')
->execute([password_hash($new, PASSWORD_DEFAULT), (int) $me['id']]);
json_out(['ok' => true]);
}
// ---- Mot de passe oublié : demande de lien ----------------------------
if ($action === 'forgot') {
$login = trim((string) (body_json()['login'] ?? ''));
// Réponse générique systématique (ne révèle pas l'existence d'un compte).
$generic = ['ok' => true, 'message' => 'Si un compte correspond, un e-mail de réinitialisation a été envoyé.'];
if ($login === '') {
json_out($generic);
}
$st = db()->prepare('SELECT id, username, email FROM users WHERE username = ? OR (email IS NOT NULL AND email = ?) LIMIT 1');
$st->execute([$login, $login]);
$u = $st->fetch();
if ($u && !empty($u['email'])) {
// Invalide les anciens jetons non utilisés puis en crée un nouveau.
db()->prepare('UPDATE password_resets SET used = 1 WHERE user_id = ? AND used = 0')->execute([(int) $u['id']]);
$token = reset_token_new();
db()->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))')
->execute([(int) $u['id'], hash('sha256', $token), RESET_TTL_MINUTES]);
$link = APP_BASE_URL . '/reset.html?token=' . $token;
$body = "Bonjour,\n\nUne réinitialisation du mot de passe du compte « {$u['username']} » a été demandée.\n"
. "Cliquez sur ce lien (valable " . RESET_TTL_MINUTES . " minutes) pour choisir un nouveau mot de passe :\n\n$link\n\n"
. "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.\n";
send_mail($u['email'], 'Réinitialisation de votre mot de passe', $body);
}
json_out($generic);
}
// ---- Mot de passe oublié : application du nouveau mot de passe ---------
if ($action === 'reset') {
$b = body_json();
$token = (string) ($b['token'] ?? '');
$new = (string) ($b['new'] ?? '');
if ($token === '') {
json_error('Lien invalide.');
}
$st = db()->prepare('SELECT id, user_id FROM password_resets WHERE token_hash = ? AND used = 0 AND expires_at > NOW() LIMIT 1');
$st->execute([hash('sha256', $token)]);
$row = $st->fetch();
if (!$row) {
json_error('Lien de réinitialisation invalide ou expiré.', 400);
}
if ($err = password_policy_check($new)) {
json_error($err);
}
db()->prepare('UPDATE users SET password_hash = ?, must_change = 0, failed_attempts = 0, locked_until = NULL WHERE id = ?')
->execute([password_hash($new, PASSWORD_DEFAULT), (int) $row['user_id']]);
db()->prepare('UPDATE password_resets SET used = 1 WHERE id = ?')->execute([(int) $row['id']]);
json_out(['ok' => true]);
}
json_error('Action inconnue.', 404);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Commentaires — sur une fiche d'établissement OU sur un document précis.
*
* Mêmes règles dans les deux cas : un commentaire est horodaté, porte un texte et
* le nom de son auteur, et n'est visible que par les membres d'un même groupe que
* l'auteur (le super-admin voit tout). Poster nécessite d'appartenir à au moins un
* groupe. L'auteur ou le super-admin peuvent supprimer ; pas d'édition.
*
* Portée : ?uai=UAI (établissement, commentaires « généraux ») ou
* ?item_id=N (document précis). Un commentaire de document n'apparaît pas
* dans les commentaires d'établissement et inversement.
*
* GET ?uai=UAI → { auth, username, role, can_post, can_validate, comments[] }
* GET ?item_id=N → { auth, username, role, can_post, comments[] }
* POST ?uai=UAI | ?item_id=N {body} → crée un commentaire (membre d'un groupe requis)
* DELETE ?id=N → supprime (auteur ou super-admin)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$method = $_SERVER['REQUEST_METHOD'];
$itemId = isset($_GET['item_id']) && $_GET['item_id'] !== '' ? (int) $_GET['item_id'] : 0;
// ---- Liste des commentaires visibles ----------------------------------
if ($method === 'GET') {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($itemId <= 0 && $uai === '') {
json_error('Paramètre uai ou item_id requis.');
}
$me = current_user();
if (!$me) {
json_out(['auth' => false, 'can_post' => false, 'comments' => []]);
}
$isSuper = $me['role'] === 'super';
$canPost = $isSuper || !empty(user_group_ids((int) $me['id']));
// Clause de portée : un document précis, ou l'établissement (hors documents).
if ($itemId > 0) {
$scopeSql = 'c.item_id = ?';
$scopeArg = $itemId;
} else {
$scopeSql = 'c.uai = ? AND c.item_id IS NULL';
$scopeArg = $uai;
}
// Super : tout ; sinon ses propres commentaires + ceux d'un membre partageant
// au moins un groupe avec lui.
if ($isSuper) {
$sql = "SELECT c.id, c.user_id, c.body, c.created_at, u.username
FROM comments c JOIN users u ON u.id = c.user_id
WHERE $scopeSql ORDER BY c.created_at DESC";
$st = db()->prepare($sql);
$st->execute([$scopeArg]);
} else {
$sql = "SELECT c.id, c.user_id, c.body, c.created_at, u.username
FROM comments c JOIN users u ON u.id = c.user_id
WHERE $scopeSql AND (
c.user_id = ?
OR EXISTS (SELECT 1 FROM group_members a
JOIN group_members b ON a.group_id = b.group_id
WHERE a.user_id = ? AND b.user_id = c.user_id))
ORDER BY c.created_at DESC";
$st = db()->prepare($sql);
$st->execute([$scopeArg, (int) $me['id'], (int) $me['id']]);
}
$out = [];
foreach ($st as $r) {
$out[] = [
'id' => (int) $r['id'],
'username' => $r['username'],
'body' => $r['body'],
'created_at' => $r['created_at'],
'mine' => (int) $r['user_id'] === (int) $me['id'],
];
}
$resp = [
'auth' => true,
'username' => $me['username'],
'role' => $me['role'],
'can_post' => $canPost,
'comments' => $out,
];
// Au niveau établissement, indique si l'utilisateur peut éditer les documents.
if ($itemId <= 0) {
$resp['can_validate'] = can_validate_uai($me, $uai);
}
json_out($resp);
}
// ---- Dépôt d'un commentaire -------------------------------------------
if ($method === 'POST') {
$me = require_login();
if ($me['role'] !== 'super' && empty(user_group_ids((int) $me['id']))) {
json_error('Vous devez appartenir à un groupe pour commenter.', 403);
}
$body = trim((string) (body_json()['body'] ?? ''));
if ($body === '') {
json_error('Le commentaire est vide.');
}
if ($itemId > 0) {
// Commentaire sur un document : on récupère l'UAI de l'élément.
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
$st->execute([$itemId]);
$uai = $st->fetchColumn();
if ($uai === false) {
json_error('Document introuvable.', 404);
}
db()->prepare('INSERT INTO comments (uai, item_id, user_id, body) VALUES (?, ?, ?, ?)')
->execute([$uai, $itemId, (int) $me['id'], $body]);
} else {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai ou item_id requis.');
}
$chk = db()->prepare('SELECT 1 FROM etablissements WHERE id = ?');
$chk->execute([$uai]);
if (!$chk->fetchColumn()) {
json_error('Établissement inconnu.', 404);
}
db()->prepare('INSERT INTO comments (uai, user_id, body) VALUES (?, ?, ?)')
->execute([$uai, (int) $me['id'], $body]);
}
json_out(['ok' => true, 'id' => (int) db()->lastInsertId()], 201);
}
// ---- Suppression (auteur ou super) ------------------------------------
if ($method === 'DELETE') {
$me = require_login();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$st = db()->prepare('SELECT user_id FROM comments WHERE id = ?');
$st->execute([$id]);
$owner = $st->fetchColumn();
if ($owner === false) {
json_error('Commentaire introuvable.', 404);
}
if ($me['role'] !== 'super' && (int) $owner !== (int) $me['id']) {
json_error('Vous ne pouvez supprimer que vos propres commentaires.', 403);
}
db()->prepare('DELETE FROM comments WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* Autocomplétion des communes (pour affecter un périmètre à un admin).
* GET ?q=... → jusqu'à 30 couples commune + département distincts.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
require_super();
$q = trim((string) ($_GET['q'] ?? ''));
if (strlen($q) < 2) {
json_out([]);
}
$st = db()->prepare(
'SELECT com, depc, dep, COUNT(*) AS nb
FROM etablissements
WHERE com LIKE ?
GROUP BY com, depc, dep
ORDER BY com
LIMIT 30'
);
$st->execute([$q . '%']);
$out = [];
foreach ($st as $r) {
$out[] = [
'com' => $r['com'],
'depc' => $r['depc'],
'dep' => $r['dep'],
'nb' => (int) $r['nb'],
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+375
View File
@@ -0,0 +1,375 @@
<?php
/**
* Socle commun : connexion PDO MySQL, sorties JSON, session/authentification,
* et import des établissements (partagé entre setup.php et api/import.php).
*
* Identifiants par défaut de Laragon (root / mot de passe vide).
*/
declare(strict_types=1);
const DB_HOST = '127.0.0.1';
const DB_PORT = 3306;
const DB_NAME = 'ecoles';
const DB_USER = 'root';
const DB_PASS = '';
// --- Application & envoi d'e-mails -----------------------------------------
// URL publique de base (pour les liens de réinitialisation). Adaptez si besoin.
const APP_BASE_URL = 'http://ecoles.test';
// Transport du mailer : 'log' (écrit dans private/mail_outbox.log, défaut local et sûr),
// 'mail' (fonction PHP mail()), ou 'smtp' (client SMTP intégré ci-dessous).
const MAIL_TRANSPORT = 'log';
const MAIL_FROM = 'no-reply@ecoles.test';
const MAIL_FROM_NAME = 'Carte des établissements';
// Paramètres SMTP (utilisés seulement si MAIL_TRANSPORT === 'smtp').
const SMTP_HOST = '';
const SMTP_PORT = 587;
const SMTP_USER = '';
const SMTP_PASS = '';
const SMTP_SECURE = 'tls'; // 'tls' (STARTTLS, port 587) | 'ssl' (port 465) | '' (aucun)
/** Connexion partagée à la base applicative. */
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
// ---------------------------------------------------------------------------
// Sorties JSON
// ---------------------------------------------------------------------------
/** Émet une réponse JSON et arrête le script. */
function json_out($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/** Émet une erreur JSON et arrête le script. */
function json_error(string $message, int $status = 400): void
{
json_out(['error' => $message], $status);
}
/** Corps de requête JSON décodé en tableau. */
function body_json(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '', true);
return is_array($data) ? $data : [];
}
// ---------------------------------------------------------------------------
// Session / authentification
// ---------------------------------------------------------------------------
function session_boot(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
/** Utilisateur connecté (id, username, role) ou null. */
function current_user(): ?array
{
static $user = false;
if ($user !== false) {
return $user;
}
session_boot();
if (empty($_SESSION['uid'])) {
return $user = null;
}
$st = db()->prepare('SELECT id, username, role, must_change FROM users WHERE id = ?');
$st->execute([$_SESSION['uid']]);
$row = $st->fetch();
if ($row) {
$row['id'] = (int) $row['id'];
$row['must_change'] = (int) $row['must_change'];
}
return $user = ($row ?: null);
}
/** Exige une connexion ; renvoie l'utilisateur ou répond 401. */
function require_login(): array
{
$u = current_user();
if (!$u) {
json_error('Authentification requise.', 401);
}
return $u;
}
/** Exige le rôle super-admin ; renvoie l'utilisateur ou répond 403. */
function require_super(): array
{
$u = require_login();
if ($u['role'] !== 'super') {
json_error('Réservé à ladministrateur de la plateforme.', 403);
}
return $u;
}
/**
* L'utilisateur peut-il valider/modérer l'établissement $uai ?
* Vrai si super-admin, OU s'il a un périmètre personnel (user_scopes, legacy)
* correspondant, OU s'il est membre d'un groupe « valideur » (user_groups.can_validate)
* dont une commune (group_scopes) correspond à l'établissement.
* Les commentateurs (role « member ») n'ont aucun droit de validation.
*/
function can_validate_uai(array $user, string $uai): bool
{
if ($user['role'] === 'super') {
return true;
}
if ($user['role'] !== 'perimeter') {
return false; // « member » (commentateur) : pas de modération
}
$st = db()->prepare(
'SELECT 1 FROM etablissements e
JOIN user_scopes s ON s.com = e.com AND s.depc = e.depc
WHERE e.id = ? AND s.user_id = ?
LIMIT 1'
);
$st->execute([$uai, $user['id']]);
if ($st->fetchColumn()) {
return true;
}
$st = db()->prepare(
'SELECT 1 FROM etablissements e
JOIN group_members gm ON gm.user_id = ?
JOIN user_groups g ON g.id = gm.group_id AND g.can_validate = 1
JOIN group_scopes gs ON gs.group_id = g.id AND gs.com = e.com AND gs.depc = e.depc
WHERE e.id = ?
LIMIT 1'
);
$st->execute([$user['id'], $uai]);
return (bool) $st->fetchColumn();
}
/** Identifiants des groupes auxquels l'utilisateur appartient. */
function user_group_ids(int $userId): array
{
$st = db()->prepare('SELECT group_id FROM group_members WHERE user_id = ?');
$st->execute([$userId]);
return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
// ---------------------------------------------------------------------------
// Politique de mots de passe
// ---------------------------------------------------------------------------
const PASSWORD_MIN_LEN = 8;
const PASSWORD_MIN_CLASSES = 3; // parmi : minuscule, majuscule, chiffre, caractère spécial
/**
* Vérifie la robustesse d'un mot de passe (≥ 8 caractères et au moins 3 types
* parmi minuscule/majuscule/chiffre/spécial). Renvoie null si conforme, sinon
* un message d'erreur en français.
*/
function password_policy_check(string $pwd): ?string
{
if (strlen($pwd) < PASSWORD_MIN_LEN) {
return 'Le mot de passe doit contenir au moins ' . PASSWORD_MIN_LEN . ' caractères.';
}
$classes = (preg_match('/[a-z]/', $pwd) ? 1 : 0)
+ (preg_match('/[A-Z]/', $pwd) ? 1 : 0)
+ (preg_match('/[0-9]/', $pwd) ? 1 : 0)
+ (preg_match('/[^A-Za-z0-9]/', $pwd) ? 1 : 0);
if ($classes < PASSWORD_MIN_CLASSES) {
return 'Le mot de passe doit combiner au moins ' . PASSWORD_MIN_CLASSES
. ' types parmi : minuscule, majuscule, chiffre, caractère spécial.';
}
return null;
}
/** Jeton de réinitialisation aléatoire (à stocker haché en base). */
function reset_token_new(): string
{
return bin2hex(random_bytes(32));
}
// ---------------------------------------------------------------------------
// Envoi d'e-mails (transport enfichable : log / mail / smtp)
// ---------------------------------------------------------------------------
/** Envoie un e-mail texte. Renvoie true si remis au transport. */
function send_mail(string $to, string $subject, string $body): bool
{
switch (MAIL_TRANSPORT) {
case 'mail':
$headers = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . ">\r\n"
. "Content-Type: text/plain; charset=utf-8\r\n";
return mail($to, $subject, $body, $headers);
case 'smtp':
return smtp_send($to, $subject, $body);
case 'log':
default:
$dir = dirname(__DIR__) . '/private';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$line = "==== " . date('Y-m-d H:i:s') . " ====\n"
. "To: $to\nSubject: $subject\n\n$body\n\n";
return (bool) @file_put_contents($dir . '/mail_outbox.log', $line, FILE_APPEND);
}
}
/** Client SMTP minimal (AUTH LOGIN, STARTTLS ou SSL). Renvoie true si accepté. */
function smtp_send(string $to, string $subject, string $body): bool
{
$host = SMTP_SECURE === 'ssl' ? 'ssl://' . SMTP_HOST : SMTP_HOST;
$fp = @fsockopen($host, SMTP_PORT, $errno, $errstr, 15);
if (!$fp) {
return false;
}
$read = static function () use ($fp): string {
$data = '';
while (($line = fgets($fp, 515)) !== false) {
$data .= $line;
if (strlen($line) < 4 || $line[3] === ' ') {
break;
}
}
return $data;
};
$cmd = static function (string $c) use ($fp, $read): string {
fwrite($fp, $c . "\r\n");
return $read();
};
$ok = static fn(string $r, string $code): bool => strncmp($r, $code, 3) === 0;
$read(); // bannière
$ehlo = 'EHLO ' . (parse_url(APP_BASE_URL, PHP_URL_HOST) ?: 'localhost');
$cmd($ehlo);
if (SMTP_SECURE === 'tls') {
if (!$ok($cmd('STARTTLS'), '220')) {
fclose($fp);
return false;
}
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($fp);
return false;
}
$cmd($ehlo);
}
if (SMTP_USER !== '') {
$cmd('AUTH LOGIN');
$cmd(base64_encode(SMTP_USER));
if (!$ok($cmd(base64_encode(SMTP_PASS)), '235')) {
fclose($fp);
return false;
}
}
$cmd('MAIL FROM:<' . MAIL_FROM . '>');
$cmd('RCPT TO:<' . $to . '>');
if (!$ok($cmd('DATA'), '354')) {
fclose($fp);
return false;
}
$headers = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . ">\r\n"
. 'To: <' . $to . ">\r\n"
. 'Subject: ' . $subject . "\r\n"
. "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n";
$data = $headers . "\r\n" . str_replace("\r\n.", "\r\n..", $body) . "\r\n.";
$sent = $ok($cmd($data), '250');
$cmd('QUIT');
fclose($fp);
return $sent;
}
// ---------------------------------------------------------------------------
// Import des établissements (opendata)
// ---------------------------------------------------------------------------
/**
* Convertit une ligne brute de l'annuaire opendata en enregistrement compact
* (mêmes clés courtes que data/etablissements.json). Renvoie null si pas de géoloc.
*/
function map_opendata_row(array $r): ?array
{
$lat = $r['latitude'] ?? null;
$lon = $r['longitude'] ?? null;
if ($lat === null || $lon === null) {
return null;
}
$opt = static fn($v) => ((int) ($v ?? 0) === 1) ? 1 : 0;
return [
'id' => $r['identifiant_de_l_etablissement'] ?? null,
'nom' => $r['nom_etablissement'] ?? null,
'type' => $r['type_etablissement'] ?? null,
'stat' => $r['statut_public_prive'] ?? null,
'reg' => $r['libelle_region'] ?? null,
'aca' => $r['libelle_academie'] ?? null,
'dep' => $r['libelle_departement'] ?? null,
'depc' => $r['code_departement'] ?? null,
'com' => $r['nom_commune'] ?? null,
'cp' => $r['code_postal'] ?? null,
'adr' => $r['adresse_1'] ?? null,
'tel' => $r['telephone'] ?? null,
'mail' => $r['mail'] ?? null,
'web' => $r['web'] ?? null,
'etat' => $r['etat'] ?? null,
'circ' => $r['nom_circonscription'] ?? null,
'lat' => round((float) $lat, 5),
'lon' => round((float) $lon, 5),
'rest' => $opt($r['restauration'] ?? 0),
'heb' => $opt($r['hebergement'] ?? 0),
'ulis' => $opt($r['ulis'] ?? 0),
'segp' => $opt($r['segpa'] ?? 0),
'appr' => $opt($r['apprentissage'] ?? 0),
];
}
/**
* Insère/met à jour une liste d'établissements (clés courtes) en base.
* Renvoie le nombre de lignes traitées.
*/
function import_etablissements(PDO $pdo, array $rows): int
{
$sql = 'INSERT INTO etablissements
(id,nom,type,stat,reg,aca,dep,depc,com,cp,adr,tel,mail,web,etat,circ,lat,lon,rest,heb,ulis,segp,appr)
VALUES (:id,:nom,:type,:stat,:reg,:aca,:dep,:depc,:com,:cp,:adr,:tel,:mail,:web,:etat,:circ,:lat,:lon,:rest,:heb,:ulis,:segp,:appr)
ON DUPLICATE KEY UPDATE
nom=VALUES(nom),type=VALUES(type),stat=VALUES(stat),reg=VALUES(reg),aca=VALUES(aca),
dep=VALUES(dep),depc=VALUES(depc),com=VALUES(com),cp=VALUES(cp),adr=VALUES(adr),
tel=VALUES(tel),mail=VALUES(mail),web=VALUES(web),etat=VALUES(etat),circ=VALUES(circ),
lat=VALUES(lat),lon=VALUES(lon),rest=VALUES(rest),heb=VALUES(heb),ulis=VALUES(ulis),
segp=VALUES(segp),appr=VALUES(appr)';
$ins = $pdo->prepare($sql);
$defaults = [
'nom' => null, 'type' => null, 'stat' => null, 'reg' => null, 'aca' => null,
'dep' => null, 'depc' => null, 'com' => null, 'cp' => null, 'adr' => null,
'tel' => null, 'mail' => null, 'web' => null, 'etat' => null, 'circ' => null,
'lat' => null, 'lon' => null, 'rest' => 0, 'heb' => 0, 'ulis' => 0, 'segp' => 0, 'appr' => 0,
];
$pdo->beginTransaction();
$n = 0;
foreach ($rows as $r) {
if (empty($r['id'])) {
continue;
}
$ins->execute(array_merge($defaults, array_intersect_key($r, $defaults), ['id' => $r['id']]));
if (++$n % 5000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();
return $n;
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* DPE (ADEME) rattachés à un établissement, par proximité géographique.
* GET ?uai=UAI → [ { numero_dpe, etiquette_dpe, etiquette_ges, conso, ges,
* surface, periode, cat_erp, date_dpe, date_fin,
* adresse, distance_m }, ... ] trié par distance croissante
* POST ?action=latest body { uais:[...] } → { uai: { dpe, ges } } meilleur match (le + proche)
*
* Rattachement calculé par match_dpe.php (table etab_dpe). Source : import_dpe.php.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
// Batch : meilleur DPE (le plus proche) pour une liste d'UAI → { uai: {dpe, ges} }.
if (($_GET['action'] ?? '') === 'latest') {
$uais = body_json()['uais'] ?? [];
if (!is_array($uais) || !$uais) {
json_out([]);
}
$uais = array_slice(array_values(array_unique(array_map('strval', $uais))), 0, 5000);
$ph = implode(',', array_fill(0, count($uais), '?'));
// Pour chaque UAI, le rattachement de plus petite distance. En cas
// d'ex æquo, la boucle PHP ci-dessous ne garde qu'une ligne par UAI
// (pas de GROUP BY ⇒ compatible only_full_group_by, MySQL et MariaDB).
$sql = "SELECT ed.uai, d.etiquette_dpe, d.etiquette_ges
FROM etab_dpe ed
JOIN dpe d ON d.numero_dpe = ed.numero_dpe
JOIN (SELECT uai, MIN(distance_m) AS dmin
FROM etab_dpe WHERE uai IN ($ph) GROUP BY uai) m
ON m.uai = ed.uai AND m.dmin = ed.distance_m";
$st = db()->prepare($sql);
$st->execute($uais);
$out = [];
foreach ($st as $r) {
$out[$r['uai']] = ['dpe' => $r['etiquette_dpe'], 'ges' => $r['etiquette_ges']];
}
json_out($out);
}
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$st = db()->prepare(
'SELECT d.numero_dpe, d.etiquette_dpe, d.etiquette_ges, d.conso, d.ges,
d.surface, d.periode, d.cat_erp, d.date_dpe, d.date_fin, d.adresse,
ed.distance_m
FROM etab_dpe ed
JOIN dpe d ON d.numero_dpe = ed.numero_dpe
WHERE ed.uai = ?
ORDER BY ed.distance_m'
);
$st->execute([$uai]);
$out = [];
foreach ($st as $r) {
$out[] = [
'numero_dpe' => $r['numero_dpe'],
'etiquette_dpe' => $r['etiquette_dpe'],
'etiquette_ges' => $r['etiquette_ges'],
'conso' => $r['conso'] !== null ? (float) $r['conso'] : null,
'ges' => $r['ges'] !== null ? (float) $r['ges'] : null,
'surface' => $r['surface'] !== null ? (int) $r['surface'] : null,
'periode' => $r['periode'],
'cat_erp' => $r['cat_erp'],
'date_dpe' => $r['date_dpe'],
'date_fin' => $r['date_fin'],
'adresse' => $r['adresse'],
'distance_m' => (int) $r['distance_m'],
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* Bibliothèque partagée DPE (ADEME) : création des tables, import du dataset
* tertiaire et rattachement géographique aux établissements.
*
* Utilisée par les scripts CLI (import_dpe.php, match_dpe.php) et par
* l'endpoint web super-admin (api/import_dpe.php), pour éviter toute
* divergence de logique.
*/
declare(strict_types=1);
require_once __DIR__ . '/db.php';
const DPE_DATASET = 'https://data.ademe.fr/data-fair/api/v1/datasets/dpe01tertiaire/lines';
const DPE_FIELDS = 'numero_dpe,etiquette_dpe,etiquette_ges,conso_kwhep_m2_an,'
. 'emission_ges_kg_co2_m2_an,surface_utile,periode_construction,categorie_erp,'
. 'date_etablissement_dpe,date_fin_validite_dpe,code_insee_ban,code_postal_ban,'
. 'adresse_ban,nom_commune_ban,_geopoint';
/** GET JSON via cURL (suit les redirections). */
function dpe_fetch_json(string $url): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Requête échouée : ' . $err);
}
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($code >= 400) {
throw new RuntimeException("HTTP $code sur l'API ADEME.");
}
$data = json_decode($body, true);
if (!is_array($data)) {
throw new RuntimeException('Réponse JSON ADEME invalide.');
}
return $data;
}
/** Crée la table « dpe » si absente. */
function dpe_create_table(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS dpe (
numero_dpe VARCHAR(20) NOT NULL PRIMARY KEY,
etiquette_dpe CHAR(1) NULL,
etiquette_ges CHAR(1) NULL,
conso DECIMAL(10,1) NULL, -- conso_kwhep_m2_an (énergie primaire kWh/m²/an)
ges DECIMAL(10,2) NULL, -- emission_ges_kg_co2_m2_an
surface INT NULL, -- surface_utile (m²)
periode VARCHAR(40) NULL, -- periode_construction
cat_erp VARCHAR(60) NULL, -- categorie_erp
date_dpe DATE NULL, -- date_etablissement_dpe
date_fin DATE NULL, -- date_fin_validite_dpe
code_insee VARCHAR(5) NULL, -- code_insee_ban (clé de rattachement commune)
cp VARCHAR(5) NULL,
adresse VARCHAR(255) NULL,
commune VARCHAR(120) NULL,
lat DECIMAL(9,6) NULL,
lon DECIMAL(9,6) NULL,
INDEX idx_insee (code_insee)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
/**
* Importe les DPE tertiaires de l'ADEME dans la table « dpe ».
* @param string|null $dep filtre département (code) ou null pour tout
* @param callable $log fn(string) pour la progression
* @return int nombre de DPE importés
*/
function dpe_import(PDO $pdo, ?string $dep, callable $log): int
{
dpe_create_table($pdo);
$url = DPE_DATASET . '?size=10000&sort=numero_dpe&select=' . rawurlencode(DPE_FIELDS);
if ($dep !== null && $dep !== '') {
$url .= '&qs=' . rawurlencode('code_departement_ban:"' . $dep . '"');
$log("Filtre département = $dep.");
}
$ins = $pdo->prepare(
'REPLACE INTO dpe
(numero_dpe, etiquette_dpe, etiquette_ges, conso, ges, surface, periode,
cat_erp, date_dpe, date_fin, code_insee, cp, adresse, commune, lat, lon)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
);
$letter = static fn($v) => ($v !== null && $v !== '') ? strtoupper(substr((string) $v, 0, 1)) : null;
$date = static fn($v) => (is_string($v) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) ? $v : null;
$numOrNull = static fn($v) => ($v === null || $v === '') ? null : (float) $v;
$page = 0;
$n = 0;
$total = null;
$pdo->beginTransaction();
while ($url) {
$res = dpe_fetch_json($url);
$total ??= $res['total'] ?? null;
$rows = $res['results'] ?? [];
if (!$rows) {
break;
}
foreach ($rows as $r) {
$id = $r['numero_dpe'] ?? '';
if ($id === '') {
continue;
}
$lat = $lon = null;
if (!empty($r['_geopoint']) && str_contains($r['_geopoint'], ',')) {
[$lat, $lon] = array_map('floatval', explode(',', $r['_geopoint'], 2));
}
$ins->execute([
substr($id, 0, 20),
$letter($r['etiquette_dpe'] ?? null),
$letter($r['etiquette_ges'] ?? null),
$numOrNull($r['conso_kwhep_m2_an'] ?? null),
$numOrNull($r['emission_ges_kg_co2_m2_an'] ?? null),
isset($r['surface_utile']) && $r['surface_utile'] !== '' ? (int) round((float) $r['surface_utile']) : null,
$r['periode_construction'] ?? null,
$r['categorie_erp'] ?? null,
$date($r['date_etablissement_dpe'] ?? null),
$date($r['date_fin_validite_dpe'] ?? null),
$r['code_insee_ban'] ?? null,
$r['code_postal_ban'] ?? null,
$r['adresse_ban'] ?? null,
$r['nom_commune_ban'] ?? null,
$lat, $lon,
]);
$n++;
}
$pdo->commit();
$pdo->beginTransaction();
$page++;
$log(sprintf('Page %d — %d DPE importés%s', $page, $n, $total ? " / $total" : ''));
// Page suivante (curseur). data-fair renvoie l'URL complète dans « next ».
$url = $res['next'] ?? null;
if ($url) {
usleep(800000); // ménage le quota anonyme de l'API (20 s de traitement / 60 s)
}
}
$pdo->commit();
return $n;
}
/** Crée la table de rattachement « etab_dpe » si absente. */
function dpe_ensure_link_table(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS etab_dpe (
uai VARCHAR(12) NOT NULL,
numero_dpe VARCHAR(20) NOT NULL,
distance_m SMALLINT NOT NULL, -- distance école↔bâtiment (m)
PRIMARY KEY (uai, numero_dpe),
INDEX idx_uai (uai)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
/**
* Rattache les DPE aux établissements par proximité (même commune, ≤ $radius m).
* @return array{pairs:int, ecoles:int}
*/
function dpe_match(PDO $pdo, int $radius, callable $log): array
{
foreach (['dpe', 'etablissements'] as $t) {
if (!$pdo->query('SHOW TABLES LIKE ' . $pdo->quote($t))->fetchColumn()) {
throw new RuntimeException("Table « $t » absente — lancez d'abord l'import.");
}
}
if (!$pdo->query("SHOW COLUMNS FROM etablissements LIKE 'comc'")->fetchColumn()) {
throw new RuntimeException("Colonne « comc » absente — lancez d'abord import_comc.php.");
}
dpe_ensure_link_table($pdo);
$log("Rattachement (rayon = {$radius} m)…");
$pdo->exec('TRUNCATE TABLE etab_dpe');
// Jointure restreinte à la commune (index idx_insee / idx_comc) puis filtre
// par distance sphérique (ST_Distance_Sphere ⇒ MySQL 5.7.6+ / MariaDB 10.2.4+).
$sql =
'INSERT INTO etab_dpe (uai, numero_dpe, distance_m)
SELECT e.id, d.numero_dpe,
ROUND(ST_Distance_Sphere(POINT(e.lon, e.lat), POINT(d.lon, d.lat)))
FROM etablissements e
JOIN dpe d ON d.code_insee = e.comc
WHERE e.comc IS NOT NULL AND e.lat IS NOT NULL AND e.lon IS NOT NULL
AND d.lat IS NOT NULL AND d.lon IS NOT NULL
AND ST_Distance_Sphere(POINT(e.lon, e.lat), POINT(d.lon, d.lat)) <= ?';
$st = $pdo->prepare($sql);
$st->execute([$radius]);
return [
'pairs' => (int) $pdo->query('SELECT COUNT(*) FROM etab_dpe')->fetchColumn(),
'ecoles' => (int) $pdo->query('SELECT COUNT(DISTINCT uai) FROM etab_dpe')->fetchColumn(),
];
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* Historique des effectifs et nombres de classes d'une école (1er degré).
* GET ?uai=UAI → [ { annee, eleves, classes }, ... ] trié par année croissante
* GET ?action=commune&uai=UAI → [ { annee, eleves }, ... ] effectifs cumulés de la commune
* de l'établissement (somme de toutes ses écoles par année)
* GET ?action=latest (POST) → { uai: eleves } dernier effectif (batch)
*
* Source : dataset « fr-en-ecoles-effectifs-nb_classes » (cf. import_effectifs.php).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
// Effectifs cumulés de la commune : somme des élèves des écoles PUBLIQUES
// de la même commune (com + depc), par année.
if (($_GET['action'] ?? '') === 'commune') {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$e = db()->prepare('SELECT com, depc FROM etablissements WHERE id = ?');
$e->execute([$uai]);
$loc = $e->fetch();
if (!$loc || $loc['com'] === null || $loc['depc'] === null) {
json_out([]);
}
$st = db()->prepare(
"SELECT ef.annee, SUM(ef.eleves) AS eleves
FROM effectifs ef
JOIN etablissements e ON e.id = ef.uai
WHERE e.com = ? AND e.depc = ? AND e.stat = 'Public'
GROUP BY ef.annee
ORDER BY ef.annee"
);
$st->execute([$loc['com'], $loc['depc']]);
$out = [];
foreach ($st as $r) {
$out[] = ['annee' => (int) $r['annee'], 'eleves' => (int) $r['eleves']];
}
json_out($out);
}
// Batch : dernier effectif connu pour une liste d'UAI → { uai: eleves }.
if (($_GET['action'] ?? '') === 'latest') {
$uais = body_json()['uais'] ?? [];
if (!is_array($uais) || !$uais) {
json_out([]);
}
$uais = array_slice(array_values(array_unique(array_map('strval', $uais))), 0, 5000);
$ph = implode(',', array_fill(0, count($uais), '?'));
$sql = "SELECT ef.uai, ef.eleves
FROM effectifs ef
JOIN (SELECT uai, MAX(annee) AS y FROM effectifs WHERE uai IN ($ph) GROUP BY uai) m
ON m.uai = ef.uai AND m.y = ef.annee";
$st = db()->prepare($sql);
$st->execute($uais);
$out = [];
foreach ($st as $r) {
$out[$r['uai']] = (int) $r['eleves'];
}
json_out($out);
}
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$st = db()->prepare('SELECT annee, eleves, classes FROM effectifs WHERE uai = ? ORDER BY annee');
$st->execute([$uai]);
$out = [];
foreach ($st as $r) {
$out[] = [
'annee' => (int) $r['annee'],
'eleves' => (int) $r['eleves'],
'classes' => (int) $r['classes'],
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* GET api/etablissements.php
* Renvoie tous les établissements au format attendu par la carte (clés courtes).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$cols = 'id,nom,type,stat,reg,aca,dep,depc,com,cp,adr,tel,mail,web,etat,circ,lat,lon,rest,heb,ulis,segp';
$stmt = db()->query("SELECT $cols FROM etablissements");
$rows = [];
while ($r = $stmt->fetch()) {
$r['lat'] = $r['lat'] !== null ? (float) $r['lat'] : null;
$r['lon'] = $r['lon'] !== null ? (float) $r['lon'] : null;
foreach (['rest', 'heb', 'ulis', 'segp'] as $k) {
$r[$k] = (int) $r[$k];
}
$rows[] = $r;
}
json_out($rows);
} catch (Throwable $e) {
json_error('Base de données : ' . $e->getMessage(), 500);
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* Arborescence globale de dossiers (gérée par le super-admin).
* GET → liste à plat de tous les dossiers (public)
* POST {name, parent_id?} → crée un dossier (super-admin)
* PUT {id, name?, parent_id?, position?} → modifie (super-admin)
* DELETE ?id=N → supprime (et ses sous-dossiers) (super-admin)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$rows = db()->query('SELECT id, parent_id, name, position FROM folders ORDER BY position, name')->fetchAll();
foreach ($rows as &$r) {
$r['id'] = (int) $r['id'];
$r['parent_id'] = $r['parent_id'] !== null ? (int) $r['parent_id'] : null;
$r['position'] = (int) $r['position'];
}
json_out($rows);
}
require_super();
if ($method === 'POST') {
$b = body_json();
$name = trim((string) ($b['name'] ?? ''));
if ($name === '') {
json_error('Nom de dossier requis.');
}
$parent = isset($b['parent_id']) && $b['parent_id'] !== null && $b['parent_id'] !== ''
? (int) $b['parent_id'] : null;
$pos = (int) ($b['position'] ?? 0);
$st = db()->prepare('INSERT INTO folders (parent_id, name, position) VALUES (?, ?, ?)');
$st->execute([$parent, $name, $pos]);
json_out(['id' => (int) db()->lastInsertId(), 'parent_id' => $parent, 'name' => $name, 'position' => $pos], 201);
}
if ($method === 'PUT') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$sets = [];
$args = [];
if (array_key_exists('name', $b)) {
$name = trim((string) $b['name']);
if ($name === '') {
json_error('Nom invalide.');
}
$sets[] = 'name = ?';
$args[] = $name;
}
if (array_key_exists('parent_id', $b)) {
$parent = ($b['parent_id'] === null || $b['parent_id'] === '') ? null : (int) $b['parent_id'];
if ($parent === $id) {
json_error('Un dossier ne peut pas être son propre parent.');
}
$sets[] = 'parent_id = ?';
$args[] = $parent;
}
if (array_key_exists('position', $b)) {
$sets[] = 'position = ?';
$args[] = (int) $b['position'];
}
if (!$sets) {
json_error('Rien à modifier.');
}
$args[] = $id;
db()->prepare('UPDATE folders SET ' . implode(', ', $sets) . ' WHERE id = ?')->execute($args);
json_out(['ok' => true]);
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM folders WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* Gestion des groupes d'utilisateurs (super-admin uniquement).
*
* Un groupe rassemble des utilisateurs. S'il est « valideur » (can_validate=1),
* ses membres peuvent valider les documents des établissements de ses communes
* (group_scopes). Les commentaires de fiche sont visibles entre membres d'un
* même groupe (voir api/comments.php).
*
* GET → liste des groupes (membres + communes)
* POST {name, can_validate?} → crée un groupe
* POST ?action=toggle {id, can_validate} → (dés)active le droit de validation
* POST ?action=member {group_id, user_id} → ajoute un membre
* POST ?action=scope {group_id, com, depc}→ ajoute une commune au périmètre
* DELETE ?id=N → supprime un groupe
* DELETE ?action=member&group_id=G&user_id=U → retire un membre
* DELETE ?action=scope&id=N → retire une commune
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
require_super();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// ---- Liste des groupes (avec membres et communes) ---------------------
if ($method === 'GET') {
$groups = db()->query('SELECT id, name, can_validate, created_at FROM user_groups ORDER BY name')->fetchAll();
$members = db()->query(
'SELECT gm.group_id, u.id, u.username
FROM group_members gm JOIN users u ON u.id = gm.user_id
ORDER BY u.username'
)->fetchAll();
$scopes = db()->query('SELECT id, group_id, com, depc FROM group_scopes ORDER BY com')->fetchAll();
$byMembers = [];
foreach ($members as $m) {
$byMembers[(int) $m['group_id']][] = ['id' => (int) $m['id'], 'username' => $m['username']];
}
$byScopes = [];
foreach ($scopes as $s) {
$byScopes[(int) $s['group_id']][] = ['id' => (int) $s['id'], 'com' => $s['com'], 'depc' => $s['depc']];
}
foreach ($groups as &$g) {
$g['id'] = (int) $g['id'];
$g['can_validate'] = (int) $g['can_validate'];
$g['members'] = $byMembers[$g['id']] ?? [];
$g['scopes'] = $byScopes[$g['id']] ?? [];
}
json_out($groups);
}
// ---- Bascule du droit de validation -----------------------------------
if ($method === 'POST' && $action === 'toggle') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$can = !empty($b['can_validate']) ? 1 : 0;
db()->prepare('UPDATE user_groups SET can_validate = ? WHERE id = ?')->execute([$can, $id]);
json_out(['ok' => true, 'can_validate' => $can]);
}
// ---- Ajout d'un membre ------------------------------------------------
if ($method === 'POST' && $action === 'member') {
$b = body_json();
$gid = (int) ($b['group_id'] ?? 0);
$uid = (int) ($b['user_id'] ?? 0);
if ($gid <= 0 || $uid <= 0) {
json_error('group_id et user_id requis.');
}
db()->prepare('INSERT IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
->execute([$gid, $uid]);
json_out(['ok' => true]);
}
// ---- Ajout d'une commune au périmètre ---------------------------------
if ($method === 'POST' && $action === 'scope') {
$b = body_json();
$gid = (int) ($b['group_id'] ?? 0);
$com = trim((string) ($b['com'] ?? ''));
$depc = trim((string) ($b['depc'] ?? ''));
if ($gid <= 0 || $com === '' || $depc === '') {
json_error('group_id, com et depc requis.');
}
db()->prepare('INSERT IGNORE INTO group_scopes (group_id, com, depc) VALUES (?, ?, ?)')
->execute([$gid, $com, $depc]);
json_out(['ok' => true]);
}
// ---- Création d'un groupe ---------------------------------------------
if ($method === 'POST') {
$b = body_json();
$name = trim((string) ($b['name'] ?? ''));
$can = !empty($b['can_validate']) ? 1 : 0;
if ($name === '') {
json_error('Nom de groupe requis.');
}
$exists = db()->prepare('SELECT 1 FROM user_groups WHERE name = ?');
$exists->execute([$name]);
if ($exists->fetchColumn()) {
json_error('Ce nom de groupe existe déjà.');
}
db()->prepare('INSERT INTO user_groups (name, can_validate) VALUES (?, ?)')->execute([$name, $can]);
json_out(['id' => (int) db()->lastInsertId(), 'name' => $name, 'can_validate' => $can], 201);
}
// ---- Retrait d'un membre ----------------------------------------------
if ($method === 'DELETE' && $action === 'member') {
$gid = (int) ($_GET['group_id'] ?? 0);
$uid = (int) ($_GET['user_id'] ?? 0);
if ($gid <= 0 || $uid <= 0) {
json_error('group_id et user_id requis.');
}
db()->prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?')->execute([$gid, $uid]);
json_out(['ok' => true]);
}
// ---- Retrait d'une commune --------------------------------------------
if ($method === 'DELETE' && $action === 'scope') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM group_scopes WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
// ---- Suppression d'un groupe ------------------------------------------
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM user_groups WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* Import de la dernière version de l'opendata (super-admin).
* POST → télécharge l'export de l'annuaire et met à jour la base.
*
* Nécessite un accès Internet sortant côté serveur.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
const OPENDATA_URL =
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-annuaire-education/exports/json'
. '?select=identifiant_de_l_etablissement,nom_etablissement,type_etablissement,statut_public_prive,'
. 'libelle_region,libelle_academie,libelle_departement,code_departement,nom_commune,code_postal,adresse_1,'
. 'telephone,mail,web,etat,nom_circonscription,restauration,hebergement,ulis,segpa,apprentissage,'
. 'latitude,longitude';
try {
require_super();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_error('Méthode non supportée.', 405);
}
set_time_limit(0);
ini_set('memory_limit', '1024M');
// Téléchargement.
$raw = false;
if (function_exists('curl_init')) {
$ch = curl_init(OPENDATA_URL);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 600,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Téléchargement échoué : ' . $err);
}
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
throw new RuntimeException("Réponse HTTP $code de l'opendata.");
}
} else {
$raw = @file_get_contents(OPENDATA_URL);
if ($raw === false) {
throw new RuntimeException('Téléchargement échoué (file_get_contents).');
}
}
$rows = json_decode($raw, true);
unset($raw);
if (!is_array($rows)) {
throw new RuntimeException('Réponse opendata illisible.');
}
// Transformation (clés courtes, géoloc obligatoire).
$clean = [];
$skipped = 0;
foreach ($rows as $r) {
$rec = map_opendata_row($r);
if ($rec === null || empty($rec['id'])) {
$skipped++;
continue;
}
$clean[] = $rec;
}
unset($rows);
$n = import_etablissements(db(), $clean);
$total = (int) db()->query('SELECT COUNT(*) FROM etablissements')->fetchColumn();
json_out([
'ok' => true,
'imported' => $n,
'skipped' => $skipped,
'total' => $total,
]);
} catch (Throwable $e) {
json_error('Import : ' . $e->getMessage(), 500);
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* Import des DPE ADEME + rattachement aux établissements (super-admin).
* POST body { dep?: "75", radius?: 80 }
* → télécharge le dataset DPE tertiaire, remplit « dpe », puis rattache
* les DPE aux écoles par proximité (table « etab_dpe »).
*
* ⚠ Opération longue (le dataset complet ≈ 532 000 DPE) : à réserver à un
* import ponctuel. Pour de gros volumes, préférez les scripts CLI
* (php import_dpe.php ; php match_dpe.php). Accès Internet requis côté serveur.
*/
declare(strict_types=1);
require __DIR__ . '/dpe_lib.php';
try {
require_super();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_error('Méthode non supportée.', 405);
}
set_time_limit(0);
ini_set('memory_limit', '512M');
$in = body_json();
$dep = isset($in['dep']) && $in['dep'] !== '' ? preg_replace('/[^0-9AB]/', '', strtoupper((string) $in['dep'])) : null;
$radius = isset($in['radius']) ? max(1, min(2000, (int) $in['radius'])) : 5;
$pdo = db();
$imported = dpe_import($pdo, $dep, static fn(string $m) => null);
$geo = (int) $pdo->query('SELECT COUNT(*) FROM dpe WHERE lat IS NOT NULL')->fetchColumn();
$match = dpe_match($pdo, $radius, static fn(string $m) => null);
json_out([
'ok' => true,
'imported' => $imported,
'geocoded' => $geo,
'radius' => $radius,
'links' => $match['pairs'],
'etabs' => $match['ecoles'],
'dep' => $dep,
]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
json_error('Import DPE : ' . $e->getMessage(), 500);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Historique de l'IPS (indice de position sociale) d'une école (1er degré).
* GET ?uai=UAI → [ { annee, ips, ips_dep }, ... ] trié par année croissante
* ips = IPS de l'établissement
* ips_dep = IPS moyen départemental (référence), ou null
*
* Source : dataset « fr-en-ips-ecoles-ap2022 » (cf. import_ips.php).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
// La table peut être absente si l'import n'a pas encore été lancé.
if (!db()->query("SHOW TABLES LIKE 'ips'")->fetchColumn()) {
json_out([]);
}
$st = db()->prepare('SELECT annee, ips, ips_dep FROM ips WHERE uai = ? ORDER BY annee');
$st->execute([$uai]);
$out = [];
foreach ($st as $r) {
$out[] = [
'annee' => (int) $r['annee'],
'ips' => (float) $r['ips'],
'ips_dep' => $r['ips_dep'] !== null ? (float) $r['ips_dep'] : null,
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* Population des zones IRIS (recensement INSEE 2022).
*
* GET ?code=<code_iris>&com=<code_commune>
* → { iris_pop, com_pop, com_name } pour un IRIS et sa commune
* POST ?action=batch body {codes:[...]}
* → { "<code_iris>": pop, ... } pour colorer la couche
*
* Population communale = somme des IRIS de la commune. Pour Paris, Lyon et
* Marseille, les données sont à l'arrondissement : on agrège à l'échelle de
* la VILLE entière (com_name renvoie alors « Paris » / « Lyon » / « Marseille »).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
/** Ville PLM correspondant à un code commune-arrondissement, ou null. */
function plm_city(string $com): ?array
{
if ($com >= '75101' && $com <= '75120') return ['Paris', '75101', '75120'];
if ($com >= '69381' && $com <= '69389') return ['Lyon', '69381', '69389'];
if ($com >= '13201' && $com <= '13216') return ['Marseille', '13201', '13216'];
return null;
}
try {
// --- Batch : populations d'une liste d'IRIS (pour la coloration) ---
if (($_GET['action'] ?? '') === 'batch') {
$codes = body_json()['codes'] ?? [];
if (!is_array($codes) || !$codes) {
json_out([]);
}
$codes = array_slice(array_values(array_unique(array_map('strval', $codes))), 0, 5000);
$ph = implode(',', array_fill(0, count($codes), '?'));
$st = db()->prepare("SELECT code, pop FROM iris_pop WHERE code IN ($ph)");
$st->execute($codes);
$out = [];
foreach ($st as $r) {
$out[$r['code']] = (float) $r['pop'];
}
json_out($out);
}
// --- Détail d'un IRIS + sa commune ---
$code = trim((string) ($_GET['code'] ?? ''));
$com = trim((string) ($_GET['com'] ?? ''));
$irisPop = null;
if ($code !== '') {
$st = db()->prepare('SELECT pop FROM iris_pop WHERE code = ?');
$st->execute([$code]);
$v = $st->fetchColumn();
if ($v !== false) {
$irisPop = (int) round((float) $v);
}
}
$comPop = null;
$comName = null;
if ($com !== '') {
$plm = plm_city($com);
if ($plm) {
$comName = $plm[0];
$st = db()->prepare('SELECT SUM(pop) FROM iris_pop WHERE com BETWEEN ? AND ?');
$st->execute([$plm[1], $plm[2]]);
} else {
$st = db()->prepare('SELECT SUM(pop) FROM iris_pop WHERE com = ?');
$st->execute([$com]);
}
$v = $st->fetchColumn();
if ($v !== null && $v !== false) {
$comPop = (int) round((float) $v);
}
}
json_out(['iris_pop' => $irisPop, 'com_pop' => $comPop, 'com_name' => $comName]);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+273
View File
@@ -0,0 +1,273 @@
<?php
/**
* Éléments d'un établissement : fichiers, photos, liens.
*
* GET ?uai=UAI [&kind=] → éléments VALIDÉS (public)
* GET ?action=queue → file d'attente de modération (admin, selon périmètre)
* POST ?uai=UAI (multipart) → soumission par tout internaute (statut « pending »)
* champs : kind, folder_id?, title?, description?, url?,
* submitter_name?, submitter_email?
* fichier : « file » (pour kind=file|photo)
* POST ?action=validate (JSON) → {id, decision:'approve'|'reject', reason?} (admin)
* POST ?action=update (JSON) → {id, title, description?} édition (valideurs)
* DELETE ?id=N → suppression (admin, selon périmètre)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
/** Sous-dossier d'upload selon le type. */
function kind_dir(string $kind): string
{
return $kind === 'photo' ? 'photos' : 'files';
}
/** URL publique d'un élément stocké sur disque. */
function item_url(array $r): ?string
{
if ($r['kind'] === 'link') {
return $r['url'];
}
if (!$r['filename']) {
return null;
}
return 'uploads/' . kind_dir($r['kind']) . '/' . rawurlencode($r['filename']);
}
function shape_item(array $r): array
{
return [
'id' => (int) $r['id'],
'folder_id' => $r['folder_id'] !== null ? (int) $r['folder_id'] : null,
'kind' => $r['kind'],
'title' => $r['title'],
'description' => $r['description'] ?? null,
'url' => item_url($r),
'size' => $r['size'] !== null ? (int) $r['size'] : null,
'mime' => $r['mime'],
'status' => $r['status'],
'submitted_at' => $r['submitted_at'] ?? null,
];
}
try {
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// ---- File d'attente de modération (admin) -----------------------------
if ($method === 'GET' && $action === 'queue') {
$user = require_login();
if ($user['role'] !== 'super' && $user['role'] !== 'perimeter') {
json_out([]); // commentateur : aucune file de modération
}
$sql = "SELECT i.*, e.nom AS etab_nom, e.com AS etab_com, e.depc AS etab_depc, e.dep AS etab_dep
FROM items i
JOIN etablissements e ON e.id = i.uai";
$args = [];
if ($user['role'] === 'super') {
$sql .= " WHERE i.status = 'pending'";
} else {
// Périmètre personnel (legacy) OU groupe « valideur » de l'utilisateur.
$sql .= " WHERE i.status = 'pending' AND (
EXISTS (SELECT 1 FROM user_scopes s
WHERE s.user_id = ? AND s.com = e.com AND s.depc = e.depc)
OR EXISTS (SELECT 1 FROM group_members gm
JOIN user_groups g ON g.id = gm.group_id AND g.can_validate = 1
JOIN group_scopes gs ON gs.group_id = g.id AND gs.com = e.com AND gs.depc = e.depc
WHERE gm.user_id = ?))";
$args[] = $user['id'];
$args[] = $user['id'];
}
$sql .= ' ORDER BY i.submitted_at ASC';
$st = db()->prepare($sql);
$st->execute($args);
$out = [];
foreach ($st as $r) {
$out[] = shape_item($r) + [
'uai' => $r['uai'],
'etab' => $r['etab_nom'] . ' — ' . $r['etab_com'] . ' (' . $r['etab_depc'] . ')',
'submitter_name' => $r['submitter_name'],
'submitter_email' => $r['submitter_email'],
];
}
json_out($out);
}
// ---- Liste publique (éléments validés) --------------------------------
if ($method === 'GET') {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$sql = "SELECT * FROM items WHERE uai = ? AND status = 'approved'";
$args = [$uai];
if (!empty($_GET['kind'])) {
$sql .= ' AND kind = ?';
$args[] = $_GET['kind'];
}
$sql .= ' ORDER BY submitted_at DESC, id DESC';
$st = db()->prepare($sql);
$st->execute($args);
$out = [];
foreach ($st as $r) {
$out[] = shape_item($r);
}
json_out($out);
}
// ---- Validation / rejet (admin) ---------------------------------------
if ($method === 'POST' && $action === 'validate') {
$user = require_login();
$b = body_json();
$id = (int) ($b['id'] ?? 0);
$decision = $b['decision'] ?? '';
if ($id <= 0 || !in_array($decision, ['approve', 'reject'], true)) {
json_error('Paramètres invalides (id, decision).');
}
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
$st->execute([$id]);
$uai = $st->fetchColumn();
if ($uai === false) {
json_error('Élément introuvable.', 404);
}
if (!can_validate_uai($user, (string) $uai)) {
json_error('Hors de votre périmètre.', 403);
}
$status = $decision === 'approve' ? 'approved' : 'rejected';
$reason = $decision === 'reject' ? (trim((string) ($b['reason'] ?? '')) ?: null) : null;
db()->prepare(
'UPDATE items SET status = ?, reject_reason = ?, validated_by = ?, validated_at = NOW() WHERE id = ?'
)->execute([$status, $reason, $user['id'], $id]);
json_out(['ok' => true, 'id' => $id, 'status' => $status]);
}
// ---- Édition du libellé / description (valideurs) ---------------------
if ($method === 'POST' && $action === 'update') {
$user = require_login();
$b = body_json();
$id = (int) ($b['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
$st->execute([$id]);
$uai = $st->fetchColumn();
if ($uai === false) {
json_error('Élément introuvable.', 404);
}
if (!can_validate_uai($user, (string) $uai)) {
json_error('Hors de votre périmètre.', 403);
}
$title = trim((string) ($b['title'] ?? ''));
$description = trim((string) ($b['description'] ?? ''));
if ($title === '') {
json_error('Le libellé ne peut pas être vide.');
}
db()->prepare('UPDATE items SET title = ?, description = ? WHERE id = ?')
->execute([$title, $description !== '' ? $description : null, $id]);
json_out(['ok' => true, 'id' => $id]);
}
// ---- Soumission (tout internaute) -------------------------------------
if ($method === 'POST') {
$uai = trim((string) ($_GET['uai'] ?? $_POST['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$chk = db()->prepare('SELECT 1 FROM etablissements WHERE id = ?');
$chk->execute([$uai]);
if (!$chk->fetchColumn()) {
json_error('Établissement inconnu.', 404);
}
$kind = $_POST['kind'] ?? '';
if (!in_array($kind, ['file', 'photo', 'link'], true)) {
json_error('Type invalide (file, photo ou link).');
}
$folderId = (isset($_POST['folder_id']) && $_POST['folder_id'] !== '')
? (int) $_POST['folder_id'] : null;
if ($folderId !== null) {
$f = db()->prepare('SELECT 1 FROM folders WHERE id = ?');
$f->execute([$folderId]);
if (!$f->fetchColumn()) {
json_error('Dossier inconnu.');
}
}
$title = trim((string) ($_POST['title'] ?? ''));
$description = trim((string) ($_POST['description'] ?? '')) ?: null;
$submitterName = trim((string) ($_POST['submitter_name'] ?? '')) ?: null;
$submitterMail = trim((string) ($_POST['submitter_email'] ?? '')) ?: null;
$filename = null;
$url = null;
$size = null;
$mime = null;
if ($kind === 'link') {
$url = trim((string) ($_POST['url'] ?? ''));
if (!preg_match('#^https?://#i', $url)) {
json_error('URL invalide (doit commencer par http:// ou https://).');
}
if ($title === '') {
$title = $url;
}
} else {
if (empty($_FILES['file']) || (int) $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
json_error('Fichier requis.');
}
$tmp = $_FILES['file']['tmp_name'];
$orig = (string) $_FILES['file']['name'];
$size = (int) $_FILES['file']['size'];
$mime = mime_content_type($tmp) ?: ($_FILES['file']['type'] ?? 'application/octet-stream');
if ($kind === 'photo' && strncmp($mime, 'image/', 6) !== 0) {
json_error('Le fichier nest pas une image.');
}
$dir = dirname(__DIR__) . '/uploads/' . kind_dir($kind);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', pathinfo($orig, PATHINFO_EXTENSION) ?: ''));
$filename = bin2hex(random_bytes(8)) . ($ext !== '' ? '.' . $ext : '');
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
json_error('Échec de lenregistrement du fichier.', 500);
}
if ($title === '') {
$title = $orig;
}
}
db()->prepare(
'INSERT INTO items (uai, folder_id, kind, title, description, filename, url, size, mime, status, submitter_name, submitter_email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, \'pending\', ?, ?)'
)->execute([$uai, $folderId, $kind, $title, $description, $filename, $url, $size, $mime, $submitterName, $submitterMail]);
json_out(['ok' => true, 'id' => (int) db()->lastInsertId(), 'status' => 'pending'], 201);
}
// ---- Suppression (admin) ----------------------------------------------
if ($method === 'DELETE') {
$user = require_login();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$st = db()->prepare('SELECT uai, kind, filename FROM items WHERE id = ?');
$st->execute([$id]);
$row = $st->fetch();
if (!$row) {
json_error('Élément introuvable.', 404);
}
if (!can_validate_uai($user, (string) $row['uai'])) {
json_error('Hors de votre périmètre.', 403);
}
db()->prepare('DELETE FROM comments WHERE item_id = ?')->execute([$id]);
db()->prepare('DELETE FROM items WHERE id = ?')->execute([$id]);
if ($row['filename']) {
@unlink(dirname(__DIR__) . '/uploads/' . kind_dir($row['kind']) . '/' . $row['filename']);
}
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* Historique de la population municipale (INSEE) de la zone d'un établissement.
* GET ?uai=UAI → { zone: [{annee,pop},…], zone_estimated: bool, ville: [...]|null, ville_nom }
*
* « zone » = commune, ou arrondissement pour Paris/Lyon/Marseille (code INSEE comc de
* l'établissement). Pour un arrondissement, « ville » donne en plus la courbe historique
* de la ville entière. Sinon « ville » est null.
*
* Lyon/Marseille n'ont pas d'historique INSEE par arrondissement : « zone » est alors
* ESTIMÉE (zone_estimated=true) = population de la ville × part de l'arrondissement dans
* les IRIS du recensement 2022 (iris_pop).
*
* Source : pop_commune (cf. import_pop_hist.php), prolongée à 2022 via iris_pop.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
/**
* Ville entière pour un arrondissement Paris/Lyon/Marseille :
* [ codes pop_commune (historique), codes iris_pop (point 2022), nom ] — ou null.
* Paris n'a pas de code commune unique dans pop_commune : on somme les arrondissements.
*/
function plm_ville(string $comc): ?array
{
$i = (int) $comc;
$codes = static fn(int $a, int $b) => array_map('strval', range($a, $b));
if ($i >= 13201 && $i <= 13216) return [['13055'], $codes(13201, 13216), 'Marseille'];
if ($i >= 69381 && $i <= 69389) return [['69123'], $codes(69381, 69389), 'Lyon'];
if ($i >= 75101 && $i <= 75120) return [$codes(75101, 75120), $codes(75101, 75120), 'Paris'];
return null;
}
/** Somme des populations IRIS (recensement 2022) pour une liste de codes commune/arrondissement. */
function iris_sum(array $codes): float
{
if (!$codes) {
return 0.0;
}
$ph = implode(',', array_fill(0, count($codes), '?'));
$st = db()->prepare("SELECT SUM(pop) FROM iris_pop WHERE com IN ($ph)");
$st->execute(array_map('strval', $codes));
return (float) $st->fetchColumn();
}
/**
* Construit une série [{annee,pop}] : somme de pop_commune sur $histCodes (par année),
* prolongée à 2022 par la somme d'iris_pop sur $irisCodes.
*/
function pop_series(bool $hasHist, bool $hasIris, array $histCodes, array $irisCodes): array
{
$out = [];
$hasYear = [];
if ($hasHist && $histCodes) {
$ph = implode(',', array_fill(0, count($histCodes), '?'));
$st = db()->prepare("SELECT annee, SUM(pop) AS pop FROM pop_commune WHERE comc IN ($ph) GROUP BY annee ORDER BY annee");
$st->execute($histCodes);
foreach ($st as $r) {
$y = (int) $r['annee'];
$out[] = ['annee' => $y, 'pop' => (int) $r['pop']];
$hasYear[$y] = true;
}
}
if ($hasIris && $irisCodes && empty($hasYear[2022])) {
$ph = implode(',', array_fill(0, count($irisCodes), '?'));
$p = db()->prepare("SELECT ROUND(SUM(pop)) FROM iris_pop WHERE com IN ($ph)");
$p->execute($irisCodes);
$v = $p->fetchColumn();
if ($v !== null && $v !== false && (float) $v > 0) {
$out[] = ['annee' => 2022, 'pop' => (int) $v];
}
}
usort($out, static fn($a, $b) => $a['annee'] - $b['annee']);
return $out;
}
try {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$hasHist = (bool) db()->query("SHOW TABLES LIKE 'pop_commune'")->fetchColumn();
$hasIris = (bool) db()->query("SHOW TABLES LIKE 'iris_pop'")->fetchColumn();
if (!$hasHist && !$hasIris) {
json_out(['zone' => [], 'ville' => null, 'ville_nom' => null]);
}
$e = db()->prepare('SELECT comc FROM etablissements WHERE id = ?');
$e->execute([$uai]);
$comc = $e->fetchColumn();
if (!$comc) {
json_out(['zone' => [], 'ville' => null, 'ville_nom' => null]);
}
$comc = (string) $comc;
// Zone = commune ou arrondissement (code de l'établissement directement).
$zone = pop_series($hasHist, $hasIris, [$comc], [$comc]);
// Ville entière (seulement pour un arrondissement PLM).
$ville = null;
$villeNom = null;
$estimated = false;
if ($plm = plm_ville($comc)) {
[$histCodes, $irisCodes, $villeNom] = $plm;
$ville = pop_series($hasHist, $hasIris, $histCodes, $irisCodes);
// Si l'arrondissement n'a pas d'historique propre (Lyon/Marseille), on l'ESTIME :
// population de la ville × part de l'arrondissement dans les IRIS (recensement 2022).
if (count($zone) < 2 && $ville && $hasIris) {
$arrIris = iris_sum([$comc]);
$cityIris = iris_sum($irisCodes);
if ($arrIris > 0 && $cityIris > 0) {
$share = $arrIris / $cityIris;
$zone = array_map(
static fn(array $p) => ['annee' => $p['annee'], 'pop' => (int) round($p['pop'] * $share)],
$ville
);
$estimated = true;
}
}
}
json_out([
'zone' => $zone,
'zone_estimated' => $estimated,
'ville' => $ville,
'ville_nom' => $villeNom,
]);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Gestion des administrateurs (super-admin uniquement).
* GET → liste des utilisateurs + périmètres + email
* POST {username, password, role?, email?} → crée un utilisateur (role « perimeter »
* par défaut ; « member » = simple commentateur ;
* mot de passe soumis à la politique, must_change=1)
* POST ?action=password {id, password}→ réinitialise un mot de passe (politique, must_change)
* POST ?action=email {id, email} → met à jour l'e-mail d'un compte
* POST ?action=scope {user_id, com, depc} → ajoute une commune au périmètre
* DELETE ?id=N → supprime un admin
* DELETE ?action=scope&id=N → retire une commune d'un périmètre
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$me = require_super();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
if ($method === 'GET') {
$users = db()->query('SELECT id, username, role, email, must_change, created_at FROM users ORDER BY role DESC, username')->fetchAll();
$scopes = db()->query('SELECT id, user_id, com, depc FROM user_scopes ORDER BY com')->fetchAll();
$byUser = [];
foreach ($scopes as $s) {
$byUser[(int) $s['user_id']][] = ['id' => (int) $s['id'], 'com' => $s['com'], 'depc' => $s['depc']];
}
foreach ($users as &$u) {
$u['id'] = (int) $u['id'];
$u['must_change'] = (int) $u['must_change'];
$u['scopes'] = $byUser[$u['id']] ?? [];
}
json_out($users);
}
// Mise à jour de l'e-mail d'un compte.
if ($method === 'POST' && $action === 'email') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
$email = trim((string) ($b['email'] ?? ''));
if ($id <= 0) {
json_error('id requis.');
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_error('Adresse e-mail invalide.');
}
db()->prepare('UPDATE users SET email = ? WHERE id = ?')->execute([$email !== '' ? $email : null, $id]);
json_out(['ok' => true]);
}
if ($method === 'POST' && $action === 'scope') {
$b = body_json();
$uid = (int) ($b['user_id'] ?? 0);
$com = trim((string) ($b['com'] ?? ''));
$depc = trim((string) ($b['depc'] ?? ''));
if ($uid <= 0 || $com === '' || $depc === '') {
json_error('user_id, com et depc requis.');
}
db()->prepare('INSERT IGNORE INTO user_scopes (user_id, com, depc) VALUES (?, ?, ?)')
->execute([$uid, $com, $depc]);
json_out(['ok' => true]);
}
if ($method === 'POST' && $action === 'password') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
$pwd = (string) ($b['password'] ?? '');
if ($id <= 0) {
json_error('id requis.');
}
if ($err = password_policy_check($pwd)) {
json_error($err);
}
// Mot de passe posé par l'admin → l'utilisateur devra le changer à sa connexion.
db()->prepare('UPDATE users SET password_hash = ?, must_change = 1, failed_attempts = 0, locked_until = NULL WHERE id = ?')
->execute([password_hash($pwd, PASSWORD_DEFAULT), $id]);
json_out(['ok' => true]);
}
if ($method === 'POST') {
$b = body_json();
$username = trim((string) ($b['username'] ?? ''));
$pwd = (string) ($b['password'] ?? '');
$email = trim((string) ($b['email'] ?? ''));
$roleIn = (string) ($b['role'] ?? 'perimeter');
$role = in_array($roleIn, ['super', 'perimeter', 'member'], true) ? $roleIn : 'perimeter';
if ($username === '') {
json_error('Identifiant requis.');
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_error('Adresse e-mail invalide.');
}
if ($err = password_policy_check($pwd)) {
json_error($err);
}
$exists = db()->prepare('SELECT 1 FROM users WHERE username = ?');
$exists->execute([$username]);
if ($exists->fetchColumn()) {
json_error('Cet identifiant existe déjà.');
}
// Mot de passe initial posé par l'admin → changement forcé à la 1re connexion.
db()->prepare('INSERT INTO users (username, password_hash, role, email, must_change) VALUES (?, ?, ?, ?, 1)')
->execute([$username, password_hash($pwd, PASSWORD_DEFAULT), $role, $email !== '' ? $email : null]);
json_out(['id' => (int) db()->lastInsertId(), 'username' => $username, 'role' => $role], 201);
}
if ($method === 'DELETE' && $action === 'scope') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM user_scopes WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
if ($id === (int) $me['id']) {
json_error('Vous ne pouvez pas supprimer votre propre compte.');
}
db()->prepare('DELETE FROM users WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+821
View File
@@ -0,0 +1,821 @@
/* Carte des établissements scolaires — filtrage côté client. */
'use strict';
// Couleurs par type d'établissement (utilisées markers + légende).
const TYPE_COLORS = {
'Ecole': '#2a9d8f',
'Collège': '#e9c46a',
'Lycée': '#e76f51',
'Médico-social': '#8e7dbe',
'Service Administratif': '#6c757d',
'EREA': '#457b9d',
'Autre': '#adb5bd',
};
const DEFAULT_COLOR = '#adb5bd';
const colorFor = (t) => TYPE_COLORS[t] || DEFAULT_COLOR;
// Coloration des marqueurs : par type d'établissement (défaut) ou par étiquette DPE.
const DPE_COLORS = {
A: '#319834', B: '#33cc31', C: '#cbfc34', D: '#fbfe06',
E: '#fbcc05', F: '#fc8104', G: '#fc0205',
};
const DPE_NA = '#b8c2cc'; // pas de DPE rattaché
const dpeColor = (l) => DPE_COLORS[String(l || '').toUpperCase()] || DPE_NA;
let colorMode = 'type'; // 'type' | 'dpe'
const dpeByUai = new Map(); // UAI → étiquette énergie (meilleur match), chargé à la demande
// Lieux nommés pour le deep-link ?place=… (centre + zoom).
const PLACES = {
marseille: [43.296, 5.37, 12],
aix: [43.529, 5.447, 13],
paris: [48.857, 2.352, 12],
lyon: [45.758, 4.835, 12],
france: [46.6, 2.4, 6],
};
function markerColorFor(e) {
return colorMode === 'dpe' ? dpeColor(dpeByUai.get(e.id)) : colorFor(e.type);
}
// Options proposées : clé compacte (dans les données) → libellé affiché.
const OPTIONS = [
{ key: 'rest', label: 'Restauration' },
{ key: 'heb', label: 'Internat (hébergement)' },
{ key: 'ulis', label: 'ULIS' },
{ key: 'segp', label: 'SEGPA' },
];
let ALL = []; // tous les enregistrements
let lastSubset = []; // dernier sous-ensemble filtré (pour la coloration DPE)
const BY_ID = new Map(); // UAI → enregistrement (pour ouvrir la fiche)
const TOTAL = () => ALL.length;
// --- Carte ---
const map = L.map('map', { preferCanvas: true }).setView([46.6, 2.4], 6);
// Fonds de carte : « clair » (CartoDB Positron) par défaut pour faire ressortir
// les points, fond OSM standard en alternative.
const baseLight = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 20,
subdomains: 'abcd',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> · © <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
const baseOsm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
});
// --- Couche IRIS (contours INSEE/IGN) colorée par densité de population ---
// Chargée par zone visible (WFS Géoplateforme) ; rendue sous les points
// (pane dédié) et non interactive pour ne pas gêner les clics.
const IRIS_MIN_ZOOM = 11;
const IRIS_URL = 'https://data.geopf.fr/wfs/ows';
map.createPane('iris');
map.getPane('iris').style.zIndex = 250; // entre tuiles (200) et points (≥400)
// Échelle de densité (habitants / km²) → palette séquentielle (clair → foncé).
const DENS_BINS = [0, 500, 2000, 5000, 10000, 20000, 40000];
const DENS_COLORS = ['#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8d3c', '#f03b20', '#bd0026'];
function densColor(d) {
if (d == null) return '#cfd6de'; // population inconnue
let i = 0;
while (i < DENS_BINS.length - 1 && d >= DENS_BINS[i + 1]) i++;
return DENS_COLORS[i];
}
// Surface d'un anneau [lng,lat] en m² (formule sphérique), mise en cache.
const EARTH_R = 6378137;
const rad = (x) => x * Math.PI / 180;
function ringArea(ring) {
let a = 0;
for (let i = 0, n = ring.length; i < n; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[(i + 1) % n];
a += rad(x2 - x1) * (2 + Math.sin(rad(y1)) + Math.sin(rad(y2)));
}
return Math.abs(a * EARTH_R * EARTH_R / 2);
}
function featureAreaKm2(f) {
if (f.properties._km2 != null) return f.properties._km2;
const g = f.geometry || {};
let m2 = 0;
const polys = g.type === 'Polygon' ? [g.coordinates]
: g.type === 'MultiPolygon' ? g.coordinates : [];
for (const rings of polys) {
m2 += ringArea(rings[0]);
for (let k = 1; k < rings.length; k++) m2 -= ringArea(rings[k]); // trous
}
return (f.properties._km2 = m2 / 1e6);
}
function featureDensity(f) {
const pop = f.properties._pop;
if (pop == null) return null;
const km2 = featureAreaKm2(f);
return km2 > 0 ? pop / km2 : null;
}
function densityStyle(f) {
const d = featureDensity(f);
return {
color: '#555',
weight: 1,
opacity: 0.7,
fillColor: densColor(d),
fillOpacity: d == null ? 0.15 : 0.6,
};
}
// Échelle « taux d'écoliers publics » (% de la population) → palette verte.
const RATIO_BINS = [0, 4, 6, 8, 10, 13];
const RATIO_COLORS = ['#edf8e9', '#c7e9c0', '#a1d99b', '#74c476', '#31a354', '#006d2c'];
function ratioColor(p) {
if (p == null) return '#cfd6de';
let i = 0;
while (i < RATIO_BINS.length - 1 && p >= RATIO_BINS[i + 1]) i++;
return RATIO_COLORS[i];
}
// Taux par IRIS = élèves des écoles publiques situées dans l'IRIS / population de l'IRIS.
function ratioStyle(f) {
const pop = f.properties._pop;
const stu = f.properties._students;
const pct = (pop != null && pop > 0 && stu != null) ? stu / pop * 100 : null;
return {
color: '#555',
weight: 1,
opacity: 0.7,
fillColor: ratioColor(pct),
fillOpacity: pct == null ? 0.15 : 0.65,
};
}
let irisMode = 'density'; // 'density' | 'ratio'
const currentStyle = (f) => (irisMode === 'ratio' ? ratioStyle : densityStyle)(f);
const irisLayer = L.geoJSON(null, {
pane: 'iris',
renderer: L.svg({ pane: 'iris' }), // SVG dédié : tracé net, indépendant du canvas des points
interactive: false,
style: currentStyle,
attribution: 'Contours IRIS® © IGN / INSEE',
});
let irisActive = false;
let irisAbort = null;
let irisTimer = null;
let irisFeatures = []; // dernières features chargées (pour le clic)
let suppressIrisClick = false; // évite le popup IRIS quand on clique un établissement
const irisHint = L.control({ position: 'topright' });
irisHint.onAdd = () => {
const d = L.DomUtil.create('div', 'iris-hint');
d.textContent = '🔎 Zoomez pour afficher les contours IRIS';
return d;
};
let irisHintShown = false;
function toggleIrisHint(show) {
if (show && !irisHintShown) { irisHint.addTo(map); irisHintShown = true; }
else if (!show && irisHintShown) { map.removeControl(irisHint); irisHintShown = false; }
}
async function refreshIris() {
if (!irisActive) return;
if (map.getZoom() < IRIS_MIN_ZOOM) {
irisLayer.clearLayers();
irisFeatures = [];
toggleIrisHint(true);
return;
}
toggleIrisHint(false);
const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]
.map((n) => n.toFixed(5)).join(',') + ',urn:ogc:def:crs:OGC:1.3:CRS84';
const url = `${IRIS_URL}?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature`
+ '&TYPENAMES=STATISTICALUNITS.IRIS:contours_iris&OUTPUTFORMAT=application/json'
+ `&SRSNAME=CRS:84&COUNT=2000&BBOX=${bbox}`;
try {
if (irisAbort) irisAbort.abort();
irisAbort = new AbortController();
const res = await fetch(url, { signal: irisAbort.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
irisLayer.clearLayers();
irisLayer.addData(data);
irisFeatures = data.features || [];
await styleIris();
} catch (e) {
if (e.name !== 'AbortError') console.warn('IRIS :', e.message);
}
}
// Récupère la population (INSEE 2022) des IRIS visibles → f.properties._pop.
async function loadIrisPops() {
const codes = irisFeatures.map((f) => f.properties.code_iris).filter(Boolean);
if (!codes.length) return;
let pops;
try {
pops = await fetch('api/iris.php?action=batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes }),
}).then((r) => r.json());
} catch {
return;
}
irisFeatures.forEach((f) => {
f.properties._pop = Object.prototype.hasOwnProperty.call(pops, f.properties.code_iris)
? pops[f.properties.code_iris] : null;
});
}
// Compte les écoliers des écoles PUBLIQUES situées dans chaque IRIS visible
// (école rattachée à l'IRIS qui la contient) → f.properties._students.
async function loadIrisStudents() {
irisFeatures.forEach((f) => { f.properties._students = 0; });
const b = map.getBounds();
const ecoles = ALL.filter((e) =>
e.type === 'Ecole' && e.stat === 'Public' && e.lat != null && b.contains([e.lat, e.lon]));
if (!ecoles.length) return;
let eff = {};
try {
eff = await fetch('api/effectifs.php?action=latest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uais: ecoles.map((e) => e.id) }),
}).then((r) => r.json());
} catch {
return;
}
for (const e of ecoles) {
const f = findIris({ lng: e.lon, lat: e.lat });
if (f) f.properties._students += (eff[e.id] || 0);
}
}
// Applique la coloration courante (densité ou taux de scolarisation par IRIS).
async function styleIris() {
if (!irisFeatures.length) return;
await loadIrisPops();
if (irisMode === 'ratio') {
await loadIrisStudents();
irisLayer.setStyle(ratioStyle);
} else {
irisLayer.setStyle(densityStyle);
}
}
// --- IRIS cliquables : clic carte → point-dans-polygone → popup population ---
function pointInRing(lng, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
if (((yi > lat) !== (yj > lat)) &&
(lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
}
function polygonContains(lng, lat, rings) {
// rings[0] = contour extérieur, rings[1..] = trous
if (!pointInRing(lng, lat, rings[0])) return false;
for (let k = 1; k < rings.length; k++) {
if (pointInRing(lng, lat, rings[k])) return false;
}
return true;
}
function findIris(latlng) {
const lng = latlng.lng, lat = latlng.lat;
for (const f of irisFeatures) {
const g = f.geometry;
if (!g) continue;
if (g.type === 'Polygon') {
if (polygonContains(lng, lat, g.coordinates)) return f;
} else if (g.type === 'MultiPolygon') {
for (const poly of g.coordinates) {
if (polygonContains(lng, lat, poly)) return f;
}
}
}
return null;
}
const TYP_IRIS = { H: 'Habitat', A: 'Activité', D: 'Divers', Z: 'Non défini' };
map.on('click', (e) => {
if (!irisActive || suppressIrisClick) return;
const f = findIris(e.latlng);
if (!f) return;
const p = f.properties || {};
const typ = TYP_IRIS[p.type_iris] || p.type_iris || '';
const head = `<div class="iris-popup"><h3>${p.nom_iris || 'IRIS'}</h3>`
+ `<div class="meta">${p.nom_commune || ''}${typ ? ' · ' + typ : ''}</div>`;
const popup = L.popup({ maxWidth: 260 })
.setLatLng(e.latlng)
.setContent(head + '<div class="row">Population 2022 : …</div></div>')
.openOn(map);
const fmt = (n) => n == null ? 'n.c.' : n.toLocaleString('fr') + ' hab.';
fetch(`api/iris.php?code=${encodeURIComponent(p.code_iris || '')}&com=${encodeURIComponent(p.code_insee || '')}`)
.then((r) => r.json())
.then((d) => {
const km2 = featureAreaKm2(f);
const dens = (d.iris_pop != null && km2 > 0) ? Math.round(d.iris_pop / km2) : null;
const comLabel = d.com_name || p.nom_commune || 'Commune';
// En mode « taux d'écoliers », _students est calculé par IRIS.
const stu = f.properties._students;
let scoLine = '';
if (stu != null && d.iris_pop != null) {
const tx = d.iris_pop > 0 ? (stu / d.iris_pop * 100).toFixed(1) + ' %' : 'n.c.';
scoLine = `<div class="row"><b>Écoliers publics (IRIS) :</b> ${stu.toLocaleString('fr')} (${tx})</div>`;
}
popup.setContent(head
+ `<div class="row"><b>IRIS :</b> ${fmt(d.iris_pop)}</div>`
+ (dens != null ? `<div class="row">Densité : ${dens.toLocaleString('fr')} hab/km²</div>` : '')
+ scoLine
+ `<div class="row"><b>${esc(comLabel)} :</b> ${fmt(d.com_pop)}</div>`
+ `<div class="uai">Code IRIS : ${p.code_iris || ''} — recensement 2022</div></div>`);
})
.catch(() => popup.setContent(head + '<div class="row">Population indisponible.</div></div>'));
});
// Construit le contenu de la légende selon le mode de coloration courant.
function irisLegendHtml() {
let bins, colors, title, unit;
if (irisMode === 'ratio') {
bins = RATIO_BINS; colors = RATIO_COLORS;
title = 'Écoliers publics / population'; unit = ' %';
} else {
bins = DENS_BINS; colors = DENS_COLORS;
title = 'Densité (hab/km²)'; unit = '';
}
let html = `<strong>${title}</strong>`;
for (let i = 0; i < colors.length; i++) {
const from = bins[i], to = bins[i + 1];
const label = to
? `${from.toLocaleString('fr')}${to.toLocaleString('fr')}${unit}`
: `${from.toLocaleString('fr')}${unit}`;
html += `<div class="row"><span class="swatch" style="background:${colors[i]}"></span>${label}</div>`;
}
return html + '<div class="row"><span class="swatch" style="background:#cfd6de"></span>n.c.</div>';
}
// Contrôle combiné : sélecteur de mode + légende (affiché avec la couche IRIS).
let irisPanelDiv = null;
const irisPanel = L.control({ position: 'bottomleft' });
irisPanel.onAdd = () => {
const div = L.DomUtil.create('div', 'legend iris-legend');
L.DomEvent.disableClickPropagation(div);
irisPanelDiv = div;
renderIrisPanel();
return div;
};
function renderIrisPanel() {
if (!irisPanelDiv) return;
irisPanelDiv.innerHTML = `
<div class="iris-mode">
<label><input type="radio" name="irismode" value="density" ${irisMode === 'density' ? 'checked' : ''}> Densité de population</label>
<label><input type="radio" name="irismode" value="ratio" ${irisMode === 'ratio' ? 'checked' : ''}> Taux d'écoliers publics</label>
</div>
<div class="iris-legend-body">${irisLegendHtml()}</div>`;
irisPanelDiv.querySelectorAll('input[name="irismode"]').forEach((r) =>
r.addEventListener('change', (ev) => {
irisMode = ev.target.value;
renderIrisPanel();
styleIris();
}));
}
map.on('overlayadd', (e) => {
if (e.layer === irisLayer) { irisActive = true; irisPanel.addTo(map); refreshIris(); }
});
map.on('overlayremove', (e) => {
if (e.layer === irisLayer) {
irisActive = false;
irisLayer.clearLayers();
irisFeatures = [];
toggleIrisHint(false);
map.removeControl(irisPanel);
irisPanelDiv = null;
}
});
map.on('moveend', () => { clearTimeout(irisTimer); irisTimer = setTimeout(refreshIris, 400); });
L.control.layers(
{ 'Fond clair': baseLight, 'OpenStreetMap': baseOsm },
{ 'Contours IRIS (INSEE/IGN)': irisLayer },
{ collapsed: false }
).addTo(map);
const cluster = L.markerClusterGroup({
chunkedLoading: true,
spiderfyOnMaxZoom: true,
maxClusterRadius: 50,
disableClusteringAtZoom: 16,
});
map.addLayer(cluster);
// --- Centrage automatique sur la position de l'internaute ---
// (si l'utilisateur l'autorise ; sinon on conserve la vue France par défaut).
let userMarker = null;
map.on('locationfound', (e) => {
if (userMarker) map.removeLayer(userMarker);
userMarker = L.layerGroup([
L.circle(e.latlng, { radius: e.accuracy, color: '#1d3557', weight: 1, fillOpacity: 0.08 }),
L.circleMarker(e.latlng, { radius: 7, color: '#fff', weight: 2, fillColor: '#1d3557', fillOpacity: 1 })
.bindPopup('Vous êtes ici'),
]).addTo(map);
locating = false;
});
map.on('locationerror', (e) => {
// Position refusée ou indisponible : la vue France reste affichée.
console.info('Géolocalisation indisponible :', e.message);
if (locating) alert('Localisation indisponible : ' + e.message);
locating = false;
});
let locating = false;
function locateUser() {
locating = true;
map.locate({ setView: true, maxZoom: 13, enableHighAccuracy: true });
}
// Bouton « Me localiser » sur la carte.
const locateBtn = L.control({ position: 'topleft' });
locateBtn.onAdd = () => {
const div = L.DomUtil.create('div', 'leaflet-bar locate-ctrl');
const a = L.DomUtil.create('a', '', div);
a.href = '#';
a.title = 'Me localiser';
a.setAttribute('role', 'button');
a.setAttribute('aria-label', 'Me localiser');
a.textContent = '📍';
L.DomEvent.on(a, 'click', (ev) => {
L.DomEvent.preventDefault(ev);
L.DomEvent.stopPropagation(ev);
locateUser();
});
return div;
};
locateBtn.addTo(map);
// Centrage automatique au chargement (silencieux si refusé).
locateUser();
locating = false;
// --- Utilitaires ---
const norm = (s) => (s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
));
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
function uniqSorted(values) {
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'fr'));
}
// --- Construction des popups (paresseuse) ---
function buildPopup(e) {
const badgeCls = e.etat === 'OUVERT' ? 'ouvert' : 'fermer';
const lines = [];
lines.push(`<h3>${esc(e.nom || 'Établissement')}</h3>`);
lines.push(`<div class="meta">${esc(e.type || '')} · ${esc(e.stat || '')}` +
(e.etat ? ` <span class="badge ${badgeCls}">${esc(e.etat)}</span>` : '') + `</div>`);
if (e.adr) lines.push(`<div class="row">${esc(e.adr)}</div>`);
const ville = [e.cp, e.com].filter(Boolean).join(' ');
if (ville) lines.push(`<div class="row">${esc(ville)}</div>`);
if (e.dep) lines.push(`<div class="row">${esc(e.dep)} · ${esc(e.aca || '')}</div>`);
if (e.circ) lines.push(`<div class="row">Circonscription : ${esc(e.circ)}</div>`);
const opts = OPTIONS.filter((o) => e[o.key] === 1).map((o) => o.label);
if (opts.length) lines.push(`<div class="row">Options : ${esc(opts.join(' · '))}</div>`);
if (e.tel) lines.push(`<div class="row">📞 ${esc(e.tel)}</div>`);
if (e.mail) lines.push(`<div class="row">✉ <a href="mailto:${esc(e.mail)}">${esc(e.mail)}</a></div>`);
if (e.web) {
const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web;
lines.push(`<div class="row">🔗 <a href="${esc(url)}" target="_blank" rel="noopener">Site web</a></div>`);
}
if (e.id) lines.push(`<div class="uai">UAI : ${esc(e.id)}</div>`);
lines.push(`<button type="button" class="popup-fiche" data-id="${esc(e.id)}">Voir la fiche complète →</button>`);
return `<div class="popup">${lines.join('')}</div>`;
}
function squareIcon(color) {
return L.divIcon({
className: 'sq-marker',
html: `<span style="background:${color}"></span>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
});
}
function markerFor(e) {
if (e._m) return e._m;
const color = markerColorFor(e);
let m;
if (e.stat === 'Privé') {
// Établissement privé : marqueur carré (divIcon).
m = L.marker([e.lat, e.lon], { icon: squareIcon(color) });
} else {
// Public (et autres) : puce ronde.
m = L.circleMarker([e.lat, e.lon], {
radius: 5,
weight: 1,
color: '#fff',
fillColor: color,
fillOpacity: 0.85,
});
}
m.bindPopup(() => buildPopup(e), { maxWidth: 280 });
// Priorité au point d'établissement : on neutralise le popup IRIS sur ce clic.
m.on('click', () => { suppressIrisClick = true; setTimeout(() => { suppressIrisClick = false; }, 60); });
e._m = m;
return m;
}
// Recolore les marqueurs déjà créés selon le mode de coloration courant.
function recolorMarkers() {
ALL.forEach((e) => {
const m = e._m;
if (!m) return;
const c = markerColorFor(e);
if (typeof m.setStyle === 'function') m.setStyle({ fillColor: c }); // puce ronde (public)
else if (typeof m.setIcon === 'function') m.setIcon(squareIcon(c)); // carré (privé)
});
}
// Charge les étiquettes DPE (meilleur match) manquantes pour un lot d'établissements.
// L'API batch est plafonnée à 5000 UAI ⇒ on découpe en tranches.
async function ensureDpeColors(records) {
const missing = [...new Set(
records.map((e) => e.id).filter((id) => id && !dpeByUai.has(id))
)];
if (!missing.length) return;
for (let i = 0; i < missing.length; i += 5000) {
const chunk = missing.slice(i, i + 5000);
let res;
try {
res = await fetch('api/dpe.php?action=latest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uais: chunk }),
}).then((r) => r.json());
} catch {
res = {};
}
// Mémorise une valeur pour CHAQUE UAI demandé (null si pas de DPE) afin
// de ne pas le re-demander à chaque changement de filtre.
chunk.forEach((id) => dpeByUai.set(id, (res && res[id]) ? res[id].dpe : null));
}
}
// --- État des filtres ---
const els = {
search: document.getElementById('search'),
fType: document.getElementById('f-type'),
fStat: document.getElementById('f-stat'),
fEtat: document.getElementById('f-etat'),
reg: document.getElementById('f-reg'),
aca: document.getElementById('f-aca'),
dep: document.getElementById('f-dep'),
circ: document.getElementById('f-circ'),
fOpt: document.getElementById('f-opt'),
reset: document.getElementById('reset'),
count: document.getElementById('count'),
legend: document.getElementById('legend'),
};
function checkedValues(container) {
return [...container.querySelectorAll('input:checked')].map((i) => i.value);
}
function buildChecks(container, values, withColor) {
container.innerHTML = '';
values.forEach((v) => {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = v;
cb.checked = true;
cb.addEventListener('change', applyFilters);
label.appendChild(cb);
if (withColor) {
const sw = document.createElement('span');
sw.className = 'swatch';
sw.style.background = colorFor(v);
label.appendChild(sw);
}
const txt = document.createElement('span');
txt.textContent = v;
label.appendChild(txt);
container.appendChild(label);
});
}
// Cases d'options, décochées par défaut (coché = contrainte « possède l'option »).
function buildOptionChecks(container) {
container.innerHTML = '';
OPTIONS.forEach((o) => {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = o.key;
cb.checked = false;
cb.addEventListener('change', applyFilters);
const txt = document.createElement('span');
txt.textContent = o.label;
label.append(cb, txt);
container.appendChild(label);
});
}
function fillSelect(select, values, allLabel) {
const current = select.value;
select.innerHTML = '';
const opt0 = document.createElement('option');
opt0.value = '';
opt0.textContent = allLabel;
select.appendChild(opt0);
values.forEach((v) => {
const o = document.createElement('option');
o.value = v;
o.textContent = v;
select.appendChild(o);
});
// Conserve la sélection si toujours valide.
select.value = values.includes(current) ? current : '';
}
// Recalcule les selects géographiques en cascade (région → académie → département → circonscription).
function refreshGeoSelects() {
const reg = els.reg.value, aca = els.aca.value, dep = els.dep.value;
fillSelect(els.reg, uniqSorted(ALL.map((e) => e.reg)), 'Toutes les régions');
els.reg.value = reg;
let pool = ALL.filter((e) => !els.reg.value || e.reg === els.reg.value);
fillSelect(els.aca, uniqSorted(pool.map((e) => e.aca)), 'Toutes les académies');
els.aca.value = aca;
pool = pool.filter((e) => !els.aca.value || e.aca === els.aca.value);
fillSelect(els.dep, uniqSorted(pool.map((e) => e.dep)), 'Tous les départements');
els.dep.value = dep;
pool = pool.filter((e) => !els.dep.value || e.dep === els.dep.value);
fillSelect(els.circ, uniqSorted(pool.map((e) => e.circ)), 'Toutes les circonscriptions');
els.circ.value = els.circ.value; // déjà conservé par fillSelect
}
// --- Filtrage + rendu ---
function applyFilters() {
const types = new Set(checkedValues(els.fType));
const stats = new Set(checkedValues(els.fStat));
const etats = new Set(checkedValues(els.fEtat));
const reg = els.reg.value, aca = els.aca.value, dep = els.dep.value, circ = els.circ.value;
const opts = checkedValues(els.fOpt); // options requises (logique ET)
const q = norm(els.search.value.trim());
const subset = ALL.filter((e) => {
if (!types.has(e.type)) return false;
if (!stats.has(e.stat)) return false;
if (!etats.has(e.etat)) return false;
if (reg && e.reg !== reg) return false;
if (aca && e.aca !== aca) return false;
if (dep && e.dep !== dep) return false;
if (circ && e.circ !== circ) return false;
for (const k of opts) if (e[k] !== 1) return false;
if (q && !e._s.includes(q)) return false;
return true;
});
lastSubset = subset;
cluster.clearLayers();
cluster.addLayers(subset.map(markerFor));
els.count.textContent =
`${subset.length.toLocaleString('fr')} / ${TOTAL().toLocaleString('fr')} établissements`;
// En coloration DPE, charge les étiquettes du sous-ensemble puis recolore.
if (colorMode === 'dpe') {
ensureDpeColors(subset).then(recolorMarkers);
}
}
function onGeoChange() {
refreshGeoSelects();
applyFilters();
}
function legendBody() {
if (colorMode === 'dpe') {
return '<strong>Étiquette énergie (DPE)</strong>' +
['A', 'B', 'C', 'D', 'E', 'F', 'G'].map((l) =>
`<div class="row"><span class="swatch" style="background:${DPE_COLORS[l]}"></span>${l}</div>`
).join('') +
`<div class="row"><span class="swatch" style="background:${DPE_NA}"></span>Aucun DPE rattaché</div>`;
}
return '<strong>Type d\'établissement</strong>' +
Object.entries(TYPE_COLORS).map(([t, c]) =>
`<div class="row"><span class="swatch" style="background:${c}"></span>${t}</div>`
).join('') +
'<div class="row" style="margin-top:6px"><span class="swatch" style="background:#6c757d"></span>Public (rond)</div>' +
'<div class="row"><span class="swatch" style="background:#6c757d;border-radius:2px"></span>Privé (carré)</div>';
}
function buildLegend() {
els.legend.innerHTML =
`<div class="legend-mode">
<label><input type="radio" name="colormode" value="type" ${colorMode === 'type' ? 'checked' : ''}> Type</label>
<label><input type="radio" name="colormode" value="dpe" ${colorMode === 'dpe' ? 'checked' : ''}> DPE</label>
</div>
<div class="legend-body">${legendBody()}</div>`;
els.legend.querySelectorAll('input[name="colormode"]').forEach((r) =>
r.addEventListener('change', (ev) => {
colorMode = ev.target.value;
buildLegend();
if (colorMode === 'dpe') {
ensureDpeColors(lastSubset).then(recolorMarkers);
} else {
recolorMarkers();
}
}));
}
function resetFilters() {
els.search.value = '';
els.fType.querySelectorAll('input').forEach((i) => (i.checked = true));
els.fStat.querySelectorAll('input').forEach((i) => (i.checked = true));
els.fEtat.querySelectorAll('input').forEach((i) => (i.checked = true));
els.fOpt.querySelectorAll('input').forEach((i) => (i.checked = false));
els.reg.value = els.aca.value = els.dep.value = els.circ.value = '';
refreshGeoSelects();
applyFilters();
}
// --- Démarrage ---
async function init() {
try {
const res = await fetch('api/etablissements.php');
if (!res.ok) throw new Error('HTTP ' + res.status);
ALL = await res.json();
} catch (err) {
els.count.textContent = 'Erreur de chargement des données.';
console.error(err);
return;
}
// Pré-calcul du champ de recherche normalisé + index par UAI.
ALL.forEach((e) => {
e._s = norm((e.nom || '') + ' ' + (e.com || ''));
if (e.id) BY_ID.set(e.id, e);
});
buildChecks(els.fType, uniqSorted(ALL.map((e) => e.type)), true);
buildChecks(els.fStat, uniqSorted(ALL.map((e) => e.stat)), false);
buildChecks(els.fEtat, uniqSorted(ALL.map((e) => e.etat)), false);
buildOptionChecks(els.fOpt);
refreshGeoSelects();
// Deep-link : ?color=dpe&place=marseille (ou ?center=lat,lon&zoom=N).
const params = new URLSearchParams(location.search);
if ((params.get('color') || '').toLowerCase() === 'dpe') colorMode = 'dpe';
buildLegend();
els.search.addEventListener('input', debounce(applyFilters, 250));
[els.reg, els.aca, els.dep].forEach((s) => s.addEventListener('change', onGeoChange));
els.circ.addEventListener('change', applyFilters);
els.reset.addEventListener('click', resetFilters);
// Fiche plein écran : ouverture depuis la popup, recentrage sans rechargement.
Fiche.init({
onLocate: (e) => {
map.setView([e.lat, e.lon], 17, { animate: true });
markerFor(e).openPopup();
},
});
document.getElementById('map').addEventListener('click', (ev) => {
const btn = ev.target.closest('.popup-fiche');
if (!btn) return;
const rec = BY_ID.get(btn.dataset.id);
if (rec) Fiche.open(rec);
});
applyFilters();
// Centrage initial depuis l'URL (après le rendu des marqueurs).
const place = (params.get('place') || '').toLowerCase();
const center = params.get('center');
if (PLACES[place]) {
const [lat, lon, z] = PLACES[place];
map.setView([lat, lon], z);
} else if (center && /^-?\d+\.?\d*,-?\d+\.?\d*$/.test(center)) {
const [lat, lon] = center.split(',').map(Number);
map.setView([lat, lon], Number(params.get('zoom')) || 13);
}
}
init();
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* Convertit l'export brut (raw_export.json) de l'annuaire de l'education
* en un fichier compact consomme par la carte.
*
* - ClES courtes pour reduire la taille (~3x plus petit).
* - Coordonnees arrondies a 5 decimales (~1 m de precision).
* - Les etablissements sans geolocalisation sont ecartes.
*
* Usage : php build_data.php
*/
$src = __DIR__ . '/raw_export.json';
$dstDir = __DIR__ . '/data';
$dst = $dstDir . '/etablissements.json';
if (!is_file($src)) {
fwrite(STDERR, "Fichier source introuvable : $src\n");
exit(1);
}
if (!is_dir($dstDir) && !mkdir($dstDir, 0777, true) && !is_dir($dstDir)) {
fwrite(STDERR, "Impossible de creer le dossier $dstDir\n");
exit(1);
}
fwrite(STDERR, "Lecture de l'export brut...\n");
$rows = json_decode(file_get_contents($src), true);
if (!is_array($rows)) {
fwrite(STDERR, "JSON source invalide.\n");
exit(1);
}
$out = [];
$skipped = 0;
foreach ($rows as $r) {
$lat = $r['latitude'] ?? null;
$lon = $r['longitude'] ?? null;
if ($lat === null || $lon === null) {
$skipped++;
continue;
}
$rec = [
'id' => $r['identifiant_de_l_etablissement'] ?? null,
'nom' => $r['nom_etablissement'] ?? null,
'type' => $r['type_etablissement'] ?? null,
'stat' => $r['statut_public_prive'] ?? null,
'reg' => $r['libelle_region'] ?? null,
'aca' => $r['libelle_academie'] ?? null,
'dep' => $r['libelle_departement'] ?? null,
'depc' => $r['code_departement'] ?? null,
'com' => $r['nom_commune'] ?? null,
'cp' => $r['code_postal'] ?? null,
'adr' => $r['adresse_1'] ?? null,
'tel' => $r['telephone'] ?? null,
'mail' => $r['mail'] ?? null,
'web' => $r['web'] ?? null,
'etat' => $r['etat'] ?? null,
'circ' => $r['nom_circonscription'] ?? null,
'lat' => round((float) $lat, 5),
'lon' => round((float) $lon, 5),
];
// Options : flag stocke seulement quand l'option est presente (1), pour alleger.
$options = [
'rest' => 'restauration',
'heb' => 'hebergement', // internat
'ulis' => 'ulis',
'segp' => 'segpa',
'appr' => 'apprentissage',
];
foreach ($options as $key => $field) {
if ((int) ($r[$field] ?? 0) === 1) {
$rec[$key] = 1;
}
}
$out[] = $rec;
}
file_put_contents($dst, json_encode($out, JSON_UNESCAPED_UNICODE));
$count = count($out);
$size = round(filesize($dst) / 1048576, 1);
fwrite(STDERR, "OK : $count etablissements ecrits ($size Mo), $skipped sans geoloc ignores.\n");
fwrite(STDERR, "Fichier : $dst\n");
+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 };
})();
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mot de passe oublié</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card">
<h1>Mot de passe oublié</h1>
<p class="hint">Saisissez votre identifiant ou votre adresse e-mail. Si un compte
correspond et qu'une adresse e-mail y est associée, un lien de réinitialisation
vous sera envoyé.</p>
<form id="forgot-form" autocomplete="off">
<div class="field">
<label for="login">Identifiant ou e-mail</label>
<input id="login" type="text" required>
</div>
<button type="submit" class="btn-primary">Envoyer le lien</button>
<p id="msg" class="propose-msg"></p>
</form>
<p class="auth-foot"><a href="admin.html">← Retour à la connexion</a></p>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
$('forgot-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
$('msg').className = 'propose-msg';
$('msg').textContent = 'Envoi…';
try {
const res = await fetch('api/auth.php?action=forgot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: $('login').value.trim() }),
});
const data = await res.json().catch(() => ({}));
$('msg').className = 'propose-msg ok';
$('msg').textContent = data.message || 'Si un compte correspond, un e-mail a été envoyé.';
$('forgot-form').reset();
} catch (err) {
$('msg').className = 'propose-msg err';
$('msg').textContent = 'Erreur : ' + err.message;
}
});
</script>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<?php
/**
* Ajoute / met à jour le code INSEE commune (comc) des établissements,
* depuis l'opendata annuaire. Nécessaire pour relier les écoles à la
* population INSEE (indexée par code commune) — cf. taux de scolarisation.
*
* Usage : php import_comc.php
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
const ANNUAIRE_COMC =
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-annuaire-education/'
. 'exports/csv?select=identifiant_de_l_etablissement,code_commune&delimiter=%3B';
function say(string $m): void { echo $m . "\n"; flush(); }
try {
$pdo = db();
try {
$pdo->exec('ALTER TABLE etablissements ADD COLUMN comc VARCHAR(5) NULL');
$pdo->exec('ALTER TABLE etablissements ADD INDEX idx_comc (comc)');
say('✔ Colonne comc ajoutée.');
} catch (Throwable $e) {
say(' Colonne comc déjà présente.');
}
$tmp = __DIR__ . '/data/insee/annuaire_comc.csv';
@mkdir(dirname($tmp), 0777, true);
say('… Téléchargement (identifiant + code_commune) …');
$fp = fopen($tmp, 'w');
$ch = curl_init(ANNUAIRE_COMC);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
if (!curl_exec($ch)) {
throw new RuntimeException('Téléchargement échoué : ' . curl_error($ch));
}
curl_close($ch);
fclose($fp);
$fh = fopen($tmp, 'r');
$header = fgetcsv($fh, 0, ';');
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
$iId = array_search('identifiant_de_l_etablissement', $header, true);
$iCom = array_search('code_commune', $header, true);
if ($iId === false || $iCom === false) {
throw new RuntimeException('Colonnes attendues introuvables.');
}
$upd = $pdo->prepare('UPDATE etablissements SET comc = ? WHERE id = ?');
$pdo->beginTransaction();
$n = 0;
while (($r = fgetcsv($fh, 0, ';')) !== false) {
$id = $r[$iId] ?? '';
$com = $r[$iCom] ?? '';
if ($id === '' || $com === '') {
continue;
}
$upd->execute([$com, $id]);
if (++$n % 5000 === 0) { $pdo->commit(); $pdo->beginTransaction(); }
}
$pdo->commit();
fclose($fh);
@unlink($tmp);
$filled = (int) $pdo->query("SELECT COUNT(*) FROM etablissements WHERE comc IS NOT NULL AND comc <> ''")->fetchColumn();
say("✔ comc renseigné pour $filled établissements (sur $n lignes).");
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Import des DPE des bâtiments tertiaires (ADEME) dans la table « dpe ».
* Source : data.ademe.fr, dataset « dpe01tertiaire » (DPE tertiaire depuis juillet 2021).
* API data-fair : .../lines (pagination par curseur « after / next »).
*
* ⚠ Le dataset n'a PAS d'identifiant école (UAI) : les bâtiments sont repérés
* par adresse BAN + géolocalisation. Le rattachement école↔DPE se fait
* ensuite par proximité géographique (cf. match_dpe.php).
*
* Usage :
* php import_dpe.php # importe tout (~532 000 DPE, plusieurs minutes)
* php import_dpe.php --dep=75 # n'importe qu'un département (test rapide)
*
* La logique est partagée avec l'endpoint web (api/dpe_lib.php).
*/
declare(strict_types=1);
require __DIR__ . '/api/dpe_lib.php';
function say(string $m): void { echo $m . "\n"; flush(); }
try {
$dep = null;
foreach ($argv ?? [] as $a) {
if (preg_match('/^--dep=(\w+)$/', $a, $m)) {
$dep = $m[1];
}
}
$pdo = db();
$n = dpe_import($pdo, $dep, static fn(string $m) => say(' … ' . $m));
$geo = (int) $pdo->query('SELECT COUNT(*) FROM dpe WHERE lat IS NOT NULL')->fetchColumn();
say("✔ dpe : $n DPE importés ($geo géolocalisés).");
say('→ Lancez maintenant « php match_dpe.php » pour rattacher les DPE aux établissements.');
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* Import des effectifs et nombres de classes des écoles (1er degré) — historique.
* Source : data.education.gouv.fr, dataset « fr-en-ecoles-effectifs-nb_classes ».
*
* Télécharge l'export CSV (champs réduits) puis remplit la table effectifs
* (uai, annee → élèves, classes). Clé = numéro d'école (UAI) + rentrée scolaire.
*
* Usage : php import_effectifs.php
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
const EFFECTIFS_CSV =
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/'
. 'fr-en-ecoles-effectifs-nb_classes/exports/csv'
. '?select=rentree_scolaire,numero_ecole,nombre_total_eleves,nombre_total_classes&delimiter=%3B';
function say(string $m): void { echo $m . "\n"; flush(); }
try {
$pdo = db();
$pdo->exec(
'CREATE TABLE IF NOT EXISTS effectifs (
uai VARCHAR(12) NOT NULL,
annee SMALLINT NOT NULL,
eleves INT NOT NULL DEFAULT 0,
classes INT NOT NULL DEFAULT 0,
PRIMARY KEY (uai, annee),
INDEX idx_uai (uai)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Télécharge le CSV dans un fichier temporaire si absent localement.
$csv = __DIR__ . '/data/insee/effectifs.csv';
if (!is_file($csv)) {
@mkdir(dirname($csv), 0777, true);
say('… Téléchargement de lexport CSV …');
$fp = fopen($csv, 'w');
$ch = curl_init(EFFECTIFS_CSV);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
if (!curl_exec($ch)) {
throw new RuntimeException('Téléchargement échoué : ' . curl_error($ch));
}
curl_close($ch);
fclose($fp);
}
$fh = fopen($csv, 'r');
$header = fgetcsv($fh, 0, ';');
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]); // retire un éventuel BOM
$iY = array_search('rentree_scolaire', $header, true);
$iU = array_search('numero_ecole', $header, true);
$iE = array_search('nombre_total_eleves', $header, true);
$iC = array_search('nombre_total_classes', $header, true);
if (in_array(false, [$iY, $iU, $iE, $iC], true)) {
throw new RuntimeException('Colonnes attendues introuvables dans le CSV.');
}
$ins = $pdo->prepare('REPLACE INTO effectifs (uai, annee, eleves, classes) VALUES (?, ?, ?, ?)');
$pdo->beginTransaction();
$n = 0;
$num = static fn($v) => (int) round((float) str_replace(',', '.', (string) $v));
while (($row = fgetcsv($fh, 0, ';')) !== false) {
$uai = $row[$iU] ?? '';
$annee = (int) ($row[$iY] ?? 0);
if ($uai === '' || $annee === 0) {
continue;
}
$ins->execute([$uai, $annee, $num($row[$iE] ?? 0), $num($row[$iC] ?? 0)]);
if (++$n % 5000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
if ($n % 100000 === 0) say("$n lignes");
}
}
$pdo->commit();
fclose($fh);
$ecoles = (int) $pdo->query('SELECT COUNT(DISTINCT uai) FROM effectifs')->fetchColumn();
say("✔ effectifs : $n lignes importées ($ecoles écoles).");
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* Import des effectifs du 2nd degré (collèges et lycées) — historique.
* Sources data.education.gouv.fr :
* - fr-en-college-effectifs-niveau-sexe-lv (numero_college, nombre_eleves_total)
* - fr-en-lycee_gt-effectifs-niveau-sexe-lv (numero_lycee, nombre_d_eleves)
* - fr-en-lycee_pro-effectifs-niveau-sexe-lv (numero_lycee, nombre_d_eleves)
*
* Alimente la même table « effectifs » (uai, annee → eleves). Le nombre de
* classes n'existe pas pour le 2nd degré (classes = 0). Les lycées polyvalents
* (présents en GT et PRO) sont sommés. Idempotent.
*
* Usage : php import_effectifs_2d.php
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
function say(string $m): void { echo $m . "\n"; flush(); }
const SOURCES = [
['fr-en-college-effectifs-niveau-sexe-lv', 'numero_college', 'nombre_eleves_total'],
['fr-en-lycee_gt-effectifs-niveau-sexe-lv', 'numero_lycee', 'nombre_d_eleves'],
['fr-en-lycee_pro-effectifs-niveau-sexe-lv','numero_lycee', 'nombre_d_eleves'],
];
function download_csv(string $dataset, string $uaiCol, string $popCol, string $dest): void
{
$url = "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/$dataset/exports/csv"
. "?select=rentree_scolaire,$uaiCol,$popCol&delimiter=%3B";
$fp = fopen($dest, 'w');
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
$ok = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
fclose($fp);
if (!$ok) {
throw new RuntimeException("Téléchargement échoué ($dataset) : $err");
}
}
try {
$pdo = db();
$pdo->exec(
'CREATE TABLE IF NOT EXISTS effectifs (
uai VARCHAR(12) NOT NULL,
annee SMALLINT NOT NULL,
eleves INT NOT NULL DEFAULT 0,
classes INT NOT NULL DEFAULT 0,
PRIMARY KEY (uai, annee),
INDEX idx_uai (uai)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$tmpDir = __DIR__ . '/data/insee';
@mkdir($tmpDir, 0777, true);
// Accumulateur uai → annee → élèves (somme GT+PRO pour les polyvalents).
$acc = [];
foreach (SOURCES as [$ds, $uaiCol, $popCol]) {
$csv = "$tmpDir/$ds.csv";
say("… Téléchargement $ds");
download_csv($ds, $uaiCol, $popCol, $csv);
$fh = fopen($csv, 'r');
$header = fgetcsv($fh, 0, ';');
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
$iY = array_search('rentree_scolaire', $header, true);
$iU = array_search($uaiCol, $header, true);
$iE = array_search($popCol, $header, true);
if (in_array(false, [$iY, $iU, $iE], true)) {
throw new RuntimeException("Colonnes introuvables dans $ds.");
}
$rows = 0;
while (($r = fgetcsv($fh, 0, ';')) !== false) {
$uai = $r[$iU] ?? '';
$annee = (int) substr((string) ($r[$iY] ?? ''), 0, 4);
if ($uai === '' || $annee === 0) {
continue;
}
$eleves = (int) round((float) str_replace(',', '.', (string) ($r[$iE] ?? '0')));
$acc[$uai][$annee] = ($acc[$uai][$annee] ?? 0) + $eleves;
$rows++;
}
fclose($fh);
@unlink($csv);
say(" $ds : $rows lignes.");
}
$ins = $pdo->prepare('REPLACE INTO effectifs (uai, annee, eleves, classes) VALUES (?, ?, ?, 0)');
$pdo->beginTransaction();
$n = 0;
foreach ($acc as $uai => $byYear) {
foreach ($byYear as $annee => $eleves) {
$ins->execute([$uai, $annee, $eleves]);
if (++$n % 5000 === 0) { $pdo->commit(); $pdo->beginTransaction(); }
}
}
$pdo->commit();
say("✔ Effectifs 2nd degré : $n lignes (collèges + lycées) importées.");
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* Import de l'IPS (indice de position sociale) des établissements — historique.
* Source : data.education.gouv.fr. Trois datasets remplis dans la même table « ips » :
* - écoles : « fr-en-ips-ecoles-ap2022 » (ips + ips_departemental ; rentrées 2022→2024)
* - collèges : « fr-en-ips-colleges-ap2022 » (ips ; rentrée 2022-2023)
* - lycées : « fr-en-ips-lycees-ap2022 » (ips_ensemble_gt_pro, repli ips_voie_gt)
*
* Clé = UAI + rentrée scolaire (année de début, ex. « 2022-2023 » → 2022).
* ips_dep (IPS moyen départemental de référence) n'existe que pour les écoles.
*
* Usage : php import_ips.php (les trois niveaux)
* php import_ips.php --niveau=colleges (un seul : ecoles|colleges|lycees)
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
function say(string $m): void { echo $m . "\n"; flush(); }
// Datasets et mapping des colonnes (le nom de la colonne IPS varie selon le niveau).
const IPS_DATASETS = [
'ecoles' => [
'ds' => 'fr-en-ips-ecoles-ap2022',
'select' => 'rentree_scolaire,uai,ips,ips_departemental',
'ips' => ['ips'],
'dep' => 'ips_departemental',
'file' => 'ips_ecoles.csv',
],
'colleges' => [
'ds' => 'fr-en-ips-colleges-ap2022',
'select' => 'rentree_scolaire,uai,ips',
'ips' => ['ips'],
'dep' => null,
'file' => 'ips_colleges.csv',
],
'lycees' => [
'ds' => 'fr-en-ips-lycees-ap2022',
'select' => 'rentree_scolaire,uai,ips_ensemble_gt_pro,ips_voie_gt',
'ips' => ['ips_ensemble_gt_pro', 'ips_voie_gt'], // ensemble, sinon voie GT
'dep' => null,
'file' => 'ips_lycees.csv',
],
];
/** Télécharge l'export CSV d'un dataset vers $path s'il est absent. */
function ips_download(string $ds, string $select, string $path): void
{
if (is_file($path)) {
return;
}
@mkdir(dirname($path), 0777, true);
$url = 'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/'
. $ds . '/exports/csv?select=' . rawurlencode($select) . '&delimiter=%3B';
say("… Téléchargement de $ds");
$fp = fopen($path, 'w');
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
$ok = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
fclose($fp);
if (!$ok) {
@unlink($path);
throw new RuntimeException("Téléchargement échoué ($ds) : $err");
}
}
/** Importe un dataset IPS dans la table « ips ». Renvoie le nombre de lignes. */
function ips_import_one(PDO $pdo, array $cfg): int
{
$path = __DIR__ . '/data/insee/' . $cfg['file'];
ips_download($cfg['ds'], $cfg['select'], $path);
$fh = fopen($path, 'r');
$header = fgetcsv($fh, 0, ';');
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]); // retire un éventuel BOM
$idx = static fn(string $name) => array_search($name, $header, true);
$iY = $idx('rentree_scolaire');
$iU = $idx('uai');
$ipsCols = array_values(array_filter(array_map($idx, $cfg['ips']), static fn($v) => $v !== false));
$iD = $cfg['dep'] !== null ? $idx($cfg['dep']) : false;
if ($iY === false || $iU === false || !$ipsCols) {
fclose($fh);
throw new RuntimeException("Colonnes attendues introuvables dans {$cfg['file']}.");
}
$ins = $pdo->prepare('REPLACE INTO ips (uai, annee, ips, ips_dep) VALUES (?, ?, ?, ?)');
$dec = static fn($v) => ($v === null || $v === '') ? null : (float) str_replace(',', '.', (string) $v);
$pdo->beginTransaction();
$n = 0;
while (($row = fgetcsv($fh, 0, ';')) !== false) {
$uai = $row[$iU] ?? '';
$annee = (int) ($row[$iY] ?? 0);
// Première colonne IPS non vide (ensemble, sinon repli).
$ips = null;
foreach ($ipsCols as $c) {
$ips = $dec($row[$c] ?? null);
if ($ips !== null) {
break;
}
}
if ($uai === '' || $annee === 0 || $ips === null) {
continue;
}
$ins->execute([$uai, $annee, $ips, $iD !== false ? $dec($row[$iD] ?? null) : null]);
if (++$n % 5000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();
fclose($fh);
return $n;
}
try {
$pdo = db();
$pdo->exec(
'CREATE TABLE IF NOT EXISTS ips (
uai VARCHAR(12) NOT NULL,
annee SMALLINT NOT NULL,
ips DECIMAL(5,1) NOT NULL,
ips_dep DECIMAL(5,1) NULL,
PRIMARY KEY (uai, annee),
INDEX idx_uai (uai)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Filtre éventuel sur un niveau.
$only = null;
foreach ($argv ?? [] as $a) {
if (preg_match('/^--niveau=(ecoles|colleges|lycees)$/', $a, $m)) {
$only = $m[1];
}
}
$total = 0;
foreach (IPS_DATASETS as $niveau => $cfg) {
if ($only !== null && $only !== $niveau) {
continue;
}
$n = ips_import_one($pdo, $cfg);
say("$niveau : $n lignes importées.");
$total += $n;
}
$ecoles = (int) $pdo->query('SELECT COUNT(DISTINCT uai) FROM ips')->fetchColumn();
say("✔ ips : $total lignes au total ($ecoles établissements distincts).");
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* Import des populations du recensement INSEE 2022 (base infracommunale
* « évolution et structure de la population »).
*
* Importe le fichier IRIS dans la table iris_pop (code IRIS → population P22_POP).
* La population communale est déduite par somme des IRIS d'une même commune
* (les 5 premiers caractères du code IRIS = code commune).
*
* Pré-requis : télécharger et DÉZIPPER la base dans data/insee/ :
* https://www.insee.fr/fr/statistiques/fichier/8647014/base-ic-evol-struct-pop-2022_csv.zip
* → data/insee/base-ic-evol-struct-pop-2022.CSV
*
* Usage : php import_pop.php
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
function say(string $m): void { echo $m . "\n"; flush(); }
try {
$pdo = db();
$pdo->exec(
'CREATE TABLE IF NOT EXISTS iris_pop (
code VARCHAR(12) NOT NULL PRIMARY KEY,
com VARCHAR(5) NOT NULL,
pop DOUBLE NOT NULL DEFAULT 0,
INDEX idx_com (com)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$csv = __DIR__ . '/data/insee/base-ic-evol-struct-pop-2022.CSV';
if (!is_file($csv)) {
throw new RuntimeException(
"Fichier absent : $csv\n" .
" Téléchargez et dézippez la base IRIS dans data/insee/ (voir l'en-tête de ce script)."
);
}
$fh = fopen($csv, 'r');
$header = fgetcsv($fh, 0, ';');
$iIris = array_search('IRIS', $header, true);
$iCom = array_search('COM', $header, true);
$iPop = array_search('P22_POP', $header, true);
if ($iIris === false || $iCom === false || $iPop === false) {
throw new RuntimeException('Colonnes IRIS / COM / P22_POP introuvables.');
}
$ins = $pdo->prepare('REPLACE INTO iris_pop (code, com, pop) VALUES (?, ?, ?)');
$pdo->beginTransaction();
$n = 0;
while (($row = fgetcsv($fh, 0, ';')) !== false) {
$code = $row[$iIris] ?? '';
if ($code === '') {
continue;
}
$com = $row[$iCom] ?? substr($code, 0, 5);
$pop = (float) str_replace(',', '.', (string) ($row[$iPop] ?? '0'));
$ins->execute([$code, $com, $pop]);
if (++$n % 5000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();
fclose($fh);
say("✔ iris_pop : $n IRIS importés (population 2022).");
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* Import de l'historique des populations légales communales (INSEE).
* Source : public.opendatasoft.com, dataset « historique-des-populations-legales »
* (population municipale par commune et par recensement, ~1876 → 2022).
*
* Remplit la table pop_commune (comc = code INSEE commune, annee → population).
* Sert à tracer la courbe d'évolution de la population de la commune dans la fiche.
*
* Usage : php import_pop_hist.php
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
const POP_HIST_CSV =
'https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/'
. 'historique-des-populations-legales/exports/csv'
. '?select=code_insee,annee,population_municipale&delimiter=%3B';
function say(string $m): void { echo $m . "\n"; flush(); }
try {
$pdo = db();
$pdo->exec(
'CREATE TABLE IF NOT EXISTS pop_commune (
comc VARCHAR(5) NOT NULL,
annee SMALLINT NOT NULL,
pop INT NOT NULL DEFAULT 0,
PRIMARY KEY (comc, annee),
INDEX idx_comc (comc)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$csv = __DIR__ . '/data/insee/pop_hist_communes.csv';
if (!is_file($csv)) {
@mkdir(dirname($csv), 0777, true);
say('… Téléchargement de lexport CSV …');
$fp = fopen($csv, 'w');
$ch = curl_init(POP_HIST_CSV);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
$ok = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
fclose($fp);
if (!$ok) {
@unlink($csv);
throw new RuntimeException('Téléchargement échoué : ' . $err);
}
}
$fh = fopen($csv, 'r');
$header = fgetcsv($fh, 0, ';');
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]); // retire un éventuel BOM
$iC = array_search('code_insee', $header, true);
$iY = array_search('annee', $header, true);
$iP = array_search('population_municipale', $header, true);
if ($iC === false || $iY === false || $iP === false) {
throw new RuntimeException('Colonnes code_insee / annee / population_municipale introuvables.');
}
$ins = $pdo->prepare('REPLACE INTO pop_commune (comc, annee, pop) VALUES (?, ?, ?)');
$pdo->beginTransaction();
$n = 0;
while (($row = fgetcsv($fh, 0, ';')) !== false) {
$comc = trim((string) ($row[$iC] ?? ''));
$annee = (int) ($row[$iY] ?? 0);
$pop = (int) round((float) str_replace([' ', ','], ['', '.'], (string) ($row[$iP] ?? '0')));
if ($comc === '' || $annee === 0) {
continue;
}
$ins->execute([$comc, $annee, $pop]);
if (++$n % 10000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
if ($n % 100000 === 0) say("$n lignes");
}
}
$pdo->commit();
fclose($fh);
$communes = (int) $pdo->query('SELECT COUNT(DISTINCT comc) FROM pop_commune')->fetchColumn();
say("✔ pop_commune : $n lignes importées ($communes communes).");
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* Import de l'historique des populations légales communales — source officielle INSEE.
* Base « séries historiques » du recensement (Comparateur de territoires) :
* https://www.insee.fr/fr/statistiques/fichier/8582555/base-cc-serie-historique-2022_csv.zip
*
* Population municipale à 9 recensements : 1968, 1975, 1982, 1990, 1999, 2006, 2011,
* 2016, 2022 (colonnes D68_POP … D99_POP puis P06_POP … P22_POP). Cette base est au
* niveau commune ET arrondissement municipal : elle contient donc le VRAI historique
* par arrondissement pour Paris (75101…), Lyon (69381…) et Marseille (13201…), ainsi
* que les totaux communes (75056 / 69123 / 13055).
*
* Remplit la table pop_commune (comc, annee, pop). Lit le CSV dans le zip sans
* l'extension PHP « zip » (mini-lecteur ZIP + gzinflate).
*
* Usage : php import_pop_insee.php
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
ini_set('memory_limit', '1024M');
const INSEE_SERIE_URL = 'https://www.insee.fr/fr/statistiques/fichier/8582555/base-cc-serie-historique-2022_csv.zip';
const INSEE_CSV_NAME = 'base-cc-serie-historique-2022.CSV';
// Colonne de population → année du recensement.
const POP_COLS = [
'P22_POP' => 2022, 'P16_POP' => 2016, 'P11_POP' => 2011, 'P06_POP' => 2006,
'D99_POP' => 1999, 'D90_POP' => 1990, 'D82_POP' => 1982, 'D75_POP' => 1975, 'D68_POP' => 1968,
];
function say(string $m): void { echo $m . "\n"; flush(); }
/**
* Lit une entrée d'un fichier ZIP (méthodes « stored » et « deflate ») sans ext/zip.
* Renvoie le contenu décompressé, ou null si absente.
*/
function zip_entry(string $path, string $name): ?string
{
$fh = fopen($path, 'rb');
if (!$fh) {
return null;
}
$size = filesize($path);
$look = min($size, 22 + 65536);
fseek($fh, $size - $look);
$buf = fread($fh, $look);
$eo = strrpos($buf, "PK\x05\x06");
if ($eo === false) { fclose($fh); return null; }
$eocd = unpack('Vsig/vdisk/vcddisk/ventries/vtotal/Vcdsize/Vcdoffset/vcomlen', substr($buf, $eo, 22));
fseek($fh, $eocd['cdoffset']);
$cd = fread($fh, $eocd['cdsize']);
$off = 0;
$found = null;
$n = strlen($cd);
while ($off + 46 <= $n && substr($cd, $off, 4) === "PK\x01\x02") {
$h = unpack('vver/vverneed/vflags/vmethod/vmtime/vmdate/Vcrc/Vcsize/Vusize/vnlen/velen/vclen/vdisk/viattr/Veattr/Vloffset', substr($cd, $off + 4, 42));
$fname = substr($cd, $off + 46, $h['nlen']);
if ($fname === $name) { $found = $h; break; }
$off += 46 + $h['nlen'] + $h['elen'] + $h['clen'];
}
if (!$found) { fclose($fh); return null; }
fseek($fh, $found['loffset']);
$lh = unpack('Vsig/vver/vflags/vmethod/vmtime/vmdate/Vcrc/Vcsize/Vusize/vnlen/velen', fread($fh, 30));
fseek($fh, $found['loffset'] + 30 + $lh['nlen'] + $lh['elen']);
$comp = fread($fh, $found['csize']);
fclose($fh);
if ($found['method'] === 0) {
return $comp;
}
if ($found['method'] === 8) {
return gzinflate($comp);
}
return null;
}
try {
$pdo = db();
$pdo->exec(
'CREATE TABLE IF NOT EXISTS pop_commune (
comc VARCHAR(5) NOT NULL,
annee SMALLINT NOT NULL,
pop INT NOT NULL DEFAULT 0,
PRIMARY KEY (comc, annee),
INDEX idx_comc (comc)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$zipPath = __DIR__ . '/data/insee/serie_hist.zip';
if (!is_file($zipPath)) {
@mkdir(dirname($zipPath), 0777, true);
say('… Téléchargement de la base INSEE (~6,7 Mo) …');
$fp = fopen($zipPath, 'w');
$ch = curl_init(INSEE_SERIE_URL);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
$ok = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
fclose($fp);
if (!$ok || filesize($zipPath) < 100000) {
@unlink($zipPath);
throw new RuntimeException('Téléchargement échoué : ' . $err);
}
}
say('… Lecture du CSV …');
$csv = zip_entry($zipPath, INSEE_CSV_NAME);
if ($csv === null || $csv === '') {
throw new RuntimeException('CSV introuvable dans le zip : ' . INSEE_CSV_NAME);
}
$lines = preg_split('/\r\n|\n|\r/', $csv);
$header = str_getcsv(array_shift($lines), ';');
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]); // BOM
$iCode = array_search('CODGEO', $header, true);
if ($iCode === false) {
throw new RuntimeException('Colonne CODGEO introuvable.');
}
$colIdx = [];
foreach (POP_COLS as $col => $annee) {
$k = array_search($col, $header, true);
if ($k !== false) {
$colIdx[$k] = $annee;
}
}
if (!$colIdx) {
throw new RuntimeException('Aucune colonne de population trouvée.');
}
$ins = $pdo->prepare('REPLACE INTO pop_commune (comc, annee, pop) VALUES (?, ?, ?)');
$pdo->exec('TRUNCATE TABLE pop_commune');
$pdo->beginTransaction();
$n = 0;
foreach ($lines as $line) {
if ($line === '') {
continue;
}
$row = str_getcsv($line, ';');
$code = trim((string) ($row[$iCode] ?? ''));
if (!preg_match('/^[0-9AB]\w{4}$/', $code)) {
continue;
}
foreach ($colIdx as $k => $annee) {
$v = $row[$k] ?? '';
if ($v === '' || !is_numeric(str_replace([' ', ','], ['', '.'], $v))) {
continue;
}
$ins->execute([$code, $annee, (int) round((float) str_replace([' ', ','], ['', '.'], $v))]);
if (++$n % 10000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
}
$pdo->commit();
$communes = (int) $pdo->query('SELECT COUNT(DISTINCT comc) FROM pop_commune')->fetchColumn();
$arr = (int) $pdo->query("SELECT COUNT(DISTINCT comc) FROM pop_commune WHERE comc BETWEEN '13201' AND '13216' OR comc BETWEEN '69381' AND '69389' OR comc LIKE '751__'")->fetchColumn();
say("✔ pop_commune : $n lignes ($communes codes dont $arr arrondissements PLM, INSEE séries historiques 1968→2022).");
say('🎉 Terminé.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+183
View File
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte des établissements scolaires — France</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div id="app">
<aside id="sidebar">
<header>
<h1>Établissements scolaires</h1>
<p class="subtitle">Annuaire de l'Éducation nationale</p>
<p id="count" class="count">Chargement…</p>
</header>
<div class="filters">
<div class="field">
<label for="search">Recherche (nom / commune)</label>
<input type="search" id="search" placeholder="Ex. Jean Jaurès, Lyon…" autocomplete="off">
</div>
<fieldset>
<legend>Type d'établissement</legend>
<div id="f-type" class="checks"></div>
</fieldset>
<fieldset>
<legend>Statut</legend>
<div id="f-stat" class="checks"></div>
</fieldset>
<fieldset>
<legend>État</legend>
<div id="f-etat" class="checks"></div>
</fieldset>
<div class="field">
<label for="f-reg">Région</label>
<select id="f-reg"></select>
</div>
<div class="field">
<label for="f-aca">Académie</label>
<select id="f-aca"></select>
</div>
<div class="field">
<label for="f-dep">Département</label>
<select id="f-dep"></select>
</div>
<div class="field">
<label for="f-circ">Circonscription</label>
<select id="f-circ"></select>
</div>
<fieldset>
<legend>Options proposées</legend>
<div id="f-opt" class="checks"></div>
<p class="hint">Coché = uniquement les établissements proposant l'option.</p>
</fieldset>
<button id="reset" type="button">Réinitialiser les filtres</button>
</div>
<footer>
<p><a href="admin.html">🔐 Espace administration</a></p>
<p>Source : <a href="https://data.education.gouv.fr/explore/dataset/fr-en-annuaire-education/" target="_blank" rel="noopener">data.education.gouv.fr</a> — fond de carte © OpenStreetMap</p>
</footer>
</aside>
<main id="map"></main>
</div>
<div id="legend" class="legend"></div>
<!-- Fiche établissement plein écran (overlay superposé à la carte) -->
<div id="fiche" class="fiche" hidden>
<header class="fiche-bar">
<button id="fiche-back" class="fiche-back" type="button">← Retour à la carte</button>
<div class="fiche-title">
<h2 id="fiche-nom"></h2>
<span id="fiche-sub" class="fiche-sub"></span>
</div>
</header>
<nav class="fiche-tabs">
<button class="fiche-tab active" type="button" data-tab="infos">Informations</button>
<button class="fiche-tab" type="button" data-tab="docs">Documents</button>
<button class="fiche-tab" type="button" data-tab="photos">Photothèque</button>
<button class="fiche-tab" type="button" data-tab="propose"> Proposer un élément</button>
</nav>
<div class="fiche-body">
<section id="tab-infos" class="tab-panel active">
<div class="tab-actions">
<button id="fiche-locate" type="button" class="btn-secondary">📍 Localiser sur la carte</button>
</div>
<div class="info-cols">
<div id="info-main" class="info-main"><!-- cartes : identité, coordonnées… --></div>
<div id="info-side" class="info-side"><!-- commentaires --></div>
</div>
</section>
<section id="tab-docs" class="tab-panel">
<div class="docs-layout">
<nav id="folder-tree" class="folder-tree"></nav>
<div id="folder-items" class="folder-items"></div>
</div>
</section>
<section id="tab-photos" class="tab-panel">
<div id="photo-grid" class="photo-grid"></div>
</section>
<section id="tab-propose" class="tab-panel">
<form id="propose-form" class="propose-form" autocomplete="off">
<p class="propose-intro">
Proposez un document, une photo ou un lien. Votre proposition sera
publiée après validation par un administrateur.
</p>
<div class="field">
<label>Type d'élément</label>
<div class="radio-row">
<label><input type="radio" name="kind" value="file" checked> 📄 Fichier</label>
<label><input type="radio" name="kind" value="photo"> 🖼 Photo</label>
<label><input type="radio" name="kind" value="link"> 🔗 Lien</label>
</div>
</div>
<div class="field">
<label for="p-folder">Dossier</label>
<select id="p-folder" name="folder_id"></select>
</div>
<div class="field" data-when="file photo">
<label for="p-file">Fichier</label>
<input id="p-file" type="file">
</div>
<div class="field" data-when="link">
<label for="p-url">URL du lien</label>
<input id="p-url" type="url" placeholder="https://…">
</div>
<div class="field">
<label for="p-title">Libellé <span class="opt">(facultatif pour un fichier)</span></label>
<input id="p-title" type="text" name="title" placeholder="Ex. Règlement intérieur 2025">
</div>
<div class="field">
<label for="p-desc">Description <span class="opt">(facultatif)</span></label>
<textarea id="p-desc" name="description" rows="3" placeholder="Texte explicatif : de quoi s'agit-il, contexte, date du document…"></textarea>
</div>
<div class="field-2">
<div class="field">
<label for="p-name">Votre nom <span class="opt">(facultatif)</span></label>
<input id="p-name" type="text" name="submitter_name">
</div>
<div class="field">
<label for="p-mail">Votre e-mail <span class="opt">(facultatif)</span></label>
<input id="p-mail" type="email" name="submitter_email">
</div>
</div>
<button type="submit" class="btn-primary">Envoyer la proposition</button>
<p id="propose-msg" class="propose-msg"></p>
</form>
</section>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script src="fiche.js"></script>
<script src="app.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* Rattachement école ↔ DPE par proximité géographique.
*
* Le dataset ADEME n'a pas d'UAI : on relie chaque établissement aux DPE
* situés dans LA MÊME COMMUNE (code_insee = comc) et à moins de R mètres
* (distance sphérique). Plusieurs DPE peuvent être rattachés à une école
* (campus multi-bâtiments) ; la distance est conservée pour mesurer la
* fiabilité du rattachement.
*
* Pré-requis :
* - php import_dpe.php (table « dpe » remplie)
* - php import_comc.php (colonne « comc » des établissements remplie)
*
* Usage :
* php match_dpe.php # rayon par défaut : 5 m (même bâtiment / adresse)
* php match_dpe.php --radius=80 # rayon élargi
*
* La logique est partagée avec l'endpoint web (api/dpe_lib.php).
*/
declare(strict_types=1);
require __DIR__ . '/api/dpe_lib.php';
function say(string $m): void { echo $m . "\n"; flush(); }
try {
$radius = 5;
foreach ($argv ?? [] as $a) {
if (preg_match('/^--radius=(\d+)$/', $a, $m)) {
$radius = max(1, (int) $m[1]);
}
}
$stats = dpe_match(db(), $radius, static fn(string $m) => say('… ' . $m));
say("✔ etab_dpe : {$stats['pairs']} rattachements pour {$stats['ecoles']} établissements (≤ {$radius} m).");
say('🎉 Terminé.');
} catch (Throwable $e) {
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+61
View File
@@ -0,0 +1,61 @@
/* Politique de mot de passe — validation + jauge côté client.
* Miroir de password_policy_check() (api/db.php) : ≥ 8 caractères et au moins
* 3 types parmi minuscule / majuscule / chiffre / caractère spécial.
* Expose window.PWPolicy = { check, attach }.
*/
(function () {
'use strict';
const MIN_LEN = 8;
const MIN_CLASSES = 3;
function classes(pwd) {
return {
lower: /[a-z]/.test(pwd),
upper: /[A-Z]/.test(pwd),
digit: /[0-9]/.test(pwd),
special: /[^A-Za-z0-9]/.test(pwd),
};
}
function check(pwd) {
pwd = pwd || '';
const c = classes(pwd);
const nbClasses = (c.lower ? 1 : 0) + (c.upper ? 1 : 0) + (c.digit ? 1 : 0) + (c.special ? 1 : 0);
const lenOk = pwd.length >= MIN_LEN;
const classesOk = nbClasses >= MIN_CLASSES;
const unmet = [];
if (!lenOk) unmet.push(`au moins ${MIN_LEN} caractères`);
if (!classesOk) unmet.push(`au moins ${MIN_CLASSES} types (minuscule, majuscule, chiffre, spécial)`);
// Score 0..4 pour la jauge.
let score = 0;
if (lenOk) score++;
if (pwd.length >= 12) score++;
score += Math.max(0, nbClasses - 1);
score = Math.min(4, score);
return { ok: lenOk && classesOk, unmet, score, lenOk, classesOk, nbClasses };
}
const LABELS = ['Très faible', 'Faible', 'Moyen', 'Bon', 'Fort'];
function attach(input, meter) {
if (!input || !meter) return;
const render = () => {
const r = check(input.value);
if (!input.value) { meter.innerHTML = ''; return; }
const pct = (r.score / 4) * 100;
const lvl = r.ok ? Math.max(2, r.score) : Math.min(1, r.score);
meter.innerHTML =
`<div class="pw-bar"><span class="pw-bar-fill lvl-${lvl}" style="width:${pct}%"></span></div>
<div class="pw-label">${r.ok ? '✓ ' : ''}${LABELS[r.score]}</div>
<ul class="pw-crit">
<li class="${r.lenOk ? 'ok' : 'ko'}">≥ ${MIN_LEN} caractères</li>
<li class="${r.classesOk ? 'ok' : 'ko'}">≥ ${MIN_CLASSES} types parmi minuscule / majuscule / chiffre / spécial</li>
</ul>`;
};
input.addEventListener('input', render);
render();
}
window.PWPolicy = { check, attach };
})();
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Réinitialiser le mot de passe</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card">
<h1>Nouveau mot de passe</h1>
<form id="reset-form" autocomplete="off">
<div class="field">
<label for="pw">Nouveau mot de passe</label>
<input id="pw" type="password" required>
<div id="pw-meter" class="pw-meter"></div>
</div>
<div class="field">
<label for="pw2">Confirmer</label>
<input id="pw2" type="password" required>
</div>
<button type="submit" class="btn-primary">Valider</button>
<p id="msg" class="propose-msg"></p>
</form>
<p class="auth-foot"><a href="admin.html">← Retour à la connexion</a></p>
</div>
</div>
<script src="pwpolicy.js"></script>
<script>
const $ = (id) => document.getElementById(id);
const token = new URLSearchParams(location.search).get('token') || '';
PWPolicy.attach($('pw'), $('pw-meter'));
if (!token) {
$('reset-form').hidden = true;
$('msg').className = 'propose-msg err';
$('msg').textContent = 'Lien invalide : jeton manquant.';
}
$('reset-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const pw = $('pw').value, pw2 = $('pw2').value;
const r = PWPolicy.check(pw);
if (!r.ok) { showErr('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; }
if (pw !== pw2) { showErr('Les deux mots de passe ne correspondent pas.'); return; }
try {
const res = await fetch('api/auth.php?action=reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new: pw }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) { showErr(data.error || ('HTTP ' + res.status)); return; }
$('reset-form').hidden = true;
$('msg').className = 'propose-msg ok';
$('msg').innerHTML = '✅ Mot de passe mis à jour. <a href="admin.html">Se connecter</a>.';
} catch (err) { showErr('Erreur : ' + err.message); }
});
function showErr(t) { $('msg').className = 'propose-msg err'; $('msg').textContent = t; }
</script>
</body>
</html>
+320
View File
@@ -0,0 +1,320 @@
<?php
/**
* Initialisation de la base « ecoles » :
* - crée la base et les tables (etablissements, users, user_scopes, user_groups,
* group_members, group_scopes, comments, folders, items) ;
* - importe les établissements depuis data/etablissements.json ;
* - crée un super-admin par défaut et une arborescence d'exemple ;
* - crée les dossiers d'upload.
*
* Usage :
* php setup.php (ligne de commande)
* php setup.php --reset (réimporte les établissements)
* http://.../setup.php (navigateur ; ?reset=1 pour réimporter)
*
* Idempotent : relançable sans risque.
*/
declare(strict_types=1);
require __DIR__ . '/api/db.php';
$cli = PHP_SAPI === 'cli';
$reset = isset($_GET['reset']) || in_array('--reset', $argv ?? [], true);
if (!$cli) {
header('Content-Type: text/plain; charset=utf-8');
}
function say(string $m): void
{
echo $m . "\n";
flush();
}
/** Ajoute une colonne si elle n'existe pas déjà (migration idempotente). */
function add_col_if_missing(PDO $pdo, string $table, string $col, string $ddl): void
{
$st = $pdo->prepare(
'SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $col]);
if (!$st->fetchColumn()) {
$pdo->exec("ALTER TABLE `$table` ADD COLUMN $ddl");
say(" + colonne $table.$col ajoutée.");
}
}
try {
// 1. Création de la base (connexion sans schéma).
$root = new PDO(
'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4',
DB_USER,
DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$root->exec('CREATE DATABASE IF NOT EXISTS `' . DB_NAME . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
say('✔ Base « ' . DB_NAME . ' » prête.');
$pdo = db();
// 2. Établissements.
$pdo->exec(
'CREATE TABLE IF NOT EXISTS etablissements (
id VARCHAR(12) NOT NULL PRIMARY KEY,
nom VARCHAR(255),
type VARCHAR(80),
stat VARCHAR(40),
reg VARCHAR(120),
aca VARCHAR(120),
dep VARCHAR(120),
depc VARCHAR(10),
com VARCHAR(160),
cp VARCHAR(10),
adr VARCHAR(255),
tel VARCHAR(40),
mail VARCHAR(160),
web VARCHAR(255),
etat VARCHAR(20),
circ VARCHAR(255),
lat DECIMAL(9,5),
lon DECIMAL(9,5),
rest TINYINT NOT NULL DEFAULT 0,
heb TINYINT NOT NULL DEFAULT 0,
ulis TINYINT NOT NULL DEFAULT 0,
segp TINYINT NOT NULL DEFAULT 0,
appr TINYINT NOT NULL DEFAULT 0,
INDEX idx_type (type),
INDEX idx_reg (reg),
INDEX idx_aca (aca),
INDEX idx_dep (dep),
INDEX idx_comdep (com, depc),
INDEX idx_etat (etat)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Anciennes tables de la v1 (remplacées par « items »).
$pdo->exec('DROP TABLE IF EXISTS documents');
$pdo->exec('DROP TABLE IF EXISTS photos');
// 3. Utilisateurs (admins) et périmètres.
$pdo->exec(
"CREATE TABLE IF NOT EXISTS users (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(80) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('super','perimeter','member') NOT NULL DEFAULT 'perimeter',
email VARCHAR(160) NULL,
must_change TINYINT NOT NULL DEFAULT 0,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Migration des bases existantes : rôle « member » + colonnes de politique de mot de passe.
$pdo->exec("ALTER TABLE users MODIFY role ENUM('super','perimeter','member') NOT NULL DEFAULT 'perimeter'");
add_col_if_missing($pdo, 'users', 'email', 'email VARCHAR(160) NULL');
add_col_if_missing($pdo, 'users', 'must_change', 'must_change TINYINT NOT NULL DEFAULT 0');
add_col_if_missing($pdo, 'users', 'failed_attempts', 'failed_attempts INT NOT NULL DEFAULT 0');
add_col_if_missing($pdo, 'users', 'locked_until', 'locked_until DATETIME NULL');
// Jetons de réinitialisation de mot de passe (lien à usage unique).
$pdo->exec(
'CREATE TABLE IF NOT EXISTS password_resets (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash CHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
used TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token_hash),
CONSTRAINT fk_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS user_scopes (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
com VARCHAR(160) NOT NULL,
depc VARCHAR(10) NOT NULL,
UNIQUE KEY uniq_scope (user_id, com, depc),
INDEX idx_comdep (com, depc),
CONSTRAINT fk_scope_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// 3 bis. Groupes d'utilisateurs (équipes). can_validate = le groupe peut valider
// les documents ; group_scopes porte alors son périmètre de communes.
$pdo->exec(
'CREATE TABLE IF NOT EXISTS user_groups (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL UNIQUE,
can_validate TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS group_members (
group_id INT NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (group_id, user_id),
INDEX idx_user (user_id),
CONSTRAINT fk_gm_group FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
CONSTRAINT fk_gm_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS group_scopes (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
group_id INT NOT NULL,
com VARCHAR(160) NOT NULL,
depc VARCHAR(10) NOT NULL,
UNIQUE KEY uniq_gscope (group_id, com, depc),
INDEX idx_comdep (com, depc),
CONSTRAINT fk_gs_group FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// 3 ter. Commentaires de fiche : horodatés, visibles entre membres d'un même groupe.
$pdo->exec(
'CREATE TABLE IF NOT EXISTS comments (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
uai VARCHAR(12) NOT NULL,
item_id INT NULL, -- commentaire sur un document précis (sinon NULL)
user_id INT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_uai (uai),
INDEX idx_item (item_id),
CONSTRAINT fk_com_uai FOREIGN KEY (uai) REFERENCES etablissements(id) ON DELETE CASCADE,
CONSTRAINT fk_com_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// 4. Arborescence globale de dossiers.
$pdo->exec(
'CREATE TABLE IF NOT EXISTS folders (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
parent_id INT NULL,
name VARCHAR(160) NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_parent (parent_id),
CONSTRAINT fk_folder_parent FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// 5. Éléments : fichiers, photos, liens — soumis puis validés.
$pdo->exec(
"CREATE TABLE IF NOT EXISTS items (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
uai VARCHAR(12) NOT NULL,
folder_id INT NULL,
kind ENUM('file','photo','link') NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
filename VARCHAR(255) NULL,
url VARCHAR(2048) NULL,
size BIGINT NULL,
mime VARCHAR(160) NULL,
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
submitter_name VARCHAR(160) NULL,
submitter_email VARCHAR(160) NULL,
reject_reason VARCHAR(500) NULL,
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
validated_by INT NULL,
validated_at DATETIME NULL,
INDEX idx_uai (uai),
INDEX idx_status (status),
INDEX idx_folder (folder_id),
CONSTRAINT fk_item_uai FOREIGN KEY (uai) REFERENCES etablissements(id) ON DELETE CASCADE,
CONSTRAINT fk_item_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL,
CONSTRAINT fk_item_validator FOREIGN KEY (validated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Migrations des bases existantes : description des éléments + portée document des commentaires.
add_col_if_missing($pdo, 'items', 'description', 'description TEXT NULL AFTER title');
add_col_if_missing($pdo, 'comments', 'item_id', 'item_id INT NULL AFTER uai');
// Index sur comments.item_id si absent.
$hasIdx = $pdo->query("SHOW INDEX FROM comments WHERE Key_name = 'idx_item'")->fetchColumn();
if (!$hasIdx) {
$pdo->exec('ALTER TABLE comments ADD INDEX idx_item (item_id)');
}
say('✔ Tables prêtes.');
// 6. Dossiers d'upload.
foreach (['files', 'photos'] as $d) {
$p = __DIR__ . '/uploads/' . $d;
if (!is_dir($p)) {
mkdir($p, 0777, true);
}
}
say('✔ Dossiers uploads/ prêts.');
// 7. Super-admin par défaut (changement de mot de passe forcé à la 1re connexion).
if ((int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() === 0) {
$pdo->prepare('INSERT INTO users (username, password_hash, role, must_change) VALUES (?, ?, ?, 1)')
->execute(['admin', password_hash('admin', PASSWORD_DEFAULT), 'super']);
say('✔ Super-admin créé : « admin » / « admin » → changement de mot de passe demandé à la 1re connexion.');
}
// 7 bis. Dossier privé (boîte d'envoi du mailer en mode « log »), non servi par Apache.
$priv = __DIR__ . '/private';
if (!is_dir($priv)) {
mkdir($priv, 0777, true);
}
if (!is_file($priv . '/.htaccess')) {
file_put_contents($priv . '/.htaccess', "Require all denied\n");
}
say('✔ Dossier private/ prêt.');
// 8. Arborescence d'exemple (modifiable ensuite dans l'admin).
if ((int) $pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn() === 0) {
$tree = [
'Administratif' => ['Budgets', 'Comptes-rendus de conseil'],
'Pédagogie' => ['Projet d’établissement', 'Règlement intérieur'],
'Travaux & bâtiments' => [],
'Photos' => ['Façade', 'Intérieur'],
'Liens utiles' => [],
];
$insTop = $pdo->prepare('INSERT INTO folders (parent_id, name, position) VALUES (NULL, ?, ?)');
$insSub = $pdo->prepare('INSERT INTO folders (parent_id, name, position) VALUES (?, ?, ?)');
$pos = 0;
foreach ($tree as $top => $subs) {
$insTop->execute([$top, $pos++]);
$pid = (int) $pdo->lastInsertId();
$sp = 0;
foreach ($subs as $sub) {
$insSub->execute([$pid, $sub, $sp++]);
}
}
say('✔ Arborescence dexemple créée.');
}
// 9. Import des établissements.
$count = (int) $pdo->query('SELECT COUNT(*) FROM etablissements')->fetchColumn();
if ($count > 0 && !$reset) {
say(" $count établissements déjà en base — import ignoré (utilisez --reset / ?reset=1 pour réimporter).");
} else {
$jsonPath = __DIR__ . '/data/etablissements.json';
if (!is_file($jsonPath)) {
throw new RuntimeException("Fichier introuvable : $jsonPath");
}
say('… Lecture de ' . basename($jsonPath) . ' …');
$rows = json_decode(file_get_contents($jsonPath), true);
if (!is_array($rows)) {
throw new RuntimeException('JSON invalide.');
}
$n = import_etablissements($pdo, $rows);
say("✔ Import terminé : $n établissements.");
}
say('');
say('🎉 Initialisation terminée.');
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
say('!! ERREUR : ' . $e->getMessage());
exit(1);
}
+812
View File
@@ -0,0 +1,812 @@
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #1d2733;
}
#app {
display: flex;
height: 100vh;
width: 100%;
}
/* ---------- Sidebar ---------- */
#sidebar {
width: 320px;
flex: 0 0 320px;
background: #f7f9fb;
border-right: 1px solid #dfe4ea;
display: flex;
flex-direction: column;
overflow-y: auto;
}
#sidebar header {
padding: 16px 16px 8px;
border-bottom: 1px solid #e6eaef;
background: #1d3557;
color: #fff;
}
#sidebar h1 {
font-size: 18px;
margin: 0;
line-height: 1.2;
}
.subtitle {
margin: 2px 0 8px;
font-size: 12px;
opacity: .8;
}
.count {
margin: 0;
font-size: 13px;
font-weight: 600;
background: rgba(255, 255, 255, .15);
padding: 6px 8px;
border-radius: 6px;
}
.filters {
padding: 12px 16px;
flex: 1;
}
.field { margin-bottom: 14px; }
label {
display: block;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .03em;
color: #5a6b7b;
margin-bottom: 4px;
}
input[type="search"], select {
width: 100%;
padding: 8px 10px;
border: 1px solid #cfd6de;
border-radius: 6px;
font-size: 14px;
background: #fff;
}
input[type="search"]:focus, select:focus {
outline: none;
border-color: #1d3557;
box-shadow: 0 0 0 2px rgba(29, 53, 87, .15);
}
fieldset {
border: 1px solid #e0e6ec;
border-radius: 8px;
margin: 0 0 14px;
padding: 8px 10px 10px;
}
legend {
font-size: 12px;
font-weight: 700;
color: #1d3557;
padding: 0 4px;
}
.checks { display: flex; flex-direction: column; gap: 4px; }
.checks label {
display: flex;
align-items: center;
gap: 7px;
text-transform: none;
letter-spacing: 0;
font-weight: 500;
font-size: 13px;
color: #1d2733;
margin: 0;
cursor: pointer;
}
.checks input { margin: 0; }
.hint {
font-size: 11px;
color: #8a98a6;
margin: 6px 2px 0;
line-height: 1.3;
}
.swatch {
width: 11px;
height: 11px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, .25);
flex: 0 0 auto;
}
.tag {
margin-left: auto;
font-size: 11px;
color: #8a98a6;
font-variant-numeric: tabular-nums;
}
#reset {
width: 100%;
padding: 9px;
border: 1px solid #1d3557;
background: #fff;
color: #1d3557;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
#reset:hover { background: #1d3557; color: #fff; }
#sidebar footer {
padding: 10px 16px;
font-size: 11px;
color: #7b8794;
border-top: 1px solid #e6eaef;
}
#sidebar footer a { color: #1d3557; }
/* ---------- Map ---------- */
#map { flex: 1; height: 100%; }
/* ---------- Popup ---------- */
.popup h3 { margin: 0 0 4px; font-size: 15px; color: #1d3557; }
.popup .meta { font-size: 12px; color: #5a6b7b; margin-bottom: 6px; }
.popup .row { font-size: 13px; margin: 2px 0; }
.popup a { color: #1d3557; }
.popup .badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 10px;
margin-left: 4px;
}
.badge.ouvert { background: #d8f3dc; color: #1b4332; }
.badge.fermer { background: #ffe5d9; color: #9d0208; }
.popup .uai { color: #97a3af; font-size: 11px; margin-top: 6px; }
/* ---------- Legend ---------- */
.legend {
position: absolute;
bottom: 18px;
right: 12px;
background: rgba(255, 255, 255, .94);
border: 1px solid #dfe4ea;
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, .12);
z-index: 1000;
}
.legend .row { display: flex; align-items: center; gap: 7px; margin: 2px 0; }
/* Sélecteur de coloration des marqueurs (Type / DPE) en tête de légende */
.legend-mode {
display: flex; gap: 12px; margin-bottom: 6px;
padding-bottom: 6px; border-bottom: 1px solid #eef2f6;
}
.legend-mode label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
/* Légende de densité IRIS (contrôle Leaflet, pas en position absolue) */
.iris-legend { position: static; margin: 0 0 10px 10px; max-width: 240px; }
.iris-legend .swatch { border-radius: 2px; }
.iris-mode { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid #e6eaef; }
.iris-mode label { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; cursor: pointer; text-transform: none; letter-spacing: 0; color: #1d2733; margin: 0; }
.iris-mode input { margin: 0; }
/* Infobulle IRIS */
.iris-popup h3 { margin: 0 0 4px; font-size: 15px; color: #0f4395; }
.iris-popup .meta { font-size: 12px; color: #5a6b7b; margin-bottom: 6px; }
.iris-popup .row { font-size: 13px; margin: 2px 0; }
.iris-popup .uai { color: #97a3af; font-size: 11px; margin-top: 6px; }
/* Marqueur carré (établissements privés) */
.sq-marker { background: transparent; border: none; }
.sq-marker span {
display: block;
width: 10px;
height: 10px;
border: 1px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .3);
}
/* Bouton « Me localiser » */
.locate-ctrl a {
font-size: 16px;
text-align: center;
cursor: pointer;
text-decoration: none;
}
/* Message d'aide couche IRIS */
.iris-hint {
background: rgba(15, 67, 149, .92);
color: #fff;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, .2);
}
/* ---------- Bouton fiche dans la popup ---------- */
.popup-fiche {
margin-top: 10px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: #1d3557;
color: #fff;
font-weight: 600;
font-size: 13px;
cursor: pointer;
}
.popup-fiche:hover { background: #16294a; }
/* ---------- Fiche plein écran (overlay) ---------- */
.fiche {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
flex-direction: column;
background: #eef2f6;
}
.fiche[hidden] { display: none; }
.fiche-bar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 20px;
background: #1d3557;
color: #fff;
flex: 0 0 auto;
}
.fiche-back {
flex: 0 0 auto;
padding: 12px 22px;
font-size: 16px;
font-weight: 700;
border: 2px solid rgba(255, 255, 255, .55);
border-radius: 10px;
background: rgba(255, 255, 255, .1);
color: #fff;
cursor: pointer;
transition: background .12s, transform .12s;
}
.fiche-back:hover { background: #fff; color: #1d3557; }
.fiche-back:active { transform: translateY(1px); }
.fiche-title { min-width: 0; }
.fiche-title h2 {
margin: 0;
font-size: 20px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fiche-sub { font-size: 13px; opacity: .8; }
/* Onglets */
.fiche-tabs {
display: flex;
gap: 4px;
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #dfe4ea;
flex: 0 0 auto;
}
.fiche-tab {
padding: 13px 18px;
border: none;
background: none;
font-size: 14px;
font-weight: 600;
color: #5a6b7b;
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -1px;
}
.fiche-tab:hover { color: #1d3557; }
.fiche-tab.active { color: #1d3557; border-bottom-color: #1d3557; }
/* Corps + panneaux */
.fiche-body { flex: 1; overflow-y: auto; padding: 20px; }
.tab-panel { display: none; max-width: 1100px; margin: 0 auto; }
.tab-panel.active { display: block; }
.tab-actions { margin-bottom: 16px; }
.btn-primary, .btn-secondary {
display: inline-block;
padding: 9px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid #1d3557;
}
.btn-primary { background: #1d3557; color: #fff; }
.btn-primary:hover { background: #16294a; }
.btn-secondary { background: #fff; color: #1d3557; }
.btn-secondary:hover { background: #1d3557; color: #fff; }
/* Informations */
/* Deux colonnes : infos à gauche, commentaires à droite. */
.info-cols { display: flex; gap: 16px; align-items: flex-start; }
.info-main { flex: 2 1 0; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
.info-side { flex: 1 1 340px; min-width: 300px; max-width: 460px; }
.info-side .comments-block { position: sticky; top: 0; }
@media (max-width: 880px) {
.info-cols { flex-direction: column; }
.info-side { max-width: none; width: 100%; }
.info-side .comments-block { position: static; }
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.info-card {
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 10px;
padding: 16px 18px;
}
.info-card h3 {
margin: 0 0 10px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
color: #1d3557;
}
.info-card dl { margin: 0; }
.info-row {
display: flex;
gap: 10px;
padding: 5px 0;
border-top: 1px solid #f0f3f6;
font-size: 14px;
}
.info-row:first-child { border-top: none; }
.info-row dt { flex: 0 0 130px; color: #7b8794; font-weight: 600; }
.info-row dd { margin: 0; flex: 1; word-break: break-word; }
.info-row a { color: #1d3557; }
.info-row .badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 10px;
}
.info-row .badge.ouvert { background: #d8f3dc; color: #1b4332; }
.info-row .badge.fermer { background: #ffe5d9; color: #9d0208; }
/* Gestion documentaire */
.doc-list { list-style: none; margin: 0; padding: 0; }
.doc-item {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 14px;
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 8px;
margin-bottom: 8px;
}
.doc-icon { font-size: 18px; }
.doc-name { flex: 1; color: #1d3557; font-weight: 500; text-decoration: none; }
.doc-name:hover { text-decoration: underline; }
.doc-size { font-size: 12px; color: #8a98a6; font-variant-numeric: tabular-nums; }
.doc-del, .photo-del {
border: none;
background: #f0f3f6;
color: #9d0208;
border-radius: 6px;
width: 26px;
height: 26px;
cursor: pointer;
font-size: 13px;
flex: 0 0 auto;
}
.doc-del:hover, .photo-del:hover { background: #ffe5d9; }
.empty { color: #8a98a6; font-style: italic; padding: 8px 2px; }
/* Photothèque */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 14px;
}
.photo-item {
position: relative;
margin: 0;
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 10px;
overflow: hidden;
}
.photo-item img {
display: block;
width: 100%;
height: 140px;
object-fit: cover;
cursor: zoom-in;
}
.photo-item figcaption {
padding: 7px 10px;
font-size: 12px;
color: #5a6b7b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.photo-del {
position: absolute;
top: 6px;
right: 6px;
background: rgba(255, 255, 255, .9);
}
/* Lightbox */
.lightbox {
position: fixed;
inset: 0;
z-index: 3000;
background: rgba(0, 0, 0, .85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
cursor: zoom-out;
padding: 30px;
}
.lightbox img { max-width: 92vw; max-height: 82vh; border-radius: 6px; }
.lightbox .lb-cap { color: #fff; font-size: 14px; }
/* ---------- Onglet Documents : arbre + éléments ---------- */
.docs-layout { display: flex; gap: 18px; align-items: flex-start; }
.folder-tree {
flex: 0 0 260px;
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 10px;
padding: 10px;
max-height: 70vh;
overflow: auto;
}
.folder-items { flex: 1; min-width: 0; }
.tree, .tree ul { list-style: none; margin: 0; padding: 0; }
.tree ul { margin-left: 14px; }
.tree-folder {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.tree-folder:hover { background: #eef2f6; }
.tree-folder.active { background: #1d3557; color: #fff; }
.tree-count {
margin-left: auto;
font-size: 11px;
background: #e0e6ec;
color: #1d3557;
border-radius: 10px;
padding: 0 7px;
font-variant-numeric: tabular-nums;
}
.tree-folder.active .tree-count { background: rgba(255, 255, 255, .25); color: #fff; }
.item-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.item-row {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 14px;
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 8px;
}
.item-icon { font-size: 18px; }
.item-name { flex: 1; color: #1d3557; font-weight: 500; text-decoration: none; word-break: break-word; }
.item-name:hover { text-decoration: underline; }
.item-size { font-size: 12px; color: #8a98a6; font-variant-numeric: tabular-nums; }
.item-photo {
margin: 0;
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 10px;
overflow: hidden;
}
.item-photo img { display: block; width: 100%; height: 140px; object-fit: cover; cursor: zoom-in; }
.item-photo figcaption {
padding: 7px 10px; font-size: 12px; color: #5a6b7b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Bloc effectifs */
.effectifs-block { margin-top: 16px; max-width: 640px; }
.eff-latest { font-size: 14px; color: #1d2733; margin-bottom: 8px; }
.eff-num { font-size: 26px; font-weight: 700; color: #1d3557; }
.eff-year { color: #8a98a6; font-size: 13px; }
.eff-spark { width: 100%; height: auto; display: block; }
.eff-axis { font-size: 12px; fill: #8a98a6; }
.eff-evol { font-size: 13px; font-weight: 600; margin-top: 4px; }
.eff-evol.up { color: #1b7a44; }
.eff-evol.down { color: #c1121f; }
.eff-evol.flat { color: #5a6b7b; }
.eff-sub { font-size: 14px; color: #2a9d8f; margin: 16px 0 6px; padding-top: 12px; border-top: 1px solid #eef2f6; }
.eff-sub.pop-sub { color: #577590; }
/* Effectifs et IPS côte à côte (repli en colonne quand c'est étroit). */
.eff-ips-row { display: flex; gap: 16px; flex-wrap: wrap; }
.eff-ips-row > .info-card { flex: 1 1 320px; min-width: 0; }
.ips-legend { display: flex; gap: 16px; font-size: 12px; margin: 2px 0 8px; color: #5a6b7a; }
.ips-k { display: inline-flex; align-items: center; gap: 6px; }
.ips-k::before { content: ''; display: inline-block; width: 16px; border-top: 3px solid; }
.ips-k-etab::before { border-color: #9d4edd; }
.ips-k-dep::before { border-color: #b08968; border-top-style: dashed; }
.ips-note, .pop-note { font-size: 11px; color: #8a98a6; margin: 4px 0 0; font-style: italic; }
/* ---------- DPE (performance énergétique, ADEME) ---------- */
.dpe-block { margin-top: 16px; max-width: 640px; }
.dpe-list { list-style: none; margin: 0; padding: 0; }
.dpe-item { padding: 8px 0; border-top: 1px solid #eef2f6; }
.dpe-item:first-child { border-top: none; padding-top: 0; }
.dpe-badges { display: flex; gap: 8px; margin-bottom: 4px; }
.dpe-badge {
display: inline-flex; align-items: baseline; gap: 5px;
font-size: 15px; font-weight: 700; color: #fff;
padding: 3px 10px; border-radius: 6px; line-height: 1.1;
}
.dpe-badge .dpe-k {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: .03em; opacity: .85;
}
/* Échelle officielle A (sobre, vert) → G (énergivore, rouge) */
.dpe-badge.dpe-a { background: #319834; }
.dpe-badge.dpe-b { background: #33cc31; }
.dpe-badge.dpe-c { background: #cbfc34; color: #1d2733; }
.dpe-badge.dpe-d { background: #fbfe06; color: #1d2733; }
.dpe-badge.dpe-e { background: #fbcc05; color: #1d2733; }
.dpe-badge.dpe-f { background: #fc8104; }
.dpe-badge.dpe-g { background: #fc0205; }
.dpe-badge.dpe-na { background: #b8c2cc; color: #1d2733; }
.dpe-metaline { font-size: 13px; color: #1d2733; }
.dpe-addr { font-size: 12px; color: #5a6b7b; margin-top: 2px; }
.dpe-dist { color: #97a3af; }
.dpe-dates { font-size: 12px; color: #8a98a6; margin-top: 2px; }
.dpe-note { font-size: 11px; color: #97a3af; margin: 10px 0 0; line-height: 1.4; }
/* ---------- Onglet « Proposer » ---------- */
.propose-form { max-width: 620px; }
.propose-intro { font-size: 14px; color: #5a6b7b; margin: 0 0 16px; }
.radio-row { display: flex; gap: 18px; }
.radio-row label {
display: flex; align-items: center; gap: 6px;
text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 14px; color: #1d2733; cursor: pointer;
}
.field-2 { display: flex; gap: 14px; }
.field-2 .field { flex: 1; }
.field .opt { font-weight: 400; text-transform: none; letter-spacing: 0; color: #8a98a6; }
input[type="text"], input[type="email"], input[type="url"], input[type="password"], input[type="file"] {
width: 100%; padding: 8px 10px; border: 1px solid #cfd6de; border-radius: 6px; font-size: 14px; background: #fff;
}
.propose-msg { font-size: 14px; margin: 12px 0 0; }
.propose-msg.ok { color: #1b7a44; }
.propose-msg.err { color: #9d0208; }
/* ---------- Boutons additionnels ---------- */
.btn-danger {
display: inline-block; padding: 9px 16px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
border: 1px solid #c1121f; background: #fff; color: #c1121f;
}
.btn-danger:hover { background: #c1121f; color: #fff; }
.btn-sm { padding: 5px 10px; font-size: 13px; }
.badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 1px 8px; border-radius: 10px; background: #e0e6ec; color: #1d3557;
}
.badge.ouvert { background: #d8f3dc; color: #1b4332; }
.badge.fermer { background: #ffe5d9; color: #9d0208; }
/* ---------- Page d'administration ---------- */
.admin-page { background: #eef2f6; }
.admin-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 22px; background: #1d3557; color: #fff;
}
.admin-bar h1 { margin: 0; font-size: 20px; }
.admin-user { display: flex; align-items: center; gap: 12px; font-size: 14px; }
.admin-user a { text-decoration: none; }
.admin-main, .login-card { max-width: 980px; margin: 22px auto; padding: 0 16px; }
.admin-section {
background: #fff; border: 1px solid #dfe4ea; border-radius: 12px;
padding: 18px 20px; margin-bottom: 20px;
}
.admin-section h2 { margin: 0 0 6px; font-size: 17px; color: #1d3557; }
.login-card { max-width: 420px; }
.inline-form { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.inline-form input, .inline-form select { width: auto; flex: 1; min-width: 160px; }
/* File de validation */
.queue { display: flex; flex-direction: column; gap: 12px; }
.q-item {
display: flex; gap: 14px; justify-content: space-between;
border: 1px solid #dfe4ea; border-radius: 10px; padding: 12px 14px; background: #fbfcfd;
}
.q-main { min-width: 0; }
.q-title { font-weight: 600; font-size: 15px; color: #1d3557; }
.q-meta { font-size: 12px; color: #7b8794; margin-top: 2px; }
.q-preview { margin-top: 6px; font-size: 13px; }
.q-preview a { color: #1d3557; }
.q-thumb { max-height: 70px; border-radius: 6px; margin-top: 4px; }
.q-actions { display: flex; flex-direction: column; gap: 6px; flex: 0 0 auto; }
/* Arborescence admin */
.folders-admin .fa-row {
display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 6px;
}
.folders-admin .fa-row:hover { background: #eef2f6; }
.fa-name { font-size: 14px; }
.fa-rename, .fa-del {
border: none; background: none; cursor: pointer; font-size: 13px; padding: 2px 6px; border-radius: 5px;
}
.fa-rename:hover { background: #e0e6ec; }
.fa-del:hover { background: #ffe5d9; }
/* Admins de périmètre */
.users-admin { display: flex; flex-direction: column; gap: 12px; }
.user-card { border: 1px solid #dfe4ea; border-radius: 10px; padding: 12px 14px; }
.user-head { display: flex; align-items: center; gap: 10px; }
.user-actions { margin-left: auto; display: flex; gap: 6px; }
.user-scopes { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; font-size: 13px; }
.chip {
display: inline-flex; align-items: center; gap: 5px;
background: #eef2f6; border: 1px solid #dfe4ea; border-radius: 14px; padding: 2px 6px 2px 10px; font-size: 12px;
}
.chip-x { border: none; background: none; color: #9d0208; cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
.scope-editor { position: relative; margin-top: 8px; max-width: 360px; }
.scope-results {
display: flex; flex-direction: column;
border: 1px solid #dfe4ea; border-radius: 8px; margin-top: 4px; overflow: hidden; background: #fff;
}
.scope-results:empty { display: none; }
.scope-result {
text-align: left; border: none; background: #fff; padding: 7px 10px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #f0f3f6;
}
.scope-result:hover { background: #eef2f6; }
.muted { color: #8a98a6; font-size: 12px; }
.badge.warn { background: #fff4e5; color: #9a5b00; border: 1px solid #ffd8a8; }
.user-email { margin: 4px 0; }
.inline-check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; }
.inline-check input { width: auto; flex: none; min-width: 0; }
.group-members { margin: 6px 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.group-members select { padding: 4px 8px; font-size: 13px; }
/* ---------- Commentaires de fiche ---------- */
.comments-block .comment-thread { max-width: 760px; }
.comment-intro { color: #5a6b7a; font-size: 14px; margin: 0 0 14px; }
.comment-login { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
.comment-login input { width: auto; flex: 1; min-width: 160px; }
.comment-form { margin-bottom: 18px; }
.comment-form textarea {
width: 100%; box-sizing: border-box; resize: vertical; padding: 10px;
border: 1px solid #dfe4ea; border-radius: 8px; font: inherit;
}
.comment-form-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.comment-who { color: #5a6b7a; font-size: 13px; }
.comment-msg { font-size: 13px; }
.comment-msg.err { color: #9d0208; }
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.comment-item { border: 1px solid #e6ebf0; border-radius: 10px; padding: 10px 12px; background: #fbfcfd; }
.comment-head { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.comment-author { font-weight: 600; color: #1d3557; }
.comment-date { color: #8a98a6; font-size: 12px; }
.comment-del { margin-left: auto; border: none; background: none; cursor: pointer; font-size: 14px; opacity: 0.6; }
.comment-del:hover { opacity: 1; }
.comment-body { white-space: pre-wrap; word-break: break-word; font-size: 14px; line-height: 1.45; }
.comment-forgot { font-size: 12px; margin: 6px 0 0; }
/* ---------- Cartes documents (onglet Documents) ---------- */
.doc-list { display: flex; flex-direction: column; gap: 12px; }
.doc-card { border: 1px solid #e6ebf0; border-radius: 10px; padding: 10px 12px; background: #fff; }
.doc-name { font-size: 14px; font-weight: 600; color: #1d3557; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.doc-name .item-name { font-weight: 600; }
.doc-thumb { margin: 0 0 6px; max-width: 220px; }
.doc-thumb img { width: 100%; border-radius: 6px; display: block; cursor: zoom-in; }
.item-desc { font-size: 13px; color: #475663; margin: 6px 0; white-space: pre-wrap; word-break: break-word; }
.doc-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 6px; }
.item-date { font-size: 12px; color: #8a98a6; }
.doc-actions { margin-left: auto; display: inline-flex; gap: 8px; }
.doc-edit, .comment-toggle {
border: 1px solid #dfe4ea; background: #f4f7fa; border-radius: 6px;
padding: 3px 9px; font-size: 12px; cursor: pointer; color: #1d3557;
}
.doc-edit:hover, .comment-toggle:hover { background: #e9eff5; }
.comment-toggle.open { background: #1d3557; color: #fff; border-color: #1d3557; }
.doc-edit-box { margin-top: 10px; }
.doc-edit-form { display: flex; flex-direction: column; gap: 8px; }
.doc-edit-form input, .doc-edit-form textarea {
width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid #dfe4ea; border-radius: 8px; font: inherit;
}
.doc-edit-actions { display: flex; align-items: center; gap: 10px; }
.doc-thread { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #e1e7ee; }
.photo-date { color: #8a98a6; font-size: 11px; }
.q-desc { font-size: 13px; color: #475663; margin: 2px 0 4px; white-space: pre-wrap; }
/* ---------- Pages d'authentification (forgot / reset / account) ---------- */
.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: #eef2f6; }
.auth-card {
width: 100%; max-width: 420px; background: #fff; border: 1px solid #e1e7ee;
border-radius: 12px; padding: 26px 26px 20px; box-shadow: 0 6px 24px rgba(20, 40, 60, 0.08);
}
.auth-card h1 { font-size: 20px; margin: 0 0 12px; color: #1d3557; }
.auth-card .field { margin-bottom: 14px; }
.auth-card .btn-primary { width: 100%; }
.auth-foot { margin-top: 16px; font-size: 13px; text-align: center; }
.forced-banner {
background: #fff4e5; border: 1px solid #ffd8a8; color: #9a5b00;
border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
}
/* ---------- Jauge de robustesse de mot de passe ---------- */
.pw-meter { margin-top: 8px; }
.pw-bar { height: 6px; background: #e6ebf0; border-radius: 4px; overflow: hidden; }
.pw-bar-fill { display: block; height: 100%; transition: width 0.2s; background: #fc8104; }
.pw-bar-fill.lvl-0, .pw-bar-fill.lvl-1 { background: #fc0205; }
.pw-bar-fill.lvl-2 { background: #fbcc05; }
.pw-bar-fill.lvl-3 { background: #93d04f; }
.pw-bar-fill.lvl-4 { background: #319834; }
.pw-label { font-size: 12px; color: #5a6b7a; margin-top: 3px; }
.pw-crit { list-style: none; margin: 4px 0 0; padding: 0; font-size: 12px; }
.pw-crit li { padding-left: 18px; position: relative; color: #9d0208; }
.pw-crit li.ok { color: #1b7a44; }
.pw-crit li::before { content: '✗'; position: absolute; left: 0; }
.pw-crit li.ok::before { content: '✓'; }
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
#app { flex-direction: column; }
#sidebar { width: 100%; flex: 0 0 auto; max-height: 45vh; }
#map { height: 55vh; }
.fiche-bar { gap: 12px; padding: 10px 14px; }
.fiche-back { padding: 10px 14px; font-size: 14px; }
.fiche-title h2 { font-size: 16px; }
.fiche-tabs { padding: 0 8px; overflow-x: auto; }
.fiche-tab { padding: 12px 12px; white-space: nowrap; }
.info-row dt { flex-basis: 100px; }
}