From 5633969d9828d813bd6f04162fa78337f8b99b20 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 3 Jun 2026 21:35:41 +0200 Subject: [PATCH] =?UTF-8?q?Version=20initiale=20=E2=80=94=20carte=20des=20?= =?UTF-8?q?=C3=A9tablissements=20scolaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 20 + README.md | 296 ++++++++++++++ account.html | 87 ++++ admin.html | 116 ++++++ admin.js | 460 +++++++++++++++++++++ api/auth.php | 153 +++++++ api/comments.php | 153 +++++++ api/communes.php | 36 ++ api/db.php | 375 +++++++++++++++++ api/dpe.php | 75 ++++ api/dpe_lib.php | 209 ++++++++++ api/effectifs.php | 82 ++++ api/etablissements.php | 25 ++ api/folders.php | 89 ++++ api/groups.php | 144 +++++++ api/import.php | 86 ++++ api/import_dpe.php | 47 +++ api/ips.php | 35 ++ api/iris.php | 79 ++++ api/items.php | 273 +++++++++++++ api/population.php | 130 ++++++ api/users.php | 132 ++++++ app.js | 821 +++++++++++++++++++++++++++++++++++++ build_data.php | 86 ++++ fiche.js | 868 ++++++++++++++++++++++++++++++++++++++++ forgot.html | 50 +++ import_comc.php | 78 ++++ import_dpe.php | 44 ++ import_effectifs.php | 93 +++++ import_effectifs_2d.php | 113 ++++++ import_ips.php | 161 ++++++++ import_pop.php | 77 ++++ import_pop_hist.php | 94 +++++ import_pop_insee.php | 174 ++++++++ index.html | 183 +++++++++ match_dpe.php | 41 ++ pwpolicy.js | 61 +++ reset.html | 64 +++ setup.php | 320 +++++++++++++++ style.css | 812 +++++++++++++++++++++++++++++++++++++ 40 files changed, 7242 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 account.html create mode 100644 admin.html create mode 100644 admin.js create mode 100644 api/auth.php create mode 100644 api/comments.php create mode 100644 api/communes.php create mode 100644 api/db.php create mode 100644 api/dpe.php create mode 100644 api/dpe_lib.php create mode 100644 api/effectifs.php create mode 100644 api/etablissements.php create mode 100644 api/folders.php create mode 100644 api/groups.php create mode 100644 api/import.php create mode 100644 api/import_dpe.php create mode 100644 api/ips.php create mode 100644 api/iris.php create mode 100644 api/items.php create mode 100644 api/population.php create mode 100644 api/users.php create mode 100644 app.js create mode 100644 build_data.php create mode 100644 fiche.js create mode 100644 forgot.html create mode 100644 import_comc.php create mode 100644 import_dpe.php create mode 100644 import_effectifs.php create mode 100644 import_effectifs_2d.php create mode 100644 import_ips.php create mode 100644 import_pop.php create mode 100644 import_pop_hist.php create mode 100644 import_pop_insee.php create mode 100644 index.html create mode 100644 match_dpe.php create mode 100644 pwpolicy.js create mode 100644 reset.html create mode 100644 setup.php create mode 100644 style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63d1a7e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..58dc43d --- /dev/null +++ b/README.md @@ -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* + (), + 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) | diff --git a/account.html b/account.html new file mode 100644 index 0000000..75181e1 --- /dev/null +++ b/account.html @@ -0,0 +1,87 @@ + + + + + + Mon mot de passe + + + +
+
+

Mon mot de passe

+ +

+ + + + + + +
+
+ + + + diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..0e75896 --- /dev/null +++ b/admin.html @@ -0,0 +1,116 @@ + + + + + + Administration — Établissements scolaires + + + + +
+

🔐 Administration

+
+ + + ← Carte + +
+
+ + + + + +
+ + +
+

File d'attente de validation

+

Éléments proposés par les internautes, en attente dans votre périmètre.

