Version initiale — carte des établissements scolaires
Carte Leaflet (annuaire Éducation nationale) + API PHP/MySQL : fiche établissement (effectifs, IPS, population INSEE, DPE ADEME), GED documents/photos, commentaires par groupe, gestion utilisateurs et politique de mots de passe. Imports opendata (effectifs, IPS, population, DPE, IRIS). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -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
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# Carte des établissements scolaires (France)
|
||||||
|
|
||||||
|
Carte interactive OpenStreetMap / Leaflet des ~68 500 établissements de
|
||||||
|
l'annuaire de l'Éducation nationale, avec filtres côté client.
|
||||||
|
|
||||||
|
Source des données : [data.education.gouv.fr — annuaire-education](https://data.education.gouv.fr/explore/dataset/fr-en-annuaire-education/).
|
||||||
|
|
||||||
|
## Lancer
|
||||||
|
|
||||||
|
L'application nécessite **PHP + MySQL** (la carte et la GED sont servies par une
|
||||||
|
API PHP qui lit la base ; pas de fichier `file://`).
|
||||||
|
|
||||||
|
### 1. Initialiser la base (une seule fois)
|
||||||
|
|
||||||
|
MySQL doit tourner (Laragon le démarre). Puis :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php setup.php # crée la base « ecoles », les tables, importe les établissements
|
||||||
|
php setup.php --reset # réimporte les établissements depuis data/etablissements.json
|
||||||
|
```
|
||||||
|
|
||||||
|
> Identifiants par défaut (Laragon : `root` / mot de passe vide) dans `api/db.php`.
|
||||||
|
|
||||||
|
### 2. Servir l'application
|
||||||
|
|
||||||
|
**Avec Laragon** : place le projet dans `www/`, puis ouvre `http://ecoles.test/`
|
||||||
|
(ou `http://localhost/ecoles/`).
|
||||||
|
|
||||||
|
**Sans Laragon**, serveur PHP intégré :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php -S 127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
puis ouvre http://127.0.0.1:8000/.
|
||||||
|
|
||||||
|
## Fiche établissement
|
||||||
|
|
||||||
|
Un clic sur un point → **« Voir la fiche complète »** ouvre une fiche plein écran
|
||||||
|
(overlay superposé à la carte, donc le retour ne recharge pas la carte) avec
|
||||||
|
quatre onglets :
|
||||||
|
|
||||||
|
- **Informations** — toutes les données de l'établissement + bouton « Localiser sur la carte »,
|
||||||
|
les effectifs, et le **fil de commentaires** de l'établissement (voir *Groupes & commentaires*)
|
||||||
|
- **Documents** — arborescence de dossiers + fichiers / liens / photos **validés**
|
||||||
|
- **Photothèque** — galerie des photos validées
|
||||||
|
- **Proposer un élément** — tout internaute peut soumettre un fichier, une photo ou un lien
|
||||||
|
|
||||||
|
Les éléments sont stockés **sur disque** dans `uploads/`, leurs métadonnées en base
|
||||||
|
(table `items`, type `file` / `photo` / `link`), indexés par UAI et rangés dans
|
||||||
|
l'arborescence globale (`folders`).
|
||||||
|
|
||||||
|
Chaque document/photo de l'onglet *Documents* affiche, en plus de son libellé, sa
|
||||||
|
**date de dépôt** et une **description** (texte explicatif). La description est saisie par
|
||||||
|
le proposant au dépôt et **modifiable** ensuite par les **valideurs** (bouton ✏️ visible
|
||||||
|
sur la fiche quand on est connecté avec un droit de validation sur l'établissement). Chaque
|
||||||
|
document a son **fil de commentaires** dépliable (bouton 💬), soumis aux **mêmes règles**
|
||||||
|
que les commentaires d'établissement (visibilité par groupe, dépôt réservé aux membres d'un
|
||||||
|
groupe, suppression par l'auteur ou le super-admin) — voir *Groupes & commentaires*. Les
|
||||||
|
commentaires d'un document et ceux de l'établissement restent distincts
|
||||||
|
(`comments.item_id`).
|
||||||
|
|
||||||
|
## Droits & modération
|
||||||
|
|
||||||
|
Trois niveaux :
|
||||||
|
|
||||||
|
| Rôle | Droits |
|
||||||
|
|---|---|
|
||||||
|
| **Internaute** (anonyme) | Proposer des éléments (statut « en attente ») |
|
||||||
|
| **Commentateur** | Compte sans droit de modération : participe aux **commentaires** des fiches via ses groupes |
|
||||||
|
| **Admin de périmètre** | Valider/rejeter les éléments des communes de son périmètre (commune + département) ou de ses groupes valideurs ; commente aussi |
|
||||||
|
| **Super-admin (plateforme)** | Tout valider, gérer l'arborescence, créer les utilisateurs et les groupes, importer l'opendata |
|
||||||
|
|
||||||
|
- Seuls les éléments **validés** sont visibles du public.
|
||||||
|
- Espace d'administration : **`admin.html`** (lien en bas de la barre latérale).
|
||||||
|
- Super-admin par défaut créé par `setup.php` : **`admin` / `admin`** — *à changer*.
|
||||||
|
- L'import opendata (bouton dans l'admin) télécharge la dernière version depuis
|
||||||
|
data.education.gouv.fr et met la base à jour (accès Internet requis côté serveur).
|
||||||
|
|
||||||
|
## Groupes d'utilisateurs & commentaires
|
||||||
|
|
||||||
|
Le super-admin organise les utilisateurs en **groupes** (section *Groupes
|
||||||
|
d'utilisateurs* de l'admin) :
|
||||||
|
|
||||||
|
- Un groupe marqué **valideur** donne à ses membres le droit de **valider les
|
||||||
|
documents** des établissements de **ses communes** (même modèle de périmètre que les
|
||||||
|
admins de périmètre, mais porté au niveau du groupe). Un utilisateur peut appartenir à
|
||||||
|
plusieurs groupes ; ses droits de validation sont l'union de ceux de ses groupes
|
||||||
|
valideurs.
|
||||||
|
- Tables : `user_groups` (nom + `can_validate`), `group_members` (appartenance n..n),
|
||||||
|
`group_scopes` (communes du groupe valideur). Le contrôle est centralisé dans
|
||||||
|
`can_validate_uai()` (`api/db.php`), qui accepte super-admin, périmètre personnel
|
||||||
|
*(legacy)* **ou** groupe valideur. API : `api/groups.php` (super-admin).
|
||||||
|
|
||||||
|
**Commentaires de fiche** — affichés directement dans l'onglet *Informations* :
|
||||||
|
|
||||||
|
- Un commentaire est **horodaté** (date + heure), porte un **texte** et le **nom** de son
|
||||||
|
auteur (table `comments`).
|
||||||
|
- Il n'est **visible qu'entre membres d'un même groupe** que l'auteur ; le super-admin
|
||||||
|
voit tout. Déposer un commentaire nécessite d'**appartenir à au moins un groupe**.
|
||||||
|
- L'**auteur** (et le super-admin) peuvent supprimer un commentaire ; pas d'édition
|
||||||
|
(horodatage figé). API : `api/comments.php`.
|
||||||
|
- La connexion se fait **directement dans la carte commentaires** (mini-formulaire), la session étant
|
||||||
|
partagée avec l'espace d'administration.
|
||||||
|
- Pour les participants qui ne doivent **pas** modérer, le super-admin crée des comptes
|
||||||
|
de rôle **Commentateur** (section *Utilisateurs* de l'admin) : ils se connectent et
|
||||||
|
commentent via leurs groupes, sans accès à la file de validation.
|
||||||
|
|
||||||
|
## Sécurité des mots de passe
|
||||||
|
|
||||||
|
- **Hachage** : bcrypt (`password_hash` / `PASSWORD_DEFAULT`), vérifié par
|
||||||
|
`password_verify`. Aucun mot de passe n'est stocké ni transmis en clair.
|
||||||
|
- **Politique de complexité** (`password_policy_check`, `api/db.php`) : **≥ 8 caractères**
|
||||||
|
et **au moins 3 types** parmi minuscule / majuscule / chiffre / caractère spécial.
|
||||||
|
Appliquée à la création, à la réinitialisation et au changement. Côté navigateur, une
|
||||||
|
**jauge de robustesse** (`pwpolicy.js`) guide la saisie.
|
||||||
|
- **Changement forcé** : les comptes créés par un admin (et `admin`/`admin` par défaut)
|
||||||
|
sont marqués `must_change` → l'utilisateur doit choisir un nouveau mot de passe à sa
|
||||||
|
première connexion (`account.html?forced=1`).
|
||||||
|
- **Self-service** : tout utilisateur connecté peut changer son mot de passe via
|
||||||
|
**`account.html`** (saisie du mot de passe actuel).
|
||||||
|
- **Mot de passe oublié** : depuis l'écran de connexion → **`forgot.html`** (identifiant
|
||||||
|
ou e-mail). Un lien à **usage unique** (valable 1 h) est envoyé par e-mail et consommé
|
||||||
|
sur **`reset.html`**. Table `password_resets` (jeton stocké **haché** en SHA-256).
|
||||||
|
- **Anti-bruteforce** : après **5 échecs** de connexion, le compte est **verrouillé 15 min**
|
||||||
|
(`users.failed_attempts` / `locked_until`).
|
||||||
|
- **Envoi d'e-mails** — transport configurable dans `api/db.php` (`MAIL_TRANSPORT`) :
|
||||||
|
- `log` (par défaut) : écrit l'e-mail (dont le lien) dans `private/mail_outbox.log`
|
||||||
|
(dossier protégé par `.htaccess`) — pratique en local sans serveur de mail ;
|
||||||
|
- `mail` : fonction PHP `mail()` ;
|
||||||
|
- `smtp` : client SMTP intégré (renseigner `SMTP_HOST/PORT/USER/PASS/SECURE`).
|
||||||
|
Pensez à ajuster `APP_BASE_URL` pour que les liens de réinitialisation soient corrects.
|
||||||
|
|
||||||
|
## Couche IRIS & population
|
||||||
|
|
||||||
|
- Fond clair (CartoDB Positron) par défaut ; sélecteur de couches en haut à droite.
|
||||||
|
- Couche **Contours IRIS** (WFS Géoplateforme `STATISTICALUNITS.IRIS:contours_iris`), chargée par zone visible (zoom ≥ 11).
|
||||||
|
- **IRIS cliquables** : un clic sur une zone affiche son nom, sa commune, son type, et la
|
||||||
|
**population du recensement INSEE 2022** (de l'IRIS et de la commune).
|
||||||
|
- Populations importées via `php import_pop.php` dans la table `iris_pop`
|
||||||
|
(population communale = somme des IRIS de la commune).
|
||||||
|
- Bouton **« 📍 Me localiser »** + centrage automatique sur la position de l'internaute
|
||||||
|
(nécessite HTTPS ou localhost).
|
||||||
|
- **Coloration par densité** : chaque IRIS est colorié selon sa densité (population ÷ surface),
|
||||||
|
avec une légende ; un clic affiche population de l'IRIS, densité, et population de la commune.
|
||||||
|
|
||||||
|
## Effectifs des écoles (1er degré)
|
||||||
|
|
||||||
|
- Dataset `fr-en-ecoles-effectifs-nb_classes` importé via `php import_effectifs.php`
|
||||||
|
dans la table `effectifs` (uai, année → élèves, classes ; ~809 000 lignes, 55 360 écoles).
|
||||||
|
- La fiche d'une **école / collège / lycée** affiche, dans l'onglet *Informations*, ses
|
||||||
|
**derniers effectifs** (élèves ; classes pour le 1er degré) et un **graphique d'évolution**
|
||||||
|
sur tout l'historique connu. (1er degré depuis 2009 ; collèges/lycées via leurs datasets dédiés.)
|
||||||
|
- Une carte voisine montre les **effectifs cumulés des écoles publiques de la commune**
|
||||||
|
(somme des élèves des établissements **publics** de la commune, par année) pour situer
|
||||||
|
l'établissement dans son contexte local. API : `api/effectifs.php?action=commune&uai=…`.
|
||||||
|
- **À côté**, un graphique de l'**IPS** (indice de position sociale) de l'établissement.
|
||||||
|
Trois datasets importés via `php import_ips.php` dans la table `ips` (clé `uai` + année) :
|
||||||
|
`fr-en-ips-ecoles-ap2022` (écoles, rentrées 2022→2024, **+ référence départementale**),
|
||||||
|
`fr-en-ips-colleges-ap2022` (collèges) et `fr-en-ips-lycees-ap2022` (lycées, IPS
|
||||||
|
ensemble GT+Pro). Pour les écoles, la courbe de l'établissement est tracée avec la
|
||||||
|
**référence départementale** (même échelle) ; collèges et lycées n'ont qu'une rentrée
|
||||||
|
(2022-2023) pour l'instant, donc un point. API : `api/ips.php?uai=…`. Un IPS élevé reflète
|
||||||
|
un milieu social plus favorisé (référence nationale ≈ 100).
|
||||||
|
`php import_ips.php --niveau=ecoles|colleges|lycees` permet d'importer un seul niveau.
|
||||||
|
- À côté de cette courbe, un graphique d'**évolution de la population** (INSEE) sur les
|
||||||
|
recensements successifs (**1968 → 2022**, 9 dates) au niveau de la **commune** — ou de
|
||||||
|
l'**arrondissement** pour Paris/Lyon/Marseille (déduit du code INSEE `comc`). Pour un
|
||||||
|
arrondissement, une **2ᵉ courbe « ville entière »** donne en plus l'évolution de la ville
|
||||||
|
complète. Source : INSEE, *base communale séries historiques du recensement*
|
||||||
|
(<https://www.insee.fr/fr/statistiques/fichier/8582555/base-cc-serie-historique-2022_csv.zip>),
|
||||||
|
qui contient le **vrai historique par arrondissement** (Paris 75101…, Lyon 69381…,
|
||||||
|
Marseille 13201…) ainsi que les totaux communes, importée via `php import_pop_insee.php`
|
||||||
|
dans la table `pop_commune`. API : `api/population.php?uai=…` →
|
||||||
|
`{zone, zone_estimated, ville, ville_nom}`.
|
||||||
|
- L'importeur lit le CSV dans le zip sans l'extension PHP `zip` (mini-lecteur ZIP +
|
||||||
|
`gzinflate`). `zone_estimated` n'est utilisé qu'en **repli** (zone sans historique
|
||||||
|
propre) : population de la ville × part de l'arrondissement dans les IRIS 2022.
|
||||||
|
- `import_pop_hist.php` reste une **alternative** (CSV Opendatasoft, s'arrête ~2014, sans
|
||||||
|
arrondissements).
|
||||||
|
|
||||||
|
## Performance énergétique (DPE — ADEME)
|
||||||
|
|
||||||
|
- Dataset `dpe01tertiaire` (DPE des **bâtiments tertiaires** depuis juillet 2021,
|
||||||
|
~532 000 diagnostics), importé via l'API data-fair de l'ADEME.
|
||||||
|
- ⚠ Ce jeu de données **n'a pas d'identifiant école (UAI)** : les bâtiments sont
|
||||||
|
repérés par adresse BAN + géolocalisation. Le rattachement école↔DPE est donc
|
||||||
|
**géographique** (plus proche bâtiment de la même commune, sous **5 m** par
|
||||||
|
défaut — soit le même bâtiment / la même adresse), comme le rattachement
|
||||||
|
école→IRIS. La distance affichée mesure la fiabilité du lien ; `--radius=N`
|
||||||
|
permet d'élargir.
|
||||||
|
- Import en deux temps :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php import_dpe.php # remplit la table « dpe » (long ; --dep=75 pour tester)
|
||||||
|
php import_comc.php # (si pas déjà fait) code INSEE commune des écoles
|
||||||
|
php match_dpe.php # rattache les DPE aux écoles (table « etab_dpe » ; --radius=N)
|
||||||
|
```
|
||||||
|
|
||||||
|
- La fiche d'un établissement affiche, dans l'onglet *Informations*, le ou les
|
||||||
|
**DPE rattachés** : étiquettes **Énergie** et **GES** (A→G, palette officielle),
|
||||||
|
consommation, émissions, surface, dates et adresse du bâtiment.
|
||||||
|
- **Coloration de la carte** : un sélecteur dans la légende (en bas à droite)
|
||||||
|
permet de colorer les marqueurs par **type d'établissement** (défaut) ou par
|
||||||
|
**étiquette DPE** (A→G ; gris = aucun DPE rattaché). Les étiquettes du
|
||||||
|
sous-ensemble affiché sont chargées à la demande via `api/dpe.php?action=latest`.
|
||||||
|
- **Import depuis l'admin** : un super-admin peut lancer l'import + le rattachement
|
||||||
|
depuis `admin.html` (section *Performance énergétique (DPE)*), avec un département
|
||||||
|
optionnel et un rayon réglable. Opération longue ⇒ pour le dataset complet,
|
||||||
|
préférez les scripts CLI ci-dessus.
|
||||||
|
- API : `api/dpe.php?uai=…` (liste triée par distance) et `?action=latest` (batch,
|
||||||
|
meilleur match par UAI). Logique d'import/rattachement partagée dans `api/dpe_lib.php`.
|
||||||
|
|
||||||
|
## Coloration des IRIS (2 modes)
|
||||||
|
|
||||||
|
Quand la couche IRIS est active, un sélecteur (en bas à gauche) permet de choisir :
|
||||||
|
|
||||||
|
1. **Densité de population** — population 2022 ÷ surface de l'IRIS (palette jaune→rouge).
|
||||||
|
2. **Taux d'écoliers publics** — **à l'échelle de l'IRIS** : élèves des écoles publiques
|
||||||
|
(1er degré, dernière rentrée) **situées dans l'IRIS** ÷ population de l'IRIS,
|
||||||
|
en palette **verte** (vert pâle = faible taux → vert foncé = fort taux). Chaque école
|
||||||
|
est rattachée à l'IRIS qui la contient (point-dans-polygone, côté navigateur).
|
||||||
|
|
||||||
|
Chaque mode a sa propre légende. Données : `api/iris.php` (population), `api/effectifs.php?action=latest`
|
||||||
|
(effectifs des écoles). Le rattachement école→IRIS et le calcul du taux se font côté client sur la zone visible.
|
||||||
|
|
||||||
|
## Filtres disponibles
|
||||||
|
|
||||||
|
- Recherche texte (nom d'établissement / commune), insensible aux accents
|
||||||
|
- Type d'établissement (École, Collège, Lycée, Médico-social, EREA…)
|
||||||
|
- Statut (Public / Privé)
|
||||||
|
- État (OUVERT / À FERMER)
|
||||||
|
- Région → Académie → Département → Circonscription (sélecteurs en cascade)
|
||||||
|
- Options proposées : Restauration, Internat, ULIS, SEGPA (logique ET ; coché = établissements proposant l'option)
|
||||||
|
|
||||||
|
> Note : la colonne `apprentissage` existe dans la source mais n'y est jamais renseignée (0 établissement), elle n'est donc pas proposée comme filtre.
|
||||||
|
|
||||||
|
Les points sont regroupés (clustering) pour rester fluides à l'échelle nationale
|
||||||
|
et colorés par type d'établissement (voir la légende).
|
||||||
|
|
||||||
|
## Mettre à jour les données
|
||||||
|
|
||||||
|
1. Télécharger l'export à jour (champs réduits) :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-annuaire-education/exports/json?select=identifiant_de_l_etablissement,nom_etablissement,type_etablissement,statut_public_prive,libelle_region,libelle_academie,libelle_departement,code_departement,nom_commune,code_postal,adresse_1,telephone,mail,web,etat,nom_circonscription,restauration,hebergement,ulis,segpa,apprentissage,latitude,longitude" -o raw_export.json
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Recompacter vers `data/etablissements.json` :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php build_data.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `index.html` | Page carte (sidebar + overlay fiche) |
|
||||||
|
| `admin.html` · `admin.js` | Espace d'administration (connexion, modération, arbre, admins, import) |
|
||||||
|
| `style.css` | Mise en forme (carte, fiche, galeries, admin, pages d'auth) |
|
||||||
|
| `app.js` | Chargement (API), filtres en cascade, clustering, popups |
|
||||||
|
| `account.html` · `forgot.html` · `reset.html` | Parcours mot de passe (changer / oublié / réinitialiser) |
|
||||||
|
| `pwpolicy.js` | Politique de mot de passe côté client (validation + jauge) |
|
||||||
|
| `fiche.js` | Fiche plein écran : infos, documents (arbre), photothèque, proposition |
|
||||||
|
| `setup.php` | Crée base + tables, super-admin, arbre d'exemple, importe les établissements |
|
||||||
|
| `api/db.php` | PDO MySQL + JSON + session/auth + import partagé |
|
||||||
|
| `api/auth.php` | Connexion / déconnexion / utilisateur courant |
|
||||||
|
| `api/etablissements.php` | GET : tous les établissements (sert la carte) |
|
||||||
|
| `api/items.php` | Éléments (fichiers/photos/liens) : liste, soumission, validation, suppression |
|
||||||
|
| `api/folders.php` | Arborescence globale (CRUD, super-admin) |
|
||||||
|
| `api/users.php` | Admins de périmètre + leurs communes (super-admin) |
|
||||||
|
| `api/groups.php` | Groupes d'utilisateurs : membres, communes, droit de validation (super-admin) |
|
||||||
|
| `api/comments.php` | Commentaires de fiche (horodatés, visibles par groupe) |
|
||||||
|
| `api/communes.php` | Autocomplétion des communes (affectation de périmètre) |
|
||||||
|
| `api/import.php` | Import de la dernière version de l'opendata (super-admin) |
|
||||||
|
| `api/iris.php` | Population INSEE 2022 d'un IRIS et de sa commune (+ densité par batch) |
|
||||||
|
| `api/effectifs.php` | Historique des effectifs d'un établissement + `?action=latest` (batch) + `?action=commune` (cumul commune) |
|
||||||
|
| `api/ips.php` | Historique de l'IPS (indice de position sociale) d'une école |
|
||||||
|
| `api/population.php` | Historique de la population municipale (INSEE) de la commune |
|
||||||
|
| `api/dpe.php` | DPE rattachés à un établissement (`?uai=`) + `?action=latest` (batch, meilleur match) |
|
||||||
|
| `api/dpe_lib.php` | Logique partagée DPE (création tables, import data-fair, rattachement géo) |
|
||||||
|
| `api/import_dpe.php` | Import DPE + rattachement déclenché depuis l'admin (super-admin) |
|
||||||
|
| `import_dpe.php` | Importe les DPE tertiaires de l'ADEME dans `dpe` (CLI ; appelle `dpe_lib`) |
|
||||||
|
| `match_dpe.php` | Rattache les DPE aux écoles par proximité géographique (CLI ; table `etab_dpe`) |
|
||||||
|
| `import_pop.php` | Importe les populations INSEE 2022 dans `iris_pop` (voir en-tête du script) |
|
||||||
|
| `import_effectifs.php` | Importe les effectifs des écoles (1er degré) dans `effectifs` |
|
||||||
|
| `import_ips.php` | Importe l'IPS (indice de position sociale) des écoles, collèges et lycées dans `ips` |
|
||||||
|
| `import_pop_insee.php` | Importe l'historique des populations INSEE (base séries historiques, communes **et arrondissements**, 1968→2022) dans `pop_commune` |
|
||||||
|
| `import_pop_hist.php` | Variante : historique des populations via CSV Opendatasoft (~2014) → `pop_commune` |
|
||||||
|
| `import_effectifs_2d.php` | Importe les effectifs des collèges et lycées dans `effectifs` |
|
||||||
|
| `import_comc.php` | Renseigne le code INSEE commune (`comc`) des établissements |
|
||||||
|
| `uploads/files/` · `uploads/photos/` | Fichiers et photos stockés sur disque |
|
||||||
|
| `build_data.php` | Convertit `raw_export.json` → `data/etablissements.json` (source du premier import) |
|
||||||
|
| `data/etablissements.json` | Jeu de données importé en base par `setup.php` |
|
||||||
|
| `raw_export.json` | Export brut temporaire (supprimable après build) |
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mon mot de passe</title>
|
||||||
|
<link rel="stylesheet" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Mon mot de passe</h1>
|
||||||
|
<div id="forced-banner" class="forced-banner" hidden>
|
||||||
|
🔒 Pour des raisons de sécurité, vous devez choisir un nouveau mot de passe avant de continuer.
|
||||||
|
</div>
|
||||||
|
<p id="who" class="hint"></p>
|
||||||
|
|
||||||
|
<div id="not-auth" hidden>
|
||||||
|
<p class="propose-msg err">Vous n'êtes pas connecté.</p>
|
||||||
|
<p class="auth-foot"><a href="admin.html">Se connecter</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="change-form" autocomplete="off" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="cur">Mot de passe actuel</label>
|
||||||
|
<input id="cur" type="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pw">Nouveau mot de passe</label>
|
||||||
|
<input id="pw" type="password" required>
|
||||||
|
<div id="pw-meter" class="pw-meter"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pw2">Confirmer</label>
|
||||||
|
<input id="pw2" type="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Changer le mot de passe</button>
|
||||||
|
<p id="msg" class="propose-msg"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p id="nav-foot" class="auth-foot" hidden><a href="admin.html">← Espace administration</a> · <a href="index.html">Carte</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="pwpolicy.js"></script>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const forced = new URLSearchParams(location.search).get('forced') === '1';
|
||||||
|
PWPolicy.attach($('pw'), $('pw-meter'));
|
||||||
|
|
||||||
|
(async function boot() {
|
||||||
|
let user = null;
|
||||||
|
try { user = (await (await fetch('api/auth.php?action=me')).json()).user; } catch {}
|
||||||
|
if (!user) { $('not-auth').hidden = false; return; }
|
||||||
|
$('who').textContent = `Connecté : ${user.username}`;
|
||||||
|
$('change-form').hidden = false;
|
||||||
|
if (forced || user.must_change) {
|
||||||
|
$('forced-banner').hidden = false;
|
||||||
|
} else {
|
||||||
|
$('nav-foot').hidden = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
$('change-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const cur = $('cur').value, pw = $('pw').value, pw2 = $('pw2').value;
|
||||||
|
const r = PWPolicy.check(pw);
|
||||||
|
if (!r.ok) { showErr('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; }
|
||||||
|
if (pw !== pw2) { showErr('Les deux mots de passe ne correspondent pas.'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/auth.php?action=change_password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ current: cur, new: pw }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showErr(data.error || ('HTTP ' + res.status)); return; }
|
||||||
|
$('change-form').reset();
|
||||||
|
$('msg').className = 'propose-msg ok';
|
||||||
|
$('msg').innerHTML = '✅ Mot de passe changé. <a href="admin.html">Continuer</a>.';
|
||||||
|
$('forced-banner').hidden = true;
|
||||||
|
} catch (err) { showErr('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function showErr(t) { $('msg').className = 'propose-msg err'; $('msg').textContent = t; }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Administration — Établissements scolaires</title>
|
||||||
|
<link rel="stylesheet" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body class="admin-page">
|
||||||
|
|
||||||
|
<header class="admin-bar">
|
||||||
|
<h1>🔐 Administration</h1>
|
||||||
|
<div class="admin-user">
|
||||||
|
<span id="who"></span>
|
||||||
|
<a id="account-link" href="account.html" class="btn-secondary btn-sm" hidden>🔑 Mon mot de passe</a>
|
||||||
|
<a href="index.html" class="btn-secondary btn-sm">← Carte</a>
|
||||||
|
<button id="logout" type="button" class="btn-secondary btn-sm" hidden>Déconnexion</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Connexion -->
|
||||||
|
<section id="login" class="admin-section login-card" hidden>
|
||||||
|
<h2>Connexion administrateur</h2>
|
||||||
|
<form id="login-form" autocomplete="off">
|
||||||
|
<div class="field">
|
||||||
|
<label for="l-user">Identifiant</label>
|
||||||
|
<input id="l-user" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="l-pass">Mot de passe</label>
|
||||||
|
<input id="l-pass" type="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Se connecter</button>
|
||||||
|
<p id="login-msg" class="propose-msg err"></p>
|
||||||
|
<p class="auth-foot"><a href="forgot.html">Mot de passe oublié ?</a></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Espace connecté -->
|
||||||
|
<main id="admin" class="admin-main" hidden>
|
||||||
|
|
||||||
|
<!-- File de validation -->
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>File d'attente de validation <span id="queue-count" class="tree-count"></span></h2>
|
||||||
|
<p class="hint">Éléments proposés par les internautes, en attente dans votre périmètre.</p>
|
||||||
|
<div id="queue" class="queue"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Réservé au super-admin -->
|
||||||
|
<div id="super-only" hidden>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Arborescence des dossiers</h2>
|
||||||
|
<p class="hint">Arbre global appliqué à tous les établissements.</p>
|
||||||
|
<form id="folder-form" class="inline-form">
|
||||||
|
<input id="nf-name" type="text" placeholder="Nom du dossier" required>
|
||||||
|
<select id="nf-parent"></select>
|
||||||
|
<button type="submit" class="btn-primary btn-sm">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
<div id="folders-admin" class="folders-admin"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Utilisateurs</h2>
|
||||||
|
<p class="hint"><strong>Admin de périmètre</strong> : valide les documents de ses communes / groupes valideurs. <strong>Commentateur</strong> : participe seulement aux commentaires de fiche (via ses groupes), sans accès à la modération.</p>
|
||||||
|
<form id="user-form" class="inline-form">
|
||||||
|
<input id="nu-user" type="text" placeholder="Identifiant" required>
|
||||||
|
<input id="nu-mail" type="email" placeholder="E-mail (pour la récupération)">
|
||||||
|
<input id="nu-pass" type="text" placeholder="Mot de passe" required>
|
||||||
|
<select id="nu-role" title="Rôle de l'utilisateur">
|
||||||
|
<option value="perimeter">Admin de périmètre</option>
|
||||||
|
<option value="member">Commentateur</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-primary btn-sm">Créer l'utilisateur</button>
|
||||||
|
</form>
|
||||||
|
<div id="nu-pw-meter" class="pw-meter"></div>
|
||||||
|
<p class="hint">Le mot de passe doit faire ≥ 8 caractères et combiner ≥ 3 types (minuscule, majuscule, chiffre, spécial). L'utilisateur devra le changer à sa 1<sup>re</sup> connexion.</p>
|
||||||
|
<div id="users-admin" class="users-admin"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Groupes d'utilisateurs</h2>
|
||||||
|
<p class="hint">Un groupe rassemble des utilisateurs. Un groupe <strong>valideur</strong> permet à ses membres de valider les documents des établissements de ses communes. Les commentaires de fiche sont visibles entre membres d'un même groupe.</p>
|
||||||
|
<form id="group-form" class="inline-form">
|
||||||
|
<input id="ng-name" type="text" placeholder="Nom du groupe" required>
|
||||||
|
<label class="inline-check"><input id="ng-validate" type="checkbox"> peut valider les documents</label>
|
||||||
|
<button type="submit" class="btn-primary btn-sm">Créer un groupe</button>
|
||||||
|
</form>
|
||||||
|
<div id="groups-admin" class="users-admin"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Import de l'opendata</h2>
|
||||||
|
<p class="hint">Télécharge la dernière version de l'annuaire de l'Éducation nationale et met à jour la base (accès Internet requis côté serveur).</p>
|
||||||
|
<button id="import-btn" type="button" class="btn-primary">⤓ Importer la dernière version</button>
|
||||||
|
<p id="import-msg" class="propose-msg"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Performance énergétique (DPE — ADEME)</h2>
|
||||||
|
<p class="hint">Télécharge les DPE des bâtiments tertiaires de l'ADEME et les rattache aux établissements par proximité géographique. <strong>Opération longue</strong> (le dataset complet ≈ 532 000 DPE) ; laissez un département vide pour tout importer, ou indiquez un code pour un import ciblé. Accès Internet requis côté serveur.</p>
|
||||||
|
<form id="dpe-form" class="inline-form">
|
||||||
|
<input id="dpe-dep" type="text" placeholder="Département (ex. 75) — vide = tout" maxlength="3">
|
||||||
|
<input id="dpe-radius" type="number" min="1" max="2000" value="5" title="Rayon de rattachement (m) — 5 m = même bâtiment / adresse">
|
||||||
|
<button id="dpe-btn" type="submit" class="btn-primary btn-sm">⤓ Importer & rattacher</button>
|
||||||
|
</form>
|
||||||
|
<p id="dpe-msg" class="propose-msg"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="pwpolicy.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = '<p class="empty">Compte commentateur : rendez-vous sur une fiche d’établissement (onglet 💬 Commentaires) pour participer.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadQueue();
|
||||||
|
if (me.role === 'super') {
|
||||||
|
await loadFolders();
|
||||||
|
await loadUsers();
|
||||||
|
await loadGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('login-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
me = (await postJson('api/auth.php?action=login',
|
||||||
|
{ username: $('l-user').value, password: $('l-pass').value })).user;
|
||||||
|
$('login-msg').textContent = '';
|
||||||
|
await showAdmin();
|
||||||
|
} catch (err) {
|
||||||
|
$('login-msg').textContent = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('logout').addEventListener('click', async () => {
|
||||||
|
await apiJson('api/auth.php?action=logout', { method: 'POST' });
|
||||||
|
me = null;
|
||||||
|
showLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- File de validation ---
|
||||||
|
async function loadQueue() {
|
||||||
|
let q;
|
||||||
|
try { q = await apiJson('api/items.php?action=queue'); }
|
||||||
|
catch (err) { $('queue').innerHTML = `<p class="empty">${esc(err.message)}</p>`; return; }
|
||||||
|
|
||||||
|
$('queue-count').textContent = q.length || '';
|
||||||
|
if (!q.length) { $('queue').innerHTML = '<p class="empty">Aucun élément en attente. 🎉</p>'; return; }
|
||||||
|
|
||||||
|
$('queue').innerHTML = q.map((it) => {
|
||||||
|
const icon = it.kind === 'photo' ? '🖼' : it.kind === 'link' ? '🔗' : '📄';
|
||||||
|
let preview;
|
||||||
|
if (it.kind === 'photo') preview = `<a href="${esc(it.url)}" target="_blank"><img class="q-thumb" src="${esc(it.url)}" alt=""></a>`;
|
||||||
|
else if (it.kind === 'link') preview = `<a href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.url)}</a>`;
|
||||||
|
else preview = `<a href="${esc(it.url)}" target="_blank">Télécharger</a>`;
|
||||||
|
const who = [it.submitter_name, it.submitter_email].filter(Boolean).join(' · ') || 'Anonyme';
|
||||||
|
const descHtml = it.description ? `<div class="q-desc">${esc(it.description)}</div>` : '';
|
||||||
|
return `<div class="q-item" data-id="${it.id}">
|
||||||
|
<div class="q-main">
|
||||||
|
<div class="q-title">${icon} ${esc(it.title)}</div>
|
||||||
|
${descHtml}
|
||||||
|
<div class="q-meta">${esc(it.etab)}</div>
|
||||||
|
<div class="q-meta">Proposé par : ${esc(who)} · ${esc(it.submitted_at || '')}</div>
|
||||||
|
<div class="q-preview">${preview}</div>
|
||||||
|
</div>
|
||||||
|
<div class="q-actions">
|
||||||
|
<button class="btn-primary btn-sm q-approve">✓ Valider</button>
|
||||||
|
<button class="btn-danger btn-sm q-reject">✗ Rejeter</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('queue').addEventListener('click', async (ev) => {
|
||||||
|
const card = ev.target.closest('.q-item');
|
||||||
|
if (!card) return;
|
||||||
|
const id = Number(card.dataset.id);
|
||||||
|
try {
|
||||||
|
if (ev.target.closest('.q-approve')) {
|
||||||
|
await postJson('api/items.php?action=validate', { id, decision: 'approve' });
|
||||||
|
} else if (ev.target.closest('.q-reject')) {
|
||||||
|
const reason = prompt('Motif du rejet (facultatif) :') ?? '';
|
||||||
|
await postJson('api/items.php?action=validate', { id, decision: 'reject', reason });
|
||||||
|
} else return;
|
||||||
|
await loadQueue();
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Arborescence (super-admin) ---
|
||||||
|
async function loadFolders() {
|
||||||
|
folders = await apiJson('api/folders.php');
|
||||||
|
renderFolderParentSelect();
|
||||||
|
renderFoldersAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenOf = (pid) => folders
|
||||||
|
.filter((f) => f.parent_id === pid)
|
||||||
|
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, 'fr'));
|
||||||
|
|
||||||
|
function renderFolderParentSelect() {
|
||||||
|
const opts = ['<option value="">(Racine)</option>'];
|
||||||
|
const walk = (pid, d) => childrenOf(pid).forEach((f) => {
|
||||||
|
opts.push(`<option value="${f.id}">${' '.repeat(d)}${esc(f.name)}</option>`);
|
||||||
|
walk(f.id, d + 1);
|
||||||
|
});
|
||||||
|
walk(null, 0);
|
||||||
|
$('nf-parent').innerHTML = opts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderNodeHtml(f) {
|
||||||
|
const kids = childrenOf(f.id);
|
||||||
|
let html = `<li><span class="fa-row" data-id="${f.id}">
|
||||||
|
<span class="fa-name">📁 ${esc(f.name)}</span>
|
||||||
|
<button class="fa-rename" title="Renommer">✎</button>
|
||||||
|
<button class="fa-del" title="Supprimer">🗑</button>
|
||||||
|
</span>`;
|
||||||
|
if (kids.length) html += '<ul>' + kids.map(folderNodeHtml).join('') + '</ul>';
|
||||||
|
return html + '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFoldersAdmin() {
|
||||||
|
const roots = childrenOf(null);
|
||||||
|
$('folders-admin').innerHTML = roots.length
|
||||||
|
? '<ul class="tree">' + roots.map(folderNodeHtml).join('') + '</ul>'
|
||||||
|
: '<p class="empty">Aucun dossier.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('folder-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJson('api/folders.php', { name: $('nf-name').value.trim(), parent_id: $('nf-parent').value || null });
|
||||||
|
$('nf-name').value = '';
|
||||||
|
await loadFolders();
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('folders-admin').addEventListener('click', async (ev) => {
|
||||||
|
const row = ev.target.closest('.fa-row');
|
||||||
|
if (!row) return;
|
||||||
|
const id = Number(row.dataset.id);
|
||||||
|
const name = row.querySelector('.fa-name').textContent.replace(/^📁\s*/, '');
|
||||||
|
try {
|
||||||
|
if (ev.target.closest('.fa-rename')) {
|
||||||
|
const nv = prompt('Nouveau nom :', name);
|
||||||
|
if (nv && nv.trim()) {
|
||||||
|
await apiJson('api/folders.php', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name: nv.trim() }) });
|
||||||
|
await loadFolders();
|
||||||
|
}
|
||||||
|
} else if (ev.target.closest('.fa-del')) {
|
||||||
|
if (confirm(`Supprimer « ${name} » et ses sous-dossiers ?\nLes éléments associés seront déplacés vers « Sans dossier ».`)) {
|
||||||
|
await apiJson(`api/folders.php?id=${id}`, { method: 'DELETE' });
|
||||||
|
await loadFolders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Administrateurs de périmètre (super-admin) ---
|
||||||
|
async function loadUsers() {
|
||||||
|
const users = await apiJson('api/users.php');
|
||||||
|
$('users-admin').innerHTML = users.map((u) => {
|
||||||
|
const roleBadge = u.role === 'super'
|
||||||
|
? '<span class="badge ouvert">plateforme</span>'
|
||||||
|
: u.role === 'member'
|
||||||
|
? '<span class="badge">commentateur</span>'
|
||||||
|
: '<span class="badge">périmètre</span>';
|
||||||
|
let scopes;
|
||||||
|
if (u.role === 'super') scopes = '<em>Tous les établissements</em>';
|
||||||
|
else if (u.role === 'member') scopes = '<em>Commente via ses groupes (aucune validation)</em>';
|
||||||
|
else scopes = u.scopes.map((s) =>
|
||||||
|
`<span class="chip">${esc(s.com)} (${esc(s.depc)}) <button class="chip-x" data-scope="${s.id}">×</button></span>`
|
||||||
|
).join(' ') || '<em>Aucune commune</em>';
|
||||||
|
// Périmètre personnel réservé aux admins de périmètre.
|
||||||
|
const scopeEditor = u.role === 'perimeter' ? `
|
||||||
|
<div class="scope-editor">
|
||||||
|
<input class="scope-search" data-uid="${u.id}" placeholder="Ajouter une commune…">
|
||||||
|
<div class="scope-results" data-uid="${u.id}"></div>
|
||||||
|
</div>` : '';
|
||||||
|
const mustBadge = u.must_change ? '<span class="badge warn">⚠ doit changer</span>' : '';
|
||||||
|
const emailHtml = u.email ? esc(u.email) : '<em>pas d’e-mail</em>';
|
||||||
|
return `<div class="user-card" data-uid="${u.id}">
|
||||||
|
<div class="user-head">
|
||||||
|
<strong>${esc(u.username)}</strong> ${roleBadge} ${mustBadge}
|
||||||
|
<span class="user-actions">
|
||||||
|
<button class="btn-secondary btn-sm u-mail">✉ E-mail</button>
|
||||||
|
<button class="btn-secondary btn-sm u-pass">Mot de passe</button>
|
||||||
|
${u.id === me.id ? '' : '<button class="btn-danger btn-sm u-del">Supprimer</button>'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-email muted">📧 ${emailHtml}</div>
|
||||||
|
<div class="user-scopes">${scopes}</div>
|
||||||
|
${scopeEditor}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.PWPolicy) PWPolicy.attach($('nu-pass'), $('nu-pw-meter'));
|
||||||
|
|
||||||
|
$('user-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const pw = $('nu-pass').value;
|
||||||
|
if (window.PWPolicy) {
|
||||||
|
const r = PWPolicy.check(pw);
|
||||||
|
if (!r.ok) { alert('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await postJson('api/users.php', {
|
||||||
|
username: $('nu-user').value.trim(),
|
||||||
|
email: $('nu-mail').value.trim(),
|
||||||
|
password: pw,
|
||||||
|
role: $('nu-role').value,
|
||||||
|
});
|
||||||
|
$('nu-user').value = ''; $('nu-mail').value = ''; $('nu-pass').value = '';
|
||||||
|
$('nu-pw-meter').innerHTML = '';
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchCommunes = debounce(async (input) => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
const box = input.parentElement.querySelector('.scope-results');
|
||||||
|
if (q.length < 2) { box.innerHTML = ''; return; }
|
||||||
|
try {
|
||||||
|
const rows = await apiJson('api/communes.php?q=' + encodeURIComponent(q));
|
||||||
|
box.innerHTML = rows.map((r) =>
|
||||||
|
`<button class="scope-result" data-com="${esc(r.com)}" data-depc="${esc(r.depc)}">
|
||||||
|
${esc(r.com)} <span class="muted">${esc(r.dep)} (${esc(r.depc)}) · ${r.nb}</span>
|
||||||
|
</button>`).join('') || '<div class="muted" style="padding:4px 8px">Aucune commune</div>';
|
||||||
|
} catch { box.innerHTML = ''; }
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
$('users-admin').addEventListener('input', (ev) => {
|
||||||
|
if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('users-admin').addEventListener('click', async (ev) => {
|
||||||
|
const card = ev.target.closest('.user-card');
|
||||||
|
if (!card) return;
|
||||||
|
const uid = Number(card.dataset.uid);
|
||||||
|
try {
|
||||||
|
if (ev.target.closest('.scope-result')) {
|
||||||
|
const b = ev.target.closest('.scope-result');
|
||||||
|
await postJson('api/users.php?action=scope', { user_id: uid, com: b.dataset.com, depc: b.dataset.depc });
|
||||||
|
await loadUsers();
|
||||||
|
} else if (ev.target.closest('.chip-x')) {
|
||||||
|
await apiJson(`api/users.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' });
|
||||||
|
await loadUsers();
|
||||||
|
} else if (ev.target.closest('.u-mail')) {
|
||||||
|
const cur = card.querySelector('.user-email').textContent.replace(/^📧\s*/, '').trim();
|
||||||
|
const email = prompt('Adresse e-mail (vide pour retirer) :', cur === 'pas 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) =>
|
||||||
|
`<span class="chip">${esc(m.username)} <button class="chip-x" data-member="${m.id}">×</button></span>`
|
||||||
|
).join(' ') || '<em>Aucun membre</em>';
|
||||||
|
const addable = allUsers.filter((u) => !memberIds.has(u.id));
|
||||||
|
const memberSelect = addable.length
|
||||||
|
? `<select class="g-member-add"><option value="">+ Ajouter un membre…</option>`
|
||||||
|
+ addable.map((u) => `<option value="${u.id}">${esc(u.username)}</option>`).join('')
|
||||||
|
+ `</select>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const valBadge = g.can_validate ? '<span class="badge ouvert">valideur</span>' : '';
|
||||||
|
let scopesHtml = '';
|
||||||
|
if (g.can_validate) {
|
||||||
|
const chips = g.scopes.map((s) =>
|
||||||
|
`<span class="chip">${esc(s.com)} (${esc(s.depc)}) <button class="chip-x" data-scope="${s.id}">×</button></span>`
|
||||||
|
).join(' ') || '<em>Aucune commune</em>';
|
||||||
|
scopesHtml = `
|
||||||
|
<div class="user-scopes">${chips}</div>
|
||||||
|
<div class="scope-editor">
|
||||||
|
<input class="scope-search" placeholder="Ajouter une commune…">
|
||||||
|
<div class="scope-results"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="user-card group-card" data-gid="${g.id}">
|
||||||
|
<div class="user-head">
|
||||||
|
<strong>${esc(g.name)}</strong> ${valBadge}
|
||||||
|
<span class="user-actions">
|
||||||
|
<label class="inline-check"><input type="checkbox" class="g-validate" ${g.can_validate ? 'checked' : ''}> valideur</label>
|
||||||
|
<button class="btn-danger btn-sm g-del">Supprimer</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="group-members">${memberChips} ${memberSelect}</div>
|
||||||
|
${scopesHtml}
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<p class="empty">Aucun groupe.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('group-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJson('api/groups.php', { name: $('ng-name').value.trim(), can_validate: $('ng-validate').checked });
|
||||||
|
$('ng-name').value = ''; $('ng-validate').checked = false;
|
||||||
|
await loadGroups();
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('groups-admin').addEventListener('input', (ev) => {
|
||||||
|
if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('groups-admin').addEventListener('change', async (ev) => {
|
||||||
|
const card = ev.target.closest('.group-card');
|
||||||
|
if (!card) return;
|
||||||
|
const gid = Number(card.dataset.gid);
|
||||||
|
try {
|
||||||
|
if (ev.target.classList.contains('g-validate')) {
|
||||||
|
await postJson('api/groups.php?action=toggle', { id: gid, can_validate: ev.target.checked });
|
||||||
|
await loadGroups();
|
||||||
|
} else if (ev.target.classList.contains('g-member-add') && ev.target.value) {
|
||||||
|
await postJson('api/groups.php?action=member', { group_id: gid, user_id: Number(ev.target.value) });
|
||||||
|
await loadGroups();
|
||||||
|
}
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('groups-admin').addEventListener('click', async (ev) => {
|
||||||
|
const card = ev.target.closest('.group-card');
|
||||||
|
if (!card) return;
|
||||||
|
const gid = Number(card.dataset.gid);
|
||||||
|
try {
|
||||||
|
if (ev.target.closest('.scope-result')) {
|
||||||
|
const b = ev.target.closest('.scope-result');
|
||||||
|
await postJson('api/groups.php?action=scope', { group_id: gid, com: b.dataset.com, depc: b.dataset.depc });
|
||||||
|
await loadGroups();
|
||||||
|
} else if (ev.target.closest('.chip-x') && ev.target.dataset.member) {
|
||||||
|
await apiJson(`api/groups.php?action=member&group_id=${gid}&user_id=${ev.target.dataset.member}`, { method: 'DELETE' });
|
||||||
|
await loadGroups();
|
||||||
|
} else if (ev.target.closest('.chip-x') && ev.target.dataset.scope) {
|
||||||
|
await apiJson(`api/groups.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' });
|
||||||
|
await loadGroups();
|
||||||
|
} else if (ev.target.closest('.g-del')) {
|
||||||
|
if (confirm('Supprimer ce groupe ? Les membres et le périmètre associés seront retirés.')) {
|
||||||
|
await apiJson(`api/groups.php?id=${gid}`, { method: 'DELETE' });
|
||||||
|
await loadGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Import opendata (super-admin) ---
|
||||||
|
$('import-btn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Télécharger et importer la dernière version de l\'opendata ?\nCela peut prendre quelques minutes.')) return;
|
||||||
|
const btn = $('import-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
$('import-msg').className = 'propose-msg';
|
||||||
|
$('import-msg').textContent = '⏳ Téléchargement et import en cours…';
|
||||||
|
try {
|
||||||
|
const r = await apiJson('api/import.php', { method: 'POST' });
|
||||||
|
$('import-msg').className = 'propose-msg ok';
|
||||||
|
$('import-msg').textContent = `✅ Import terminé : ${r.imported} établissements traités, ${r.skipped} ignorés. Total en base : ${r.total}.`;
|
||||||
|
} catch (err) {
|
||||||
|
$('import-msg').className = 'propose-msg err';
|
||||||
|
$('import-msg').textContent = 'Erreur : ' + err.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Import DPE ADEME + rattachement (super-admin) ---
|
||||||
|
$('dpe-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const dep = $('dpe-dep').value.trim();
|
||||||
|
const radius = Number($('dpe-radius').value) || 80;
|
||||||
|
const scope = dep ? `du département ${dep}` : 'COMPLET (~532 000 DPE)';
|
||||||
|
if (!confirm(`Importer le dataset DPE ${scope} et rattacher les bâtiments aux écoles (rayon ${radius} m) ?\nCela peut prendre plusieurs minutes.`)) return;
|
||||||
|
const btn = $('dpe-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
$('dpe-msg').className = 'propose-msg';
|
||||||
|
$('dpe-msg').textContent = '⏳ Import et rattachement en cours… (ne fermez pas cette page)';
|
||||||
|
try {
|
||||||
|
const r = await postJson('api/import_dpe.php', { dep, radius });
|
||||||
|
$('dpe-msg').className = 'propose-msg ok';
|
||||||
|
$('dpe-msg').textContent = `✅ Terminé : ${r.imported} DPE importés (${r.geocoded} géolocalisés) · ${r.links} rattachements pour ${r.etabs} établissements (≤ ${r.radius} m).`;
|
||||||
|
} catch (err) {
|
||||||
|
$('dpe-msg').className = 'propose-msg err';
|
||||||
|
$('dpe-msg').textContent = 'Erreur : ' + err.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
boot();
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Authentification et gestion du mot de passe.
|
||||||
|
* GET ?action=me → utilisateur courant (ou null)
|
||||||
|
* POST ?action=login → {username, password}
|
||||||
|
* POST ?action=logout
|
||||||
|
* POST ?action=change_password → {current, new} (utilisateur connecté)
|
||||||
|
* POST ?action=forgot → {login} (identifiant ou e-mail)
|
||||||
|
* POST ?action=reset → {token, new} (lien de réinitialisation)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
const LOGIN_MAX_ATTEMPTS = 5; // échecs avant verrouillage
|
||||||
|
const LOGIN_LOCK_MINUTES = 15; // durée du verrou
|
||||||
|
const RESET_TTL_MINUTES = 60; // validité d'un lien de réinitialisation
|
||||||
|
|
||||||
|
try {
|
||||||
|
$action = $_GET['action'] ?? 'me';
|
||||||
|
|
||||||
|
if ($action === 'me') {
|
||||||
|
json_out(['user' => current_user()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Connexion --------------------------------------------------------
|
||||||
|
if ($action === 'login') {
|
||||||
|
$b = body_json();
|
||||||
|
$username = trim((string) ($b['username'] ?? ''));
|
||||||
|
$password = (string) ($b['password'] ?? '');
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
json_error('Identifiant et mot de passe requis.');
|
||||||
|
}
|
||||||
|
$st = db()->prepare('SELECT id, username, password_hash, role, must_change, failed_attempts, locked_until FROM users WHERE username = ?');
|
||||||
|
$st->execute([$username]);
|
||||||
|
$u = $st->fetch();
|
||||||
|
|
||||||
|
// Verrou anti-bruteforce.
|
||||||
|
if ($u && $u['locked_until'] !== null && strtotime($u['locked_until']) > time()) {
|
||||||
|
json_error('Compte temporairement verrouillé suite à trop 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Commentaires — sur une fiche d'établissement OU sur un document précis.
|
||||||
|
*
|
||||||
|
* Mêmes règles dans les deux cas : un commentaire est horodaté, porte un texte et
|
||||||
|
* le nom de son auteur, et n'est visible que par les membres d'un même groupe que
|
||||||
|
* l'auteur (le super-admin voit tout). Poster nécessite d'appartenir à au moins un
|
||||||
|
* groupe. L'auteur ou le super-admin peuvent supprimer ; pas d'édition.
|
||||||
|
*
|
||||||
|
* Portée : ?uai=UAI (établissement, commentaires « généraux ») ou
|
||||||
|
* ?item_id=N (document précis). Un commentaire de document n'apparaît pas
|
||||||
|
* dans les commentaires d'établissement et inversement.
|
||||||
|
*
|
||||||
|
* GET ?uai=UAI → { auth, username, role, can_post, can_validate, comments[] }
|
||||||
|
* GET ?item_id=N → { auth, username, role, can_post, comments[] }
|
||||||
|
* POST ?uai=UAI | ?item_id=N {body} → crée un commentaire (membre d'un groupe requis)
|
||||||
|
* DELETE ?id=N → supprime (auteur ou super-admin)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$itemId = isset($_GET['item_id']) && $_GET['item_id'] !== '' ? (int) $_GET['item_id'] : 0;
|
||||||
|
|
||||||
|
// ---- Liste des commentaires visibles ----------------------------------
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($itemId <= 0 && $uai === '') {
|
||||||
|
json_error('Paramètre uai ou item_id requis.');
|
||||||
|
}
|
||||||
|
$me = current_user();
|
||||||
|
if (!$me) {
|
||||||
|
json_out(['auth' => false, 'can_post' => false, 'comments' => []]);
|
||||||
|
}
|
||||||
|
$isSuper = $me['role'] === 'super';
|
||||||
|
$canPost = $isSuper || !empty(user_group_ids((int) $me['id']));
|
||||||
|
|
||||||
|
// Clause de portée : un document précis, ou l'établissement (hors documents).
|
||||||
|
if ($itemId > 0) {
|
||||||
|
$scopeSql = 'c.item_id = ?';
|
||||||
|
$scopeArg = $itemId;
|
||||||
|
} else {
|
||||||
|
$scopeSql = 'c.uai = ? AND c.item_id IS NULL';
|
||||||
|
$scopeArg = $uai;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Super : tout ; sinon ses propres commentaires + ceux d'un membre partageant
|
||||||
|
// au moins un groupe avec lui.
|
||||||
|
if ($isSuper) {
|
||||||
|
$sql = "SELECT c.id, c.user_id, c.body, c.created_at, u.username
|
||||||
|
FROM comments c JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE $scopeSql ORDER BY c.created_at DESC";
|
||||||
|
$st = db()->prepare($sql);
|
||||||
|
$st->execute([$scopeArg]);
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT c.id, c.user_id, c.body, c.created_at, u.username
|
||||||
|
FROM comments c JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE $scopeSql AND (
|
||||||
|
c.user_id = ?
|
||||||
|
OR EXISTS (SELECT 1 FROM group_members a
|
||||||
|
JOIN group_members b ON a.group_id = b.group_id
|
||||||
|
WHERE a.user_id = ? AND b.user_id = c.user_id))
|
||||||
|
ORDER BY c.created_at DESC";
|
||||||
|
$st = db()->prepare($sql);
|
||||||
|
$st->execute([$scopeArg, (int) $me['id'], (int) $me['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int) $r['id'],
|
||||||
|
'username' => $r['username'],
|
||||||
|
'body' => $r['body'],
|
||||||
|
'created_at' => $r['created_at'],
|
||||||
|
'mine' => (int) $r['user_id'] === (int) $me['id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$resp = [
|
||||||
|
'auth' => true,
|
||||||
|
'username' => $me['username'],
|
||||||
|
'role' => $me['role'],
|
||||||
|
'can_post' => $canPost,
|
||||||
|
'comments' => $out,
|
||||||
|
];
|
||||||
|
// Au niveau établissement, indique si l'utilisateur peut éditer les documents.
|
||||||
|
if ($itemId <= 0) {
|
||||||
|
$resp['can_validate'] = can_validate_uai($me, $uai);
|
||||||
|
}
|
||||||
|
json_out($resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Dépôt d'un commentaire -------------------------------------------
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$me = require_login();
|
||||||
|
if ($me['role'] !== 'super' && empty(user_group_ids((int) $me['id']))) {
|
||||||
|
json_error('Vous devez appartenir à un groupe pour commenter.', 403);
|
||||||
|
}
|
||||||
|
$body = trim((string) (body_json()['body'] ?? ''));
|
||||||
|
if ($body === '') {
|
||||||
|
json_error('Le commentaire est vide.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemId > 0) {
|
||||||
|
// Commentaire sur un document : on récupère l'UAI de l'élément.
|
||||||
|
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
|
||||||
|
$st->execute([$itemId]);
|
||||||
|
$uai = $st->fetchColumn();
|
||||||
|
if ($uai === false) {
|
||||||
|
json_error('Document introuvable.', 404);
|
||||||
|
}
|
||||||
|
db()->prepare('INSERT INTO comments (uai, item_id, user_id, body) VALUES (?, ?, ?, ?)')
|
||||||
|
->execute([$uai, $itemId, (int) $me['id'], $body]);
|
||||||
|
} else {
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai ou item_id requis.');
|
||||||
|
}
|
||||||
|
$chk = db()->prepare('SELECT 1 FROM etablissements WHERE id = ?');
|
||||||
|
$chk->execute([$uai]);
|
||||||
|
if (!$chk->fetchColumn()) {
|
||||||
|
json_error('Établissement inconnu.', 404);
|
||||||
|
}
|
||||||
|
db()->prepare('INSERT INTO comments (uai, user_id, body) VALUES (?, ?, ?)')
|
||||||
|
->execute([$uai, (int) $me['id'], $body]);
|
||||||
|
}
|
||||||
|
json_out(['ok' => true, 'id' => (int) db()->lastInsertId()], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Suppression (auteur ou super) ------------------------------------
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$me = require_login();
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
$st = db()->prepare('SELECT user_id FROM comments WHERE id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$owner = $st->fetchColumn();
|
||||||
|
if ($owner === false) {
|
||||||
|
json_error('Commentaire introuvable.', 404);
|
||||||
|
}
|
||||||
|
if ($me['role'] !== 'super' && (int) $owner !== (int) $me['id']) {
|
||||||
|
json_error('Vous ne pouvez supprimer que vos propres commentaires.', 403);
|
||||||
|
}
|
||||||
|
db()->prepare('DELETE FROM comments WHERE id = ?')->execute([$id]);
|
||||||
|
json_out(['deleted' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_error('Méthode non supportée.', 405);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Autocomplétion des communes (pour affecter un périmètre à un admin).
|
||||||
|
* GET ?q=... → jusqu'à 30 couples commune + département distincts.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_super();
|
||||||
|
$q = trim((string) ($_GET['q'] ?? ''));
|
||||||
|
if (strlen($q) < 2) {
|
||||||
|
json_out([]);
|
||||||
|
}
|
||||||
|
$st = db()->prepare(
|
||||||
|
'SELECT com, depc, dep, COUNT(*) AS nb
|
||||||
|
FROM etablissements
|
||||||
|
WHERE com LIKE ?
|
||||||
|
GROUP BY com, depc, dep
|
||||||
|
ORDER BY com
|
||||||
|
LIMIT 30'
|
||||||
|
);
|
||||||
|
$st->execute([$q . '%']);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'com' => $r['com'],
|
||||||
|
'depc' => $r['depc'],
|
||||||
|
'dep' => $r['dep'],
|
||||||
|
'nb' => (int) $r['nb'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
+375
@@ -0,0 +1,375 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Socle commun : connexion PDO MySQL, sorties JSON, session/authentification,
|
||||||
|
* et import des établissements (partagé entre setup.php et api/import.php).
|
||||||
|
*
|
||||||
|
* Identifiants par défaut de Laragon (root / mot de passe vide).
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const DB_HOST = '127.0.0.1';
|
||||||
|
const DB_PORT = 3306;
|
||||||
|
const DB_NAME = 'ecoles';
|
||||||
|
const DB_USER = 'root';
|
||||||
|
const DB_PASS = '';
|
||||||
|
|
||||||
|
// --- Application & envoi d'e-mails -----------------------------------------
|
||||||
|
// URL publique de base (pour les liens de réinitialisation). Adaptez si besoin.
|
||||||
|
const APP_BASE_URL = 'http://ecoles.test';
|
||||||
|
// Transport du mailer : 'log' (écrit dans private/mail_outbox.log, défaut local et sûr),
|
||||||
|
// 'mail' (fonction PHP mail()), ou 'smtp' (client SMTP intégré ci-dessous).
|
||||||
|
const MAIL_TRANSPORT = 'log';
|
||||||
|
const MAIL_FROM = 'no-reply@ecoles.test';
|
||||||
|
const MAIL_FROM_NAME = 'Carte des établissements';
|
||||||
|
// Paramètres SMTP (utilisés seulement si MAIL_TRANSPORT === 'smtp').
|
||||||
|
const SMTP_HOST = '';
|
||||||
|
const SMTP_PORT = 587;
|
||||||
|
const SMTP_USER = '';
|
||||||
|
const SMTP_PASS = '';
|
||||||
|
const SMTP_SECURE = 'tls'; // 'tls' (STARTTLS, port 587) | 'ssl' (port 465) | '' (aucun)
|
||||||
|
|
||||||
|
/** Connexion partagée à la base applicative. */
|
||||||
|
function db(): PDO
|
||||||
|
{
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo instanceof PDO) {
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
|
||||||
|
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sorties JSON
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Émet une réponse JSON et arrête le script. */
|
||||||
|
function json_out($data, int $status = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Émet une erreur JSON et arrête le script. */
|
||||||
|
function json_error(string $message, int $status = 400): void
|
||||||
|
{
|
||||||
|
json_out(['error' => $message], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corps de requête JSON décodé en tableau. */
|
||||||
|
function body_json(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw ?: '', true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session / authentification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function session_boot(): void
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utilisateur connecté (id, username, role) ou null. */
|
||||||
|
function current_user(): ?array
|
||||||
|
{
|
||||||
|
static $user = false;
|
||||||
|
if ($user !== false) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
session_boot();
|
||||||
|
if (empty($_SESSION['uid'])) {
|
||||||
|
return $user = null;
|
||||||
|
}
|
||||||
|
$st = db()->prepare('SELECT id, username, role, must_change FROM users WHERE id = ?');
|
||||||
|
$st->execute([$_SESSION['uid']]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if ($row) {
|
||||||
|
$row['id'] = (int) $row['id'];
|
||||||
|
$row['must_change'] = (int) $row['must_change'];
|
||||||
|
}
|
||||||
|
return $user = ($row ?: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exige une connexion ; renvoie l'utilisateur ou répond 401. */
|
||||||
|
function require_login(): array
|
||||||
|
{
|
||||||
|
$u = current_user();
|
||||||
|
if (!$u) {
|
||||||
|
json_error('Authentification requise.', 401);
|
||||||
|
}
|
||||||
|
return $u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exige le rôle super-admin ; renvoie l'utilisateur ou répond 403. */
|
||||||
|
function require_super(): array
|
||||||
|
{
|
||||||
|
$u = require_login();
|
||||||
|
if ($u['role'] !== 'super') {
|
||||||
|
json_error('Réservé à 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;
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* DPE (ADEME) rattachés à un établissement, par proximité géographique.
|
||||||
|
* GET ?uai=UAI → [ { numero_dpe, etiquette_dpe, etiquette_ges, conso, ges,
|
||||||
|
* surface, periode, cat_erp, date_dpe, date_fin,
|
||||||
|
* adresse, distance_m }, ... ] trié par distance croissante
|
||||||
|
* POST ?action=latest body { uais:[...] } → { uai: { dpe, ges } } meilleur match (le + proche)
|
||||||
|
*
|
||||||
|
* Rattachement calculé par match_dpe.php (table etab_dpe). Source : import_dpe.php.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Batch : meilleur DPE (le plus proche) pour une liste d'UAI → { uai: {dpe, ges} }.
|
||||||
|
if (($_GET['action'] ?? '') === 'latest') {
|
||||||
|
$uais = body_json()['uais'] ?? [];
|
||||||
|
if (!is_array($uais) || !$uais) {
|
||||||
|
json_out([]);
|
||||||
|
}
|
||||||
|
$uais = array_slice(array_values(array_unique(array_map('strval', $uais))), 0, 5000);
|
||||||
|
$ph = implode(',', array_fill(0, count($uais), '?'));
|
||||||
|
// Pour chaque UAI, le rattachement de plus petite distance. En cas
|
||||||
|
// d'ex æquo, la boucle PHP ci-dessous ne garde qu'une ligne par UAI
|
||||||
|
// (pas de GROUP BY ⇒ compatible only_full_group_by, MySQL et MariaDB).
|
||||||
|
$sql = "SELECT ed.uai, d.etiquette_dpe, d.etiquette_ges
|
||||||
|
FROM etab_dpe ed
|
||||||
|
JOIN dpe d ON d.numero_dpe = ed.numero_dpe
|
||||||
|
JOIN (SELECT uai, MIN(distance_m) AS dmin
|
||||||
|
FROM etab_dpe WHERE uai IN ($ph) GROUP BY uai) m
|
||||||
|
ON m.uai = ed.uai AND m.dmin = ed.distance_m";
|
||||||
|
$st = db()->prepare($sql);
|
||||||
|
$st->execute($uais);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[$r['uai']] = ['dpe' => $r['etiquette_dpe'], 'ges' => $r['etiquette_ges']];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai requis.');
|
||||||
|
}
|
||||||
|
$st = db()->prepare(
|
||||||
|
'SELECT d.numero_dpe, d.etiquette_dpe, d.etiquette_ges, d.conso, d.ges,
|
||||||
|
d.surface, d.periode, d.cat_erp, d.date_dpe, d.date_fin, d.adresse,
|
||||||
|
ed.distance_m
|
||||||
|
FROM etab_dpe ed
|
||||||
|
JOIN dpe d ON d.numero_dpe = ed.numero_dpe
|
||||||
|
WHERE ed.uai = ?
|
||||||
|
ORDER BY ed.distance_m'
|
||||||
|
);
|
||||||
|
$st->execute([$uai]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'numero_dpe' => $r['numero_dpe'],
|
||||||
|
'etiquette_dpe' => $r['etiquette_dpe'],
|
||||||
|
'etiquette_ges' => $r['etiquette_ges'],
|
||||||
|
'conso' => $r['conso'] !== null ? (float) $r['conso'] : null,
|
||||||
|
'ges' => $r['ges'] !== null ? (float) $r['ges'] : null,
|
||||||
|
'surface' => $r['surface'] !== null ? (int) $r['surface'] : null,
|
||||||
|
'periode' => $r['periode'],
|
||||||
|
'cat_erp' => $r['cat_erp'],
|
||||||
|
'date_dpe' => $r['date_dpe'],
|
||||||
|
'date_fin' => $r['date_fin'],
|
||||||
|
'adresse' => $r['adresse'],
|
||||||
|
'distance_m' => (int) $r['distance_m'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Bibliothèque partagée DPE (ADEME) : création des tables, import du dataset
|
||||||
|
* tertiaire et rattachement géographique aux établissements.
|
||||||
|
*
|
||||||
|
* Utilisée par les scripts CLI (import_dpe.php, match_dpe.php) et par
|
||||||
|
* l'endpoint web super-admin (api/import_dpe.php), pour éviter toute
|
||||||
|
* divergence de logique.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
const DPE_DATASET = 'https://data.ademe.fr/data-fair/api/v1/datasets/dpe01tertiaire/lines';
|
||||||
|
const DPE_FIELDS = 'numero_dpe,etiquette_dpe,etiquette_ges,conso_kwhep_m2_an,'
|
||||||
|
. 'emission_ges_kg_co2_m2_an,surface_utile,periode_construction,categorie_erp,'
|
||||||
|
. 'date_etablissement_dpe,date_fin_validite_dpe,code_insee_ban,code_postal_ban,'
|
||||||
|
. 'adresse_ban,nom_commune_ban,_geopoint';
|
||||||
|
|
||||||
|
/** GET JSON via cURL (suit les redirections). */
|
||||||
|
function dpe_fetch_json(string $url): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
CURLOPT_USERAGENT => 'ecoles-import/1.0',
|
||||||
|
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
throw new RuntimeException('Requête échouée : ' . $err);
|
||||||
|
}
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($code >= 400) {
|
||||||
|
throw new RuntimeException("HTTP $code sur l'API ADEME.");
|
||||||
|
}
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
throw new RuntimeException('Réponse JSON ADEME invalide.');
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée la table « dpe » si absente. */
|
||||||
|
function dpe_create_table(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS dpe (
|
||||||
|
numero_dpe VARCHAR(20) NOT NULL PRIMARY KEY,
|
||||||
|
etiquette_dpe CHAR(1) NULL,
|
||||||
|
etiquette_ges CHAR(1) NULL,
|
||||||
|
conso DECIMAL(10,1) NULL, -- conso_kwhep_m2_an (énergie primaire kWh/m²/an)
|
||||||
|
ges DECIMAL(10,2) NULL, -- emission_ges_kg_co2_m2_an
|
||||||
|
surface INT NULL, -- surface_utile (m²)
|
||||||
|
periode VARCHAR(40) NULL, -- periode_construction
|
||||||
|
cat_erp VARCHAR(60) NULL, -- categorie_erp
|
||||||
|
date_dpe DATE NULL, -- date_etablissement_dpe
|
||||||
|
date_fin DATE NULL, -- date_fin_validite_dpe
|
||||||
|
code_insee VARCHAR(5) NULL, -- code_insee_ban (clé de rattachement commune)
|
||||||
|
cp VARCHAR(5) NULL,
|
||||||
|
adresse VARCHAR(255) NULL,
|
||||||
|
commune VARCHAR(120) NULL,
|
||||||
|
lat DECIMAL(9,6) NULL,
|
||||||
|
lon DECIMAL(9,6) NULL,
|
||||||
|
INDEX idx_insee (code_insee)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importe les DPE tertiaires de l'ADEME dans la table « dpe ».
|
||||||
|
* @param string|null $dep filtre département (code) ou null pour tout
|
||||||
|
* @param callable $log fn(string) pour la progression
|
||||||
|
* @return int nombre de DPE importés
|
||||||
|
*/
|
||||||
|
function dpe_import(PDO $pdo, ?string $dep, callable $log): int
|
||||||
|
{
|
||||||
|
dpe_create_table($pdo);
|
||||||
|
|
||||||
|
$url = DPE_DATASET . '?size=10000&sort=numero_dpe&select=' . rawurlencode(DPE_FIELDS);
|
||||||
|
if ($dep !== null && $dep !== '') {
|
||||||
|
$url .= '&qs=' . rawurlencode('code_departement_ban:"' . $dep . '"');
|
||||||
|
$log("Filtre département = $dep.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ins = $pdo->prepare(
|
||||||
|
'REPLACE INTO dpe
|
||||||
|
(numero_dpe, etiquette_dpe, etiquette_ges, conso, ges, surface, periode,
|
||||||
|
cat_erp, date_dpe, date_fin, code_insee, cp, adresse, commune, lat, lon)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$letter = static fn($v) => ($v !== null && $v !== '') ? strtoupper(substr((string) $v, 0, 1)) : null;
|
||||||
|
$date = static fn($v) => (is_string($v) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) ? $v : null;
|
||||||
|
$numOrNull = static fn($v) => ($v === null || $v === '') ? null : (float) $v;
|
||||||
|
|
||||||
|
$page = 0;
|
||||||
|
$n = 0;
|
||||||
|
$total = null;
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
while ($url) {
|
||||||
|
$res = dpe_fetch_json($url);
|
||||||
|
$total ??= $res['total'] ?? null;
|
||||||
|
$rows = $res['results'] ?? [];
|
||||||
|
if (!$rows) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$id = $r['numero_dpe'] ?? '';
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$lat = $lon = null;
|
||||||
|
if (!empty($r['_geopoint']) && str_contains($r['_geopoint'], ',')) {
|
||||||
|
[$lat, $lon] = array_map('floatval', explode(',', $r['_geopoint'], 2));
|
||||||
|
}
|
||||||
|
$ins->execute([
|
||||||
|
substr($id, 0, 20),
|
||||||
|
$letter($r['etiquette_dpe'] ?? null),
|
||||||
|
$letter($r['etiquette_ges'] ?? null),
|
||||||
|
$numOrNull($r['conso_kwhep_m2_an'] ?? null),
|
||||||
|
$numOrNull($r['emission_ges_kg_co2_m2_an'] ?? null),
|
||||||
|
isset($r['surface_utile']) && $r['surface_utile'] !== '' ? (int) round((float) $r['surface_utile']) : null,
|
||||||
|
$r['periode_construction'] ?? null,
|
||||||
|
$r['categorie_erp'] ?? null,
|
||||||
|
$date($r['date_etablissement_dpe'] ?? null),
|
||||||
|
$date($r['date_fin_validite_dpe'] ?? null),
|
||||||
|
$r['code_insee_ban'] ?? null,
|
||||||
|
$r['code_postal_ban'] ?? null,
|
||||||
|
$r['adresse_ban'] ?? null,
|
||||||
|
$r['nom_commune_ban'] ?? null,
|
||||||
|
$lat, $lon,
|
||||||
|
]);
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$page++;
|
||||||
|
$log(sprintf('Page %d — %d DPE importés%s', $page, $n, $total ? " / $total" : ''));
|
||||||
|
|
||||||
|
// Page suivante (curseur). data-fair renvoie l'URL complète dans « next ».
|
||||||
|
$url = $res['next'] ?? null;
|
||||||
|
if ($url) {
|
||||||
|
usleep(800000); // ménage le quota anonyme de l'API (20 s de traitement / 60 s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée la table de rattachement « etab_dpe » si absente. */
|
||||||
|
function dpe_ensure_link_table(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS etab_dpe (
|
||||||
|
uai VARCHAR(12) NOT NULL,
|
||||||
|
numero_dpe VARCHAR(20) NOT NULL,
|
||||||
|
distance_m SMALLINT NOT NULL, -- distance école↔bâtiment (m)
|
||||||
|
PRIMARY KEY (uai, numero_dpe),
|
||||||
|
INDEX idx_uai (uai)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattache les DPE aux établissements par proximité (même commune, ≤ $radius m).
|
||||||
|
* @return array{pairs:int, ecoles:int}
|
||||||
|
*/
|
||||||
|
function dpe_match(PDO $pdo, int $radius, callable $log): array
|
||||||
|
{
|
||||||
|
foreach (['dpe', 'etablissements'] as $t) {
|
||||||
|
if (!$pdo->query('SHOW TABLES LIKE ' . $pdo->quote($t))->fetchColumn()) {
|
||||||
|
throw new RuntimeException("Table « $t » absente — lancez d'abord l'import.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$pdo->query("SHOW COLUMNS FROM etablissements LIKE 'comc'")->fetchColumn()) {
|
||||||
|
throw new RuntimeException("Colonne « comc » absente — lancez d'abord import_comc.php.");
|
||||||
|
}
|
||||||
|
|
||||||
|
dpe_ensure_link_table($pdo);
|
||||||
|
$log("Rattachement (rayon = {$radius} m)…");
|
||||||
|
$pdo->exec('TRUNCATE TABLE etab_dpe');
|
||||||
|
|
||||||
|
// Jointure restreinte à la commune (index idx_insee / idx_comc) puis filtre
|
||||||
|
// par distance sphérique (ST_Distance_Sphere ⇒ MySQL 5.7.6+ / MariaDB 10.2.4+).
|
||||||
|
$sql =
|
||||||
|
'INSERT INTO etab_dpe (uai, numero_dpe, distance_m)
|
||||||
|
SELECT e.id, d.numero_dpe,
|
||||||
|
ROUND(ST_Distance_Sphere(POINT(e.lon, e.lat), POINT(d.lon, d.lat)))
|
||||||
|
FROM etablissements e
|
||||||
|
JOIN dpe d ON d.code_insee = e.comc
|
||||||
|
WHERE e.comc IS NOT NULL AND e.lat IS NOT NULL AND e.lon IS NOT NULL
|
||||||
|
AND d.lat IS NOT NULL AND d.lon IS NOT NULL
|
||||||
|
AND ST_Distance_Sphere(POINT(e.lon, e.lat), POINT(d.lon, d.lat)) <= ?';
|
||||||
|
$st = $pdo->prepare($sql);
|
||||||
|
$st->execute([$radius]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pairs' => (int) $pdo->query('SELECT COUNT(*) FROM etab_dpe')->fetchColumn(),
|
||||||
|
'ecoles' => (int) $pdo->query('SELECT COUNT(DISTINCT uai) FROM etab_dpe')->fetchColumn(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Historique des effectifs et nombres de classes d'une école (1er degré).
|
||||||
|
* GET ?uai=UAI → [ { annee, eleves, classes }, ... ] trié par année croissante
|
||||||
|
* GET ?action=commune&uai=UAI → [ { annee, eleves }, ... ] effectifs cumulés de la commune
|
||||||
|
* de l'établissement (somme de toutes ses écoles par année)
|
||||||
|
* GET ?action=latest (POST) → { uai: eleves } dernier effectif (batch)
|
||||||
|
*
|
||||||
|
* Source : dataset « fr-en-ecoles-effectifs-nb_classes » (cf. import_effectifs.php).
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Effectifs cumulés de la commune : somme des élèves des écoles PUBLIQUES
|
||||||
|
// de la même commune (com + depc), par année.
|
||||||
|
if (($_GET['action'] ?? '') === 'commune') {
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai requis.');
|
||||||
|
}
|
||||||
|
$e = db()->prepare('SELECT com, depc FROM etablissements WHERE id = ?');
|
||||||
|
$e->execute([$uai]);
|
||||||
|
$loc = $e->fetch();
|
||||||
|
if (!$loc || $loc['com'] === null || $loc['depc'] === null) {
|
||||||
|
json_out([]);
|
||||||
|
}
|
||||||
|
$st = db()->prepare(
|
||||||
|
"SELECT ef.annee, SUM(ef.eleves) AS eleves
|
||||||
|
FROM effectifs ef
|
||||||
|
JOIN etablissements e ON e.id = ef.uai
|
||||||
|
WHERE e.com = ? AND e.depc = ? AND e.stat = 'Public'
|
||||||
|
GROUP BY ef.annee
|
||||||
|
ORDER BY ef.annee"
|
||||||
|
);
|
||||||
|
$st->execute([$loc['com'], $loc['depc']]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = ['annee' => (int) $r['annee'], 'eleves' => (int) $r['eleves']];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch : dernier effectif connu pour une liste d'UAI → { uai: eleves }.
|
||||||
|
if (($_GET['action'] ?? '') === 'latest') {
|
||||||
|
$uais = body_json()['uais'] ?? [];
|
||||||
|
if (!is_array($uais) || !$uais) {
|
||||||
|
json_out([]);
|
||||||
|
}
|
||||||
|
$uais = array_slice(array_values(array_unique(array_map('strval', $uais))), 0, 5000);
|
||||||
|
$ph = implode(',', array_fill(0, count($uais), '?'));
|
||||||
|
$sql = "SELECT ef.uai, ef.eleves
|
||||||
|
FROM effectifs ef
|
||||||
|
JOIN (SELECT uai, MAX(annee) AS y FROM effectifs WHERE uai IN ($ph) GROUP BY uai) m
|
||||||
|
ON m.uai = ef.uai AND m.y = ef.annee";
|
||||||
|
$st = db()->prepare($sql);
|
||||||
|
$st->execute($uais);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[$r['uai']] = (int) $r['eleves'];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai requis.');
|
||||||
|
}
|
||||||
|
$st = db()->prepare('SELECT annee, eleves, classes FROM effectifs WHERE uai = ? ORDER BY annee');
|
||||||
|
$st->execute([$uai]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'annee' => (int) $r['annee'],
|
||||||
|
'eleves' => (int) $r['eleves'],
|
||||||
|
'classes' => (int) $r['classes'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET api/etablissements.php
|
||||||
|
* Renvoie tous les établissements au format attendu par la carte (clés courtes).
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cols = 'id,nom,type,stat,reg,aca,dep,depc,com,cp,adr,tel,mail,web,etat,circ,lat,lon,rest,heb,ulis,segp';
|
||||||
|
$stmt = db()->query("SELECT $cols FROM etablissements");
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
while ($r = $stmt->fetch()) {
|
||||||
|
$r['lat'] = $r['lat'] !== null ? (float) $r['lat'] : null;
|
||||||
|
$r['lon'] = $r['lon'] !== null ? (float) $r['lon'] : null;
|
||||||
|
foreach (['rest', 'heb', 'ulis', 'segp'] as $k) {
|
||||||
|
$r[$k] = (int) $r[$k];
|
||||||
|
}
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
json_out($rows);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Base de données : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Arborescence globale de dossiers (gérée par le super-admin).
|
||||||
|
* GET → liste à plat de tous les dossiers (public)
|
||||||
|
* POST {name, parent_id?} → crée un dossier (super-admin)
|
||||||
|
* PUT {id, name?, parent_id?, position?} → modifie (super-admin)
|
||||||
|
* DELETE ?id=N → supprime (et ses sous-dossiers) (super-admin)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = db()->query('SELECT id, parent_id, name, position FROM folders ORDER BY position, name')->fetchAll();
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['id'] = (int) $r['id'];
|
||||||
|
$r['parent_id'] = $r['parent_id'] !== null ? (int) $r['parent_id'] : null;
|
||||||
|
$r['position'] = (int) $r['position'];
|
||||||
|
}
|
||||||
|
json_out($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_super();
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$b = body_json();
|
||||||
|
$name = trim((string) ($b['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
json_error('Nom de dossier requis.');
|
||||||
|
}
|
||||||
|
$parent = isset($b['parent_id']) && $b['parent_id'] !== null && $b['parent_id'] !== ''
|
||||||
|
? (int) $b['parent_id'] : null;
|
||||||
|
$pos = (int) ($b['position'] ?? 0);
|
||||||
|
$st = db()->prepare('INSERT INTO folders (parent_id, name, position) VALUES (?, ?, ?)');
|
||||||
|
$st->execute([$parent, $name, $pos]);
|
||||||
|
json_out(['id' => (int) db()->lastInsertId(), 'parent_id' => $parent, 'name' => $name, 'position' => $pos], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$b = body_json();
|
||||||
|
$id = (int) ($b['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
$sets = [];
|
||||||
|
$args = [];
|
||||||
|
if (array_key_exists('name', $b)) {
|
||||||
|
$name = trim((string) $b['name']);
|
||||||
|
if ($name === '') {
|
||||||
|
json_error('Nom invalide.');
|
||||||
|
}
|
||||||
|
$sets[] = 'name = ?';
|
||||||
|
$args[] = $name;
|
||||||
|
}
|
||||||
|
if (array_key_exists('parent_id', $b)) {
|
||||||
|
$parent = ($b['parent_id'] === null || $b['parent_id'] === '') ? null : (int) $b['parent_id'];
|
||||||
|
if ($parent === $id) {
|
||||||
|
json_error('Un dossier ne peut pas être son propre parent.');
|
||||||
|
}
|
||||||
|
$sets[] = 'parent_id = ?';
|
||||||
|
$args[] = $parent;
|
||||||
|
}
|
||||||
|
if (array_key_exists('position', $b)) {
|
||||||
|
$sets[] = 'position = ?';
|
||||||
|
$args[] = (int) $b['position'];
|
||||||
|
}
|
||||||
|
if (!$sets) {
|
||||||
|
json_error('Rien à modifier.');
|
||||||
|
}
|
||||||
|
$args[] = $id;
|
||||||
|
db()->prepare('UPDATE folders SET ' . implode(', ', $sets) . ' WHERE id = ?')->execute($args);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('DELETE FROM folders WHERE id = ?')->execute([$id]);
|
||||||
|
json_out(['deleted' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_error('Méthode non supportée.', 405);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gestion des groupes d'utilisateurs (super-admin uniquement).
|
||||||
|
*
|
||||||
|
* Un groupe rassemble des utilisateurs. S'il est « valideur » (can_validate=1),
|
||||||
|
* ses membres peuvent valider les documents des établissements de ses communes
|
||||||
|
* (group_scopes). Les commentaires de fiche sont visibles entre membres d'un
|
||||||
|
* même groupe (voir api/comments.php).
|
||||||
|
*
|
||||||
|
* GET → liste des groupes (membres + communes)
|
||||||
|
* POST {name, can_validate?} → crée un groupe
|
||||||
|
* POST ?action=toggle {id, can_validate} → (dés)active le droit de validation
|
||||||
|
* POST ?action=member {group_id, user_id} → ajoute un membre
|
||||||
|
* POST ?action=scope {group_id, com, depc}→ ajoute une commune au périmètre
|
||||||
|
* DELETE ?id=N → supprime un groupe
|
||||||
|
* DELETE ?action=member&group_id=G&user_id=U → retire un membre
|
||||||
|
* DELETE ?action=scope&id=N → retire une commune
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_super();
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// ---- Liste des groupes (avec membres et communes) ---------------------
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$groups = db()->query('SELECT id, name, can_validate, created_at FROM user_groups ORDER BY name')->fetchAll();
|
||||||
|
$members = db()->query(
|
||||||
|
'SELECT gm.group_id, u.id, u.username
|
||||||
|
FROM group_members gm JOIN users u ON u.id = gm.user_id
|
||||||
|
ORDER BY u.username'
|
||||||
|
)->fetchAll();
|
||||||
|
$scopes = db()->query('SELECT id, group_id, com, depc FROM group_scopes ORDER BY com')->fetchAll();
|
||||||
|
|
||||||
|
$byMembers = [];
|
||||||
|
foreach ($members as $m) {
|
||||||
|
$byMembers[(int) $m['group_id']][] = ['id' => (int) $m['id'], 'username' => $m['username']];
|
||||||
|
}
|
||||||
|
$byScopes = [];
|
||||||
|
foreach ($scopes as $s) {
|
||||||
|
$byScopes[(int) $s['group_id']][] = ['id' => (int) $s['id'], 'com' => $s['com'], 'depc' => $s['depc']];
|
||||||
|
}
|
||||||
|
foreach ($groups as &$g) {
|
||||||
|
$g['id'] = (int) $g['id'];
|
||||||
|
$g['can_validate'] = (int) $g['can_validate'];
|
||||||
|
$g['members'] = $byMembers[$g['id']] ?? [];
|
||||||
|
$g['scopes'] = $byScopes[$g['id']] ?? [];
|
||||||
|
}
|
||||||
|
json_out($groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Bascule du droit de validation -----------------------------------
|
||||||
|
if ($method === 'POST' && $action === 'toggle') {
|
||||||
|
$b = body_json();
|
||||||
|
$id = (int) ($b['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
$can = !empty($b['can_validate']) ? 1 : 0;
|
||||||
|
db()->prepare('UPDATE user_groups SET can_validate = ? WHERE id = ?')->execute([$can, $id]);
|
||||||
|
json_out(['ok' => true, 'can_validate' => $can]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ajout d'un membre ------------------------------------------------
|
||||||
|
if ($method === 'POST' && $action === 'member') {
|
||||||
|
$b = body_json();
|
||||||
|
$gid = (int) ($b['group_id'] ?? 0);
|
||||||
|
$uid = (int) ($b['user_id'] ?? 0);
|
||||||
|
if ($gid <= 0 || $uid <= 0) {
|
||||||
|
json_error('group_id et user_id requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('INSERT IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
|
||||||
|
->execute([$gid, $uid]);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ajout d'une commune au périmètre ---------------------------------
|
||||||
|
if ($method === 'POST' && $action === 'scope') {
|
||||||
|
$b = body_json();
|
||||||
|
$gid = (int) ($b['group_id'] ?? 0);
|
||||||
|
$com = trim((string) ($b['com'] ?? ''));
|
||||||
|
$depc = trim((string) ($b['depc'] ?? ''));
|
||||||
|
if ($gid <= 0 || $com === '' || $depc === '') {
|
||||||
|
json_error('group_id, com et depc requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('INSERT IGNORE INTO group_scopes (group_id, com, depc) VALUES (?, ?, ?)')
|
||||||
|
->execute([$gid, $com, $depc]);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Création d'un groupe ---------------------------------------------
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$b = body_json();
|
||||||
|
$name = trim((string) ($b['name'] ?? ''));
|
||||||
|
$can = !empty($b['can_validate']) ? 1 : 0;
|
||||||
|
if ($name === '') {
|
||||||
|
json_error('Nom de groupe requis.');
|
||||||
|
}
|
||||||
|
$exists = db()->prepare('SELECT 1 FROM user_groups WHERE name = ?');
|
||||||
|
$exists->execute([$name]);
|
||||||
|
if ($exists->fetchColumn()) {
|
||||||
|
json_error('Ce nom de groupe existe déjà.');
|
||||||
|
}
|
||||||
|
db()->prepare('INSERT INTO user_groups (name, can_validate) VALUES (?, ?)')->execute([$name, $can]);
|
||||||
|
json_out(['id' => (int) db()->lastInsertId(), 'name' => $name, 'can_validate' => $can], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Retrait d'un membre ----------------------------------------------
|
||||||
|
if ($method === 'DELETE' && $action === 'member') {
|
||||||
|
$gid = (int) ($_GET['group_id'] ?? 0);
|
||||||
|
$uid = (int) ($_GET['user_id'] ?? 0);
|
||||||
|
if ($gid <= 0 || $uid <= 0) {
|
||||||
|
json_error('group_id et user_id requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?')->execute([$gid, $uid]);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Retrait d'une commune --------------------------------------------
|
||||||
|
if ($method === 'DELETE' && $action === 'scope') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('DELETE FROM group_scopes WHERE id = ?')->execute([$id]);
|
||||||
|
json_out(['deleted' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Suppression d'un groupe ------------------------------------------
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('DELETE FROM user_groups WHERE id = ?')->execute([$id]);
|
||||||
|
json_out(['deleted' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_error('Méthode non supportée.', 405);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import de la dernière version de l'opendata (super-admin).
|
||||||
|
* POST → télécharge l'export de l'annuaire et met à jour la base.
|
||||||
|
*
|
||||||
|
* Nécessite un accès Internet sortant côté serveur.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
const OPENDATA_URL =
|
||||||
|
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-annuaire-education/exports/json'
|
||||||
|
. '?select=identifiant_de_l_etablissement,nom_etablissement,type_etablissement,statut_public_prive,'
|
||||||
|
. 'libelle_region,libelle_academie,libelle_departement,code_departement,nom_commune,code_postal,adresse_1,'
|
||||||
|
. 'telephone,mail,web,etat,nom_circonscription,restauration,hebergement,ulis,segpa,apprentissage,'
|
||||||
|
. 'latitude,longitude';
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_super();
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
json_error('Méthode non supportée.', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_time_limit(0);
|
||||||
|
ini_set('memory_limit', '1024M');
|
||||||
|
|
||||||
|
// Téléchargement.
|
||||||
|
$raw = false;
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
$ch = curl_init(OPENDATA_URL);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 600,
|
||||||
|
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||||
|
CURLOPT_USERAGENT => 'ecoles-import/1.0',
|
||||||
|
]);
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
if ($raw === false) {
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
throw new RuntimeException('Téléchargement échoué : ' . $err);
|
||||||
|
}
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($code !== 200) {
|
||||||
|
throw new RuntimeException("Réponse HTTP $code de l'opendata.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$raw = @file_get_contents(OPENDATA_URL);
|
||||||
|
if ($raw === false) {
|
||||||
|
throw new RuntimeException('Téléchargement échoué (file_get_contents).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = json_decode($raw, true);
|
||||||
|
unset($raw);
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
throw new RuntimeException('Réponse opendata illisible.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformation (clés courtes, géoloc obligatoire).
|
||||||
|
$clean = [];
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$rec = map_opendata_row($r);
|
||||||
|
if ($rec === null || empty($rec['id'])) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$clean[] = $rec;
|
||||||
|
}
|
||||||
|
unset($rows);
|
||||||
|
|
||||||
|
$n = import_etablissements(db(), $clean);
|
||||||
|
$total = (int) db()->query('SELECT COUNT(*) FROM etablissements')->fetchColumn();
|
||||||
|
|
||||||
|
json_out([
|
||||||
|
'ok' => true,
|
||||||
|
'imported' => $n,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Import : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import des DPE ADEME + rattachement aux établissements (super-admin).
|
||||||
|
* POST body { dep?: "75", radius?: 80 }
|
||||||
|
* → télécharge le dataset DPE tertiaire, remplit « dpe », puis rattache
|
||||||
|
* les DPE aux écoles par proximité (table « etab_dpe »).
|
||||||
|
*
|
||||||
|
* ⚠ Opération longue (le dataset complet ≈ 532 000 DPE) : à réserver à un
|
||||||
|
* import ponctuel. Pour de gros volumes, préférez les scripts CLI
|
||||||
|
* (php import_dpe.php ; php match_dpe.php). Accès Internet requis côté serveur.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/dpe_lib.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_super();
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
json_error('Méthode non supportée.', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_time_limit(0);
|
||||||
|
ini_set('memory_limit', '512M');
|
||||||
|
|
||||||
|
$in = body_json();
|
||||||
|
$dep = isset($in['dep']) && $in['dep'] !== '' ? preg_replace('/[^0-9AB]/', '', strtoupper((string) $in['dep'])) : null;
|
||||||
|
$radius = isset($in['radius']) ? max(1, min(2000, (int) $in['radius'])) : 5;
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$imported = dpe_import($pdo, $dep, static fn(string $m) => null);
|
||||||
|
$geo = (int) $pdo->query('SELECT COUNT(*) FROM dpe WHERE lat IS NOT NULL')->fetchColumn();
|
||||||
|
$match = dpe_match($pdo, $radius, static fn(string $m) => null);
|
||||||
|
|
||||||
|
json_out([
|
||||||
|
'ok' => true,
|
||||||
|
'imported' => $imported,
|
||||||
|
'geocoded' => $geo,
|
||||||
|
'radius' => $radius,
|
||||||
|
'links' => $match['pairs'],
|
||||||
|
'etabs' => $match['ecoles'],
|
||||||
|
'dep' => $dep,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
json_error('Import DPE : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Historique de l'IPS (indice de position sociale) d'une école (1er degré).
|
||||||
|
* GET ?uai=UAI → [ { annee, ips, ips_dep }, ... ] trié par année croissante
|
||||||
|
* ips = IPS de l'établissement
|
||||||
|
* ips_dep = IPS moyen départemental (référence), ou null
|
||||||
|
*
|
||||||
|
* Source : dataset « fr-en-ips-ecoles-ap2022 » (cf. import_ips.php).
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai requis.');
|
||||||
|
}
|
||||||
|
// La table peut être absente si l'import n'a pas encore été lancé.
|
||||||
|
if (!db()->query("SHOW TABLES LIKE 'ips'")->fetchColumn()) {
|
||||||
|
json_out([]);
|
||||||
|
}
|
||||||
|
$st = db()->prepare('SELECT annee, ips, ips_dep FROM ips WHERE uai = ? ORDER BY annee');
|
||||||
|
$st->execute([$uai]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'annee' => (int) $r['annee'],
|
||||||
|
'ips' => (float) $r['ips'],
|
||||||
|
'ips_dep' => $r['ips_dep'] !== null ? (float) $r['ips_dep'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Population des zones IRIS (recensement INSEE 2022).
|
||||||
|
*
|
||||||
|
* GET ?code=<code_iris>&com=<code_commune>
|
||||||
|
* → { iris_pop, com_pop, com_name } pour un IRIS et sa commune
|
||||||
|
* POST ?action=batch body {codes:[...]}
|
||||||
|
* → { "<code_iris>": pop, ... } pour colorer la couche
|
||||||
|
*
|
||||||
|
* Population communale = somme des IRIS de la commune. Pour Paris, Lyon et
|
||||||
|
* Marseille, les données sont à l'arrondissement : on agrège à l'échelle de
|
||||||
|
* la VILLE entière (com_name renvoie alors « Paris » / « Lyon » / « Marseille »).
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
/** Ville PLM correspondant à un code commune-arrondissement, ou null. */
|
||||||
|
function plm_city(string $com): ?array
|
||||||
|
{
|
||||||
|
if ($com >= '75101' && $com <= '75120') return ['Paris', '75101', '75120'];
|
||||||
|
if ($com >= '69381' && $com <= '69389') return ['Lyon', '69381', '69389'];
|
||||||
|
if ($com >= '13201' && $com <= '13216') return ['Marseille', '13201', '13216'];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Batch : populations d'une liste d'IRIS (pour la coloration) ---
|
||||||
|
if (($_GET['action'] ?? '') === 'batch') {
|
||||||
|
$codes = body_json()['codes'] ?? [];
|
||||||
|
if (!is_array($codes) || !$codes) {
|
||||||
|
json_out([]);
|
||||||
|
}
|
||||||
|
$codes = array_slice(array_values(array_unique(array_map('strval', $codes))), 0, 5000);
|
||||||
|
$ph = implode(',', array_fill(0, count($codes), '?'));
|
||||||
|
$st = db()->prepare("SELECT code, pop FROM iris_pop WHERE code IN ($ph)");
|
||||||
|
$st->execute($codes);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[$r['code']] = (float) $r['pop'];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Détail d'un IRIS + sa commune ---
|
||||||
|
$code = trim((string) ($_GET['code'] ?? ''));
|
||||||
|
$com = trim((string) ($_GET['com'] ?? ''));
|
||||||
|
|
||||||
|
$irisPop = null;
|
||||||
|
if ($code !== '') {
|
||||||
|
$st = db()->prepare('SELECT pop FROM iris_pop WHERE code = ?');
|
||||||
|
$st->execute([$code]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
if ($v !== false) {
|
||||||
|
$irisPop = (int) round((float) $v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$comPop = null;
|
||||||
|
$comName = null;
|
||||||
|
if ($com !== '') {
|
||||||
|
$plm = plm_city($com);
|
||||||
|
if ($plm) {
|
||||||
|
$comName = $plm[0];
|
||||||
|
$st = db()->prepare('SELECT SUM(pop) FROM iris_pop WHERE com BETWEEN ? AND ?');
|
||||||
|
$st->execute([$plm[1], $plm[2]]);
|
||||||
|
} else {
|
||||||
|
$st = db()->prepare('SELECT SUM(pop) FROM iris_pop WHERE com = ?');
|
||||||
|
$st->execute([$com]);
|
||||||
|
}
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
if ($v !== null && $v !== false) {
|
||||||
|
$comPop = (int) round((float) $v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out(['iris_pop' => $irisPop, 'com_pop' => $comPop, 'com_name' => $comName]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Éléments d'un établissement : fichiers, photos, liens.
|
||||||
|
*
|
||||||
|
* GET ?uai=UAI [&kind=] → éléments VALIDÉS (public)
|
||||||
|
* GET ?action=queue → file d'attente de modération (admin, selon périmètre)
|
||||||
|
* POST ?uai=UAI (multipart) → soumission par tout internaute (statut « pending »)
|
||||||
|
* champs : kind, folder_id?, title?, description?, url?,
|
||||||
|
* submitter_name?, submitter_email?
|
||||||
|
* fichier : « file » (pour kind=file|photo)
|
||||||
|
* POST ?action=validate (JSON) → {id, decision:'approve'|'reject', reason?} (admin)
|
||||||
|
* POST ?action=update (JSON) → {id, title, description?} édition (valideurs)
|
||||||
|
* DELETE ?id=N → suppression (admin, selon périmètre)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
/** Sous-dossier d'upload selon le type. */
|
||||||
|
function kind_dir(string $kind): string
|
||||||
|
{
|
||||||
|
return $kind === 'photo' ? 'photos' : 'files';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL publique d'un élément stocké sur disque. */
|
||||||
|
function item_url(array $r): ?string
|
||||||
|
{
|
||||||
|
if ($r['kind'] === 'link') {
|
||||||
|
return $r['url'];
|
||||||
|
}
|
||||||
|
if (!$r['filename']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return 'uploads/' . kind_dir($r['kind']) . '/' . rawurlencode($r['filename']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shape_item(array $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $r['id'],
|
||||||
|
'folder_id' => $r['folder_id'] !== null ? (int) $r['folder_id'] : null,
|
||||||
|
'kind' => $r['kind'],
|
||||||
|
'title' => $r['title'],
|
||||||
|
'description' => $r['description'] ?? null,
|
||||||
|
'url' => item_url($r),
|
||||||
|
'size' => $r['size'] !== null ? (int) $r['size'] : null,
|
||||||
|
'mime' => $r['mime'],
|
||||||
|
'status' => $r['status'],
|
||||||
|
'submitted_at' => $r['submitted_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// ---- File d'attente de modération (admin) -----------------------------
|
||||||
|
if ($method === 'GET' && $action === 'queue') {
|
||||||
|
$user = require_login();
|
||||||
|
if ($user['role'] !== 'super' && $user['role'] !== 'perimeter') {
|
||||||
|
json_out([]); // commentateur : aucune file de modération
|
||||||
|
}
|
||||||
|
$sql = "SELECT i.*, e.nom AS etab_nom, e.com AS etab_com, e.depc AS etab_depc, e.dep AS etab_dep
|
||||||
|
FROM items i
|
||||||
|
JOIN etablissements e ON e.id = i.uai";
|
||||||
|
$args = [];
|
||||||
|
if ($user['role'] === 'super') {
|
||||||
|
$sql .= " WHERE i.status = 'pending'";
|
||||||
|
} else {
|
||||||
|
// Périmètre personnel (legacy) OU groupe « valideur » de l'utilisateur.
|
||||||
|
$sql .= " WHERE i.status = 'pending' AND (
|
||||||
|
EXISTS (SELECT 1 FROM user_scopes s
|
||||||
|
WHERE s.user_id = ? AND s.com = e.com AND s.depc = e.depc)
|
||||||
|
OR EXISTS (SELECT 1 FROM group_members gm
|
||||||
|
JOIN user_groups g ON g.id = gm.group_id AND g.can_validate = 1
|
||||||
|
JOIN group_scopes gs ON gs.group_id = g.id AND gs.com = e.com AND gs.depc = e.depc
|
||||||
|
WHERE gm.user_id = ?))";
|
||||||
|
$args[] = $user['id'];
|
||||||
|
$args[] = $user['id'];
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY i.submitted_at ASC';
|
||||||
|
$st = db()->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = shape_item($r) + [
|
||||||
|
'uai' => $r['uai'],
|
||||||
|
'etab' => $r['etab_nom'] . ' — ' . $r['etab_com'] . ' (' . $r['etab_depc'] . ')',
|
||||||
|
'submitter_name' => $r['submitter_name'],
|
||||||
|
'submitter_email' => $r['submitter_email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Liste publique (éléments validés) --------------------------------
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai requis.');
|
||||||
|
}
|
||||||
|
$sql = "SELECT * FROM items WHERE uai = ? AND status = 'approved'";
|
||||||
|
$args = [$uai];
|
||||||
|
if (!empty($_GET['kind'])) {
|
||||||
|
$sql .= ' AND kind = ?';
|
||||||
|
$args[] = $_GET['kind'];
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY submitted_at DESC, id DESC';
|
||||||
|
$st = db()->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$out[] = shape_item($r);
|
||||||
|
}
|
||||||
|
json_out($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Validation / rejet (admin) ---------------------------------------
|
||||||
|
if ($method === 'POST' && $action === 'validate') {
|
||||||
|
$user = require_login();
|
||||||
|
$b = body_json();
|
||||||
|
$id = (int) ($b['id'] ?? 0);
|
||||||
|
$decision = $b['decision'] ?? '';
|
||||||
|
if ($id <= 0 || !in_array($decision, ['approve', 'reject'], true)) {
|
||||||
|
json_error('Paramètres invalides (id, decision).');
|
||||||
|
}
|
||||||
|
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$uai = $st->fetchColumn();
|
||||||
|
if ($uai === false) {
|
||||||
|
json_error('Élément introuvable.', 404);
|
||||||
|
}
|
||||||
|
if (!can_validate_uai($user, (string) $uai)) {
|
||||||
|
json_error('Hors de votre périmètre.', 403);
|
||||||
|
}
|
||||||
|
$status = $decision === 'approve' ? 'approved' : 'rejected';
|
||||||
|
$reason = $decision === 'reject' ? (trim((string) ($b['reason'] ?? '')) ?: null) : null;
|
||||||
|
db()->prepare(
|
||||||
|
'UPDATE items SET status = ?, reject_reason = ?, validated_by = ?, validated_at = NOW() WHERE id = ?'
|
||||||
|
)->execute([$status, $reason, $user['id'], $id]);
|
||||||
|
json_out(['ok' => true, 'id' => $id, 'status' => $status]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Édition du libellé / description (valideurs) ---------------------
|
||||||
|
if ($method === 'POST' && $action === 'update') {
|
||||||
|
$user = require_login();
|
||||||
|
$b = body_json();
|
||||||
|
$id = (int) ($b['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$uai = $st->fetchColumn();
|
||||||
|
if ($uai === false) {
|
||||||
|
json_error('Élément introuvable.', 404);
|
||||||
|
}
|
||||||
|
if (!can_validate_uai($user, (string) $uai)) {
|
||||||
|
json_error('Hors de votre périmètre.', 403);
|
||||||
|
}
|
||||||
|
$title = trim((string) ($b['title'] ?? ''));
|
||||||
|
$description = trim((string) ($b['description'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
json_error('Le libellé ne peut pas être vide.');
|
||||||
|
}
|
||||||
|
db()->prepare('UPDATE items SET title = ?, description = ? WHERE id = ?')
|
||||||
|
->execute([$title, $description !== '' ? $description : null, $id]);
|
||||||
|
json_out(['ok' => true, 'id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Soumission (tout internaute) -------------------------------------
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? $_POST['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai requis.');
|
||||||
|
}
|
||||||
|
$chk = db()->prepare('SELECT 1 FROM etablissements WHERE id = ?');
|
||||||
|
$chk->execute([$uai]);
|
||||||
|
if (!$chk->fetchColumn()) {
|
||||||
|
json_error('Établissement inconnu.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$kind = $_POST['kind'] ?? '';
|
||||||
|
if (!in_array($kind, ['file', 'photo', 'link'], true)) {
|
||||||
|
json_error('Type invalide (file, photo ou link).');
|
||||||
|
}
|
||||||
|
$folderId = (isset($_POST['folder_id']) && $_POST['folder_id'] !== '')
|
||||||
|
? (int) $_POST['folder_id'] : null;
|
||||||
|
if ($folderId !== null) {
|
||||||
|
$f = db()->prepare('SELECT 1 FROM folders WHERE id = ?');
|
||||||
|
$f->execute([$folderId]);
|
||||||
|
if (!$f->fetchColumn()) {
|
||||||
|
json_error('Dossier inconnu.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$title = trim((string) ($_POST['title'] ?? ''));
|
||||||
|
$description = trim((string) ($_POST['description'] ?? '')) ?: null;
|
||||||
|
$submitterName = trim((string) ($_POST['submitter_name'] ?? '')) ?: null;
|
||||||
|
$submitterMail = trim((string) ($_POST['submitter_email'] ?? '')) ?: null;
|
||||||
|
|
||||||
|
$filename = null;
|
||||||
|
$url = null;
|
||||||
|
$size = null;
|
||||||
|
$mime = null;
|
||||||
|
|
||||||
|
if ($kind === 'link') {
|
||||||
|
$url = trim((string) ($_POST['url'] ?? ''));
|
||||||
|
if (!preg_match('#^https?://#i', $url)) {
|
||||||
|
json_error('URL invalide (doit commencer par http:// ou https://).');
|
||||||
|
}
|
||||||
|
if ($title === '') {
|
||||||
|
$title = $url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (empty($_FILES['file']) || (int) $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
json_error('Fichier requis.');
|
||||||
|
}
|
||||||
|
$tmp = $_FILES['file']['tmp_name'];
|
||||||
|
$orig = (string) $_FILES['file']['name'];
|
||||||
|
$size = (int) $_FILES['file']['size'];
|
||||||
|
$mime = mime_content_type($tmp) ?: ($_FILES['file']['type'] ?? 'application/octet-stream');
|
||||||
|
if ($kind === 'photo' && strncmp($mime, 'image/', 6) !== 0) {
|
||||||
|
json_error('Le fichier 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Historique de la population municipale (INSEE) de la zone d'un établissement.
|
||||||
|
* GET ?uai=UAI → { zone: [{annee,pop},…], zone_estimated: bool, ville: [...]|null, ville_nom }
|
||||||
|
*
|
||||||
|
* « zone » = commune, ou arrondissement pour Paris/Lyon/Marseille (code INSEE comc de
|
||||||
|
* l'établissement). Pour un arrondissement, « ville » donne en plus la courbe historique
|
||||||
|
* de la ville entière. Sinon « ville » est null.
|
||||||
|
*
|
||||||
|
* Lyon/Marseille n'ont pas d'historique INSEE par arrondissement : « zone » est alors
|
||||||
|
* ESTIMÉE (zone_estimated=true) = population de la ville × part de l'arrondissement dans
|
||||||
|
* les IRIS du recensement 2022 (iris_pop).
|
||||||
|
*
|
||||||
|
* Source : pop_commune (cf. import_pop_hist.php), prolongée à 2022 via iris_pop.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ville entière pour un arrondissement Paris/Lyon/Marseille :
|
||||||
|
* [ codes pop_commune (historique), codes iris_pop (point 2022), nom ] — ou null.
|
||||||
|
* Paris n'a pas de code commune unique dans pop_commune : on somme les arrondissements.
|
||||||
|
*/
|
||||||
|
function plm_ville(string $comc): ?array
|
||||||
|
{
|
||||||
|
$i = (int) $comc;
|
||||||
|
$codes = static fn(int $a, int $b) => array_map('strval', range($a, $b));
|
||||||
|
if ($i >= 13201 && $i <= 13216) return [['13055'], $codes(13201, 13216), 'Marseille'];
|
||||||
|
if ($i >= 69381 && $i <= 69389) return [['69123'], $codes(69381, 69389), 'Lyon'];
|
||||||
|
if ($i >= 75101 && $i <= 75120) return [$codes(75101, 75120), $codes(75101, 75120), 'Paris'];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Somme des populations IRIS (recensement 2022) pour une liste de codes commune/arrondissement. */
|
||||||
|
function iris_sum(array $codes): float
|
||||||
|
{
|
||||||
|
if (!$codes) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$ph = implode(',', array_fill(0, count($codes), '?'));
|
||||||
|
$st = db()->prepare("SELECT SUM(pop) FROM iris_pop WHERE com IN ($ph)");
|
||||||
|
$st->execute(array_map('strval', $codes));
|
||||||
|
return (float) $st->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit une série [{annee,pop}] : somme de pop_commune sur $histCodes (par année),
|
||||||
|
* prolongée à 2022 par la somme d'iris_pop sur $irisCodes.
|
||||||
|
*/
|
||||||
|
function pop_series(bool $hasHist, bool $hasIris, array $histCodes, array $irisCodes): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$hasYear = [];
|
||||||
|
if ($hasHist && $histCodes) {
|
||||||
|
$ph = implode(',', array_fill(0, count($histCodes), '?'));
|
||||||
|
$st = db()->prepare("SELECT annee, SUM(pop) AS pop FROM pop_commune WHERE comc IN ($ph) GROUP BY annee ORDER BY annee");
|
||||||
|
$st->execute($histCodes);
|
||||||
|
foreach ($st as $r) {
|
||||||
|
$y = (int) $r['annee'];
|
||||||
|
$out[] = ['annee' => $y, 'pop' => (int) $r['pop']];
|
||||||
|
$hasYear[$y] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($hasIris && $irisCodes && empty($hasYear[2022])) {
|
||||||
|
$ph = implode(',', array_fill(0, count($irisCodes), '?'));
|
||||||
|
$p = db()->prepare("SELECT ROUND(SUM(pop)) FROM iris_pop WHERE com IN ($ph)");
|
||||||
|
$p->execute($irisCodes);
|
||||||
|
$v = $p->fetchColumn();
|
||||||
|
if ($v !== null && $v !== false && (float) $v > 0) {
|
||||||
|
$out[] = ['annee' => 2022, 'pop' => (int) $v];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($out, static fn($a, $b) => $a['annee'] - $b['annee']);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uai = trim((string) ($_GET['uai'] ?? ''));
|
||||||
|
if ($uai === '') {
|
||||||
|
json_error('Paramètre uai requis.');
|
||||||
|
}
|
||||||
|
$hasHist = (bool) db()->query("SHOW TABLES LIKE 'pop_commune'")->fetchColumn();
|
||||||
|
$hasIris = (bool) db()->query("SHOW TABLES LIKE 'iris_pop'")->fetchColumn();
|
||||||
|
if (!$hasHist && !$hasIris) {
|
||||||
|
json_out(['zone' => [], 'ville' => null, 'ville_nom' => null]);
|
||||||
|
}
|
||||||
|
$e = db()->prepare('SELECT comc FROM etablissements WHERE id = ?');
|
||||||
|
$e->execute([$uai]);
|
||||||
|
$comc = $e->fetchColumn();
|
||||||
|
if (!$comc) {
|
||||||
|
json_out(['zone' => [], 'ville' => null, 'ville_nom' => null]);
|
||||||
|
}
|
||||||
|
$comc = (string) $comc;
|
||||||
|
|
||||||
|
// Zone = commune ou arrondissement (code de l'établissement directement).
|
||||||
|
$zone = pop_series($hasHist, $hasIris, [$comc], [$comc]);
|
||||||
|
|
||||||
|
// Ville entière (seulement pour un arrondissement PLM).
|
||||||
|
$ville = null;
|
||||||
|
$villeNom = null;
|
||||||
|
$estimated = false;
|
||||||
|
if ($plm = plm_ville($comc)) {
|
||||||
|
[$histCodes, $irisCodes, $villeNom] = $plm;
|
||||||
|
$ville = pop_series($hasHist, $hasIris, $histCodes, $irisCodes);
|
||||||
|
|
||||||
|
// Si l'arrondissement n'a pas d'historique propre (Lyon/Marseille), on l'ESTIME :
|
||||||
|
// population de la ville × part de l'arrondissement dans les IRIS (recensement 2022).
|
||||||
|
if (count($zone) < 2 && $ville && $hasIris) {
|
||||||
|
$arrIris = iris_sum([$comc]);
|
||||||
|
$cityIris = iris_sum($irisCodes);
|
||||||
|
if ($arrIris > 0 && $cityIris > 0) {
|
||||||
|
$share = $arrIris / $cityIris;
|
||||||
|
$zone = array_map(
|
||||||
|
static fn(array $p) => ['annee' => $p['annee'], 'pop' => (int) round($p['pop'] * $share)],
|
||||||
|
$ville
|
||||||
|
);
|
||||||
|
$estimated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out([
|
||||||
|
'zone' => $zone,
|
||||||
|
'zone_estimated' => $estimated,
|
||||||
|
'ville' => $ville,
|
||||||
|
'ville_nom' => $villeNom,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gestion des administrateurs (super-admin uniquement).
|
||||||
|
* GET → liste des utilisateurs + périmètres + email
|
||||||
|
* POST {username, password, role?, email?} → crée un utilisateur (role « perimeter »
|
||||||
|
* par défaut ; « member » = simple commentateur ;
|
||||||
|
* mot de passe soumis à la politique, must_change=1)
|
||||||
|
* POST ?action=password {id, password}→ réinitialise un mot de passe (politique, must_change)
|
||||||
|
* POST ?action=email {id, email} → met à jour l'e-mail d'un compte
|
||||||
|
* POST ?action=scope {user_id, com, depc} → ajoute une commune au périmètre
|
||||||
|
* DELETE ?id=N → supprime un admin
|
||||||
|
* DELETE ?action=scope&id=N → retire une commune d'un périmètre
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$me = require_super();
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$users = db()->query('SELECT id, username, role, email, must_change, created_at FROM users ORDER BY role DESC, username')->fetchAll();
|
||||||
|
$scopes = db()->query('SELECT id, user_id, com, depc FROM user_scopes ORDER BY com')->fetchAll();
|
||||||
|
$byUser = [];
|
||||||
|
foreach ($scopes as $s) {
|
||||||
|
$byUser[(int) $s['user_id']][] = ['id' => (int) $s['id'], 'com' => $s['com'], 'depc' => $s['depc']];
|
||||||
|
}
|
||||||
|
foreach ($users as &$u) {
|
||||||
|
$u['id'] = (int) $u['id'];
|
||||||
|
$u['must_change'] = (int) $u['must_change'];
|
||||||
|
$u['scopes'] = $byUser[$u['id']] ?? [];
|
||||||
|
}
|
||||||
|
json_out($users);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour de l'e-mail d'un compte.
|
||||||
|
if ($method === 'POST' && $action === 'email') {
|
||||||
|
$b = body_json();
|
||||||
|
$id = (int) ($b['id'] ?? 0);
|
||||||
|
$email = trim((string) ($b['email'] ?? ''));
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
json_error('Adresse e-mail invalide.');
|
||||||
|
}
|
||||||
|
db()->prepare('UPDATE users SET email = ? WHERE id = ?')->execute([$email !== '' ? $email : null, $id]);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST' && $action === 'scope') {
|
||||||
|
$b = body_json();
|
||||||
|
$uid = (int) ($b['user_id'] ?? 0);
|
||||||
|
$com = trim((string) ($b['com'] ?? ''));
|
||||||
|
$depc = trim((string) ($b['depc'] ?? ''));
|
||||||
|
if ($uid <= 0 || $com === '' || $depc === '') {
|
||||||
|
json_error('user_id, com et depc requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('INSERT IGNORE INTO user_scopes (user_id, com, depc) VALUES (?, ?, ?)')
|
||||||
|
->execute([$uid, $com, $depc]);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST' && $action === 'password') {
|
||||||
|
$b = body_json();
|
||||||
|
$id = (int) ($b['id'] ?? 0);
|
||||||
|
$pwd = (string) ($b['password'] ?? '');
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
if ($err = password_policy_check($pwd)) {
|
||||||
|
json_error($err);
|
||||||
|
}
|
||||||
|
// Mot de passe posé par l'admin → l'utilisateur devra le changer à sa connexion.
|
||||||
|
db()->prepare('UPDATE users SET password_hash = ?, must_change = 1, failed_attempts = 0, locked_until = NULL WHERE id = ?')
|
||||||
|
->execute([password_hash($pwd, PASSWORD_DEFAULT), $id]);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$b = body_json();
|
||||||
|
$username = trim((string) ($b['username'] ?? ''));
|
||||||
|
$pwd = (string) ($b['password'] ?? '');
|
||||||
|
$email = trim((string) ($b['email'] ?? ''));
|
||||||
|
$roleIn = (string) ($b['role'] ?? 'perimeter');
|
||||||
|
$role = in_array($roleIn, ['super', 'perimeter', 'member'], true) ? $roleIn : 'perimeter';
|
||||||
|
if ($username === '') {
|
||||||
|
json_error('Identifiant requis.');
|
||||||
|
}
|
||||||
|
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
json_error('Adresse e-mail invalide.');
|
||||||
|
}
|
||||||
|
if ($err = password_policy_check($pwd)) {
|
||||||
|
json_error($err);
|
||||||
|
}
|
||||||
|
$exists = db()->prepare('SELECT 1 FROM users WHERE username = ?');
|
||||||
|
$exists->execute([$username]);
|
||||||
|
if ($exists->fetchColumn()) {
|
||||||
|
json_error('Cet identifiant existe déjà.');
|
||||||
|
}
|
||||||
|
// Mot de passe initial posé par l'admin → changement forcé à la 1re connexion.
|
||||||
|
db()->prepare('INSERT INTO users (username, password_hash, role, email, must_change) VALUES (?, ?, ?, ?, 1)')
|
||||||
|
->execute([$username, password_hash($pwd, PASSWORD_DEFAULT), $role, $email !== '' ? $email : null]);
|
||||||
|
json_out(['id' => (int) db()->lastInsertId(), 'username' => $username, 'role' => $role], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE' && $action === 'scope') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
db()->prepare('DELETE FROM user_scopes WHERE id = ?')->execute([$id]);
|
||||||
|
json_out(['deleted' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_error('id requis.');
|
||||||
|
}
|
||||||
|
if ($id === (int) $me['id']) {
|
||||||
|
json_error('Vous ne pouvez pas supprimer votre propre compte.');
|
||||||
|
}
|
||||||
|
db()->prepare('DELETE FROM users WHERE id = ?')->execute([$id]);
|
||||||
|
json_out(['deleted' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_error('Méthode non supportée.', 405);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
json_error('Erreur : ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
/* Carte des établissements scolaires — filtrage côté client. */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Couleurs par type d'établissement (utilisées markers + légende).
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
'Ecole': '#2a9d8f',
|
||||||
|
'Collège': '#e9c46a',
|
||||||
|
'Lycée': '#e76f51',
|
||||||
|
'Médico-social': '#8e7dbe',
|
||||||
|
'Service Administratif': '#6c757d',
|
||||||
|
'EREA': '#457b9d',
|
||||||
|
'Autre': '#adb5bd',
|
||||||
|
};
|
||||||
|
const DEFAULT_COLOR = '#adb5bd';
|
||||||
|
const colorFor = (t) => TYPE_COLORS[t] || DEFAULT_COLOR;
|
||||||
|
|
||||||
|
// Coloration des marqueurs : par type d'établissement (défaut) ou par étiquette DPE.
|
||||||
|
const DPE_COLORS = {
|
||||||
|
A: '#319834', B: '#33cc31', C: '#cbfc34', D: '#fbfe06',
|
||||||
|
E: '#fbcc05', F: '#fc8104', G: '#fc0205',
|
||||||
|
};
|
||||||
|
const DPE_NA = '#b8c2cc'; // pas de DPE rattaché
|
||||||
|
const dpeColor = (l) => DPE_COLORS[String(l || '').toUpperCase()] || DPE_NA;
|
||||||
|
|
||||||
|
let colorMode = 'type'; // 'type' | 'dpe'
|
||||||
|
const dpeByUai = new Map(); // UAI → étiquette énergie (meilleur match), chargé à la demande
|
||||||
|
|
||||||
|
// Lieux nommés pour le deep-link ?place=… (centre + zoom).
|
||||||
|
const PLACES = {
|
||||||
|
marseille: [43.296, 5.37, 12],
|
||||||
|
aix: [43.529, 5.447, 13],
|
||||||
|
paris: [48.857, 2.352, 12],
|
||||||
|
lyon: [45.758, 4.835, 12],
|
||||||
|
france: [46.6, 2.4, 6],
|
||||||
|
};
|
||||||
|
function markerColorFor(e) {
|
||||||
|
return colorMode === 'dpe' ? dpeColor(dpeByUai.get(e.id)) : colorFor(e.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options proposées : clé compacte (dans les données) → libellé affiché.
|
||||||
|
const OPTIONS = [
|
||||||
|
{ key: 'rest', label: 'Restauration' },
|
||||||
|
{ key: 'heb', label: 'Internat (hébergement)' },
|
||||||
|
{ key: 'ulis', label: 'ULIS' },
|
||||||
|
{ key: 'segp', label: 'SEGPA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let ALL = []; // tous les enregistrements
|
||||||
|
let lastSubset = []; // dernier sous-ensemble filtré (pour la coloration DPE)
|
||||||
|
const BY_ID = new Map(); // UAI → enregistrement (pour ouvrir la fiche)
|
||||||
|
const TOTAL = () => ALL.length;
|
||||||
|
|
||||||
|
// --- Carte ---
|
||||||
|
const map = L.map('map', { preferCanvas: true }).setView([46.6, 2.4], 6);
|
||||||
|
|
||||||
|
// Fonds de carte : « clair » (CartoDB Positron) par défaut pour faire ressortir
|
||||||
|
// les points, fond OSM standard en alternative.
|
||||||
|
const baseLight = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 20,
|
||||||
|
subdomains: 'abcd',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> · © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
}).addTo(map);
|
||||||
|
const baseOsm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Couche IRIS (contours INSEE/IGN) colorée par densité de population ---
|
||||||
|
// Chargée par zone visible (WFS Géoplateforme) ; rendue sous les points
|
||||||
|
// (pane dédié) et non interactive pour ne pas gêner les clics.
|
||||||
|
const IRIS_MIN_ZOOM = 11;
|
||||||
|
const IRIS_URL = 'https://data.geopf.fr/wfs/ows';
|
||||||
|
map.createPane('iris');
|
||||||
|
map.getPane('iris').style.zIndex = 250; // entre tuiles (200) et points (≥400)
|
||||||
|
|
||||||
|
// Échelle de densité (habitants / km²) → palette séquentielle (clair → foncé).
|
||||||
|
const DENS_BINS = [0, 500, 2000, 5000, 10000, 20000, 40000];
|
||||||
|
const DENS_COLORS = ['#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8d3c', '#f03b20', '#bd0026'];
|
||||||
|
function densColor(d) {
|
||||||
|
if (d == null) return '#cfd6de'; // population inconnue
|
||||||
|
let i = 0;
|
||||||
|
while (i < DENS_BINS.length - 1 && d >= DENS_BINS[i + 1]) i++;
|
||||||
|
return DENS_COLORS[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface d'un anneau [lng,lat] en m² (formule sphérique), mise en cache.
|
||||||
|
const EARTH_R = 6378137;
|
||||||
|
const rad = (x) => x * Math.PI / 180;
|
||||||
|
function ringArea(ring) {
|
||||||
|
let a = 0;
|
||||||
|
for (let i = 0, n = ring.length; i < n; i++) {
|
||||||
|
const [x1, y1] = ring[i];
|
||||||
|
const [x2, y2] = ring[(i + 1) % n];
|
||||||
|
a += rad(x2 - x1) * (2 + Math.sin(rad(y1)) + Math.sin(rad(y2)));
|
||||||
|
}
|
||||||
|
return Math.abs(a * EARTH_R * EARTH_R / 2);
|
||||||
|
}
|
||||||
|
function featureAreaKm2(f) {
|
||||||
|
if (f.properties._km2 != null) return f.properties._km2;
|
||||||
|
const g = f.geometry || {};
|
||||||
|
let m2 = 0;
|
||||||
|
const polys = g.type === 'Polygon' ? [g.coordinates]
|
||||||
|
: g.type === 'MultiPolygon' ? g.coordinates : [];
|
||||||
|
for (const rings of polys) {
|
||||||
|
m2 += ringArea(rings[0]);
|
||||||
|
for (let k = 1; k < rings.length; k++) m2 -= ringArea(rings[k]); // trous
|
||||||
|
}
|
||||||
|
return (f.properties._km2 = m2 / 1e6);
|
||||||
|
}
|
||||||
|
function featureDensity(f) {
|
||||||
|
const pop = f.properties._pop;
|
||||||
|
if (pop == null) return null;
|
||||||
|
const km2 = featureAreaKm2(f);
|
||||||
|
return km2 > 0 ? pop / km2 : null;
|
||||||
|
}
|
||||||
|
function densityStyle(f) {
|
||||||
|
const d = featureDensity(f);
|
||||||
|
return {
|
||||||
|
color: '#555',
|
||||||
|
weight: 1,
|
||||||
|
opacity: 0.7,
|
||||||
|
fillColor: densColor(d),
|
||||||
|
fillOpacity: d == null ? 0.15 : 0.6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Échelle « taux d'écoliers publics » (% de la population) → palette verte.
|
||||||
|
const RATIO_BINS = [0, 4, 6, 8, 10, 13];
|
||||||
|
const RATIO_COLORS = ['#edf8e9', '#c7e9c0', '#a1d99b', '#74c476', '#31a354', '#006d2c'];
|
||||||
|
function ratioColor(p) {
|
||||||
|
if (p == null) return '#cfd6de';
|
||||||
|
let i = 0;
|
||||||
|
while (i < RATIO_BINS.length - 1 && p >= RATIO_BINS[i + 1]) i++;
|
||||||
|
return RATIO_COLORS[i];
|
||||||
|
}
|
||||||
|
// Taux par IRIS = élèves des écoles publiques situées dans l'IRIS / population de l'IRIS.
|
||||||
|
function ratioStyle(f) {
|
||||||
|
const pop = f.properties._pop;
|
||||||
|
const stu = f.properties._students;
|
||||||
|
const pct = (pop != null && pop > 0 && stu != null) ? stu / pop * 100 : null;
|
||||||
|
return {
|
||||||
|
color: '#555',
|
||||||
|
weight: 1,
|
||||||
|
opacity: 0.7,
|
||||||
|
fillColor: ratioColor(pct),
|
||||||
|
fillOpacity: pct == null ? 0.15 : 0.65,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let irisMode = 'density'; // 'density' | 'ratio'
|
||||||
|
const currentStyle = (f) => (irisMode === 'ratio' ? ratioStyle : densityStyle)(f);
|
||||||
|
|
||||||
|
const irisLayer = L.geoJSON(null, {
|
||||||
|
pane: 'iris',
|
||||||
|
renderer: L.svg({ pane: 'iris' }), // SVG dédié : tracé net, indépendant du canvas des points
|
||||||
|
interactive: false,
|
||||||
|
style: currentStyle,
|
||||||
|
attribution: 'Contours IRIS® © IGN / INSEE',
|
||||||
|
});
|
||||||
|
|
||||||
|
let irisActive = false;
|
||||||
|
let irisAbort = null;
|
||||||
|
let irisTimer = null;
|
||||||
|
let irisFeatures = []; // dernières features chargées (pour le clic)
|
||||||
|
let suppressIrisClick = false; // évite le popup IRIS quand on clique un établissement
|
||||||
|
|
||||||
|
const irisHint = L.control({ position: 'topright' });
|
||||||
|
irisHint.onAdd = () => {
|
||||||
|
const d = L.DomUtil.create('div', 'iris-hint');
|
||||||
|
d.textContent = '🔎 Zoomez pour afficher les contours IRIS';
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
let irisHintShown = false;
|
||||||
|
function toggleIrisHint(show) {
|
||||||
|
if (show && !irisHintShown) { irisHint.addTo(map); irisHintShown = true; }
|
||||||
|
else if (!show && irisHintShown) { map.removeControl(irisHint); irisHintShown = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshIris() {
|
||||||
|
if (!irisActive) return;
|
||||||
|
if (map.getZoom() < IRIS_MIN_ZOOM) {
|
||||||
|
irisLayer.clearLayers();
|
||||||
|
irisFeatures = [];
|
||||||
|
toggleIrisHint(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleIrisHint(false);
|
||||||
|
const b = map.getBounds();
|
||||||
|
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]
|
||||||
|
.map((n) => n.toFixed(5)).join(',') + ',urn:ogc:def:crs:OGC:1.3:CRS84';
|
||||||
|
const url = `${IRIS_URL}?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature`
|
||||||
|
+ '&TYPENAMES=STATISTICALUNITS.IRIS:contours_iris&OUTPUTFORMAT=application/json'
|
||||||
|
+ `&SRSNAME=CRS:84&COUNT=2000&BBOX=${bbox}`;
|
||||||
|
try {
|
||||||
|
if (irisAbort) irisAbort.abort();
|
||||||
|
irisAbort = new AbortController();
|
||||||
|
const res = await fetch(url, { signal: irisAbort.signal });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
irisLayer.clearLayers();
|
||||||
|
irisLayer.addData(data);
|
||||||
|
irisFeatures = data.features || [];
|
||||||
|
await styleIris();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') console.warn('IRIS :', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupère la population (INSEE 2022) des IRIS visibles → f.properties._pop.
|
||||||
|
async function loadIrisPops() {
|
||||||
|
const codes = irisFeatures.map((f) => f.properties.code_iris).filter(Boolean);
|
||||||
|
if (!codes.length) return;
|
||||||
|
let pops;
|
||||||
|
try {
|
||||||
|
pops = await fetch('api/iris.php?action=batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ codes }),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
irisFeatures.forEach((f) => {
|
||||||
|
f.properties._pop = Object.prototype.hasOwnProperty.call(pops, f.properties.code_iris)
|
||||||
|
? pops[f.properties.code_iris] : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compte les écoliers des écoles PUBLIQUES situées dans chaque IRIS visible
|
||||||
|
// (école rattachée à l'IRIS qui la contient) → f.properties._students.
|
||||||
|
async function loadIrisStudents() {
|
||||||
|
irisFeatures.forEach((f) => { f.properties._students = 0; });
|
||||||
|
const b = map.getBounds();
|
||||||
|
const ecoles = ALL.filter((e) =>
|
||||||
|
e.type === 'Ecole' && e.stat === 'Public' && e.lat != null && b.contains([e.lat, e.lon]));
|
||||||
|
if (!ecoles.length) return;
|
||||||
|
|
||||||
|
let eff = {};
|
||||||
|
try {
|
||||||
|
eff = await fetch('api/effectifs.php?action=latest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uais: ecoles.map((e) => e.id) }),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of ecoles) {
|
||||||
|
const f = findIris({ lng: e.lon, lat: e.lat });
|
||||||
|
if (f) f.properties._students += (eff[e.id] || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applique la coloration courante (densité ou taux de scolarisation par IRIS).
|
||||||
|
async function styleIris() {
|
||||||
|
if (!irisFeatures.length) return;
|
||||||
|
await loadIrisPops();
|
||||||
|
if (irisMode === 'ratio') {
|
||||||
|
await loadIrisStudents();
|
||||||
|
irisLayer.setStyle(ratioStyle);
|
||||||
|
} else {
|
||||||
|
irisLayer.setStyle(densityStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IRIS cliquables : clic carte → point-dans-polygone → popup population ---
|
||||||
|
function pointInRing(lng, lat, ring) {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||||
|
const xi = ring[i][0], yi = ring[i][1];
|
||||||
|
const xj = ring[j][0], yj = ring[j][1];
|
||||||
|
if (((yi > lat) !== (yj > lat)) &&
|
||||||
|
(lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonContains(lng, lat, rings) {
|
||||||
|
// rings[0] = contour extérieur, rings[1..] = trous
|
||||||
|
if (!pointInRing(lng, lat, rings[0])) return false;
|
||||||
|
for (let k = 1; k < rings.length; k++) {
|
||||||
|
if (pointInRing(lng, lat, rings[k])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIris(latlng) {
|
||||||
|
const lng = latlng.lng, lat = latlng.lat;
|
||||||
|
for (const f of irisFeatures) {
|
||||||
|
const g = f.geometry;
|
||||||
|
if (!g) continue;
|
||||||
|
if (g.type === 'Polygon') {
|
||||||
|
if (polygonContains(lng, lat, g.coordinates)) return f;
|
||||||
|
} else if (g.type === 'MultiPolygon') {
|
||||||
|
for (const poly of g.coordinates) {
|
||||||
|
if (polygonContains(lng, lat, poly)) return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYP_IRIS = { H: 'Habitat', A: 'Activité', D: 'Divers', Z: 'Non défini' };
|
||||||
|
|
||||||
|
map.on('click', (e) => {
|
||||||
|
if (!irisActive || suppressIrisClick) return;
|
||||||
|
const f = findIris(e.latlng);
|
||||||
|
if (!f) return;
|
||||||
|
const p = f.properties || {};
|
||||||
|
const typ = TYP_IRIS[p.type_iris] || p.type_iris || '';
|
||||||
|
const head = `<div class="iris-popup"><h3>${p.nom_iris || 'IRIS'}</h3>`
|
||||||
|
+ `<div class="meta">${p.nom_commune || ''}${typ ? ' · ' + typ : ''}</div>`;
|
||||||
|
const popup = L.popup({ maxWidth: 260 })
|
||||||
|
.setLatLng(e.latlng)
|
||||||
|
.setContent(head + '<div class="row">Population 2022 : …</div></div>')
|
||||||
|
.openOn(map);
|
||||||
|
|
||||||
|
const fmt = (n) => n == null ? 'n.c.' : n.toLocaleString('fr') + ' hab.';
|
||||||
|
fetch(`api/iris.php?code=${encodeURIComponent(p.code_iris || '')}&com=${encodeURIComponent(p.code_insee || '')}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
const km2 = featureAreaKm2(f);
|
||||||
|
const dens = (d.iris_pop != null && km2 > 0) ? Math.round(d.iris_pop / km2) : null;
|
||||||
|
const comLabel = d.com_name || p.nom_commune || 'Commune';
|
||||||
|
// En mode « taux d'écoliers », _students est calculé par IRIS.
|
||||||
|
const stu = f.properties._students;
|
||||||
|
let scoLine = '';
|
||||||
|
if (stu != null && d.iris_pop != null) {
|
||||||
|
const tx = d.iris_pop > 0 ? (stu / d.iris_pop * 100).toFixed(1) + ' %' : 'n.c.';
|
||||||
|
scoLine = `<div class="row"><b>Écoliers publics (IRIS) :</b> ${stu.toLocaleString('fr')} (${tx})</div>`;
|
||||||
|
}
|
||||||
|
popup.setContent(head
|
||||||
|
+ `<div class="row"><b>IRIS :</b> ${fmt(d.iris_pop)}</div>`
|
||||||
|
+ (dens != null ? `<div class="row">Densité : ${dens.toLocaleString('fr')} hab/km²</div>` : '')
|
||||||
|
+ scoLine
|
||||||
|
+ `<div class="row"><b>${esc(comLabel)} :</b> ${fmt(d.com_pop)}</div>`
|
||||||
|
+ `<div class="uai">Code IRIS : ${p.code_iris || ''} — recensement 2022</div></div>`);
|
||||||
|
})
|
||||||
|
.catch(() => popup.setContent(head + '<div class="row">Population indisponible.</div></div>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Construit le contenu de la légende selon le mode de coloration courant.
|
||||||
|
function irisLegendHtml() {
|
||||||
|
let bins, colors, title, unit;
|
||||||
|
if (irisMode === 'ratio') {
|
||||||
|
bins = RATIO_BINS; colors = RATIO_COLORS;
|
||||||
|
title = 'Écoliers publics / population'; unit = ' %';
|
||||||
|
} else {
|
||||||
|
bins = DENS_BINS; colors = DENS_COLORS;
|
||||||
|
title = 'Densité (hab/km²)'; unit = '';
|
||||||
|
}
|
||||||
|
let html = `<strong>${title}</strong>`;
|
||||||
|
for (let i = 0; i < colors.length; i++) {
|
||||||
|
const from = bins[i], to = bins[i + 1];
|
||||||
|
const label = to
|
||||||
|
? `${from.toLocaleString('fr')}–${to.toLocaleString('fr')}${unit}`
|
||||||
|
: `≥ ${from.toLocaleString('fr')}${unit}`;
|
||||||
|
html += `<div class="row"><span class="swatch" style="background:${colors[i]}"></span>${label}</div>`;
|
||||||
|
}
|
||||||
|
return html + '<div class="row"><span class="swatch" style="background:#cfd6de"></span>n.c.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrôle combiné : sélecteur de mode + légende (affiché avec la couche IRIS).
|
||||||
|
let irisPanelDiv = null;
|
||||||
|
const irisPanel = L.control({ position: 'bottomleft' });
|
||||||
|
irisPanel.onAdd = () => {
|
||||||
|
const div = L.DomUtil.create('div', 'legend iris-legend');
|
||||||
|
L.DomEvent.disableClickPropagation(div);
|
||||||
|
irisPanelDiv = div;
|
||||||
|
renderIrisPanel();
|
||||||
|
return div;
|
||||||
|
};
|
||||||
|
function renderIrisPanel() {
|
||||||
|
if (!irisPanelDiv) return;
|
||||||
|
irisPanelDiv.innerHTML = `
|
||||||
|
<div class="iris-mode">
|
||||||
|
<label><input type="radio" name="irismode" value="density" ${irisMode === 'density' ? 'checked' : ''}> Densité de population</label>
|
||||||
|
<label><input type="radio" name="irismode" value="ratio" ${irisMode === 'ratio' ? 'checked' : ''}> Taux d'écoliers publics</label>
|
||||||
|
</div>
|
||||||
|
<div class="iris-legend-body">${irisLegendHtml()}</div>`;
|
||||||
|
irisPanelDiv.querySelectorAll('input[name="irismode"]').forEach((r) =>
|
||||||
|
r.addEventListener('change', (ev) => {
|
||||||
|
irisMode = ev.target.value;
|
||||||
|
renderIrisPanel();
|
||||||
|
styleIris();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('overlayadd', (e) => {
|
||||||
|
if (e.layer === irisLayer) { irisActive = true; irisPanel.addTo(map); refreshIris(); }
|
||||||
|
});
|
||||||
|
map.on('overlayremove', (e) => {
|
||||||
|
if (e.layer === irisLayer) {
|
||||||
|
irisActive = false;
|
||||||
|
irisLayer.clearLayers();
|
||||||
|
irisFeatures = [];
|
||||||
|
toggleIrisHint(false);
|
||||||
|
map.removeControl(irisPanel);
|
||||||
|
irisPanelDiv = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.on('moveend', () => { clearTimeout(irisTimer); irisTimer = setTimeout(refreshIris, 400); });
|
||||||
|
|
||||||
|
L.control.layers(
|
||||||
|
{ 'Fond clair': baseLight, 'OpenStreetMap': baseOsm },
|
||||||
|
{ 'Contours IRIS (INSEE/IGN)': irisLayer },
|
||||||
|
{ collapsed: false }
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
const cluster = L.markerClusterGroup({
|
||||||
|
chunkedLoading: true,
|
||||||
|
spiderfyOnMaxZoom: true,
|
||||||
|
maxClusterRadius: 50,
|
||||||
|
disableClusteringAtZoom: 16,
|
||||||
|
});
|
||||||
|
map.addLayer(cluster);
|
||||||
|
|
||||||
|
// --- Centrage automatique sur la position de l'internaute ---
|
||||||
|
// (si l'utilisateur l'autorise ; sinon on conserve la vue France par défaut).
|
||||||
|
let userMarker = null;
|
||||||
|
map.on('locationfound', (e) => {
|
||||||
|
if (userMarker) map.removeLayer(userMarker);
|
||||||
|
userMarker = L.layerGroup([
|
||||||
|
L.circle(e.latlng, { radius: e.accuracy, color: '#1d3557', weight: 1, fillOpacity: 0.08 }),
|
||||||
|
L.circleMarker(e.latlng, { radius: 7, color: '#fff', weight: 2, fillColor: '#1d3557', fillOpacity: 1 })
|
||||||
|
.bindPopup('Vous êtes ici'),
|
||||||
|
]).addTo(map);
|
||||||
|
locating = false;
|
||||||
|
});
|
||||||
|
map.on('locationerror', (e) => {
|
||||||
|
// Position refusée ou indisponible : la vue France reste affichée.
|
||||||
|
console.info('Géolocalisation indisponible :', e.message);
|
||||||
|
if (locating) alert('Localisation indisponible : ' + e.message);
|
||||||
|
locating = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
let locating = false;
|
||||||
|
function locateUser() {
|
||||||
|
locating = true;
|
||||||
|
map.locate({ setView: true, maxZoom: 13, enableHighAccuracy: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bouton « Me localiser » sur la carte.
|
||||||
|
const locateBtn = L.control({ position: 'topleft' });
|
||||||
|
locateBtn.onAdd = () => {
|
||||||
|
const div = L.DomUtil.create('div', 'leaflet-bar locate-ctrl');
|
||||||
|
const a = L.DomUtil.create('a', '', div);
|
||||||
|
a.href = '#';
|
||||||
|
a.title = 'Me localiser';
|
||||||
|
a.setAttribute('role', 'button');
|
||||||
|
a.setAttribute('aria-label', 'Me localiser');
|
||||||
|
a.textContent = '📍';
|
||||||
|
L.DomEvent.on(a, 'click', (ev) => {
|
||||||
|
L.DomEvent.preventDefault(ev);
|
||||||
|
L.DomEvent.stopPropagation(ev);
|
||||||
|
locateUser();
|
||||||
|
});
|
||||||
|
return div;
|
||||||
|
};
|
||||||
|
locateBtn.addTo(map);
|
||||||
|
|
||||||
|
// Centrage automatique au chargement (silencieux si refusé).
|
||||||
|
locateUser();
|
||||||
|
locating = false;
|
||||||
|
|
||||||
|
// --- Utilitaires ---
|
||||||
|
const norm = (s) => (s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
||||||
|
));
|
||||||
|
const debounce = (fn, ms) => {
|
||||||
|
let t;
|
||||||
|
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
||||||
|
};
|
||||||
|
|
||||||
|
function uniqSorted(values) {
|
||||||
|
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Construction des popups (paresseuse) ---
|
||||||
|
function buildPopup(e) {
|
||||||
|
const badgeCls = e.etat === 'OUVERT' ? 'ouvert' : 'fermer';
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`<h3>${esc(e.nom || 'Établissement')}</h3>`);
|
||||||
|
lines.push(`<div class="meta">${esc(e.type || '')} · ${esc(e.stat || '')}` +
|
||||||
|
(e.etat ? ` <span class="badge ${badgeCls}">${esc(e.etat)}</span>` : '') + `</div>`);
|
||||||
|
if (e.adr) lines.push(`<div class="row">${esc(e.adr)}</div>`);
|
||||||
|
const ville = [e.cp, e.com].filter(Boolean).join(' ');
|
||||||
|
if (ville) lines.push(`<div class="row">${esc(ville)}</div>`);
|
||||||
|
if (e.dep) lines.push(`<div class="row">${esc(e.dep)} · ${esc(e.aca || '')}</div>`);
|
||||||
|
if (e.circ) lines.push(`<div class="row">Circonscription : ${esc(e.circ)}</div>`);
|
||||||
|
const opts = OPTIONS.filter((o) => e[o.key] === 1).map((o) => o.label);
|
||||||
|
if (opts.length) lines.push(`<div class="row">Options : ${esc(opts.join(' · '))}</div>`);
|
||||||
|
if (e.tel) lines.push(`<div class="row">📞 ${esc(e.tel)}</div>`);
|
||||||
|
if (e.mail) lines.push(`<div class="row">✉ <a href="mailto:${esc(e.mail)}">${esc(e.mail)}</a></div>`);
|
||||||
|
if (e.web) {
|
||||||
|
const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web;
|
||||||
|
lines.push(`<div class="row">🔗 <a href="${esc(url)}" target="_blank" rel="noopener">Site web</a></div>`);
|
||||||
|
}
|
||||||
|
if (e.id) lines.push(`<div class="uai">UAI : ${esc(e.id)}</div>`);
|
||||||
|
lines.push(`<button type="button" class="popup-fiche" data-id="${esc(e.id)}">Voir la fiche complète →</button>`);
|
||||||
|
return `<div class="popup">${lines.join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function squareIcon(color) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'sq-marker',
|
||||||
|
html: `<span style="background:${color}"></span>`,
|
||||||
|
iconSize: [12, 12],
|
||||||
|
iconAnchor: [6, 6],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerFor(e) {
|
||||||
|
if (e._m) return e._m;
|
||||||
|
const color = markerColorFor(e);
|
||||||
|
let m;
|
||||||
|
if (e.stat === 'Privé') {
|
||||||
|
// Établissement privé : marqueur carré (divIcon).
|
||||||
|
m = L.marker([e.lat, e.lon], { icon: squareIcon(color) });
|
||||||
|
} else {
|
||||||
|
// Public (et autres) : puce ronde.
|
||||||
|
m = L.circleMarker([e.lat, e.lon], {
|
||||||
|
radius: 5,
|
||||||
|
weight: 1,
|
||||||
|
color: '#fff',
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
m.bindPopup(() => buildPopup(e), { maxWidth: 280 });
|
||||||
|
// Priorité au point d'établissement : on neutralise le popup IRIS sur ce clic.
|
||||||
|
m.on('click', () => { suppressIrisClick = true; setTimeout(() => { suppressIrisClick = false; }, 60); });
|
||||||
|
e._m = m;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recolore les marqueurs déjà créés selon le mode de coloration courant.
|
||||||
|
function recolorMarkers() {
|
||||||
|
ALL.forEach((e) => {
|
||||||
|
const m = e._m;
|
||||||
|
if (!m) return;
|
||||||
|
const c = markerColorFor(e);
|
||||||
|
if (typeof m.setStyle === 'function') m.setStyle({ fillColor: c }); // puce ronde (public)
|
||||||
|
else if (typeof m.setIcon === 'function') m.setIcon(squareIcon(c)); // carré (privé)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charge les étiquettes DPE (meilleur match) manquantes pour un lot d'établissements.
|
||||||
|
// L'API batch est plafonnée à 5000 UAI ⇒ on découpe en tranches.
|
||||||
|
async function ensureDpeColors(records) {
|
||||||
|
const missing = [...new Set(
|
||||||
|
records.map((e) => e.id).filter((id) => id && !dpeByUai.has(id))
|
||||||
|
)];
|
||||||
|
if (!missing.length) return;
|
||||||
|
for (let i = 0; i < missing.length; i += 5000) {
|
||||||
|
const chunk = missing.slice(i, i + 5000);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch('api/dpe.php?action=latest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uais: chunk }),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
} catch {
|
||||||
|
res = {};
|
||||||
|
}
|
||||||
|
// Mémorise une valeur pour CHAQUE UAI demandé (null si pas de DPE) afin
|
||||||
|
// de ne pas le re-demander à chaque changement de filtre.
|
||||||
|
chunk.forEach((id) => dpeByUai.set(id, (res && res[id]) ? res[id].dpe : null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- État des filtres ---
|
||||||
|
const els = {
|
||||||
|
search: document.getElementById('search'),
|
||||||
|
fType: document.getElementById('f-type'),
|
||||||
|
fStat: document.getElementById('f-stat'),
|
||||||
|
fEtat: document.getElementById('f-etat'),
|
||||||
|
reg: document.getElementById('f-reg'),
|
||||||
|
aca: document.getElementById('f-aca'),
|
||||||
|
dep: document.getElementById('f-dep'),
|
||||||
|
circ: document.getElementById('f-circ'),
|
||||||
|
fOpt: document.getElementById('f-opt'),
|
||||||
|
reset: document.getElementById('reset'),
|
||||||
|
count: document.getElementById('count'),
|
||||||
|
legend: document.getElementById('legend'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkedValues(container) {
|
||||||
|
return [...container.querySelectorAll('input:checked')].map((i) => i.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChecks(container, values, withColor) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
values.forEach((v) => {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.value = v;
|
||||||
|
cb.checked = true;
|
||||||
|
cb.addEventListener('change', applyFilters);
|
||||||
|
label.appendChild(cb);
|
||||||
|
if (withColor) {
|
||||||
|
const sw = document.createElement('span');
|
||||||
|
sw.className = 'swatch';
|
||||||
|
sw.style.background = colorFor(v);
|
||||||
|
label.appendChild(sw);
|
||||||
|
}
|
||||||
|
const txt = document.createElement('span');
|
||||||
|
txt.textContent = v;
|
||||||
|
label.appendChild(txt);
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cases d'options, décochées par défaut (coché = contrainte « possède l'option »).
|
||||||
|
function buildOptionChecks(container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
OPTIONS.forEach((o) => {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.value = o.key;
|
||||||
|
cb.checked = false;
|
||||||
|
cb.addEventListener('change', applyFilters);
|
||||||
|
const txt = document.createElement('span');
|
||||||
|
txt.textContent = o.label;
|
||||||
|
label.append(cb, txt);
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(select, values, allLabel) {
|
||||||
|
const current = select.value;
|
||||||
|
select.innerHTML = '';
|
||||||
|
const opt0 = document.createElement('option');
|
||||||
|
opt0.value = '';
|
||||||
|
opt0.textContent = allLabel;
|
||||||
|
select.appendChild(opt0);
|
||||||
|
values.forEach((v) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = v;
|
||||||
|
o.textContent = v;
|
||||||
|
select.appendChild(o);
|
||||||
|
});
|
||||||
|
// Conserve la sélection si toujours valide.
|
||||||
|
select.value = values.includes(current) ? current : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalcule les selects géographiques en cascade (région → académie → département → circonscription).
|
||||||
|
function refreshGeoSelects() {
|
||||||
|
const reg = els.reg.value, aca = els.aca.value, dep = els.dep.value;
|
||||||
|
|
||||||
|
fillSelect(els.reg, uniqSorted(ALL.map((e) => e.reg)), 'Toutes les régions');
|
||||||
|
els.reg.value = reg;
|
||||||
|
|
||||||
|
let pool = ALL.filter((e) => !els.reg.value || e.reg === els.reg.value);
|
||||||
|
fillSelect(els.aca, uniqSorted(pool.map((e) => e.aca)), 'Toutes les académies');
|
||||||
|
els.aca.value = aca;
|
||||||
|
|
||||||
|
pool = pool.filter((e) => !els.aca.value || e.aca === els.aca.value);
|
||||||
|
fillSelect(els.dep, uniqSorted(pool.map((e) => e.dep)), 'Tous les départements');
|
||||||
|
els.dep.value = dep;
|
||||||
|
|
||||||
|
pool = pool.filter((e) => !els.dep.value || e.dep === els.dep.value);
|
||||||
|
fillSelect(els.circ, uniqSorted(pool.map((e) => e.circ)), 'Toutes les circonscriptions');
|
||||||
|
els.circ.value = els.circ.value; // déjà conservé par fillSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filtrage + rendu ---
|
||||||
|
function applyFilters() {
|
||||||
|
const types = new Set(checkedValues(els.fType));
|
||||||
|
const stats = new Set(checkedValues(els.fStat));
|
||||||
|
const etats = new Set(checkedValues(els.fEtat));
|
||||||
|
const reg = els.reg.value, aca = els.aca.value, dep = els.dep.value, circ = els.circ.value;
|
||||||
|
const opts = checkedValues(els.fOpt); // options requises (logique ET)
|
||||||
|
const q = norm(els.search.value.trim());
|
||||||
|
|
||||||
|
const subset = ALL.filter((e) => {
|
||||||
|
if (!types.has(e.type)) return false;
|
||||||
|
if (!stats.has(e.stat)) return false;
|
||||||
|
if (!etats.has(e.etat)) return false;
|
||||||
|
if (reg && e.reg !== reg) return false;
|
||||||
|
if (aca && e.aca !== aca) return false;
|
||||||
|
if (dep && e.dep !== dep) return false;
|
||||||
|
if (circ && e.circ !== circ) return false;
|
||||||
|
for (const k of opts) if (e[k] !== 1) return false;
|
||||||
|
if (q && !e._s.includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
lastSubset = subset;
|
||||||
|
cluster.clearLayers();
|
||||||
|
cluster.addLayers(subset.map(markerFor));
|
||||||
|
|
||||||
|
els.count.textContent =
|
||||||
|
`${subset.length.toLocaleString('fr')} / ${TOTAL().toLocaleString('fr')} établissements`;
|
||||||
|
|
||||||
|
// En coloration DPE, charge les étiquettes du sous-ensemble puis recolore.
|
||||||
|
if (colorMode === 'dpe') {
|
||||||
|
ensureDpeColors(subset).then(recolorMarkers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGeoChange() {
|
||||||
|
refreshGeoSelects();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function legendBody() {
|
||||||
|
if (colorMode === 'dpe') {
|
||||||
|
return '<strong>Étiquette énergie (DPE)</strong>' +
|
||||||
|
['A', 'B', 'C', 'D', 'E', 'F', 'G'].map((l) =>
|
||||||
|
`<div class="row"><span class="swatch" style="background:${DPE_COLORS[l]}"></span>${l}</div>`
|
||||||
|
).join('') +
|
||||||
|
`<div class="row"><span class="swatch" style="background:${DPE_NA}"></span>Aucun DPE rattaché</div>`;
|
||||||
|
}
|
||||||
|
return '<strong>Type d\'établissement</strong>' +
|
||||||
|
Object.entries(TYPE_COLORS).map(([t, c]) =>
|
||||||
|
`<div class="row"><span class="swatch" style="background:${c}"></span>${t}</div>`
|
||||||
|
).join('') +
|
||||||
|
'<div class="row" style="margin-top:6px"><span class="swatch" style="background:#6c757d"></span>Public (rond)</div>' +
|
||||||
|
'<div class="row"><span class="swatch" style="background:#6c757d;border-radius:2px"></span>Privé (carré)</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegend() {
|
||||||
|
els.legend.innerHTML =
|
||||||
|
`<div class="legend-mode">
|
||||||
|
<label><input type="radio" name="colormode" value="type" ${colorMode === 'type' ? 'checked' : ''}> Type</label>
|
||||||
|
<label><input type="radio" name="colormode" value="dpe" ${colorMode === 'dpe' ? 'checked' : ''}> DPE</label>
|
||||||
|
</div>
|
||||||
|
<div class="legend-body">${legendBody()}</div>`;
|
||||||
|
els.legend.querySelectorAll('input[name="colormode"]').forEach((r) =>
|
||||||
|
r.addEventListener('change', (ev) => {
|
||||||
|
colorMode = ev.target.value;
|
||||||
|
buildLegend();
|
||||||
|
if (colorMode === 'dpe') {
|
||||||
|
ensureDpeColors(lastSubset).then(recolorMarkers);
|
||||||
|
} else {
|
||||||
|
recolorMarkers();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
els.search.value = '';
|
||||||
|
els.fType.querySelectorAll('input').forEach((i) => (i.checked = true));
|
||||||
|
els.fStat.querySelectorAll('input').forEach((i) => (i.checked = true));
|
||||||
|
els.fEtat.querySelectorAll('input').forEach((i) => (i.checked = true));
|
||||||
|
els.fOpt.querySelectorAll('input').forEach((i) => (i.checked = false));
|
||||||
|
els.reg.value = els.aca.value = els.dep.value = els.circ.value = '';
|
||||||
|
refreshGeoSelects();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Démarrage ---
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/etablissements.php');
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
ALL = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
els.count.textContent = 'Erreur de chargement des données.';
|
||||||
|
console.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pré-calcul du champ de recherche normalisé + index par UAI.
|
||||||
|
ALL.forEach((e) => {
|
||||||
|
e._s = norm((e.nom || '') + ' ' + (e.com || ''));
|
||||||
|
if (e.id) BY_ID.set(e.id, e);
|
||||||
|
});
|
||||||
|
|
||||||
|
buildChecks(els.fType, uniqSorted(ALL.map((e) => e.type)), true);
|
||||||
|
buildChecks(els.fStat, uniqSorted(ALL.map((e) => e.stat)), false);
|
||||||
|
buildChecks(els.fEtat, uniqSorted(ALL.map((e) => e.etat)), false);
|
||||||
|
buildOptionChecks(els.fOpt);
|
||||||
|
refreshGeoSelects();
|
||||||
|
|
||||||
|
// Deep-link : ?color=dpe&place=marseille (ou ?center=lat,lon&zoom=N).
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if ((params.get('color') || '').toLowerCase() === 'dpe') colorMode = 'dpe';
|
||||||
|
buildLegend();
|
||||||
|
|
||||||
|
els.search.addEventListener('input', debounce(applyFilters, 250));
|
||||||
|
[els.reg, els.aca, els.dep].forEach((s) => s.addEventListener('change', onGeoChange));
|
||||||
|
els.circ.addEventListener('change', applyFilters);
|
||||||
|
els.reset.addEventListener('click', resetFilters);
|
||||||
|
|
||||||
|
// Fiche plein écran : ouverture depuis la popup, recentrage sans rechargement.
|
||||||
|
Fiche.init({
|
||||||
|
onLocate: (e) => {
|
||||||
|
map.setView([e.lat, e.lon], 17, { animate: true });
|
||||||
|
markerFor(e).openPopup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
document.getElementById('map').addEventListener('click', (ev) => {
|
||||||
|
const btn = ev.target.closest('.popup-fiche');
|
||||||
|
if (!btn) return;
|
||||||
|
const rec = BY_ID.get(btn.dataset.id);
|
||||||
|
if (rec) Fiche.open(rec);
|
||||||
|
});
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
// Centrage initial depuis l'URL (après le rendu des marqueurs).
|
||||||
|
const place = (params.get('place') || '').toLowerCase();
|
||||||
|
const center = params.get('center');
|
||||||
|
if (PLACES[place]) {
|
||||||
|
const [lat, lon, z] = PLACES[place];
|
||||||
|
map.setView([lat, lon], z);
|
||||||
|
} else if (center && /^-?\d+\.?\d*,-?\d+\.?\d*$/.test(center)) {
|
||||||
|
const [lat, lon] = center.split(',').map(Number);
|
||||||
|
map.setView([lat, lon], Number(params.get('zoom')) || 13);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Convertit l'export brut (raw_export.json) de l'annuaire de l'education
|
||||||
|
* en un fichier compact consomme par la carte.
|
||||||
|
*
|
||||||
|
* - ClES courtes pour reduire la taille (~3x plus petit).
|
||||||
|
* - Coordonnees arrondies a 5 decimales (~1 m de precision).
|
||||||
|
* - Les etablissements sans geolocalisation sont ecartes.
|
||||||
|
*
|
||||||
|
* Usage : php build_data.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$src = __DIR__ . '/raw_export.json';
|
||||||
|
$dstDir = __DIR__ . '/data';
|
||||||
|
$dst = $dstDir . '/etablissements.json';
|
||||||
|
|
||||||
|
if (!is_file($src)) {
|
||||||
|
fwrite(STDERR, "Fichier source introuvable : $src\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($dstDir) && !mkdir($dstDir, 0777, true) && !is_dir($dstDir)) {
|
||||||
|
fwrite(STDERR, "Impossible de creer le dossier $dstDir\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "Lecture de l'export brut...\n");
|
||||||
|
$rows = json_decode(file_get_contents($src), true);
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
fwrite(STDERR, "JSON source invalide.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$lat = $r['latitude'] ?? null;
|
||||||
|
$lon = $r['longitude'] ?? null;
|
||||||
|
if ($lat === null || $lon === null) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rec = [
|
||||||
|
'id' => $r['identifiant_de_l_etablissement'] ?? null,
|
||||||
|
'nom' => $r['nom_etablissement'] ?? null,
|
||||||
|
'type' => $r['type_etablissement'] ?? null,
|
||||||
|
'stat' => $r['statut_public_prive'] ?? null,
|
||||||
|
'reg' => $r['libelle_region'] ?? null,
|
||||||
|
'aca' => $r['libelle_academie'] ?? null,
|
||||||
|
'dep' => $r['libelle_departement'] ?? null,
|
||||||
|
'depc' => $r['code_departement'] ?? null,
|
||||||
|
'com' => $r['nom_commune'] ?? null,
|
||||||
|
'cp' => $r['code_postal'] ?? null,
|
||||||
|
'adr' => $r['adresse_1'] ?? null,
|
||||||
|
'tel' => $r['telephone'] ?? null,
|
||||||
|
'mail' => $r['mail'] ?? null,
|
||||||
|
'web' => $r['web'] ?? null,
|
||||||
|
'etat' => $r['etat'] ?? null,
|
||||||
|
'circ' => $r['nom_circonscription'] ?? null,
|
||||||
|
'lat' => round((float) $lat, 5),
|
||||||
|
'lon' => round((float) $lon, 5),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Options : flag stocke seulement quand l'option est presente (1), pour alleger.
|
||||||
|
$options = [
|
||||||
|
'rest' => 'restauration',
|
||||||
|
'heb' => 'hebergement', // internat
|
||||||
|
'ulis' => 'ulis',
|
||||||
|
'segp' => 'segpa',
|
||||||
|
'appr' => 'apprentissage',
|
||||||
|
];
|
||||||
|
foreach ($options as $key => $field) {
|
||||||
|
if ((int) ($r[$field] ?? 0) === 1) {
|
||||||
|
$rec[$key] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = $rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($dst, json_encode($out, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$count = count($out);
|
||||||
|
$size = round(filesize($dst) / 1048576, 1);
|
||||||
|
fwrite(STDERR, "OK : $count etablissements ecrits ($size Mo), $skipped sans geoloc ignores.\n");
|
||||||
|
fwrite(STDERR, "Fichier : $dst\n");
|
||||||
@@ -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 `<div class="info-row"><dt>${esc(label)}</dt><dd>${value}</dd></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInfos(e) {
|
||||||
|
const badgeCls = e.etat === 'OUVERT' ? 'ouvert' : 'fermer';
|
||||||
|
const ville = [e.cp, e.com].filter(Boolean).join(' ');
|
||||||
|
const opts = OPTIONS.filter((o) => e[o.key] === 1).map((o) => o.label);
|
||||||
|
|
||||||
|
let webHtml = '';
|
||||||
|
if (e.web) {
|
||||||
|
const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web;
|
||||||
|
webHtml = `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(e.web)}</a>`;
|
||||||
|
}
|
||||||
|
const mailHtml = e.mail ? `<a href="mailto:${esc(e.mail)}">${esc(e.mail)}</a>` : '';
|
||||||
|
const telHtml = e.tel ? `<a href="tel:${esc(e.tel)}">${esc(e.tel)}</a>` : '';
|
||||||
|
const dep = [e.dep, e.depc ? `(${e.depc})` : ''].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
els.infoMain.querySelectorAll('.info-grid').forEach((n) => n.remove());
|
||||||
|
els.infoMain.insertAdjacentHTML('afterbegin', `
|
||||||
|
<div class="info-grid">
|
||||||
|
<section class="info-card"><h3>Identité</h3><dl>
|
||||||
|
${infoRow('Type', esc(e.type))}
|
||||||
|
${infoRow('Statut', esc(e.stat))}
|
||||||
|
${infoRow('État', e.etat ? `<span class="badge ${badgeCls}">${esc(e.etat)}</span>` : '')}
|
||||||
|
${infoRow('UAI', esc(e.id))}
|
||||||
|
</dl></section>
|
||||||
|
<section class="info-card"><h3>Coordonnées</h3><dl>
|
||||||
|
${infoRow('Adresse', esc(e.adr))}
|
||||||
|
${infoRow('Commune', esc(ville))}
|
||||||
|
${infoRow('Téléphone', telHtml)}
|
||||||
|
${infoRow('Courriel', mailHtml)}
|
||||||
|
${infoRow('Site web', webHtml)}
|
||||||
|
</dl></section>
|
||||||
|
<section class="info-card"><h3>Rattachement</h3><dl>
|
||||||
|
${infoRow('Région', esc(e.reg))}
|
||||||
|
${infoRow('Académie', esc(e.aca))}
|
||||||
|
${infoRow('Département', esc(dep))}
|
||||||
|
${infoRow('Circonscription', esc(e.circ))}
|
||||||
|
</dl></section>
|
||||||
|
<section class="info-card"><h3>Options & services</h3><dl>
|
||||||
|
${infoRow('Options proposées', opts.length ? esc(opts.join(' · ')) : '<em>Aucune renseignée</em>')}
|
||||||
|
${infoRow('Coordonnées GPS', e.lat != null ? `${e.lat}, ${e.lon}` : '')}
|
||||||
|
</dl></section>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effectifs (écoles 1er degré) : derniers chiffres + évolution ---
|
||||||
|
function sparkline(series, key, color) {
|
||||||
|
// viewBox proportionné ; le SVG conserve son ratio (pas d'étirement).
|
||||||
|
const W = 640, H = 200, padX = 34, padT = 16, padB = 26;
|
||||||
|
const vals = series.map((s) => s[key]);
|
||||||
|
const minV = Math.min(...vals), maxV = Math.max(...vals);
|
||||||
|
const x = (i) => padX + (W - 2 * padX) * (series.length === 1 ? 0.5 : i / (series.length - 1));
|
||||||
|
const y = (v) => (H - padB) - (H - padT - padB) * (maxV === minV ? 0.5 : (v - minV) / (maxV - minV));
|
||||||
|
const pts = series.map((s, i) => `${x(i).toFixed(1)},${y(s[key]).toFixed(1)}`);
|
||||||
|
const area = `${padX},${H - padB} ${pts.join(' ')} ${(W - padX).toFixed(1)},${H - padB}`;
|
||||||
|
const last = series.length - 1;
|
||||||
|
const dots = series.map((s, i) =>
|
||||||
|
`<circle cx="${x(i).toFixed(1)}" cy="${y(s[key]).toFixed(1)}" r="3" fill="${color}"><title>${s.annee} : ${s[key]}</title></circle>`
|
||||||
|
).join('');
|
||||||
|
return `<svg class="eff-spark" viewBox="0 0 ${W} ${H}" role="img">
|
||||||
|
<polygon points="${area}" fill="${color}" opacity="0.12"/>
|
||||||
|
<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="2.5"/>
|
||||||
|
${dots}
|
||||||
|
<circle cx="${x(last).toFixed(1)}" cy="${y(series[last][key]).toFixed(1)}" r="5" fill="${color}"/>
|
||||||
|
<text x="${padX}" y="${H - 6}" class="eff-axis">${series[0].annee}</text>
|
||||||
|
<text x="${W - padX}" y="${H - 6}" class="eff-axis" text-anchor="end">${series[last].annee}</text>
|
||||||
|
<text x="4" y="${padT + 6}" class="eff-axis">${maxV}</text>
|
||||||
|
<text x="4" y="${H - padB}" class="eff-axis">${minV}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EFF_LABEL = {
|
||||||
|
'Ecole': 'école (1<sup>er</sup> degré)',
|
||||||
|
'Collège': 'collège',
|
||||||
|
'Lycée': 'lycée',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graphique IPS : courbe de l'établissement + référence départementale (même échelle).
|
||||||
|
function ipsChart(series) {
|
||||||
|
const W = 640, H = 200, padX = 34, padT = 16, padB = 26;
|
||||||
|
const depVals = series.map((s) => s.ips_dep).filter((v) => v != null);
|
||||||
|
const hasDep = depVals.length > 0;
|
||||||
|
const vals = series.map((s) => s.ips).concat(depVals);
|
||||||
|
const minV = Math.min(...vals), maxV = Math.max(...vals);
|
||||||
|
const x = (i) => padX + (W - 2 * padX) * (series.length === 1 ? 0.5 : i / (series.length - 1));
|
||||||
|
const y = (v) => (H - padB) - (H - padT - padB) * (maxV === minV ? 0.5 : (v - minV) / (maxV - minV));
|
||||||
|
const poly = (key, color, extra = '') => {
|
||||||
|
const pts = series.map((s, i) => (s[key] != null ? `${x(i).toFixed(1)},${y(s[key]).toFixed(1)}` : null)).filter(Boolean);
|
||||||
|
return `<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="2.5" ${extra}/>`;
|
||||||
|
};
|
||||||
|
const dots = (key, color) => series.map((s, i) =>
|
||||||
|
s[key] != null ? `<circle cx="${x(i).toFixed(1)}" cy="${y(s[key]).toFixed(1)}" r="3" fill="${color}"><title>${s.annee} : ${s[key]}</title></circle>` : ''
|
||||||
|
).join('');
|
||||||
|
return `<svg class="eff-spark" viewBox="0 0 ${W} ${H}" role="img">
|
||||||
|
${hasDep ? poly('ips_dep', '#b08968', 'stroke-dasharray="5 4" opacity="0.85"') : ''}
|
||||||
|
${poly('ips', '#9d4edd')}
|
||||||
|
${hasDep ? dots('ips_dep', '#b08968') : ''}
|
||||||
|
${dots('ips', '#9d4edd')}
|
||||||
|
<text x="${padX}" y="${H - 6}" class="eff-axis">${series[0].annee}</text>
|
||||||
|
<text x="${W - padX}" y="${H - 6}" class="eff-axis" text-anchor="end">${series[series.length - 1].annee}</text>
|
||||||
|
<text x="4" y="${padT + 6}" class="eff-axis">${maxV}</text>
|
||||||
|
<text x="4" y="${H - padB}" class="eff-axis">${minV}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipsCardHtml(ips) {
|
||||||
|
const last = ips[ips.length - 1];
|
||||||
|
const first = ips[0];
|
||||||
|
const depTxt = last.ips_dep != null ? ` · département ${last.ips_dep}` : '';
|
||||||
|
const legend = last.ips_dep != null
|
||||||
|
? `<div class="ips-legend"><span class="ips-k ips-k-etab">Établissement</span><span class="ips-k ips-k-dep">Département</span></div>` : '';
|
||||||
|
// Ligne d'évolution seulement s'il y a au moins deux rentrées.
|
||||||
|
let evolHtml = '';
|
||||||
|
if (ips.length >= 2) {
|
||||||
|
const delta = +(last.ips - first.ips).toFixed(1);
|
||||||
|
const sign = delta > 0 ? '+' : '';
|
||||||
|
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
|
||||||
|
evolHtml = `<div class="eff-evol ${trend}">${first.annee} → ${last.annee} : ${sign}${delta} pts</div>`;
|
||||||
|
}
|
||||||
|
return `<section class="info-card ips-block">
|
||||||
|
<h3>IPS — indice de position sociale</h3>
|
||||||
|
<div class="eff-latest">
|
||||||
|
<span class="eff-num">${last.ips}</span>
|
||||||
|
<span class="eff-year">(rentrée ${last.annee}${depTxt})</span>
|
||||||
|
</div>
|
||||||
|
${legend}
|
||||||
|
${ipsChart(ips)}
|
||||||
|
${evolHtml}
|
||||||
|
<p class="ips-note">IPS élevé = milieu social plus favorisé (réf. nationale ≈ 100).</p>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carte « Effectifs » de l'établissement (sa propre évolution).
|
||||||
|
function effectifsCardHtml(serie, e) {
|
||||||
|
if (!Array.isArray(serie) || !serie.length) return '';
|
||||||
|
const last = serie[serie.length - 1];
|
||||||
|
const first = serie[0];
|
||||||
|
const delta = last.eleves - first.eleves;
|
||||||
|
const pct = first.eleves ? Math.round(delta / first.eleves * 100) : 0;
|
||||||
|
const sign = delta > 0 ? '+' : '';
|
||||||
|
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
|
||||||
|
const label = EFF_LABEL[e.type] || (e.type ? esc(e.type.toLowerCase()) : '');
|
||||||
|
const classesHtml = last.classes > 0
|
||||||
|
? ` · ${last.classes} classe${last.classes > 1 ? 's' : ''}` : '';
|
||||||
|
return `<section class="info-card effectifs-block">
|
||||||
|
<h3>Effectifs${label ? ' — ' + label : ''}</h3>
|
||||||
|
<div class="eff-latest">
|
||||||
|
<span class="eff-num">${last.eleves}</span> élèves${classesHtml}
|
||||||
|
<span class="eff-year">(rentrée ${last.annee})</span>
|
||||||
|
</div>
|
||||||
|
${sparkline(serie, 'eleves', '#1d3557')}
|
||||||
|
<div class="eff-evol ${trend}">
|
||||||
|
${first.annee} → ${last.annee} :
|
||||||
|
${sign}${delta} élève${Math.abs(delta) > 1 ? 's' : ''} (${sign}${pct} %)
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carte « Effectifs cumulés des écoles PUBLIQUES de la commune ».
|
||||||
|
function communeCardHtml(commune, e) {
|
||||||
|
if (!Array.isArray(commune) || !commune.length) return '';
|
||||||
|
const last = commune[commune.length - 1];
|
||||||
|
const first = commune[0];
|
||||||
|
let evol = '';
|
||||||
|
if (commune.length >= 2) {
|
||||||
|
const delta = last.eleves - first.eleves;
|
||||||
|
const sign = delta > 0 ? '+' : '';
|
||||||
|
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
|
||||||
|
evol = `<div class="eff-evol ${trend}">${first.annee} → ${last.annee} :
|
||||||
|
${sign}${delta.toLocaleString('fr')} élève${Math.abs(delta) > 1 ? 's' : ''}</div>`;
|
||||||
|
}
|
||||||
|
return `<section class="info-card effectifs-block">
|
||||||
|
<h3>Écoles publiques de la commune${e.com ? ' — ' + esc(e.com) : ''}</h3>
|
||||||
|
<div class="eff-latest">
|
||||||
|
<span class="eff-num">${last.eleves.toLocaleString('fr')}</span> élèves
|
||||||
|
<span class="eff-year">(rentrée ${last.annee}, cumul public)</span>
|
||||||
|
</div>
|
||||||
|
${sparkline(commune, 'eleves', '#2a9d8f')}
|
||||||
|
${evol}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloc d'une série de population : dernier chiffre + courbe + évolution.
|
||||||
|
function popSeriesHtml(serie, color) {
|
||||||
|
const last = serie[serie.length - 1];
|
||||||
|
const first = serie[0];
|
||||||
|
let evol = '';
|
||||||
|
if (serie.length >= 2) {
|
||||||
|
const delta = last.pop - first.pop;
|
||||||
|
const pct = first.pop ? Math.round(delta / first.pop * 100) : 0;
|
||||||
|
const sign = delta > 0 ? '+' : '';
|
||||||
|
const trend = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
|
||||||
|
evol = `<div class="eff-evol ${trend}">${first.annee} → ${last.annee} :
|
||||||
|
${sign}${delta.toLocaleString('fr')} hab. (${sign}${pct} %)</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="eff-latest">
|
||||||
|
<span class="eff-num">${last.pop.toLocaleString('fr')}</span> habitants
|
||||||
|
<span class="eff-year">(${last.annee})</span>
|
||||||
|
</div>
|
||||||
|
${sparkline(serie, 'pop', color)}
|
||||||
|
${evol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carte « Population » : zone (commune/arrondissement) + ville entière pour les arrondissements.
|
||||||
|
function populationCardHtml(data, e) {
|
||||||
|
const zone = (data && Array.isArray(data.zone)) ? data.zone : [];
|
||||||
|
if (!zone.length) return '';
|
||||||
|
const estimated = !!(data && data.zone_estimated);
|
||||||
|
const ville = (data && Array.isArray(data.ville)) ? data.ville : [];
|
||||||
|
const estNote = estimated
|
||||||
|
? '<p class="pop-note">Estimation : population de la ville × part de l’arrondissement dans les IRIS (recensement 2022).</p>'
|
||||||
|
: '';
|
||||||
|
let villeHtml = '';
|
||||||
|
if (ville.length) {
|
||||||
|
villeHtml = `<h4 class="eff-sub pop-sub">Ville entière${data.ville_nom ? ' — ' + esc(data.ville_nom) : ''}</h4>
|
||||||
|
${popSeriesHtml(ville, '#577590')}`;
|
||||||
|
}
|
||||||
|
return `<section class="info-card pop-block">
|
||||||
|
<h3>Population${e.com ? ' — ' + esc(e.com) : ''} ${estimated ? '(estimation)' : '(INSEE)'}</h3>
|
||||||
|
${popSeriesHtml(zone, estimated ? '#9aa7b0' : '#577590')}
|
||||||
|
${estNote}
|
||||||
|
${villeHtml}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Établissement (effectifs · IPS) puis commune (écoles publiques · population), côte à côte.
|
||||||
|
async function renderEffectifs(e) {
|
||||||
|
els.infoMain.querySelectorAll('.eff-ips-row').forEach((n) => n.remove());
|
||||||
|
const [eff, communeEff, ips, popData] = await Promise.all([
|
||||||
|
apiJson(`api/effectifs.php?uai=${encodeURIComponent(e.id)}`).catch(() => []),
|
||||||
|
apiJson(`api/effectifs.php?action=commune&uai=${encodeURIComponent(e.id)}`).catch(() => []),
|
||||||
|
apiJson(`api/ips.php?uai=${encodeURIComponent(e.id)}`).catch(() => []),
|
||||||
|
apiJson(`api/population.php?uai=${encodeURIComponent(e.id)}`).catch(() => null),
|
||||||
|
]);
|
||||||
|
const cards = [
|
||||||
|
effectifsCardHtml(Array.isArray(eff) ? eff : [], e),
|
||||||
|
(Array.isArray(ips) && ips.length) ? ipsCardHtml(ips) : '',
|
||||||
|
communeCardHtml(Array.isArray(communeEff) ? communeEff : [], e),
|
||||||
|
populationCardHtml(popData, e),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (!cards.length) return;
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'eff-ips-row';
|
||||||
|
row.innerHTML = cards.join('');
|
||||||
|
els.infoMain.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DPE (ADEME, bâtiments tertiaires) — rattaché par proximité ---
|
||||||
|
const DPE_NOTE = 'Rattaché au(x) bâtiment(s) tertiaire(s) le(s) plus proche(s) ' +
|
||||||
|
'(données ADEME). Le dataset n’a pas d’identifiant école : le lien est ' +
|
||||||
|
'géographique, la distance indique sa fiabilité.';
|
||||||
|
|
||||||
|
function dpeBadge(letter, kind) {
|
||||||
|
const l = String(letter || '').toUpperCase();
|
||||||
|
const cls = /^[A-G]$/.test(l) ? 'dpe-' + l.toLowerCase() : 'dpe-na';
|
||||||
|
const k = kind === 'ges' ? 'GES' : 'Énergie';
|
||||||
|
return `<span class="dpe-badge ${cls}"><span class="dpe-k">${k}</span>${esc(l || '—')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDpe(e) {
|
||||||
|
els.infoMain.querySelectorAll('.dpe-block').forEach((n) => n.remove());
|
||||||
|
let list;
|
||||||
|
try {
|
||||||
|
list = await apiJson(`api/dpe.php?uai=${encodeURIComponent(e.id)}`);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(list) || !list.length) return; // aucun DPE rattaché
|
||||||
|
|
||||||
|
const rows = list.map((d) => {
|
||||||
|
const meta = [];
|
||||||
|
if (d.conso != null) meta.push(`${d.conso} kWh<sub>EP</sub>/m²/an`);
|
||||||
|
if (d.ges != null) meta.push(`${d.ges} kg CO₂/m²/an`);
|
||||||
|
if (d.surface != null) meta.push(`${d.surface} m²`);
|
||||||
|
const dat = d.date_dpe ? `établi le ${esc(d.date_dpe)}` : '';
|
||||||
|
const fin = d.date_fin ? ` · valide jusqu’au ${esc(d.date_fin)}` : '';
|
||||||
|
return `<li class="dpe-item">
|
||||||
|
<div class="dpe-badges">${dpeBadge(d.etiquette_dpe, 'dpe')} ${dpeBadge(d.etiquette_ges, 'ges')}</div>
|
||||||
|
${meta.length ? `<div class="dpe-metaline">${meta.join(' · ')}</div>` : ''}
|
||||||
|
<div class="dpe-addr">${esc(d.adresse || '')} <span class="dpe-dist">≈ ${d.distance_m} m</span></div>
|
||||||
|
${dat || fin ? `<div class="dpe-dates">${dat}${fin}</div>` : ''}
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const block = document.createElement('section');
|
||||||
|
block.className = 'info-card dpe-block';
|
||||||
|
block.innerHTML = `
|
||||||
|
<h3>Performance énergétique (DPE)</h3>
|
||||||
|
<ul class="dpe-list">${rows}</ul>
|
||||||
|
<p class="dpe-note">${DPE_NOTE}</p>`;
|
||||||
|
els.infoMain.appendChild(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Commentaires (réservés aux utilisateurs, visibles par groupe) ---
|
||||||
|
const postJson = (url, body) => apiJson(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmtDateTime(s) {
|
||||||
|
const d = new Date(String(s || '').replace(' ', 'T'));
|
||||||
|
return isNaN(d) ? esc(s) : d.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
}
|
||||||
|
function fmtDate(s) {
|
||||||
|
const d = new Date(String(s || '').replace(' ', 'T'));
|
||||||
|
return isNaN(d) ? '' : d.toLocaleDateString('fr-FR');
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentItemHtml(c, canModerate) {
|
||||||
|
const del = c.mine || canModerate
|
||||||
|
? `<button class="comment-del" data-id="${c.id}" title="Supprimer">🗑</button>` : '';
|
||||||
|
return `<li class="comment-item">
|
||||||
|
<div class="comment-head">
|
||||||
|
<span class="comment-author">${esc(c.username)}</span>
|
||||||
|
<span class="comment-date">${fmtDateTime(c.created_at)}</span>
|
||||||
|
${del}
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">${esc(c.body)}</div>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contenu interne d'un fil (connexion / lecture seule / liste + saisie).
|
||||||
|
// « intro » = true pour le fil d'établissement (textes plus explicites).
|
||||||
|
function threadInnerHtml(data, intro) {
|
||||||
|
if (!data.auth) {
|
||||||
|
return `
|
||||||
|
${intro ? '<p class="comment-intro">Les commentaires sont réservés aux utilisateurs. Connectez-vous pour les consulter et en déposer.</p>' : ''}
|
||||||
|
<form class="comment-login" autocomplete="off">
|
||||||
|
<input class="cl-user" type="text" placeholder="Identifiant" required>
|
||||||
|
<input class="cl-pass" type="password" placeholder="Mot de passe" required>
|
||||||
|
<button type="submit" class="btn-primary btn-sm">Se connecter</button>
|
||||||
|
<span class="comment-msg err"></span>
|
||||||
|
</form>
|
||||||
|
<p class="comment-forgot"><a href="forgot.html" target="_blank" rel="noopener">Mot de passe oublié ?</a></p>`;
|
||||||
|
}
|
||||||
|
const canModerate = data.role === 'super';
|
||||||
|
const list = data.comments.map((c) => commentItemHtml(c, canModerate));
|
||||||
|
const listHtml = list.length
|
||||||
|
? `<ul class="comment-list">${list.join('')}</ul>`
|
||||||
|
: '<p class="empty">Aucun commentaire pour le moment.</p>';
|
||||||
|
let form;
|
||||||
|
if (data.can_post) {
|
||||||
|
form = `<form class="comment-form" autocomplete="off">
|
||||||
|
<textarea class="cf-body" rows="3" placeholder="Votre commentaire…" required></textarea>
|
||||||
|
<div class="comment-form-foot">
|
||||||
|
<span class="comment-who">Connecté : <strong>${esc(data.username)}</strong></span>
|
||||||
|
<button type="submit" class="btn-primary btn-sm">Publier</button>
|
||||||
|
</div>
|
||||||
|
<span class="comment-msg"></span>
|
||||||
|
</form>`;
|
||||||
|
} else {
|
||||||
|
form = '<p class="comment-intro">Vous n’appartenez à aucun groupe : lecture seule. Demandez à un administrateur de vous ajouter à un groupe pour commenter.</p>';
|
||||||
|
}
|
||||||
|
return form + listHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charge le contexte visiteur + les commentaires d'établissement (une fois par fiche).
|
||||||
|
async function loadViewer(uai) {
|
||||||
|
try {
|
||||||
|
estComments = await apiJson(`api/comments.php?uai=${encodeURIComponent(uai)}`);
|
||||||
|
} catch {
|
||||||
|
estComments = { auth: false, can_post: false, can_validate: false, comments: [] };
|
||||||
|
}
|
||||||
|
viewer = {
|
||||||
|
auth: !!estComments.auth,
|
||||||
|
can_post: !!estComments.can_post,
|
||||||
|
can_validate: !!estComments.can_validate,
|
||||||
|
role: estComments.role || null,
|
||||||
|
username: estComments.username || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendu du fil d'établissement, dans la colonne droite de l'onglet Informations.
|
||||||
|
function renderComments() {
|
||||||
|
els.infoSide.querySelectorAll('.comments-block').forEach((n) => n.remove());
|
||||||
|
if (!estComments) return;
|
||||||
|
const block = document.createElement('section');
|
||||||
|
block.className = 'info-card comments-block';
|
||||||
|
block.innerHTML = `<h3>💬 Commentaires</h3>
|
||||||
|
<div class="comment-thread" data-uai="${esc(current.id)}">${threadInnerHtml(estComments, true)}</div>`;
|
||||||
|
els.infoSide.appendChild(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portée d'un fil (établissement ou document) → URL d'API.
|
||||||
|
function threadUrl(threadEl) {
|
||||||
|
return threadEl.dataset.item
|
||||||
|
? `api/comments.php?item_id=${encodeURIComponent(threadEl.dataset.item)}`
|
||||||
|
: `api/comments.php?uai=${encodeURIComponent(threadEl.dataset.uai)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recharge un fil donné (sans toucher au reste de la page).
|
||||||
|
async function refreshThread(threadEl) {
|
||||||
|
let data;
|
||||||
|
try { data = await apiJson(threadUrl(threadEl)); }
|
||||||
|
catch (err) { threadEl.innerHTML = `<p class="empty">Erreur : ${esc(err.message)}</p>`; return; }
|
||||||
|
threadEl.innerHTML = threadInnerHtml(data, !threadEl.dataset.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion commune (envoi connexion / commentaire, suppression) sur tout .comment-thread.
|
||||||
|
async function onThreadSubmit(ev) {
|
||||||
|
const loginForm = ev.target.closest('.comment-login');
|
||||||
|
const commentForm = ev.target.closest('.comment-form');
|
||||||
|
if (!loginForm && !commentForm) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const threadEl = ev.target.closest('.comment-thread');
|
||||||
|
try {
|
||||||
|
if (loginForm) {
|
||||||
|
const res = await postJson('api/auth.php?action=login', {
|
||||||
|
username: loginForm.querySelector('.cl-user').value,
|
||||||
|
password: loginForm.querySelector('.cl-pass').value,
|
||||||
|
});
|
||||||
|
if (res.user && res.user.must_change) {
|
||||||
|
threadEl.innerHTML = '<p class="comment-intro">Vous devez définir un nouveau mot de passe avant de continuer. <a href="account.html?forced=1" target="_blank" rel="noopener">Changer mon mot de passe</a>, puis rouvrez la fiche.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadViewer(current.id); // met à jour droits (édition) + identité
|
||||||
|
renderComments();
|
||||||
|
renderDocs(); // réaffiche les documents (boutons d'édition)
|
||||||
|
} else {
|
||||||
|
const body = commentForm.querySelector('.cf-body').value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
await postJson(threadUrl(threadEl), { body });
|
||||||
|
await refreshThread(threadEl);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = ev.target.querySelector('.comment-msg');
|
||||||
|
if (msg) { msg.textContent = 'Erreur : ' + err.message; msg.className = 'comment-msg err'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onThreadClick(ev) {
|
||||||
|
const del = ev.target.closest('.comment-del');
|
||||||
|
if (!del) return;
|
||||||
|
const threadEl = ev.target.closest('.comment-thread');
|
||||||
|
if (!threadEl || !confirm('Supprimer ce commentaire ?')) return;
|
||||||
|
try {
|
||||||
|
await apiJson(`api/comments.php?id=${del.dataset.id}`, { method: 'DELETE' });
|
||||||
|
await refreshThread(threadEl);
|
||||||
|
} catch (err) { alert('Erreur : ' + err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactions d'une carte document : déplier le fil, éditer le libellé/description.
|
||||||
|
async function onDocClick(ev) {
|
||||||
|
const toggle = ev.target.closest('.comment-toggle');
|
||||||
|
const edit = ev.target.closest('.doc-edit');
|
||||||
|
const cancel = ev.target.closest('.doc-edit-cancel');
|
||||||
|
|
||||||
|
if (toggle) {
|
||||||
|
const card = toggle.closest('.doc-card');
|
||||||
|
const box = card.querySelector('.doc-thread');
|
||||||
|
box.hidden = !box.hidden;
|
||||||
|
toggle.classList.toggle('open', !box.hidden);
|
||||||
|
if (!box.hidden && !box.dataset.loaded) {
|
||||||
|
box.dataset.loaded = '1';
|
||||||
|
box.innerHTML = `<div class="comment-thread" data-item="${card.dataset.id}"><p class="empty">Chargement…</p></div>`;
|
||||||
|
await refreshThread(box.querySelector('.comment-thread'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (edit) {
|
||||||
|
const card = edit.closest('.doc-card');
|
||||||
|
const box = card.querySelector('.doc-edit-box');
|
||||||
|
if (!box.hidden) { box.hidden = true; box.innerHTML = ''; return; }
|
||||||
|
const it = items.find((x) => String(x.id) === String(card.dataset.id));
|
||||||
|
if (!it) return;
|
||||||
|
box.innerHTML = `<form class="doc-edit-form" autocomplete="off">
|
||||||
|
<input class="de-title" type="text" value="${esc(it.title)}" placeholder="Libellé" required>
|
||||||
|
<textarea class="de-desc" rows="2" placeholder="Description (facultatif)">${esc(it.description || '')}</textarea>
|
||||||
|
<div class="doc-edit-actions">
|
||||||
|
<button type="submit" class="btn-primary btn-sm">Enregistrer</button>
|
||||||
|
<button type="button" class="btn-secondary btn-sm doc-edit-cancel">Annuler</button>
|
||||||
|
<span class="comment-msg"></span>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
box.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancel) {
|
||||||
|
const box = cancel.closest('.doc-edit-box');
|
||||||
|
box.hidden = true;
|
||||||
|
box.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDocEditSubmit(ev) {
|
||||||
|
const form = ev.target.closest('.doc-edit-form');
|
||||||
|
if (!form) return false;
|
||||||
|
ev.preventDefault();
|
||||||
|
const card = form.closest('.doc-card');
|
||||||
|
const msg = form.querySelector('.comment-msg');
|
||||||
|
const title = form.querySelector('.de-title').value.trim();
|
||||||
|
const description = form.querySelector('.de-desc').value.trim();
|
||||||
|
if (!title) { msg.textContent = 'Le libellé est requis.'; msg.className = 'comment-msg err'; return true; }
|
||||||
|
try {
|
||||||
|
await postJson('api/items.php?action=update', { id: Number(card.dataset.id), title, description });
|
||||||
|
items = await getItems(current.id);
|
||||||
|
renderDocs();
|
||||||
|
} catch (err) { msg.textContent = 'Erreur : ' + err.message; msg.className = 'comment-msg err'; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Arborescence de dossiers ---
|
||||||
|
function childrenOf(parentId) {
|
||||||
|
return foldersCache
|
||||||
|
.filter((f) => f.parent_id === parentId)
|
||||||
|
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, 'fr'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countInFolder(folderId) {
|
||||||
|
return items.filter((it) => it.folder_id === folderId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeNodeHtml(folder) {
|
||||||
|
const kids = childrenOf(folder.id);
|
||||||
|
const n = countInFolder(folder.id);
|
||||||
|
const active = String(selectedFolder) === String(folder.id) ? ' active' : '';
|
||||||
|
let html = `<li><span class="tree-folder${active}" data-folder="${folder.id}">`
|
||||||
|
+ `📁 ${esc(folder.name)}${n ? ` <span class="tree-count">${n}</span>` : ''}</span>`;
|
||||||
|
if (kids.length) {
|
||||||
|
html += '<ul>' + kids.map(treeNodeHtml).join('') + '</ul>';
|
||||||
|
}
|
||||||
|
return html + '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree() {
|
||||||
|
const noneCount = countInFolder(null);
|
||||||
|
let html = '<ul class="tree">';
|
||||||
|
html += `<li><span class="tree-folder${selectedFolder === 'all' ? ' active' : ''}" data-folder="all">`
|
||||||
|
+ `🗂 Tous les éléments <span class="tree-count">${items.length}</span></span></li>`;
|
||||||
|
html += childrenOf(null).map(treeNodeHtml).join('');
|
||||||
|
if (noneCount) {
|
||||||
|
html += `<li><span class="tree-folder${selectedFolder === 'none' ? ' active' : ''}" data-folder="none">`
|
||||||
|
+ `📂 Sans dossier <span class="tree-count">${noneCount}</span></span></li>`;
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
els.tree.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ligne principale d'un élément (selon son type).
|
||||||
|
function itemMainHtml(it) {
|
||||||
|
if (it.kind === 'photo') {
|
||||||
|
return `<figure class="item-photo doc-thumb" data-url="${esc(it.url)}" data-title="${esc(it.title)}">
|
||||||
|
<img src="${esc(it.url)}" alt="${esc(it.title)}" loading="lazy">
|
||||||
|
</figure>
|
||||||
|
<div class="doc-name">🖼 ${esc(it.title)}</div>`;
|
||||||
|
}
|
||||||
|
if (it.kind === 'link') {
|
||||||
|
return `<div class="doc-name"><span class="item-icon">🔗</span>
|
||||||
|
<a class="item-name" href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.title)}</a></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="doc-name"><span class="item-icon">📄</span>
|
||||||
|
<a class="item-name" href="${esc(it.url)}" download="${esc(it.title)}">${esc(it.title)}</a>
|
||||||
|
<span class="item-size">${fmtSize(it.size)}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carte d'un élément : libellé, date de dépôt, description, édition, commentaires.
|
||||||
|
function docCardHtml(it) {
|
||||||
|
const date = it.submitted_at ? `<span class="item-date">📅 déposé le ${fmtDate(it.submitted_at)}</span>` : '';
|
||||||
|
const desc = it.description ? `<div class="item-desc">${esc(it.description)}</div>` : '';
|
||||||
|
const editBtn = viewer.can_validate ? `<button class="doc-edit" type="button" data-id="${it.id}">✏️ Éditer</button>` : '';
|
||||||
|
return `<div class="doc-card" data-id="${it.id}">
|
||||||
|
${itemMainHtml(it)}
|
||||||
|
${desc}
|
||||||
|
<div class="doc-meta">
|
||||||
|
${date}
|
||||||
|
<span class="doc-actions">${editBtn}<button class="comment-toggle" type="button" data-id="${it.id}">💬 Commentaires</button></span>
|
||||||
|
</div>
|
||||||
|
<div class="doc-edit-box" hidden></div>
|
||||||
|
<div class="doc-thread" hidden></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemsPanel() {
|
||||||
|
let list;
|
||||||
|
if (selectedFolder === 'all') list = items;
|
||||||
|
else if (selectedFolder === 'none') list = items.filter((it) => it.folder_id === null);
|
||||||
|
else list = items.filter((it) => String(it.folder_id) === String(selectedFolder));
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
els.items.innerHTML = '<p class="empty">Aucun élément validé dans ce dossier.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.items.innerHTML = `<div class="doc-list">${list.map(docCardHtml).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocs() {
|
||||||
|
renderTree();
|
||||||
|
renderItemsPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Photothèque (toutes les photos validées) ---
|
||||||
|
function photoFigureHtml(it) {
|
||||||
|
const date = it.submitted_at ? `<span class="photo-date">${fmtDate(it.submitted_at)}</span>` : '';
|
||||||
|
return `<figure class="item-photo" data-url="${esc(it.url)}" data-title="${esc(it.title)}">
|
||||||
|
<img src="${esc(it.url)}" alt="${esc(it.title)}" loading="lazy">
|
||||||
|
<figcaption>${esc(it.title)} ${date}</figcaption>
|
||||||
|
</figure>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhotos() {
|
||||||
|
const photos = items.filter((it) => it.kind === 'photo');
|
||||||
|
if (!photos.length) {
|
||||||
|
els.photoGrid.innerHTML = '<p class="empty">Aucune photo validée pour le moment.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.photoGrid.innerHTML = photos.map(photoFigureHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lightbox ---
|
||||||
|
function openLightbox(url, caption) {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'lightbox';
|
||||||
|
box.innerHTML = `<img src="${esc(url)}" alt="${esc(caption)}"><span class="lb-cap">${esc(caption)}</span>`;
|
||||||
|
box.addEventListener('click', () => box.remove());
|
||||||
|
document.body.appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Formulaire de proposition ---
|
||||||
|
function fillFolderSelect() {
|
||||||
|
const opts = ['<option value="">— Aucun dossier —</option>'];
|
||||||
|
const walk = (parentId, depth) => {
|
||||||
|
childrenOf(parentId).forEach((f) => {
|
||||||
|
opts.push(`<option value="${f.id}">${' '.repeat(depth)}${esc(f.name)}</option>`);
|
||||||
|
walk(f.id, depth + 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
walk(null, 0);
|
||||||
|
els.pFolder.innerHTML = opts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncProposeFields() {
|
||||||
|
const kind = els.form.querySelector('input[name="kind"]:checked').value;
|
||||||
|
els.form.querySelectorAll('[data-when]').forEach((el) => {
|
||||||
|
el.hidden = !el.dataset.when.split(' ').includes(kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProposal(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const kind = els.form.querySelector('input[name="kind"]:checked').value;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('kind', kind);
|
||||||
|
fd.append('folder_id', els.pFolder.value);
|
||||||
|
fd.append('title', $('p-title').value.trim());
|
||||||
|
fd.append('description', $('p-desc').value.trim());
|
||||||
|
fd.append('submitter_name', $('p-name').value.trim());
|
||||||
|
fd.append('submitter_email', $('p-mail').value.trim());
|
||||||
|
|
||||||
|
if (kind === 'link') {
|
||||||
|
const url = $('p-url').value.trim();
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
return showProposeMsg('Veuillez saisir une URL valide (http(s)://…).', false);
|
||||||
|
}
|
||||||
|
fd.append('url', url);
|
||||||
|
} else {
|
||||||
|
const file = $('p-file').files[0];
|
||||||
|
if (!file) return showProposeMsg('Veuillez choisir un fichier.', false);
|
||||||
|
fd.append('file', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiJson(`api/items.php?uai=${encodeURIComponent(current.id)}`, { method: 'POST', body: fd });
|
||||||
|
els.form.reset();
|
||||||
|
syncProposeFields();
|
||||||
|
showProposeMsg('✅ Merci ! Votre proposition a été enregistrée et sera publiée après validation.', true);
|
||||||
|
} catch (err) {
|
||||||
|
showProposeMsg('Erreur : ' + err.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProposeMsg(text, ok) {
|
||||||
|
els.proposeMsg.textContent = text;
|
||||||
|
els.proposeMsg.className = 'propose-msg ' + (ok ? 'ok' : 'err');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ouverture / fermeture ---
|
||||||
|
async function open(e) {
|
||||||
|
current = e;
|
||||||
|
items = [];
|
||||||
|
selectedFolder = 'all';
|
||||||
|
els.nom.textContent = e.nom || 'Établissement';
|
||||||
|
els.sub.textContent = [e.type, e.stat, [e.cp, e.com].filter(Boolean).join(' ')]
|
||||||
|
.filter(Boolean).join(' · ');
|
||||||
|
renderInfos(e);
|
||||||
|
renderEffectifs(e);
|
||||||
|
renderDpe(e);
|
||||||
|
estComments = null;
|
||||||
|
viewer = { auth: false, can_post: false, can_validate: false, role: null, username: null };
|
||||||
|
showProposeMsg('', true);
|
||||||
|
selectTab('infos');
|
||||||
|
els.root.hidden = false;
|
||||||
|
document.body.classList.add('fiche-open');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getFolders();
|
||||||
|
items = await getItems(e.id);
|
||||||
|
} catch (err) {
|
||||||
|
els.items.innerHTML = `<p class="empty">Erreur de chargement : ${esc(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
await loadViewer(e.id); // contexte connecté (droits d'édition) + commentaires d'établissement
|
||||||
|
fillFolderSelect();
|
||||||
|
syncProposeFields();
|
||||||
|
renderDocs();
|
||||||
|
renderPhotos();
|
||||||
|
renderComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
els.root.hidden = true;
|
||||||
|
document.body.classList.remove('fiche-open');
|
||||||
|
current = null;
|
||||||
|
// La carte n'est pas touchée : aucun rechargement.
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Initialisation (appelée par app.js) ---
|
||||||
|
function init(opts) {
|
||||||
|
onLocate = (opts || {}).onLocate || null;
|
||||||
|
cacheEls();
|
||||||
|
|
||||||
|
els.back.addEventListener('click', close);
|
||||||
|
els.tabs.forEach((t) => t.addEventListener('click', () => selectTab(t.dataset.tab)));
|
||||||
|
|
||||||
|
// Sélection d'un dossier dans l'arbre.
|
||||||
|
els.tree.addEventListener('click', (ev) => {
|
||||||
|
const node = ev.target.closest('.tree-folder');
|
||||||
|
if (!node) return;
|
||||||
|
selectedFolder = node.dataset.folder === 'all' || node.dataset.folder === 'none'
|
||||||
|
? node.dataset.folder : Number(node.dataset.folder);
|
||||||
|
renderDocs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrandissement des photos (onglets Documents et Photothèque).
|
||||||
|
const onPhotoClick = (ev) => {
|
||||||
|
const fig = ev.target.closest('.item-photo');
|
||||||
|
if (fig) openLightbox(fig.dataset.url, fig.dataset.title);
|
||||||
|
};
|
||||||
|
els.photoGrid.addEventListener('click', onPhotoClick);
|
||||||
|
|
||||||
|
// Onglet Documents : photo (lightbox), déplier/éditer une carte, et fils de commentaires.
|
||||||
|
els.items.addEventListener('click', (ev) => {
|
||||||
|
if (!current) return;
|
||||||
|
if (ev.target.closest('.item-photo')) { onPhotoClick(ev); return; }
|
||||||
|
if (ev.target.closest('.comment-toggle, .doc-edit, .doc-edit-cancel')) { onDocClick(ev); return; }
|
||||||
|
onThreadClick(ev); // suppression d'un commentaire dans un fil de document
|
||||||
|
});
|
||||||
|
els.items.addEventListener('submit', (ev) => {
|
||||||
|
if (!current) return;
|
||||||
|
// Routage SYNCHRONE : chaque handler appelle ev.preventDefault() avant tout await.
|
||||||
|
if (ev.target.closest('.doc-edit-form')) onDocEditSubmit(ev); // édition libellé/description
|
||||||
|
else onThreadSubmit(ev); // connexion / commentaire (fil document)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fil de commentaires d'établissement (carte dans l'onglet Informations).
|
||||||
|
els.panels.infos.addEventListener('submit', (ev) => { if (current) onThreadSubmit(ev); });
|
||||||
|
els.panels.infos.addEventListener('click', (ev) => { if (current) onThreadClick(ev); });
|
||||||
|
|
||||||
|
// Formulaire de proposition.
|
||||||
|
els.form.addEventListener('submit', submitProposal);
|
||||||
|
els.form.querySelectorAll('input[name="kind"]').forEach((r) =>
|
||||||
|
r.addEventListener('change', syncProposeFields));
|
||||||
|
|
||||||
|
els.locate.addEventListener('click', () => {
|
||||||
|
const e = current;
|
||||||
|
close();
|
||||||
|
if (e && onLocate) onLocate(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Escape' && !els.root.hidden) {
|
||||||
|
const lb = document.querySelector('.lightbox');
|
||||||
|
if (lb) lb.remove();
|
||||||
|
else close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Fiche = { init, open, close };
|
||||||
|
})();
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mot de passe oublié</title>
|
||||||
|
<link rel="stylesheet" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Mot de passe oublié</h1>
|
||||||
|
<p class="hint">Saisissez votre identifiant ou votre adresse e-mail. Si un compte
|
||||||
|
correspond et qu'une adresse e-mail y est associée, un lien de réinitialisation
|
||||||
|
vous sera envoyé.</p>
|
||||||
|
<form id="forgot-form" autocomplete="off">
|
||||||
|
<div class="field">
|
||||||
|
<label for="login">Identifiant ou e-mail</label>
|
||||||
|
<input id="login" type="text" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Envoyer le lien</button>
|
||||||
|
<p id="msg" class="propose-msg"></p>
|
||||||
|
</form>
|
||||||
|
<p class="auth-foot"><a href="admin.html">← Retour à la connexion</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
$('forgot-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
$('msg').className = 'propose-msg';
|
||||||
|
$('msg').textContent = 'Envoi…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/auth.php?action=forgot', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ login: $('login').value.trim() }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
$('msg').className = 'propose-msg ok';
|
||||||
|
$('msg').textContent = data.message || 'Si un compte correspond, un e-mail a été envoyé.';
|
||||||
|
$('forgot-form').reset();
|
||||||
|
} catch (err) {
|
||||||
|
$('msg').className = 'propose-msg err';
|
||||||
|
$('msg').textContent = 'Erreur : ' + err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Ajoute / met à jour le code INSEE commune (comc) des établissements,
|
||||||
|
* depuis l'opendata annuaire. Nécessaire pour relier les écoles à la
|
||||||
|
* population INSEE (indexée par code commune) — cf. taux de scolarisation.
|
||||||
|
*
|
||||||
|
* Usage : php import_comc.php
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
const ANNUAIRE_COMC =
|
||||||
|
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-annuaire-education/'
|
||||||
|
. 'exports/csv?select=identifiant_de_l_etablissement,code_commune&delimiter=%3B';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
try {
|
||||||
|
$pdo->exec('ALTER TABLE etablissements ADD COLUMN comc VARCHAR(5) NULL');
|
||||||
|
$pdo->exec('ALTER TABLE etablissements ADD INDEX idx_comc (comc)');
|
||||||
|
say('✔ Colonne comc ajoutée.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
say('ℹ Colonne comc déjà présente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmp = __DIR__ . '/data/insee/annuaire_comc.csv';
|
||||||
|
@mkdir(dirname($tmp), 0777, true);
|
||||||
|
say('… Téléchargement (identifiant + code_commune) …');
|
||||||
|
$fp = fopen($tmp, 'w');
|
||||||
|
$ch = curl_init(ANNUAIRE_COMC);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
|
||||||
|
CURLOPT_USERAGENT => 'ecoles-import/1.0',
|
||||||
|
]);
|
||||||
|
if (!curl_exec($ch)) {
|
||||||
|
throw new RuntimeException('Téléchargement échoué : ' . curl_error($ch));
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
$fh = fopen($tmp, 'r');
|
||||||
|
$header = fgetcsv($fh, 0, ';');
|
||||||
|
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
|
||||||
|
$iId = array_search('identifiant_de_l_etablissement', $header, true);
|
||||||
|
$iCom = array_search('code_commune', $header, true);
|
||||||
|
if ($iId === false || $iCom === false) {
|
||||||
|
throw new RuntimeException('Colonnes attendues introuvables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$upd = $pdo->prepare('UPDATE etablissements SET comc = ? WHERE id = ?');
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$n = 0;
|
||||||
|
while (($r = fgetcsv($fh, 0, ';')) !== false) {
|
||||||
|
$id = $r[$iId] ?? '';
|
||||||
|
$com = $r[$iCom] ?? '';
|
||||||
|
if ($id === '' || $com === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$upd->execute([$com, $id]);
|
||||||
|
if (++$n % 5000 === 0) { $pdo->commit(); $pdo->beginTransaction(); }
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
fclose($fh);
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
$filled = (int) $pdo->query("SELECT COUNT(*) FROM etablissements WHERE comc IS NOT NULL AND comc <> ''")->fetchColumn();
|
||||||
|
say("✔ comc renseigné pour $filled établissements (sur $n lignes).");
|
||||||
|
say('🎉 Terminé.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
http_response_code(500);
|
||||||
|
say('!! ERREUR : ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import des DPE des bâtiments tertiaires (ADEME) dans la table « dpe ».
|
||||||
|
* Source : data.ademe.fr, dataset « dpe01tertiaire » (DPE tertiaire depuis juillet 2021).
|
||||||
|
* API data-fair : .../lines (pagination par curseur « after / next »).
|
||||||
|
*
|
||||||
|
* ⚠ Le dataset n'a PAS d'identifiant école (UAI) : les bâtiments sont repérés
|
||||||
|
* par adresse BAN + géolocalisation. Le rattachement école↔DPE se fait
|
||||||
|
* ensuite par proximité géographique (cf. match_dpe.php).
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* php import_dpe.php # importe tout (~532 000 DPE, plusieurs minutes)
|
||||||
|
* php import_dpe.php --dep=75 # n'importe qu'un département (test rapide)
|
||||||
|
*
|
||||||
|
* La logique est partagée avec l'endpoint web (api/dpe_lib.php).
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/dpe_lib.php';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dep = null;
|
||||||
|
foreach ($argv ?? [] as $a) {
|
||||||
|
if (preg_match('/^--dep=(\w+)$/', $a, $m)) {
|
||||||
|
$dep = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$n = dpe_import($pdo, $dep, static fn(string $m) => say(' … ' . $m));
|
||||||
|
|
||||||
|
$geo = (int) $pdo->query('SELECT COUNT(*) FROM dpe WHERE lat IS NOT NULL')->fetchColumn();
|
||||||
|
say("✔ dpe : $n DPE importés ($geo géolocalisés).");
|
||||||
|
say('→ Lancez maintenant « php match_dpe.php » pour rattacher les DPE aux établissements.');
|
||||||
|
say('🎉 Terminé.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
http_response_code(500);
|
||||||
|
say('!! ERREUR : ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import des effectifs et nombres de classes des écoles (1er degré) — historique.
|
||||||
|
* Source : data.education.gouv.fr, dataset « fr-en-ecoles-effectifs-nb_classes ».
|
||||||
|
*
|
||||||
|
* Télécharge l'export CSV (champs réduits) puis remplit la table effectifs
|
||||||
|
* (uai, annee → élèves, classes). Clé = numéro d'école (UAI) + rentrée scolaire.
|
||||||
|
*
|
||||||
|
* Usage : php import_effectifs.php
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
const EFFECTIFS_CSV =
|
||||||
|
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/'
|
||||||
|
. 'fr-en-ecoles-effectifs-nb_classes/exports/csv'
|
||||||
|
. '?select=rentree_scolaire,numero_ecole,nombre_total_eleves,nombre_total_classes&delimiter=%3B';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS effectifs (
|
||||||
|
uai VARCHAR(12) NOT NULL,
|
||||||
|
annee SMALLINT NOT NULL,
|
||||||
|
eleves INT NOT NULL DEFAULT 0,
|
||||||
|
classes INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (uai, annee),
|
||||||
|
INDEX idx_uai (uai)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Télécharge le CSV dans un fichier temporaire si absent localement.
|
||||||
|
$csv = __DIR__ . '/data/insee/effectifs.csv';
|
||||||
|
if (!is_file($csv)) {
|
||||||
|
@mkdir(dirname($csv), 0777, true);
|
||||||
|
say('… Téléchargement de 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import des effectifs du 2nd degré (collèges et lycées) — historique.
|
||||||
|
* Sources data.education.gouv.fr :
|
||||||
|
* - fr-en-college-effectifs-niveau-sexe-lv (numero_college, nombre_eleves_total)
|
||||||
|
* - fr-en-lycee_gt-effectifs-niveau-sexe-lv (numero_lycee, nombre_d_eleves)
|
||||||
|
* - fr-en-lycee_pro-effectifs-niveau-sexe-lv (numero_lycee, nombre_d_eleves)
|
||||||
|
*
|
||||||
|
* Alimente la même table « effectifs » (uai, annee → eleves). Le nombre de
|
||||||
|
* classes n'existe pas pour le 2nd degré (classes = 0). Les lycées polyvalents
|
||||||
|
* (présents en GT et PRO) sont sommés. Idempotent.
|
||||||
|
*
|
||||||
|
* Usage : php import_effectifs_2d.php
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
['fr-en-college-effectifs-niveau-sexe-lv', 'numero_college', 'nombre_eleves_total'],
|
||||||
|
['fr-en-lycee_gt-effectifs-niveau-sexe-lv', 'numero_lycee', 'nombre_d_eleves'],
|
||||||
|
['fr-en-lycee_pro-effectifs-niveau-sexe-lv','numero_lycee', 'nombre_d_eleves'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function download_csv(string $dataset, string $uaiCol, string $popCol, string $dest): void
|
||||||
|
{
|
||||||
|
$url = "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/$dataset/exports/csv"
|
||||||
|
. "?select=rentree_scolaire,$uaiCol,$popCol&delimiter=%3B";
|
||||||
|
$fp = fopen($dest, 'w');
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
|
||||||
|
CURLOPT_USERAGENT => 'ecoles-import/1.0',
|
||||||
|
]);
|
||||||
|
$ok = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
fclose($fp);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new RuntimeException("Téléchargement échoué ($dataset) : $err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS effectifs (
|
||||||
|
uai VARCHAR(12) NOT NULL,
|
||||||
|
annee SMALLINT NOT NULL,
|
||||||
|
eleves INT NOT NULL DEFAULT 0,
|
||||||
|
classes INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (uai, annee),
|
||||||
|
INDEX idx_uai (uai)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
$tmpDir = __DIR__ . '/data/insee';
|
||||||
|
@mkdir($tmpDir, 0777, true);
|
||||||
|
|
||||||
|
// Accumulateur uai → annee → élèves (somme GT+PRO pour les polyvalents).
|
||||||
|
$acc = [];
|
||||||
|
foreach (SOURCES as [$ds, $uaiCol, $popCol]) {
|
||||||
|
$csv = "$tmpDir/$ds.csv";
|
||||||
|
say("… Téléchargement $ds …");
|
||||||
|
download_csv($ds, $uaiCol, $popCol, $csv);
|
||||||
|
|
||||||
|
$fh = fopen($csv, 'r');
|
||||||
|
$header = fgetcsv($fh, 0, ';');
|
||||||
|
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
|
||||||
|
$iY = array_search('rentree_scolaire', $header, true);
|
||||||
|
$iU = array_search($uaiCol, $header, true);
|
||||||
|
$iE = array_search($popCol, $header, true);
|
||||||
|
if (in_array(false, [$iY, $iU, $iE], true)) {
|
||||||
|
throw new RuntimeException("Colonnes introuvables dans $ds.");
|
||||||
|
}
|
||||||
|
$rows = 0;
|
||||||
|
while (($r = fgetcsv($fh, 0, ';')) !== false) {
|
||||||
|
$uai = $r[$iU] ?? '';
|
||||||
|
$annee = (int) substr((string) ($r[$iY] ?? ''), 0, 4);
|
||||||
|
if ($uai === '' || $annee === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$eleves = (int) round((float) str_replace(',', '.', (string) ($r[$iE] ?? '0')));
|
||||||
|
$acc[$uai][$annee] = ($acc[$uai][$annee] ?? 0) + $eleves;
|
||||||
|
$rows++;
|
||||||
|
}
|
||||||
|
fclose($fh);
|
||||||
|
@unlink($csv);
|
||||||
|
say(" $ds : $rows lignes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ins = $pdo->prepare('REPLACE INTO effectifs (uai, annee, eleves, classes) VALUES (?, ?, ?, 0)');
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$n = 0;
|
||||||
|
foreach ($acc as $uai => $byYear) {
|
||||||
|
foreach ($byYear as $annee => $eleves) {
|
||||||
|
$ins->execute([$uai, $annee, $eleves]);
|
||||||
|
if (++$n % 5000 === 0) { $pdo->commit(); $pdo->beginTransaction(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
say("✔ Effectifs 2nd degré : $n lignes (collèges + lycées) importées.");
|
||||||
|
say('🎉 Terminé.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
http_response_code(500);
|
||||||
|
say('!! ERREUR : ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import de l'IPS (indice de position sociale) des établissements — historique.
|
||||||
|
* Source : data.education.gouv.fr. Trois datasets remplis dans la même table « ips » :
|
||||||
|
* - écoles : « fr-en-ips-ecoles-ap2022 » (ips + ips_departemental ; rentrées 2022→2024)
|
||||||
|
* - collèges : « fr-en-ips-colleges-ap2022 » (ips ; rentrée 2022-2023)
|
||||||
|
* - lycées : « fr-en-ips-lycees-ap2022 » (ips_ensemble_gt_pro, repli ips_voie_gt)
|
||||||
|
*
|
||||||
|
* Clé = UAI + rentrée scolaire (année de début, ex. « 2022-2023 » → 2022).
|
||||||
|
* ips_dep (IPS moyen départemental de référence) n'existe que pour les écoles.
|
||||||
|
*
|
||||||
|
* Usage : php import_ips.php (les trois niveaux)
|
||||||
|
* php import_ips.php --niveau=colleges (un seul : ecoles|colleges|lycees)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
// Datasets et mapping des colonnes (le nom de la colonne IPS varie selon le niveau).
|
||||||
|
const IPS_DATASETS = [
|
||||||
|
'ecoles' => [
|
||||||
|
'ds' => 'fr-en-ips-ecoles-ap2022',
|
||||||
|
'select' => 'rentree_scolaire,uai,ips,ips_departemental',
|
||||||
|
'ips' => ['ips'],
|
||||||
|
'dep' => 'ips_departemental',
|
||||||
|
'file' => 'ips_ecoles.csv',
|
||||||
|
],
|
||||||
|
'colleges' => [
|
||||||
|
'ds' => 'fr-en-ips-colleges-ap2022',
|
||||||
|
'select' => 'rentree_scolaire,uai,ips',
|
||||||
|
'ips' => ['ips'],
|
||||||
|
'dep' => null,
|
||||||
|
'file' => 'ips_colleges.csv',
|
||||||
|
],
|
||||||
|
'lycees' => [
|
||||||
|
'ds' => 'fr-en-ips-lycees-ap2022',
|
||||||
|
'select' => 'rentree_scolaire,uai,ips_ensemble_gt_pro,ips_voie_gt',
|
||||||
|
'ips' => ['ips_ensemble_gt_pro', 'ips_voie_gt'], // ensemble, sinon voie GT
|
||||||
|
'dep' => null,
|
||||||
|
'file' => 'ips_lycees.csv',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Télécharge l'export CSV d'un dataset vers $path s'il est absent. */
|
||||||
|
function ips_download(string $ds, string $select, string $path): void
|
||||||
|
{
|
||||||
|
if (is_file($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@mkdir(dirname($path), 0777, true);
|
||||||
|
$url = 'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/'
|
||||||
|
. $ds . '/exports/csv?select=' . rawurlencode($select) . '&delimiter=%3B';
|
||||||
|
say("… Téléchargement de $ds …");
|
||||||
|
$fp = fopen($path, 'w');
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
|
||||||
|
CURLOPT_USERAGENT => 'ecoles-import/1.0',
|
||||||
|
]);
|
||||||
|
$ok = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
fclose($fp);
|
||||||
|
if (!$ok) {
|
||||||
|
@unlink($path);
|
||||||
|
throw new RuntimeException("Téléchargement échoué ($ds) : $err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Importe un dataset IPS dans la table « ips ». Renvoie le nombre de lignes. */
|
||||||
|
function ips_import_one(PDO $pdo, array $cfg): int
|
||||||
|
{
|
||||||
|
$path = __DIR__ . '/data/insee/' . $cfg['file'];
|
||||||
|
ips_download($cfg['ds'], $cfg['select'], $path);
|
||||||
|
|
||||||
|
$fh = fopen($path, 'r');
|
||||||
|
$header = fgetcsv($fh, 0, ';');
|
||||||
|
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]); // retire un éventuel BOM
|
||||||
|
$idx = static fn(string $name) => array_search($name, $header, true);
|
||||||
|
$iY = $idx('rentree_scolaire');
|
||||||
|
$iU = $idx('uai');
|
||||||
|
$ipsCols = array_values(array_filter(array_map($idx, $cfg['ips']), static fn($v) => $v !== false));
|
||||||
|
$iD = $cfg['dep'] !== null ? $idx($cfg['dep']) : false;
|
||||||
|
if ($iY === false || $iU === false || !$ipsCols) {
|
||||||
|
fclose($fh);
|
||||||
|
throw new RuntimeException("Colonnes attendues introuvables dans {$cfg['file']}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ins = $pdo->prepare('REPLACE INTO ips (uai, annee, ips, ips_dep) VALUES (?, ?, ?, ?)');
|
||||||
|
$dec = static fn($v) => ($v === null || $v === '') ? null : (float) str_replace(',', '.', (string) $v);
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$n = 0;
|
||||||
|
while (($row = fgetcsv($fh, 0, ';')) !== false) {
|
||||||
|
$uai = $row[$iU] ?? '';
|
||||||
|
$annee = (int) ($row[$iY] ?? 0);
|
||||||
|
// Première colonne IPS non vide (ensemble, sinon repli).
|
||||||
|
$ips = null;
|
||||||
|
foreach ($ipsCols as $c) {
|
||||||
|
$ips = $dec($row[$c] ?? null);
|
||||||
|
if ($ips !== null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($uai === '' || $annee === 0 || $ips === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ins->execute([$uai, $annee, $ips, $iD !== false ? $dec($row[$iD] ?? null) : null]);
|
||||||
|
if (++$n % 5000 === 0) {
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
fclose($fh);
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS ips (
|
||||||
|
uai VARCHAR(12) NOT NULL,
|
||||||
|
annee SMALLINT NOT NULL,
|
||||||
|
ips DECIMAL(5,1) NOT NULL,
|
||||||
|
ips_dep DECIMAL(5,1) NULL,
|
||||||
|
PRIMARY KEY (uai, annee),
|
||||||
|
INDEX idx_uai (uai)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filtre éventuel sur un niveau.
|
||||||
|
$only = null;
|
||||||
|
foreach ($argv ?? [] as $a) {
|
||||||
|
if (preg_match('/^--niveau=(ecoles|colleges|lycees)$/', $a, $m)) {
|
||||||
|
$only = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach (IPS_DATASETS as $niveau => $cfg) {
|
||||||
|
if ($only !== null && $only !== $niveau) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$n = ips_import_one($pdo, $cfg);
|
||||||
|
say("✔ $niveau : $n lignes importées.");
|
||||||
|
$total += $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ecoles = (int) $pdo->query('SELECT COUNT(DISTINCT uai) FROM ips')->fetchColumn();
|
||||||
|
say("✔ ips : $total lignes au total ($ecoles établissements distincts).");
|
||||||
|
say('🎉 Terminé.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
http_response_code(500);
|
||||||
|
say('!! ERREUR : ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import des populations du recensement INSEE 2022 (base infracommunale
|
||||||
|
* « évolution et structure de la population »).
|
||||||
|
*
|
||||||
|
* Importe le fichier IRIS dans la table iris_pop (code IRIS → population P22_POP).
|
||||||
|
* La population communale est déduite par somme des IRIS d'une même commune
|
||||||
|
* (les 5 premiers caractères du code IRIS = code commune).
|
||||||
|
*
|
||||||
|
* Pré-requis : télécharger et DÉZIPPER la base dans data/insee/ :
|
||||||
|
* https://www.insee.fr/fr/statistiques/fichier/8647014/base-ic-evol-struct-pop-2022_csv.zip
|
||||||
|
* → data/insee/base-ic-evol-struct-pop-2022.CSV
|
||||||
|
*
|
||||||
|
* Usage : php import_pop.php
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS iris_pop (
|
||||||
|
code VARCHAR(12) NOT NULL PRIMARY KEY,
|
||||||
|
com VARCHAR(5) NOT NULL,
|
||||||
|
pop DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_com (com)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
$csv = __DIR__ . '/data/insee/base-ic-evol-struct-pop-2022.CSV';
|
||||||
|
if (!is_file($csv)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Fichier absent : $csv\n" .
|
||||||
|
" Téléchargez et dézippez la base IRIS dans data/insee/ (voir l'en-tête de ce script)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fh = fopen($csv, 'r');
|
||||||
|
$header = fgetcsv($fh, 0, ';');
|
||||||
|
$iIris = array_search('IRIS', $header, true);
|
||||||
|
$iCom = array_search('COM', $header, true);
|
||||||
|
$iPop = array_search('P22_POP', $header, true);
|
||||||
|
if ($iIris === false || $iCom === false || $iPop === false) {
|
||||||
|
throw new RuntimeException('Colonnes IRIS / COM / P22_POP introuvables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ins = $pdo->prepare('REPLACE INTO iris_pop (code, com, pop) VALUES (?, ?, ?)');
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$n = 0;
|
||||||
|
while (($row = fgetcsv($fh, 0, ';')) !== false) {
|
||||||
|
$code = $row[$iIris] ?? '';
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$com = $row[$iCom] ?? substr($code, 0, 5);
|
||||||
|
$pop = (float) str_replace(',', '.', (string) ($row[$iPop] ?? '0'));
|
||||||
|
$ins->execute([$code, $com, $pop]);
|
||||||
|
if (++$n % 5000 === 0) {
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
say("✔ iris_pop : $n IRIS importés (population 2022).");
|
||||||
|
say('🎉 Terminé.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
http_response_code(500);
|
||||||
|
say('!! ERREUR : ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import de l'historique des populations légales communales (INSEE).
|
||||||
|
* Source : public.opendatasoft.com, dataset « historique-des-populations-legales »
|
||||||
|
* (population municipale par commune et par recensement, ~1876 → 2022).
|
||||||
|
*
|
||||||
|
* Remplit la table pop_commune (comc = code INSEE commune, annee → population).
|
||||||
|
* Sert à tracer la courbe d'évolution de la population de la commune dans la fiche.
|
||||||
|
*
|
||||||
|
* Usage : php import_pop_hist.php
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
const POP_HIST_CSV =
|
||||||
|
'https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/'
|
||||||
|
. 'historique-des-populations-legales/exports/csv'
|
||||||
|
. '?select=code_insee,annee,population_municipale&delimiter=%3B';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS pop_commune (
|
||||||
|
comc VARCHAR(5) NOT NULL,
|
||||||
|
annee SMALLINT NOT NULL,
|
||||||
|
pop INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (comc, annee),
|
||||||
|
INDEX idx_comc (comc)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
$csv = __DIR__ . '/data/insee/pop_hist_communes.csv';
|
||||||
|
if (!is_file($csv)) {
|
||||||
|
@mkdir(dirname($csv), 0777, true);
|
||||||
|
say('… Téléchargement de 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Import de l'historique des populations légales communales — source officielle INSEE.
|
||||||
|
* Base « séries historiques » du recensement (Comparateur de territoires) :
|
||||||
|
* https://www.insee.fr/fr/statistiques/fichier/8582555/base-cc-serie-historique-2022_csv.zip
|
||||||
|
*
|
||||||
|
* Population municipale à 9 recensements : 1968, 1975, 1982, 1990, 1999, 2006, 2011,
|
||||||
|
* 2016, 2022 (colonnes D68_POP … D99_POP puis P06_POP … P22_POP). Cette base est au
|
||||||
|
* niveau commune ET arrondissement municipal : elle contient donc le VRAI historique
|
||||||
|
* par arrondissement pour Paris (75101…), Lyon (69381…) et Marseille (13201…), ainsi
|
||||||
|
* que les totaux communes (75056 / 69123 / 13055).
|
||||||
|
*
|
||||||
|
* Remplit la table pop_commune (comc, annee, pop). Lit le CSV dans le zip sans
|
||||||
|
* l'extension PHP « zip » (mini-lecteur ZIP + gzinflate).
|
||||||
|
*
|
||||||
|
* Usage : php import_pop_insee.php
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
ini_set('memory_limit', '1024M');
|
||||||
|
|
||||||
|
const INSEE_SERIE_URL = 'https://www.insee.fr/fr/statistiques/fichier/8582555/base-cc-serie-historique-2022_csv.zip';
|
||||||
|
const INSEE_CSV_NAME = 'base-cc-serie-historique-2022.CSV';
|
||||||
|
|
||||||
|
// Colonne de population → année du recensement.
|
||||||
|
const POP_COLS = [
|
||||||
|
'P22_POP' => 2022, 'P16_POP' => 2016, 'P11_POP' => 2011, 'P06_POP' => 2006,
|
||||||
|
'D99_POP' => 1999, 'D90_POP' => 1990, 'D82_POP' => 1982, 'D75_POP' => 1975, 'D68_POP' => 1968,
|
||||||
|
];
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit une entrée d'un fichier ZIP (méthodes « stored » et « deflate ») sans ext/zip.
|
||||||
|
* Renvoie le contenu décompressé, ou null si absente.
|
||||||
|
*/
|
||||||
|
function zip_entry(string $path, string $name): ?string
|
||||||
|
{
|
||||||
|
$fh = fopen($path, 'rb');
|
||||||
|
if (!$fh) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$size = filesize($path);
|
||||||
|
$look = min($size, 22 + 65536);
|
||||||
|
fseek($fh, $size - $look);
|
||||||
|
$buf = fread($fh, $look);
|
||||||
|
$eo = strrpos($buf, "PK\x05\x06");
|
||||||
|
if ($eo === false) { fclose($fh); return null; }
|
||||||
|
$eocd = unpack('Vsig/vdisk/vcddisk/ventries/vtotal/Vcdsize/Vcdoffset/vcomlen', substr($buf, $eo, 22));
|
||||||
|
|
||||||
|
fseek($fh, $eocd['cdoffset']);
|
||||||
|
$cd = fread($fh, $eocd['cdsize']);
|
||||||
|
$off = 0;
|
||||||
|
$found = null;
|
||||||
|
$n = strlen($cd);
|
||||||
|
while ($off + 46 <= $n && substr($cd, $off, 4) === "PK\x01\x02") {
|
||||||
|
$h = unpack('vver/vverneed/vflags/vmethod/vmtime/vmdate/Vcrc/Vcsize/Vusize/vnlen/velen/vclen/vdisk/viattr/Veattr/Vloffset', substr($cd, $off + 4, 42));
|
||||||
|
$fname = substr($cd, $off + 46, $h['nlen']);
|
||||||
|
if ($fname === $name) { $found = $h; break; }
|
||||||
|
$off += 46 + $h['nlen'] + $h['elen'] + $h['clen'];
|
||||||
|
}
|
||||||
|
if (!$found) { fclose($fh); return null; }
|
||||||
|
|
||||||
|
fseek($fh, $found['loffset']);
|
||||||
|
$lh = unpack('Vsig/vver/vflags/vmethod/vmtime/vmdate/Vcrc/Vcsize/Vusize/vnlen/velen', fread($fh, 30));
|
||||||
|
fseek($fh, $found['loffset'] + 30 + $lh['nlen'] + $lh['elen']);
|
||||||
|
$comp = fread($fh, $found['csize']);
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
if ($found['method'] === 0) {
|
||||||
|
return $comp;
|
||||||
|
}
|
||||||
|
if ($found['method'] === 8) {
|
||||||
|
return gzinflate($comp);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS pop_commune (
|
||||||
|
comc VARCHAR(5) NOT NULL,
|
||||||
|
annee SMALLINT NOT NULL,
|
||||||
|
pop INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (comc, annee),
|
||||||
|
INDEX idx_comc (comc)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
$zipPath = __DIR__ . '/data/insee/serie_hist.zip';
|
||||||
|
if (!is_file($zipPath)) {
|
||||||
|
@mkdir(dirname($zipPath), 0777, true);
|
||||||
|
say('… Téléchargement de la base INSEE (~6,7 Mo) …');
|
||||||
|
$fp = fopen($zipPath, 'w');
|
||||||
|
$ch = curl_init(INSEE_SERIE_URL);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 600,
|
||||||
|
CURLOPT_USERAGENT => 'ecoles-import/1.0',
|
||||||
|
]);
|
||||||
|
$ok = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
fclose($fp);
|
||||||
|
if (!$ok || filesize($zipPath) < 100000) {
|
||||||
|
@unlink($zipPath);
|
||||||
|
throw new RuntimeException('Téléchargement échoué : ' . $err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
say('… Lecture du CSV …');
|
||||||
|
$csv = zip_entry($zipPath, INSEE_CSV_NAME);
|
||||||
|
if ($csv === null || $csv === '') {
|
||||||
|
throw new RuntimeException('CSV introuvable dans le zip : ' . INSEE_CSV_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = preg_split('/\r\n|\n|\r/', $csv);
|
||||||
|
$header = str_getcsv(array_shift($lines), ';');
|
||||||
|
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]); // BOM
|
||||||
|
$iCode = array_search('CODGEO', $header, true);
|
||||||
|
if ($iCode === false) {
|
||||||
|
throw new RuntimeException('Colonne CODGEO introuvable.');
|
||||||
|
}
|
||||||
|
$colIdx = [];
|
||||||
|
foreach (POP_COLS as $col => $annee) {
|
||||||
|
$k = array_search($col, $header, true);
|
||||||
|
if ($k !== false) {
|
||||||
|
$colIdx[$k] = $annee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$colIdx) {
|
||||||
|
throw new RuntimeException('Aucune colonne de population trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ins = $pdo->prepare('REPLACE INTO pop_commune (comc, annee, pop) VALUES (?, ?, ?)');
|
||||||
|
$pdo->exec('TRUNCATE TABLE pop_commune');
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$n = 0;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$row = str_getcsv($line, ';');
|
||||||
|
$code = trim((string) ($row[$iCode] ?? ''));
|
||||||
|
if (!preg_match('/^[0-9AB]\w{4}$/', $code)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($colIdx as $k => $annee) {
|
||||||
|
$v = $row[$k] ?? '';
|
||||||
|
if ($v === '' || !is_numeric(str_replace([' ', ','], ['', '.'], $v))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ins->execute([$code, $annee, (int) round((float) str_replace([' ', ','], ['', '.'], $v))]);
|
||||||
|
if (++$n % 10000 === 0) {
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
$communes = (int) $pdo->query('SELECT COUNT(DISTINCT comc) FROM pop_commune')->fetchColumn();
|
||||||
|
$arr = (int) $pdo->query("SELECT COUNT(DISTINCT comc) FROM pop_commune WHERE comc BETWEEN '13201' AND '13216' OR comc BETWEEN '69381' AND '69389' OR comc LIKE '751__'")->fetchColumn();
|
||||||
|
say("✔ pop_commune : $n lignes ($communes codes dont $arr arrondissements PLM, INSEE séries historiques 1968→2022).");
|
||||||
|
say('🎉 Terminé.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
http_response_code(500);
|
||||||
|
say('!! ERREUR : ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Carte des établissements scolaires — France</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
|
||||||
|
<link rel="stylesheet" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<aside id="sidebar">
|
||||||
|
<header>
|
||||||
|
<h1>Établissements scolaires</h1>
|
||||||
|
<p class="subtitle">Annuaire de l'Éducation nationale</p>
|
||||||
|
<p id="count" class="count">Chargement…</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="field">
|
||||||
|
<label for="search">Recherche (nom / commune)</label>
|
||||||
|
<input type="search" id="search" placeholder="Ex. Jean Jaurès, Lyon…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Type d'établissement</legend>
|
||||||
|
<div id="f-type" class="checks"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Statut</legend>
|
||||||
|
<div id="f-stat" class="checks"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>État</legend>
|
||||||
|
<div id="f-etat" class="checks"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="f-reg">Région</label>
|
||||||
|
<select id="f-reg"></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="f-aca">Académie</label>
|
||||||
|
<select id="f-aca"></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="f-dep">Département</label>
|
||||||
|
<select id="f-dep"></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="f-circ">Circonscription</label>
|
||||||
|
<select id="f-circ"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Options proposées</legend>
|
||||||
|
<div id="f-opt" class="checks"></div>
|
||||||
|
<p class="hint">Coché = uniquement les établissements proposant l'option.</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button id="reset" type="button">Réinitialiser les filtres</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><a href="admin.html">🔐 Espace administration</a></p>
|
||||||
|
<p>Source : <a href="https://data.education.gouv.fr/explore/dataset/fr-en-annuaire-education/" target="_blank" rel="noopener">data.education.gouv.fr</a> — fond de carte © OpenStreetMap</p>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="map"></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="legend" class="legend"></div>
|
||||||
|
|
||||||
|
<!-- Fiche établissement plein écran (overlay superposé à la carte) -->
|
||||||
|
<div id="fiche" class="fiche" hidden>
|
||||||
|
<header class="fiche-bar">
|
||||||
|
<button id="fiche-back" class="fiche-back" type="button">← Retour à la carte</button>
|
||||||
|
<div class="fiche-title">
|
||||||
|
<h2 id="fiche-nom">—</h2>
|
||||||
|
<span id="fiche-sub" class="fiche-sub"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="fiche-tabs">
|
||||||
|
<button class="fiche-tab active" type="button" data-tab="infos">Informations</button>
|
||||||
|
<button class="fiche-tab" type="button" data-tab="docs">Documents</button>
|
||||||
|
<button class="fiche-tab" type="button" data-tab="photos">Photothèque</button>
|
||||||
|
<button class="fiche-tab" type="button" data-tab="propose">+ Proposer un élément</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="fiche-body">
|
||||||
|
<section id="tab-infos" class="tab-panel active">
|
||||||
|
<div class="tab-actions">
|
||||||
|
<button id="fiche-locate" type="button" class="btn-secondary">📍 Localiser sur la carte</button>
|
||||||
|
</div>
|
||||||
|
<div class="info-cols">
|
||||||
|
<div id="info-main" class="info-main"><!-- cartes : identité, coordonnées… --></div>
|
||||||
|
<div id="info-side" class="info-side"><!-- commentaires --></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-docs" class="tab-panel">
|
||||||
|
<div class="docs-layout">
|
||||||
|
<nav id="folder-tree" class="folder-tree"></nav>
|
||||||
|
<div id="folder-items" class="folder-items"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-photos" class="tab-panel">
|
||||||
|
<div id="photo-grid" class="photo-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-propose" class="tab-panel">
|
||||||
|
<form id="propose-form" class="propose-form" autocomplete="off">
|
||||||
|
<p class="propose-intro">
|
||||||
|
Proposez un document, une photo ou un lien. Votre proposition sera
|
||||||
|
publiée après validation par un administrateur.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Type d'élément</label>
|
||||||
|
<div class="radio-row">
|
||||||
|
<label><input type="radio" name="kind" value="file" checked> 📄 Fichier</label>
|
||||||
|
<label><input type="radio" name="kind" value="photo"> 🖼 Photo</label>
|
||||||
|
<label><input type="radio" name="kind" value="link"> 🔗 Lien</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="p-folder">Dossier</label>
|
||||||
|
<select id="p-folder" name="folder_id"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" data-when="file photo">
|
||||||
|
<label for="p-file">Fichier</label>
|
||||||
|
<input id="p-file" type="file">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" data-when="link">
|
||||||
|
<label for="p-url">URL du lien</label>
|
||||||
|
<input id="p-url" type="url" placeholder="https://…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="p-title">Libellé <span class="opt">(facultatif pour un fichier)</span></label>
|
||||||
|
<input id="p-title" type="text" name="title" placeholder="Ex. Règlement intérieur 2025">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="p-desc">Description <span class="opt">(facultatif)</span></label>
|
||||||
|
<textarea id="p-desc" name="description" rows="3" placeholder="Texte explicatif : de quoi s'agit-il, contexte, date du document…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="p-name">Votre nom <span class="opt">(facultatif)</span></label>
|
||||||
|
<input id="p-name" type="text" name="submitter_name">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="p-mail">Votre e-mail <span class="opt">(facultatif)</span></label>
|
||||||
|
<input id="p-mail" type="email" name="submitter_email">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary">Envoyer la proposition</button>
|
||||||
|
<p id="propose-msg" class="propose-msg"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||||
|
<script src="fiche.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Rattachement école ↔ DPE par proximité géographique.
|
||||||
|
*
|
||||||
|
* Le dataset ADEME n'a pas d'UAI : on relie chaque établissement aux DPE
|
||||||
|
* situés dans LA MÊME COMMUNE (code_insee = comc) et à moins de R mètres
|
||||||
|
* (distance sphérique). Plusieurs DPE peuvent être rattachés à une école
|
||||||
|
* (campus multi-bâtiments) ; la distance est conservée pour mesurer la
|
||||||
|
* fiabilité du rattachement.
|
||||||
|
*
|
||||||
|
* Pré-requis :
|
||||||
|
* - php import_dpe.php (table « dpe » remplie)
|
||||||
|
* - php import_comc.php (colonne « comc » des établissements remplie)
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* php match_dpe.php # rayon par défaut : 5 m (même bâtiment / adresse)
|
||||||
|
* php match_dpe.php --radius=80 # rayon élargi
|
||||||
|
*
|
||||||
|
* La logique est partagée avec l'endpoint web (api/dpe_lib.php).
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/dpe_lib.php';
|
||||||
|
|
||||||
|
function say(string $m): void { echo $m . "\n"; flush(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$radius = 5;
|
||||||
|
foreach ($argv ?? [] as $a) {
|
||||||
|
if (preg_match('/^--radius=(\d+)$/', $a, $m)) {
|
||||||
|
$radius = max(1, (int) $m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = dpe_match(db(), $radius, static fn(string $m) => say('… ' . $m));
|
||||||
|
say("✔ etab_dpe : {$stats['pairs']} rattachements pour {$stats['ecoles']} établissements (≤ {$radius} m).");
|
||||||
|
say('🎉 Terminé.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
say('!! ERREUR : ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/* Politique de mot de passe — validation + jauge côté client.
|
||||||
|
* Miroir de password_policy_check() (api/db.php) : ≥ 8 caractères et au moins
|
||||||
|
* 3 types parmi minuscule / majuscule / chiffre / caractère spécial.
|
||||||
|
* Expose window.PWPolicy = { check, attach }.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const MIN_LEN = 8;
|
||||||
|
const MIN_CLASSES = 3;
|
||||||
|
|
||||||
|
function classes(pwd) {
|
||||||
|
return {
|
||||||
|
lower: /[a-z]/.test(pwd),
|
||||||
|
upper: /[A-Z]/.test(pwd),
|
||||||
|
digit: /[0-9]/.test(pwd),
|
||||||
|
special: /[^A-Za-z0-9]/.test(pwd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(pwd) {
|
||||||
|
pwd = pwd || '';
|
||||||
|
const c = classes(pwd);
|
||||||
|
const nbClasses = (c.lower ? 1 : 0) + (c.upper ? 1 : 0) + (c.digit ? 1 : 0) + (c.special ? 1 : 0);
|
||||||
|
const lenOk = pwd.length >= MIN_LEN;
|
||||||
|
const classesOk = nbClasses >= MIN_CLASSES;
|
||||||
|
const unmet = [];
|
||||||
|
if (!lenOk) unmet.push(`au moins ${MIN_LEN} caractères`);
|
||||||
|
if (!classesOk) unmet.push(`au moins ${MIN_CLASSES} types (minuscule, majuscule, chiffre, spécial)`);
|
||||||
|
// Score 0..4 pour la jauge.
|
||||||
|
let score = 0;
|
||||||
|
if (lenOk) score++;
|
||||||
|
if (pwd.length >= 12) score++;
|
||||||
|
score += Math.max(0, nbClasses - 1);
|
||||||
|
score = Math.min(4, score);
|
||||||
|
return { ok: lenOk && classesOk, unmet, score, lenOk, classesOk, nbClasses };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = ['Très faible', 'Faible', 'Moyen', 'Bon', 'Fort'];
|
||||||
|
|
||||||
|
function attach(input, meter) {
|
||||||
|
if (!input || !meter) return;
|
||||||
|
const render = () => {
|
||||||
|
const r = check(input.value);
|
||||||
|
if (!input.value) { meter.innerHTML = ''; return; }
|
||||||
|
const pct = (r.score / 4) * 100;
|
||||||
|
const lvl = r.ok ? Math.max(2, r.score) : Math.min(1, r.score);
|
||||||
|
meter.innerHTML =
|
||||||
|
`<div class="pw-bar"><span class="pw-bar-fill lvl-${lvl}" style="width:${pct}%"></span></div>
|
||||||
|
<div class="pw-label">${r.ok ? '✓ ' : ''}${LABELS[r.score]}</div>
|
||||||
|
<ul class="pw-crit">
|
||||||
|
<li class="${r.lenOk ? 'ok' : 'ko'}">≥ ${MIN_LEN} caractères</li>
|
||||||
|
<li class="${r.classesOk ? 'ok' : 'ko'}">≥ ${MIN_CLASSES} types parmi minuscule / majuscule / chiffre / spécial</li>
|
||||||
|
</ul>`;
|
||||||
|
};
|
||||||
|
input.addEventListener('input', render);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PWPolicy = { check, attach };
|
||||||
|
})();
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Réinitialiser le mot de passe</title>
|
||||||
|
<link rel="stylesheet" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Nouveau mot de passe</h1>
|
||||||
|
<form id="reset-form" autocomplete="off">
|
||||||
|
<div class="field">
|
||||||
|
<label for="pw">Nouveau mot de passe</label>
|
||||||
|
<input id="pw" type="password" required>
|
||||||
|
<div id="pw-meter" class="pw-meter"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pw2">Confirmer</label>
|
||||||
|
<input id="pw2" type="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Valider</button>
|
||||||
|
<p id="msg" class="propose-msg"></p>
|
||||||
|
</form>
|
||||||
|
<p class="auth-foot"><a href="admin.html">← Retour à la connexion</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="pwpolicy.js"></script>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const token = new URLSearchParams(location.search).get('token') || '';
|
||||||
|
PWPolicy.attach($('pw'), $('pw-meter'));
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
$('reset-form').hidden = true;
|
||||||
|
$('msg').className = 'propose-msg err';
|
||||||
|
$('msg').textContent = 'Lien invalide : jeton manquant.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('reset-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const pw = $('pw').value, pw2 = $('pw2').value;
|
||||||
|
const r = PWPolicy.check(pw);
|
||||||
|
if (!r.ok) { showErr('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; }
|
||||||
|
if (pw !== pw2) { showErr('Les deux mots de passe ne correspondent pas.'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/auth.php?action=reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, new: pw }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showErr(data.error || ('HTTP ' + res.status)); return; }
|
||||||
|
$('reset-form').hidden = true;
|
||||||
|
$('msg').className = 'propose-msg ok';
|
||||||
|
$('msg').innerHTML = '✅ Mot de passe mis à jour. <a href="admin.html">Se connecter</a>.';
|
||||||
|
} catch (err) { showErr('Erreur : ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function showErr(t) { $('msg').className = 'propose-msg err'; $('msg').textContent = t; }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Initialisation de la base « ecoles » :
|
||||||
|
* - crée la base et les tables (etablissements, users, user_scopes, user_groups,
|
||||||
|
* group_members, group_scopes, comments, folders, items) ;
|
||||||
|
* - importe les établissements depuis data/etablissements.json ;
|
||||||
|
* - crée un super-admin par défaut et une arborescence d'exemple ;
|
||||||
|
* - crée les dossiers d'upload.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* php setup.php (ligne de commande)
|
||||||
|
* php setup.php --reset (réimporte les établissements)
|
||||||
|
* http://.../setup.php (navigateur ; ?reset=1 pour réimporter)
|
||||||
|
*
|
||||||
|
* Idempotent : relançable sans risque.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/api/db.php';
|
||||||
|
|
||||||
|
$cli = PHP_SAPI === 'cli';
|
||||||
|
$reset = isset($_GET['reset']) || in_array('--reset', $argv ?? [], true);
|
||||||
|
|
||||||
|
if (!$cli) {
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
function say(string $m): void
|
||||||
|
{
|
||||||
|
echo $m . "\n";
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ajoute une colonne si elle n'existe pas déjà (migration idempotente). */
|
||||||
|
function add_col_if_missing(PDO $pdo, string $table, string $col, string $ddl): void
|
||||||
|
{
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
'SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$table, $col]);
|
||||||
|
if (!$st->fetchColumn()) {
|
||||||
|
$pdo->exec("ALTER TABLE `$table` ADD COLUMN $ddl");
|
||||||
|
say(" + colonne $table.$col ajoutée.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Création de la base (connexion sans schéma).
|
||||||
|
$root = new PDO(
|
||||||
|
'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4',
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||||
|
);
|
||||||
|
$root->exec('CREATE DATABASE IF NOT EXISTS `' . DB_NAME . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
|
||||||
|
say('✔ Base « ' . DB_NAME . ' » prête.');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// 2. Établissements.
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS etablissements (
|
||||||
|
id VARCHAR(12) NOT NULL PRIMARY KEY,
|
||||||
|
nom VARCHAR(255),
|
||||||
|
type VARCHAR(80),
|
||||||
|
stat VARCHAR(40),
|
||||||
|
reg VARCHAR(120),
|
||||||
|
aca VARCHAR(120),
|
||||||
|
dep VARCHAR(120),
|
||||||
|
depc VARCHAR(10),
|
||||||
|
com VARCHAR(160),
|
||||||
|
cp VARCHAR(10),
|
||||||
|
adr VARCHAR(255),
|
||||||
|
tel VARCHAR(40),
|
||||||
|
mail VARCHAR(160),
|
||||||
|
web VARCHAR(255),
|
||||||
|
etat VARCHAR(20),
|
||||||
|
circ VARCHAR(255),
|
||||||
|
lat DECIMAL(9,5),
|
||||||
|
lon DECIMAL(9,5),
|
||||||
|
rest TINYINT NOT NULL DEFAULT 0,
|
||||||
|
heb TINYINT NOT NULL DEFAULT 0,
|
||||||
|
ulis TINYINT NOT NULL DEFAULT 0,
|
||||||
|
segp TINYINT NOT NULL DEFAULT 0,
|
||||||
|
appr TINYINT NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_type (type),
|
||||||
|
INDEX idx_reg (reg),
|
||||||
|
INDEX idx_aca (aca),
|
||||||
|
INDEX idx_dep (dep),
|
||||||
|
INDEX idx_comdep (com, depc),
|
||||||
|
INDEX idx_etat (etat)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anciennes tables de la v1 (remplacées par « items »).
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS documents');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS photos');
|
||||||
|
|
||||||
|
// 3. Utilisateurs (admins) et périmètres.
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(80) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('super','perimeter','member') NOT NULL DEFAULT 'perimeter',
|
||||||
|
email VARCHAR(160) NULL,
|
||||||
|
must_change TINYINT NOT NULL DEFAULT 0,
|
||||||
|
failed_attempts INT NOT NULL DEFAULT 0,
|
||||||
|
locked_until DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
// Migration des bases existantes : rôle « member » + colonnes de politique de mot de passe.
|
||||||
|
$pdo->exec("ALTER TABLE users MODIFY role ENUM('super','perimeter','member') NOT NULL DEFAULT 'perimeter'");
|
||||||
|
add_col_if_missing($pdo, 'users', 'email', 'email VARCHAR(160) NULL');
|
||||||
|
add_col_if_missing($pdo, 'users', 'must_change', 'must_change TINYINT NOT NULL DEFAULT 0');
|
||||||
|
add_col_if_missing($pdo, 'users', 'failed_attempts', 'failed_attempts INT NOT NULL DEFAULT 0');
|
||||||
|
add_col_if_missing($pdo, 'users', 'locked_until', 'locked_until DATETIME NULL');
|
||||||
|
|
||||||
|
// Jetons de réinitialisation de mot de passe (lien à usage unique).
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
used TINYINT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_token (token_hash),
|
||||||
|
CONSTRAINT fk_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS user_scopes (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
com VARCHAR(160) NOT NULL,
|
||||||
|
depc VARCHAR(10) NOT NULL,
|
||||||
|
UNIQUE KEY uniq_scope (user_id, com, depc),
|
||||||
|
INDEX idx_comdep (com, depc),
|
||||||
|
CONSTRAINT fk_scope_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3 bis. Groupes d'utilisateurs (équipes). can_validate = le groupe peut valider
|
||||||
|
// les documents ; group_scopes porte alors son périmètre de communes.
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS user_groups (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
can_validate TINYINT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS group_members (
|
||||||
|
group_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, user_id),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
CONSTRAINT fk_gm_group FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_gm_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS group_scopes (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
group_id INT NOT NULL,
|
||||||
|
com VARCHAR(160) NOT NULL,
|
||||||
|
depc VARCHAR(10) NOT NULL,
|
||||||
|
UNIQUE KEY uniq_gscope (group_id, com, depc),
|
||||||
|
INDEX idx_comdep (com, depc),
|
||||||
|
CONSTRAINT fk_gs_group FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3 ter. Commentaires de fiche : horodatés, visibles entre membres d'un même groupe.
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uai VARCHAR(12) NOT NULL,
|
||||||
|
item_id INT NULL, -- commentaire sur un document précis (sinon NULL)
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_uai (uai),
|
||||||
|
INDEX idx_item (item_id),
|
||||||
|
CONSTRAINT fk_com_uai FOREIGN KEY (uai) REFERENCES etablissements(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_com_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Arborescence globale de dossiers.
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
parent_id INT NULL,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_parent (parent_id),
|
||||||
|
CONSTRAINT fk_folder_parent FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Éléments : fichiers, photos, liens — soumis puis validés.
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uai VARCHAR(12) NOT NULL,
|
||||||
|
folder_id INT NULL,
|
||||||
|
kind ENUM('file','photo','link') NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
filename VARCHAR(255) NULL,
|
||||||
|
url VARCHAR(2048) NULL,
|
||||||
|
size BIGINT NULL,
|
||||||
|
mime VARCHAR(160) NULL,
|
||||||
|
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
submitter_name VARCHAR(160) NULL,
|
||||||
|
submitter_email VARCHAR(160) NULL,
|
||||||
|
reject_reason VARCHAR(500) NULL,
|
||||||
|
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
validated_by INT NULL,
|
||||||
|
validated_at DATETIME NULL,
|
||||||
|
INDEX idx_uai (uai),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_folder (folder_id),
|
||||||
|
CONSTRAINT fk_item_uai FOREIGN KEY (uai) REFERENCES etablissements(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_item_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_item_validator FOREIGN KEY (validated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migrations des bases existantes : description des éléments + portée document des commentaires.
|
||||||
|
add_col_if_missing($pdo, 'items', 'description', 'description TEXT NULL AFTER title');
|
||||||
|
add_col_if_missing($pdo, 'comments', 'item_id', 'item_id INT NULL AFTER uai');
|
||||||
|
// Index sur comments.item_id si absent.
|
||||||
|
$hasIdx = $pdo->query("SHOW INDEX FROM comments WHERE Key_name = 'idx_item'")->fetchColumn();
|
||||||
|
if (!$hasIdx) {
|
||||||
|
$pdo->exec('ALTER TABLE comments ADD INDEX idx_item (item_id)');
|
||||||
|
}
|
||||||
|
say('✔ Tables prêtes.');
|
||||||
|
|
||||||
|
// 6. Dossiers d'upload.
|
||||||
|
foreach (['files', 'photos'] as $d) {
|
||||||
|
$p = __DIR__ . '/uploads/' . $d;
|
||||||
|
if (!is_dir($p)) {
|
||||||
|
mkdir($p, 0777, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
say('✔ Dossiers uploads/ prêts.');
|
||||||
|
|
||||||
|
// 7. Super-admin par défaut (changement de mot de passe forcé à la 1re connexion).
|
||||||
|
if ((int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() === 0) {
|
||||||
|
$pdo->prepare('INSERT INTO users (username, password_hash, role, must_change) VALUES (?, ?, ?, 1)')
|
||||||
|
->execute(['admin', password_hash('admin', PASSWORD_DEFAULT), 'super']);
|
||||||
|
say('✔ Super-admin créé : « admin » / « admin » → changement de mot de passe demandé à la 1re connexion.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7 bis. Dossier privé (boîte d'envoi du mailer en mode « log »), non servi par Apache.
|
||||||
|
$priv = __DIR__ . '/private';
|
||||||
|
if (!is_dir($priv)) {
|
||||||
|
mkdir($priv, 0777, true);
|
||||||
|
}
|
||||||
|
if (!is_file($priv . '/.htaccess')) {
|
||||||
|
file_put_contents($priv . '/.htaccess', "Require all denied\n");
|
||||||
|
}
|
||||||
|
say('✔ Dossier private/ prêt.');
|
||||||
|
|
||||||
|
// 8. Arborescence d'exemple (modifiable ensuite dans l'admin).
|
||||||
|
if ((int) $pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn() === 0) {
|
||||||
|
$tree = [
|
||||||
|
'Administratif' => ['Budgets', 'Comptes-rendus de conseil'],
|
||||||
|
'Pédagogie' => ['Projet d’établissement', 'Règlement intérieur'],
|
||||||
|
'Travaux & bâtiments' => [],
|
||||||
|
'Photos' => ['Façade', 'Intérieur'],
|
||||||
|
'Liens utiles' => [],
|
||||||
|
];
|
||||||
|
$insTop = $pdo->prepare('INSERT INTO folders (parent_id, name, position) VALUES (NULL, ?, ?)');
|
||||||
|
$insSub = $pdo->prepare('INSERT INTO folders (parent_id, name, position) VALUES (?, ?, ?)');
|
||||||
|
$pos = 0;
|
||||||
|
foreach ($tree as $top => $subs) {
|
||||||
|
$insTop->execute([$top, $pos++]);
|
||||||
|
$pid = (int) $pdo->lastInsertId();
|
||||||
|
$sp = 0;
|
||||||
|
foreach ($subs as $sub) {
|
||||||
|
$insSub->execute([$pid, $sub, $sp++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
say('✔ Arborescence 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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user