+
+
+ + + +
+ + + + + diff --git a/admin.js b/admin.js new file mode 100644 index 0000000..132de6a --- /dev/null +++ b/admin.js @@ -0,0 +1,460 @@ +/* Espace d'administration : connexion, modération, arborescence, admins, import. */ +'use strict'; + +const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"' }[c] +)); +const $ = (id) => document.getElementById(id); + +async function apiJson(url, opts) { + const res = await fetch(url, opts); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status)); + return data; +} +const postJson = (url, body) => + apiJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + +const debounce = (fn, ms) => { + let t; + return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; +}; + +let me = null; +let folders = []; +let allUsers = []; // liste des utilisateurs (pour l'ajout de membres aux groupes) + +// --- Démarrage / session --- +async function boot() { + try { + me = (await apiJson('api/auth.php?action=me')).user; + } catch { me = null; } + me ? showAdmin() : showLogin(); +} + +function showLogin() { + $('login').hidden = false; + $('admin').hidden = true; + $('logout').hidden = true; + $('who').textContent = ''; +} + +const ROLE_LABEL = { super: 'admin plateforme', perimeter: 'admin périmètre', member: 'commentateur' }; + +async function showAdmin() { + // Changement de mot de passe forcé : on redirige avant tout. + if (me.must_change) { + location.href = 'account.html?forced=1'; + return; + } + $('login').hidden = true; + $('admin').hidden = false; + $('logout').hidden = false; + $('account-link').hidden = false; + $('who').textContent = `${me.username} — ${ROLE_LABEL[me.role] || me.role}`; + $('super-only').hidden = me.role !== 'super'; + + // Commentateur : pas de file de modération ; il commente depuis les fiches. + if (me.role === 'member') { + $('queue-count').textContent = ''; + $('queue').innerHTML = '

Compte commentateur : rendez-vous sur une fiche d’établissement (onglet 💬 Commentaires) pour participer.

'; + 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 = `

${esc(err.message)}

`; return; } + + $('queue-count').textContent = q.length || ''; + if (!q.length) { $('queue').innerHTML = '

Aucun élément en attente. 🎉

'; return; } + + $('queue').innerHTML = q.map((it) => { + const icon = it.kind === 'photo' ? '🖼' : it.kind === 'link' ? '🔗' : '📄'; + let preview; + if (it.kind === 'photo') preview = ``; + else if (it.kind === 'link') preview = `${esc(it.url)}`; + else preview = `Télécharger`; + const who = [it.submitter_name, it.submitter_email].filter(Boolean).join(' · ') || 'Anonyme'; + const descHtml = it.description ? `
${esc(it.description)}
` : ''; + return `
+
+
${icon} ${esc(it.title)}
+ ${descHtml} +
${esc(it.etab)}
+
Proposé par : ${esc(who)} · ${esc(it.submitted_at || '')}
+
${preview}
+
+
+ + +
+
`; + }).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 = ['']; + const walk = (pid, d) => childrenOf(pid).forEach((f) => { + opts.push(``); + walk(f.id, d + 1); + }); + walk(null, 0); + $('nf-parent').innerHTML = opts.join(''); +} + +function folderNodeHtml(f) { + const kids = childrenOf(f.id); + let html = `
  • + 📁 ${esc(f.name)} + + + `; + if (kids.length) html += '
      ' + kids.map(folderNodeHtml).join('') + '
    '; + return html + '
  • '; +} + +function renderFoldersAdmin() { + const roots = childrenOf(null); + $('folders-admin').innerHTML = roots.length + ? '
      ' + roots.map(folderNodeHtml).join('') + '
    ' + : '

    Aucun dossier.

    '; +} + +$('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' + ? 'plateforme' + : u.role === 'member' + ? 'commentateur' + : 'périmètre'; + let scopes; + if (u.role === 'super') scopes = 'Tous les établissements'; + else if (u.role === 'member') scopes = 'Commente via ses groupes (aucune validation)'; + else scopes = u.scopes.map((s) => + `${esc(s.com)} (${esc(s.depc)}) ` + ).join(' ') || 'Aucune commune'; + // Périmètre personnel réservé aux admins de périmètre. + const scopeEditor = u.role === 'perimeter' ? ` +
    + +
    +
    ` : ''; + const mustBadge = u.must_change ? '⚠ doit changer' : ''; + const emailHtml = u.email ? esc(u.email) : 'pas d’e-mail'; + return `
    +
    + ${esc(u.username)} ${roleBadge} ${mustBadge} + +
    +
    📧 ${emailHtml}
    +
    ${scopes}
    + ${scopeEditor} +
    `; + }).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) => + ``).join('') || '
    Aucune commune
    '; + } catch { box.innerHTML = ''; } +}, 250); + +$('users-admin').addEventListener('input', (ev) => { + if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target); +}); + +$('users-admin').addEventListener('click', async (ev) => { + const card = ev.target.closest('.user-card'); + if (!card) return; + const uid = Number(card.dataset.uid); + try { + if (ev.target.closest('.scope-result')) { + const b = ev.target.closest('.scope-result'); + await postJson('api/users.php?action=scope', { user_id: uid, com: b.dataset.com, depc: b.dataset.depc }); + await loadUsers(); + } else if (ev.target.closest('.chip-x')) { + await apiJson(`api/users.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' }); + await loadUsers(); + } else if (ev.target.closest('.u-mail')) { + const cur = card.querySelector('.user-email').textContent.replace(/^📧\s*/, '').trim(); + const email = prompt('Adresse e-mail (vide pour retirer) :', cur === 'pas d’e-mail' ? '' : cur); + if (email !== null) { await postJson('api/users.php?action=email', { id: uid, email: email.trim() }); await loadUsers(); } + } else if (ev.target.closest('.u-pass')) { + const pwd = prompt('Nouveau mot de passe (≥ 8 caractères, ≥ 3 types) :'); + if (pwd) { + if (window.PWPolicy && !PWPolicy.check(pwd).ok) { alert('Mot de passe trop faible : ' + PWPolicy.check(pwd).unmet.join(' ; ') + '.'); return; } + await postJson('api/users.php?action=password', { id: uid, password: pwd }); + alert('Mot de passe mis à jour. L’utilisateur devra le changer à sa prochaine connexion.'); + await loadUsers(); + } + } else if (ev.target.closest('.u-del')) { + if (confirm('Supprimer cet administrateur ?')) { + await apiJson(`api/users.php?id=${uid}`, { method: 'DELETE' }); + await loadUsers(); + } + } + } catch (err) { alert('Erreur : ' + err.message); } +}); + +// --- Groupes d'utilisateurs (super-admin) --- +async function loadGroups() { + const [groups, users] = await Promise.all([ + apiJson('api/groups.php'), + apiJson('api/users.php'), + ]); + allUsers = users; + + $('groups-admin').innerHTML = groups.map((g) => { + const memberIds = new Set(g.members.map((m) => m.id)); + const memberChips = g.members.map((m) => + `${esc(m.username)} ` + ).join(' ') || 'Aucun membre'; + const addable = allUsers.filter((u) => !memberIds.has(u.id)); + const memberSelect = addable.length + ? `` + : ''; + + const valBadge = g.can_validate ? 'valideur' : ''; + let scopesHtml = ''; + if (g.can_validate) { + const chips = g.scopes.map((s) => + `${esc(s.com)} (${esc(s.depc)}) ` + ).join(' ') || 'Aucune commune'; + scopesHtml = ` +
    ${chips}
    +
    + +
    +
    `; + } + + return `
    +
    + ${esc(g.name)} ${valBadge} + +
    +
    ${memberChips} ${memberSelect}
    + ${scopesHtml} +
    `; + }).join('') || '

    Aucun groupe.

    '; +} + +$('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(); diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..6f8f583 --- /dev/null +++ b/api/auth.php @@ -0,0 +1,153 @@ + 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 d’essais. 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 l’actuel.'); + } + 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); +} diff --git a/api/comments.php b/api/comments.php new file mode 100644 index 0000000..43dba27 --- /dev/null +++ b/api/comments.php @@ -0,0 +1,153 @@ + 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); +} diff --git a/api/communes.php b/api/communes.php new file mode 100644 index 0000000..260100e --- /dev/null +++ b/api/communes.php @@ -0,0 +1,36 @@ +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); +} diff --git a/api/db.php b/api/db.php new file mode 100644 index 0000000..8d91754 --- /dev/null +++ b/api/db.php @@ -0,0 +1,375 @@ + 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é à l’administrateur 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; +} diff --git a/api/dpe.php b/api/dpe.php new file mode 100644 index 0000000..5660154 --- /dev/null +++ b/api/dpe.php @@ -0,0 +1,75 @@ +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); +} diff --git a/api/dpe_lib.php b/api/dpe_lib.php new file mode 100644 index 0000000..5856a01 --- /dev/null +++ b/api/dpe_lib.php @@ -0,0 +1,209 @@ + 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(), + ]; +} diff --git a/api/effectifs.php b/api/effectifs.php new file mode 100644 index 0000000..dde9af9 --- /dev/null +++ b/api/effectifs.php @@ -0,0 +1,82 @@ +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); +} diff --git a/api/etablissements.php b/api/etablissements.php new file mode 100644 index 0000000..cf5c8d3 --- /dev/null +++ b/api/etablissements.php @@ -0,0 +1,25 @@ +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); +} diff --git a/api/folders.php b/api/folders.php new file mode 100644 index 0000000..189479a --- /dev/null +++ b/api/folders.php @@ -0,0 +1,89 @@ +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); +} diff --git a/api/groups.php b/api/groups.php new file mode 100644 index 0000000..93e3f44 --- /dev/null +++ b/api/groups.php @@ -0,0 +1,144 @@ +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); +} diff --git a/api/import.php b/api/import.php new file mode 100644 index 0000000..864aab5 --- /dev/null +++ b/api/import.php @@ -0,0 +1,86 @@ + 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); +} diff --git a/api/import_dpe.php b/api/import_dpe.php new file mode 100644 index 0000000..2099b10 --- /dev/null +++ b/api/import_dpe.php @@ -0,0 +1,47 @@ + 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); +} diff --git a/api/ips.php b/api/ips.php new file mode 100644 index 0000000..8c2cbe9 --- /dev/null +++ b/api/ips.php @@ -0,0 +1,35 @@ +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); +} diff --git a/api/iris.php b/api/iris.php new file mode 100644 index 0000000..fff6e3f --- /dev/null +++ b/api/iris.php @@ -0,0 +1,79 @@ +&com= + * → { iris_pop, com_pop, com_name } pour un IRIS et sa commune + * POST ?action=batch body {codes:[...]} + * → { "": 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); +} diff --git a/api/items.php b/api/items.php new file mode 100644 index 0000000..46730c8 --- /dev/null +++ b/api/items.php @@ -0,0 +1,273 @@ + (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 n’est 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 l’enregistrement 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); +} diff --git a/api/population.php b/api/population.php new file mode 100644 index 0000000..f44c7c1 --- /dev/null +++ b/api/population.php @@ -0,0 +1,130 @@ + 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); +} diff --git a/api/users.php b/api/users.php new file mode 100644 index 0000000..13d868b --- /dev/null +++ b/api/users.php @@ -0,0 +1,132 @@ +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); +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..3be7189 --- /dev/null +++ b/app.js @@ -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: '© OpenStreetMap · © CARTO', +}).addTo(map); +const baseOsm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap', +}); + +// --- 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 = `

    ${p.nom_iris || 'IRIS'}

    ` + + `
    ${p.nom_commune || ''}${typ ? ' · ' + typ : ''}
    `; + const popup = L.popup({ maxWidth: 260 }) + .setLatLng(e.latlng) + .setContent(head + '
    Population 2022 : …
    ') + .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 = `
    Écoliers publics (IRIS) : ${stu.toLocaleString('fr')} (${tx})
    `; + } + popup.setContent(head + + `
    IRIS : ${fmt(d.iris_pop)}
    ` + + (dens != null ? `
    Densité : ${dens.toLocaleString('fr')} hab/km²
    ` : '') + + scoLine + + `
    ${esc(comLabel)} : ${fmt(d.com_pop)}
    ` + + `
    Code IRIS : ${p.code_iris || ''} — recensement 2022
    `); + }) + .catch(() => popup.setContent(head + '
    Population indisponible.
    ')); +}); + +// 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 = `${title}`; + 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 += `
    ${label}
    `; + } + return html + '
    n.c.
    '; +} + +// 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 = ` +
    + + +
    +
    ${irisLegendHtml()}
    `; + 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) => ( + { '&': '&', '<': '<', '>': '>', '"': '"' }[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(`

    ${esc(e.nom || 'Établissement')}

    `); + lines.push(`
    ${esc(e.type || '')} · ${esc(e.stat || '')}` + + (e.etat ? ` ${esc(e.etat)}` : '') + `
    `); + if (e.adr) lines.push(`
    ${esc(e.adr)}
    `); + const ville = [e.cp, e.com].filter(Boolean).join(' '); + if (ville) lines.push(`
    ${esc(ville)}
    `); + if (e.dep) lines.push(`
    ${esc(e.dep)} · ${esc(e.aca || '')}
    `); + if (e.circ) lines.push(`
    Circonscription : ${esc(e.circ)}
    `); + const opts = OPTIONS.filter((o) => e[o.key] === 1).map((o) => o.label); + if (opts.length) lines.push(`
    Options : ${esc(opts.join(' · '))}
    `); + if (e.tel) lines.push(`
    📞 ${esc(e.tel)}
    `); + if (e.mail) lines.push(``); + if (e.web) { + const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web; + lines.push(``); + } + if (e.id) lines.push(`
    UAI : ${esc(e.id)}
    `); + lines.push(``); + return ``; +} + +function squareIcon(color) { + return L.divIcon({ + className: 'sq-marker', + html: ``, + 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 'Étiquette énergie (DPE)' + + ['A', 'B', 'C', 'D', 'E', 'F', 'G'].map((l) => + `
    ${l}
    ` + ).join('') + + `
    Aucun DPE rattaché
    `; + } + return 'Type d\'établissement' + + Object.entries(TYPE_COLORS).map(([t, c]) => + `
    ${t}
    ` + ).join('') + + '
    Public (rond)
    ' + + '
    Privé (carré)
    '; +} + +function buildLegend() { + els.legend.innerHTML = + `
    + + +
    +
    ${legendBody()}
    `; + 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(); diff --git a/build_data.php b/build_data.php new file mode 100644 index 0000000..2c66253 --- /dev/null +++ b/build_data.php @@ -0,0 +1,86 @@ + $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"); diff --git a/fiche.js b/fiche.js new file mode 100644 index 0000000..d14df65 --- /dev/null +++ b/fiche.js @@ -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) => ( + { '&': '&', '<': '<', '>': '>', '"': '"' }[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 `
    ${esc(label)}
    ${value}
    `; + } + + 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 = `${esc(e.web)}`; + } + const mailHtml = e.mail ? `${esc(e.mail)}` : ''; + const telHtml = e.tel ? `${esc(e.tel)}` : ''; + const dep = [e.dep, e.depc ? `(${e.depc})` : ''].filter(Boolean).join(' '); + + els.infoMain.querySelectorAll('.info-grid').forEach((n) => n.remove()); + els.infoMain.insertAdjacentHTML('afterbegin', ` +
    +

    Identité

    + ${infoRow('Type', esc(e.type))} + ${infoRow('Statut', esc(e.stat))} + ${infoRow('État', e.etat ? `${esc(e.etat)}` : '')} + ${infoRow('UAI', esc(e.id))} +
    +

    Coordonnées

    + ${infoRow('Adresse', esc(e.adr))} + ${infoRow('Commune', esc(ville))} + ${infoRow('Téléphone', telHtml)} + ${infoRow('Courriel', mailHtml)} + ${infoRow('Site web', webHtml)} +
    +

    Rattachement

    + ${infoRow('Région', esc(e.reg))} + ${infoRow('Académie', esc(e.aca))} + ${infoRow('Département', esc(dep))} + ${infoRow('Circonscription', esc(e.circ))} +
    +

    Options & services

    + ${infoRow('Options proposées', opts.length ? esc(opts.join(' · ')) : 'Aucune renseignée')} + ${infoRow('Coordonnées GPS', e.lat != null ? `${e.lat}, ${e.lon}` : '')} +
    +
    `); + } + + // --- 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) => + `${s.annee} : ${s[key]}` + ).join(''); + return ` + + + ${dots} + + ${series[0].annee} + ${series[last].annee} + ${maxV} + ${minV} + `; + } + + const EFF_LABEL = { + 'Ecole': 'école (1er 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 ``; + }; + const dots = (key, color) => series.map((s, i) => + s[key] != null ? `${s.annee} : ${s[key]}` : '' + ).join(''); + return ` + ${hasDep ? poly('ips_dep', '#b08968', 'stroke-dasharray="5 4" opacity="0.85"') : ''} + ${poly('ips', '#9d4edd')} + ${hasDep ? dots('ips_dep', '#b08968') : ''} + ${dots('ips', '#9d4edd')} + ${series[0].annee} + ${series[series.length - 1].annee} + ${maxV} + ${minV} + `; + } + + 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 + ? `
    ÉtablissementDépartement
    ` : ''; + // 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 = `
    ${first.annee} → ${last.annee} : ${sign}${delta} pts
    `; + } + return `
    +

    IPS — indice de position sociale

    +
    + ${last.ips} + (rentrée ${last.annee}${depTxt}) +
    + ${legend} + ${ipsChart(ips)} + ${evolHtml} +

    IPS élevé = milieu social plus favorisé (réf. nationale ≈ 100).

    +
    `; + } + + // 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 `
    +

    Effectifs${label ? ' — ' + label : ''}

    +
    + ${last.eleves} élèves${classesHtml} + (rentrée ${last.annee}) +
    + ${sparkline(serie, 'eleves', '#1d3557')} +
    + ${first.annee} → ${last.annee} : + ${sign}${delta} élève${Math.abs(delta) > 1 ? 's' : ''} (${sign}${pct} %) +
    +
    `; + } + + // 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 = `
    ${first.annee} → ${last.annee} : + ${sign}${delta.toLocaleString('fr')} élève${Math.abs(delta) > 1 ? 's' : ''}
    `; + } + return `
    +

    Écoles publiques de la commune${e.com ? ' — ' + esc(e.com) : ''}

    +
    + ${last.eleves.toLocaleString('fr')} élèves + (rentrée ${last.annee}, cumul public) +
    + ${sparkline(commune, 'eleves', '#2a9d8f')} + ${evol} +
    `; + } + + // 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 = `
    ${first.annee} → ${last.annee} : + ${sign}${delta.toLocaleString('fr')} hab. (${sign}${pct} %)
    `; + } + return `
    + ${last.pop.toLocaleString('fr')} habitants + (${last.annee}) +
    + ${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 + ? '

    Estimation : population de la ville × part de l’arrondissement dans les IRIS (recensement 2022).

    ' + : ''; + let villeHtml = ''; + if (ville.length) { + villeHtml = `

    Ville entière${data.ville_nom ? ' — ' + esc(data.ville_nom) : ''}

    + ${popSeriesHtml(ville, '#577590')}`; + } + return `
    +

    Population${e.com ? ' — ' + esc(e.com) : ''} ${estimated ? '(estimation)' : '(INSEE)'}

    + ${popSeriesHtml(zone, estimated ? '#9aa7b0' : '#577590')} + ${estNote} + ${villeHtml} +
    `; + } + + // Établissement (effectifs · IPS) puis commune (écoles publiques · population), côte à côte. + async function renderEffectifs(e) { + els.infoMain.querySelectorAll('.eff-ips-row').forEach((n) => n.remove()); + const [eff, communeEff, ips, popData] = await Promise.all([ + apiJson(`api/effectifs.php?uai=${encodeURIComponent(e.id)}`).catch(() => []), + apiJson(`api/effectifs.php?action=commune&uai=${encodeURIComponent(e.id)}`).catch(() => []), + apiJson(`api/ips.php?uai=${encodeURIComponent(e.id)}`).catch(() => []), + apiJson(`api/population.php?uai=${encodeURIComponent(e.id)}`).catch(() => null), + ]); + const cards = [ + effectifsCardHtml(Array.isArray(eff) ? eff : [], e), + (Array.isArray(ips) && ips.length) ? ipsCardHtml(ips) : '', + communeCardHtml(Array.isArray(communeEff) ? communeEff : [], e), + populationCardHtml(popData, e), + ].filter(Boolean); + if (!cards.length) return; + + const row = document.createElement('div'); + row.className = 'eff-ips-row'; + row.innerHTML = cards.join(''); + els.infoMain.appendChild(row); + } + + // --- DPE (ADEME, bâtiments tertiaires) — rattaché par proximité --- + const DPE_NOTE = 'Rattaché au(x) bâtiment(s) tertiaire(s) le(s) plus proche(s) ' + + '(données ADEME). Le dataset n’a pas d’identifiant école : le lien est ' + + 'géographique, la distance indique sa fiabilité.'; + + function dpeBadge(letter, kind) { + const l = String(letter || '').toUpperCase(); + const cls = /^[A-G]$/.test(l) ? 'dpe-' + l.toLowerCase() : 'dpe-na'; + const k = kind === 'ges' ? 'GES' : 'Énergie'; + return `${k}${esc(l || '—')}`; + } + + 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} kWhEP/m²/an`); + if (d.ges != null) meta.push(`${d.ges} kg CO₂/m²/an`); + if (d.surface != null) meta.push(`${d.surface} m²`); + const dat = d.date_dpe ? `établi le ${esc(d.date_dpe)}` : ''; + const fin = d.date_fin ? ` · valide jusqu’au ${esc(d.date_fin)}` : ''; + return `
  • +
    ${dpeBadge(d.etiquette_dpe, 'dpe')} ${dpeBadge(d.etiquette_ges, 'ges')}
    + ${meta.length ? `
    ${meta.join(' · ')}
    ` : ''} +
    ${esc(d.adresse || '')} ≈ ${d.distance_m} m
    + ${dat || fin ? `
    ${dat}${fin}
    ` : ''} +
  • `; + }).join(''); + + const block = document.createElement('section'); + block.className = 'info-card dpe-block'; + block.innerHTML = ` +

    Performance énergétique (DPE)

    +
      ${rows}
    +

    ${DPE_NOTE}

    `; + 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 + ? `` : ''; + return `
  • +
    + ${esc(c.username)} + ${fmtDateTime(c.created_at)} + ${del} +
    +
    ${esc(c.body)}
    +
  • `; + } + + // 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 ? '

    Les commentaires sont réservés aux utilisateurs. Connectez-vous pour les consulter et en déposer.

    ' : ''} + +

    Mot de passe oublié ?

    `; + } + const canModerate = data.role === 'super'; + const list = data.comments.map((c) => commentItemHtml(c, canModerate)); + const listHtml = list.length + ? `
      ${list.join('')}
    ` + : '

    Aucun commentaire pour le moment.

    '; + let form; + if (data.can_post) { + form = `
    + +
    + Connecté : ${esc(data.username)} + +
    + +
    `; + } else { + form = '

    Vous n’appartenez à aucun groupe : lecture seule. Demandez à un administrateur de vous ajouter à un groupe pour commenter.

    '; + } + 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 = `

    💬 Commentaires

    +
    ${threadInnerHtml(estComments, true)}
    `; + 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 = `

    Erreur : ${esc(err.message)}

    `; 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 = '

    Vous devez définir un nouveau mot de passe avant de continuer. Changer mon mot de passe, puis rouvrez la fiche.

    '; + 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 = `

    Chargement…

    `; + 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 = `
    + + +
    + + + +
    +
    `; + 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 = `
  • ` + + `📁 ${esc(folder.name)}${n ? ` ${n}` : ''}`; + if (kids.length) { + html += '
      ' + kids.map(treeNodeHtml).join('') + '
    '; + } + return html + '
  • '; + } + + function renderTree() { + const noneCount = countInFolder(null); + let html = '
      '; + html += `
    • ` + + `🗂 Tous les éléments ${items.length}
    • `; + html += childrenOf(null).map(treeNodeHtml).join(''); + if (noneCount) { + html += `
    • ` + + `📂 Sans dossier ${noneCount}
    • `; + } + html += '
    '; + els.tree.innerHTML = html; + } + + // Ligne principale d'un élément (selon son type). + function itemMainHtml(it) { + if (it.kind === 'photo') { + return `
    + ${esc(it.title)} +
    +
    🖼 ${esc(it.title)}
    `; + } + if (it.kind === 'link') { + return ``; + } + return `
    📄 + ${esc(it.title)} + ${fmtSize(it.size)}
    `; + } + + // Carte d'un élément : libellé, date de dépôt, description, édition, commentaires. + function docCardHtml(it) { + const date = it.submitted_at ? `📅 déposé le ${fmtDate(it.submitted_at)}` : ''; + const desc = it.description ? `
    ${esc(it.description)}
    ` : ''; + const editBtn = viewer.can_validate ? `` : ''; + return `
    + ${itemMainHtml(it)} + ${desc} +
    + ${date} + ${editBtn} +
    + + +
    `; + } + + 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 = '

    Aucun élément validé dans ce dossier.

    '; + return; + } + els.items.innerHTML = `
    ${list.map(docCardHtml).join('')}
    `; + } + + function renderDocs() { + renderTree(); + renderItemsPanel(); + } + + // --- Photothèque (toutes les photos validées) --- + function photoFigureHtml(it) { + const date = it.submitted_at ? `${fmtDate(it.submitted_at)}` : ''; + return `
    + ${esc(it.title)} +
    ${esc(it.title)} ${date}
    +
    `; + } + + function renderPhotos() { + const photos = items.filter((it) => it.kind === 'photo'); + if (!photos.length) { + els.photoGrid.innerHTML = '

    Aucune photo validée pour le moment.

    '; + return; + } + els.photoGrid.innerHTML = photos.map(photoFigureHtml).join(''); + } + + // --- Lightbox --- + function openLightbox(url, caption) { + const box = document.createElement('div'); + box.className = 'lightbox'; + box.innerHTML = `${esc(caption)}${esc(caption)}`; + box.addEventListener('click', () => box.remove()); + document.body.appendChild(box); + } + + // --- Formulaire de proposition --- + function fillFolderSelect() { + const opts = ['']; + const walk = (parentId, depth) => { + childrenOf(parentId).forEach((f) => { + opts.push(``); + 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 = `

    Erreur de chargement : ${esc(err.message)}

    `; + } + 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 }; +})(); diff --git a/forgot.html b/forgot.html new file mode 100644 index 0000000..d39fd91 --- /dev/null +++ b/forgot.html @@ -0,0 +1,50 @@ + + + + + + Mot de passe oublié + + + +
    +
    +

    Mot de passe oublié

    +

    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é.

    +
    +
    + + +
    + +

    +
    +

    ← Retour à la connexion

    +
    +
    + + + diff --git a/import_comc.php b/import_comc.php new file mode 100644 index 0000000..4fc2a4e --- /dev/null +++ b/import_comc.php @@ -0,0 +1,78 @@ +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); +} diff --git a/import_dpe.php b/import_dpe.php new file mode 100644 index 0000000..ff30528 --- /dev/null +++ b/import_dpe.php @@ -0,0 +1,44 @@ + 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); +} diff --git a/import_effectifs.php b/import_effectifs.php new file mode 100644 index 0000000..ca6b256 --- /dev/null +++ b/import_effectifs.php @@ -0,0 +1,93 @@ +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 l’export 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); +} diff --git a/import_effectifs_2d.php b/import_effectifs_2d.php new file mode 100644 index 0000000..a684cdd --- /dev/null +++ b/import_effectifs_2d.php @@ -0,0 +1,113 @@ + $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); +} diff --git a/import_ips.php b/import_ips.php new file mode 100644 index 0000000..9101883 --- /dev/null +++ b/import_ips.php @@ -0,0 +1,161 @@ + [ + '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); +} diff --git a/import_pop.php b/import_pop.php new file mode 100644 index 0000000..d4511d6 --- /dev/null +++ b/import_pop.php @@ -0,0 +1,77 @@ +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); +} diff --git a/import_pop_hist.php b/import_pop_hist.php new file mode 100644 index 0000000..9460463 --- /dev/null +++ b/import_pop_hist.php @@ -0,0 +1,94 @@ +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 l’export 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); +} diff --git a/import_pop_insee.php b/import_pop_insee.php new file mode 100644 index 0000000..6095a0b --- /dev/null +++ b/import_pop_insee.php @@ -0,0 +1,174 @@ + 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); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..d9f0e2e --- /dev/null +++ b/index.html @@ -0,0 +1,183 @@ + + + + + + Carte des établissements scolaires — France + + + + + + + +
    + + +
    +
    + +
    + + + + + + + + + + diff --git a/match_dpe.php b/match_dpe.php new file mode 100644 index 0000000..028ba04 --- /dev/null +++ b/match_dpe.php @@ -0,0 +1,41 @@ + 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); +} diff --git a/pwpolicy.js b/pwpolicy.js new file mode 100644 index 0000000..421a542 --- /dev/null +++ b/pwpolicy.js @@ -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 = + `
    +
    ${r.ok ? '✓ ' : ''}${LABELS[r.score]}
    +
      +
    • ≥ ${MIN_LEN} caractères
    • +
    • ≥ ${MIN_CLASSES} types parmi minuscule / majuscule / chiffre / spécial
    • +
    `; + }; + input.addEventListener('input', render); + render(); + } + + window.PWPolicy = { check, attach }; +})(); diff --git a/reset.html b/reset.html new file mode 100644 index 0000000..d2d806a --- /dev/null +++ b/reset.html @@ -0,0 +1,64 @@ + + + + + + Réinitialiser le mot de passe + + + +
    +
    +

    Nouveau mot de passe

    +
    +
    + + +
    +
    +
    + + +
    + +

    +
    +

    ← Retour à la connexion

    +
    +
    + + + + diff --git a/setup.php b/setup.php new file mode 100644 index 0000000..5123aba --- /dev/null +++ b/setup.php @@ -0,0 +1,320 @@ +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 d’exemple 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); +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..033a0c6 --- /dev/null +++ b/style.css @@ -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; } +}