SPB Git

spb/uqo-eval

Public

Métré : plateforme ouverte, coquille d'application et Plan Studio v2 (éditeur de plan d'architecte)

- Accès ouvert sans mot de passe : session anonyme automatique, tous les projets visibles et modifiables, verrou par projet, gabarits en lecture seule (dupliquer), codes facultatifs pour élever une session (administration) ; schéma v3 (locked, source/level/meta des pages)
- Coquille ERP (MetreShell) : barre latérale de modules, pleine largeur, tableau de bord à KPI, actions rapides, tables de projets, nom de session modifiable
- Plan Studio v2 : murs à épaisseur (jonctions en onglet, boucles fermées), cloisons, portes à battant et fenêtres projetées sur les murs, pièces nommées avec surface, cotations automatiques et manuelles, textes, escaliers, colonnes, empreinte, lot ; saisie des longueurs au clavier, ortho 15°, aimant sommets/segments/grille, zoom molette, déplacement, poignées, annuler/rétablir, calques, niveaux (duplication d'étage) ; quantités en direct ; export PDF/PNG/SVG avec cartouche
- Pages sans fichier : plan vierge à l'échelle (grille papier), gabarits bungalow/cottage/garage avec ouvertures, plan de site satellite géoréférencé (Mapbox Static Images, échelle Web Mercator exacte, géocodage d'adresse / MAMH, sélecteur interactif mapbox-gl)
- Géométrie et rendu purs partagés client/serveur (plan-geometry.ts, plan-render.ts) ; routes pages (création, renommage, duplication, suppression, mesurer tout, export), geocode, duplicate/lock de projet, auth anonymous/me PATCH
- Tests : plan.test.ts (murs, ouvertures, cotations, aimant, quantités, rendu) → 123 Vitest verts ; Playwright : parcours ouvert complet (plan dessiné → registre → estimation → révision → PDF plan + estimation) → 2 verts ; correctif de conflit de segments dynamiques ([pageId] → [id]) et de hauteur du canevas SVG
- Documentation mise à jour (guide, statut, architecture, spécification du métré)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Oct 28, 2026) parent cbaeebf Browse files

38 changed files +1,777 −505

38 changed files
modified docs/construction/ARCHITECTURE.md +9 −1
@@ -53,10 +53,18 @@ data/cost.db (LECTURE : articles, prix canoniques, assemblages, grilles APCHQ/CC
53 53 - **Traçabilité** : chaque nombre du rapport descend d'une ligne de révision, elle-même liée à une mesure (formule + entrées + objet + page + calibration) et à un devis unitaire (composants + sources + dates).
54 54 - **Déterminisme** : mêmes lignes + mêmes paramètres + même instantané = même résultat (test `engine.test.ts`).
55 55
56 +## 4 bis. Plateforme ouverte (2026-10-28)
57 +
58 +À la demande du propriétaire du projet, le module est **ouvert** : `POST /api/metre/auth/anonymous` crée une session (cookie) à la première visite ; `projectAccess` renvoie « write » à tout le monde sur un projet ordinaire, « owner » au créateur, au personnel et aux éditeurs explicites, « read » sur les gabarits et les projets verrouillés. Les codes ne servent qu'à élever une session au rôle enseignant/admin (`/api/metre/auth/login` sur une session existante). La coquille `MetreShell` (barre latérale, pleine largeur) remplace la mise en page éditoriale pour tout `/metre`.
59 +
60 +## 4 ter. Éditeur de plan d'architecte
61 +
62 +`plan-geometry.ts` (pur) : décalage de polylignes en murs à épaisseur (onglets limités), anneaux extérieur/intérieur des boucles fermées, projection d'un point sur un mur (pose des ouvertures), symboles porte (vantail + arc) et fenêtre, géométrie des cotations, centroïdes, aimant (sommets → segments → ortho 15° → grille), quantités (`planQuantities`). `plan-render.ts` (pur) : SVG partagé par l'éditeur (`dangerouslySetInnerHTML` du calque de rendu) et l'export serveur (`exportPage` : SVG → PNG via sharp → PDF Lettre paysage, cartouche). Pages sans fichier : `createBlankPage` (grille papier), `createTemplatePage`, `createMapPage` (Mapbox Static Images, m/px = 40 075 016,686 × cos φ / (512 × 2^z) ÷ 2), `duplicatePage` (niveaux).
63 +
56 64 ## 5. Sécurité
57 65
58 66 - Sessions : jeton 256 bits stocké haché (SHA-256), cookie `HttpOnly; SameSite=Lax; Secure` (prod), 30 jours ; rate-limit par IP sur la connexion (10 / 10 min) et les actions sensibles.
59 −- Autorisation serveur : `requireUser` (401), `requireProject` (404 si invisible — n'expose pas l'existence —, 403 si droit insuffisant) ; rôles vérifiés sur les routes d'administration ; corrigés de devoirs jamais sélectionnés pour un étudiant.
67 +- Autorisation serveur : `requireUser` (401 sans session — le client en crée une automatiquement), `requireProject` (404 projet inexistant, 403 droit insuffisant : suppression/verrou réservés au créateur et au personnel, gabarits et projets verrouillés en lecture seule) ; rôles vérifiés sur les routes d'administration ; corrigés de devoirs jamais sélectionnés pour un étudiant.
60 68 - Téléversements : signature magique vérifiée (PDF/PNG/JPEG), tailles bornées (60 Mo, 25 Mo/page, 60 pages, 8 000 px), PNG **ré-encodés par sharp**, stockage hors `public/`, téléchargement via route autorisée (`Content-Disposition: attachment`, `nosniff`), noms de fichiers assainis.
61 69 - Entrées : toutes bornées/validées (`sanitize*`), identifiants UUID, pas de SQL dynamique non paramétré.
62 70 - Journal d'audit (`metre_audit_events`) : connexion, projets, plans, calibrations, mesures, estimations, révisions, évaluations, ingestion, QA, devoirs.
modified docs/construction/IMPLEMENTATION_STATUS.md +6 −4
@@ -9,14 +9,14 @@
9 9 | **A — Audit et baseline** | ✅ terminée | `OPENCONSTRUCTIONERP_AUDIT.md` (commit `93135eeb`), `UQO_EVAL_INTEGRATION_AUDIT.md`, baseline 69 tests / tsc / lint, journal des décisions de licence (`DATA_SOURCES_AND_LICENSES.md`) |
10 10 | **B — Fondations** | ✅ terminée | feature flag + entrée de navigation, schéma `metre.db` v1 (migrations forward-only), authentification par codes + sessions, rôles et isolation par projet, projets/membres/gabarits, préremplissage MAMH, registre des sources, pipeline d'ingestion (download → hash → parse → normalize → classify → validate → dedupe → publish) |
11 11 | **C — Estimation MVP** | ✅ terminée | registre de mesures manuelles, moteur de recettes (imbrication, pertes, productivité, cycles), moteur d'estimation en cents (ordre documenté, localisation, actualisation StatCan, TPS/TVQ), provenance par ligne (catalogue / recette / hypothèse), révisions immuables hachées, scénarios, sensibilité, rapport PDF **FR/EN**, CSV, XLSX |
12 −| **D — Plan Studio** | ✅ terminée | import PDF/PNG/JPG (rendu client pdfjs, original conservé), pages, calibration 2 points révisionnée avec incertitude, outils pièce/dalle/fondation/toit/mur/ouverture/ligne/aire, aimant/grille/calques/zoom, annuler/rétablir, raccourcis, tactile, autosauvegarde, mesure → registre, périmé/recalcul, liaison automatique ligne ↔ mesure (quantité relue à chaque calcul) |
12 +| **D — Plan Studio** | ✅ terminée (v2, éditeur d'architecte) | **création sans fichier** : plan vierge à l'échelle (grille 1 m, calibration exacte), gabarits (bungalow, cottage, garage), **plan de site satellite géoréférencé** (Mapbox, échelle exacte Web Mercator, géocodage d'adresse / MAMH), niveaux (duplication d'étage) ; import PDF/PNG/JPG (calibration 2 points) et DXF ; **murs à épaisseur avec jonctions en onglet**, cloisons, **portes à battant et fenêtres insérées dans les murs**, pièces nommées avec surface, cotations automatiques et manuelles, textes, escaliers, colonnes, empreinte, lot ; saisie des longueurs au clavier, ortho 15°, aimant sommets/segments/grille, zoom à la molette, déplacement, glisser-déposer des sommets, annuler/rétablir, calques, raccourcis ; **export PDF/PNG/SVG avec cartouche** ; quantités en direct et « Mesurer tout → registre » |
13 13 | **E — Intelligence des données québécoises** | ✅ terminée (périmètre légalement accessible) | SEAO (OCDS, CC-BY) **ingéré en production** (totaux de contrats octroyés, UNSPSC) ; permis Montréal (CC-BY) **ingéré** : agrégats annuels de valeur déclarée des travaux par arrondissement et type (seule ressource portant une valeur) + résumé du détail (562 748 permis, sans valeur) ; DTNI-11A/11B et MTMD : **import manuel** (sites protégés HTTP 403, pas de jeu ouvert) avec classification MasterFormat assistée et revue ; indices StatCan (actualisation) et grilles APCHQ réutilisés ; classes d'évidence séparées ; facteurs régionaux = ceux de `cost_locations` (hypothèses documentées, **aucun facteur empirique calculé** faute d'observations unitaires comparables par région) ; tableau de bord QA, citations, comparaison historique côte à côte de deux révisions |
14 14 | **F — Évaluation et enseignement** | ✅ terminée (périmètre de base) | cas d'évaluation liés aux révisions (remplacement/reproduction, site, physique âge-vie ou %, fonctionnel curable/incurable avec garde anti-double déduction, externe, terrain), gabarits de projets, devoirs avec corrigé protégé, remises, notation, codes d'accès par cours ; lien vers l'atelier `/cout` pour la dépréciation par composante |
15 15 | **G — Avancé** | 🟡 livrée pour l'essentiel | **Assistance IA révisable** (`src/lib/metre/ai.ts`, Claude via la clé serveur existante) : proposition d'assemblages (codes filtrés sur le catalogue), postes manquants, explication pédagogique, commentaire d'anomalies détectées par règles déterministes, classification MasterFormat des observations importées — chaque suggestion est étiquetée (modèle, version de prompt, empreinte, preuves), enregistrée en attente, acceptée/refusée par un humain, journalisée, plafonnée par budget quotidien ; **jamais de prix**. **Import DXF ASCII** (LINE, LWPOLYLINE, POLYLINE, CIRCLE ; calibration exacte depuis `$INSUNITS`). Non livré : IFC (aucun analyseur validé), reconnaissance d'images, collaboration temps réel, facteurs régionaux empiriques |
16 16
17 17 ## Définition de « terminé » — vérifiée en production (API et navigateur Chromium via Playwright)
18 18
19 −Un utilisateur peut : créer un projet → téléverser et calibrer un plan → mesurer un élément → choisir un prix d'assemblage vérifié (catalogue/recette) ou explicitement supposé → produire une estimation tracée → la lier à la méthode du coût → exporter un rapport professionnel bilingue — sans perturber le reste d'UQO Éval (pages existantes et 69 tests inchangés). Chaque chiffre a une origine reproductible (révision + instantané + registre), chaque estimation a une version, chaque jeu de données a ses droits documentés.
19 +Un utilisateur peut, sans mot de passe : créer un projet → dessiner un plan d'architecte (ou le capturer depuis la carte satellite, ou téléverser et calibrer un plan) → mesurer un élément → choisir un prix d'assemblage vérifié (catalogue/recette) ou explicitement supposé → produire une estimation tracée → la lier à la méthode du coût → exporter un rapport professionnel bilingue — sans perturber le reste d'UQO Éval (pages existantes et 69 tests inchangés). Chaque chiffre a une origine reproductible (révision + instantané + registre), chaque estimation a une version, chaque jeu de données a ses droits documentés.
20 20
21 21 ## Limitations connues
22 22
@@ -26,7 +26,7 @@ Un utilisateur peut : créer un projet → téléverser et calibrer un plan →
26 26 - **Permis Montréal** : le détail des permis ne publie **aucune valeur de travaux** ; seules les statistiques annuelles agrégées en portent une (ingérées). Le téléchargement exige un User-Agent de navigateur (403 sinon).
27 27 - **SEAO** : 2 semaines ingérées au déploiement ; relancer périodiquement depuis `/metre/admin` (≈ 19 Mo par semaine).
28 28 - **Plan Studio** : pas de reconnaissance automatique d'images ; DXF ASCII seulement (pas de DWG/DXF binaire ni IFC) ; polygones ≤ 500 points ; parcours complet couvert par un test Playwright (`e2e/metre.spec.ts`).
29 −- **Comptes** : codes d'accès + nom d'affichage (pas de SSO institutionnel).
29 +- **Accès** : plateforme OUVERTE (session anonyme automatique, projets visibles et modifiables par tous, verrou par projet, gabarits en lecture seule, codes facultatifs pour l'administration) — choix pédagogique assumé : pas de confidentialité entre utilisateurs.
30 30 - **IA** : suggestions textuelles seulement (pas d'annotation automatique de plans) ; coût suivi par appel, budget quotidien `METRE_AI_DAILY_BUDGET_USD` (5 $ US par défaut).
31 31 - **Performance** : SQLite ; adapté à un cours (voir mesures dans `VALIDATION_PLAN.md`).
32 32
@@ -38,7 +38,9 @@ src/lib/metre/{db,auth,projects,plans,estimates,report,exports}.ts
38 38 src/lib/metre/sources/{csv,seao,permits,schedule-import,ingest}.ts sources
39 39 src/lib/metre/{engine,services,examples,dxf,ai,scale}.test.ts tests (45) · e2e/metre.spec.ts (Playwright)
40 40 src/lib/metre/ai.ts · src/lib/metre/sources/dxf.ts · scripts/metre-backup.sh · scripts/metre-seed-examples.ts
41 −src/app/api/metre/** 29 routes
41 +src/app/api/metre/** 37 routes
42 +src/lib/metre/plan-geometry.ts · plan-render.ts (murs, ouvertures, cotations, rendu SVG partagé client/serveur)
43 +src/components/metre/{MetreShell,MapPicker}.tsx · metre.css (coquille ERP, carte satellite)
42 44 src/app/metre/{page,projets/[id]/page,catalogue/page,admin/page}.tsx pages
43 45 src/components/metre/{ui,MetreHome,ProjectWorkspace,PlanStudio,TakeoffLedger,EstimateEditor,ValuationPanel,AssemblyEditor,CatalogueView,AdminPanel}.tsx
44 46 public/metre/pdf.worker.min.mjs worker pdfjs
modified docs/construction/TAKEOFF_SPECIFICATION.md +19 −1
@@ -13,6 +13,15 @@
13 13 - Résultat : `px_per_meter`, `uncertainty_pct` (clic ± 1,5 px par point ; une base trop courte fait monter l'incertitude, affichée), **révision n** (jamais écrasée ; historique consultable).
14 14 - Effet : toutes les mesures de la page calculées sous une autre révision passent `stale` ; la grille d'aimantation se cale sur 0,5 m (ou 1 pi).
15 15
16 +## 2 bis. Pages créées dans l'application
17 +
18 +| Source | Création | Calibration | Usage |
19 +|---|---|---|---|
20 +| `blank` | largeur × hauteur (m), échelle 10–200 px/m, niveau | exacte (incertitude 0) | plan d'architecte dessiné de zéro |
21 +| `template` | bungalow / cottage / garage | exacte | point de départ avec murs, pièces, 6 ouvertures |
22 +| `map` | adresse ou MAMH → vue satellite Mapbox (zoom 14–20), image 2 560 × 1 920 px | exacte depuis la projection Web Mercator (incertitude notée 0,1 %) | lot, empreinte, aménagements extérieurs |
23 +| copie | « dupliquer en nouveau niveau » | copiée | étage depuis le rez-de-chaussée |
24 +
16 25 ## 3. Objets
17 26
18 27 | Outil (touche) | Type | Fermé | Propriétés | Formule par défaut |
@@ -22,7 +31,12 @@
22 31 | Fondation (F) | `foundation` | oui | épaisseur (m) | `slab_volume` |
23 32 | Toit (T) | `roof` | oui | pente (po/12) | `roof_surface_area` |
24 33 | Mur (W) | `wall` | non | hauteur (m) | `wall_net_area` |
25 −| Ouverture (O) | `opening` | point | largeur, hauteur (m), nombre, mur à déduire | `opening_area` |
34 +| Porte (D) · Fenêtre (N) | `opening` | point projeté sur le mur cliqué | type (porte/fenêtre/baie), largeur, hauteur (m), nombre, battement (±1 côté, ±2 charnière), mur à déduire | `opening_area` (+ `count`) |
35 +| Cloison (I) | `wall` | non | épaisseur 0,12 m, hauteur | `wall_net_area`, `wall_gross_area`, `polyline_length` |
36 +| Rectangle (X) | `room` | oui | deux clics ou longueur tapée | `polygon_area`, périmètre |
37 +| Cote (M) · Texte (T) | `dimension`, `text` | — | décalage ; texte, taille | non mesurés |
38 +| Escalier (S) · Colonne (C) | `stairs`, `column` | oui / point | — ; côté (m) | `polygon_area` ; `count` |
39 +| Empreinte (F) · Lot (L) | `footprint`, `lot` | oui | — | `polygon_area` |
26 40 | Ligne (L) | `line` | non | — | `polyline_length` |
27 41
28 42 Interactions : clic = sommet ; clic sur le premier sommet / **Entrée** / clic droit = fermer ; **Échap** annule ; **Suppr** retire le dernier sommet ou l'objet sélectionné ; **Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y** annuler/rétablir (50 niveaux) ; **Ctrl+D** dupliquer ; **+/−** zoom (25 % → 600 %) ; **S** aimant (sommets existants puis grille), **G** grille ; calques nommés, masquables ; contrôles tactiles (zoom et défilement natifs, `touch-action: pan-x pan-y`, cibles ≥ 44 px). Autosauvegarde : chaque création/modification est persistée immédiatement (`POST/PATCH /api/metre/objects`). Les points sont bornés à la page. Les objets sont supprimés logiquement (`deleted_at`).
@@ -46,3 +60,7 @@ Une mesure = `{ objet, page, formule, texte de formule, entrées (dict), valeur,
46 60 ## 6. Reconnaissance assistée par IA
47 61
48 62 Pas de reconnaissance d'images. L'assistance IA (onglet Estimation) propose des **rattachements mesure → assemblage** (codes existants seulement), des **postes manquants** et des **commentaires d'anomalies** ; chaque suggestion est revue, étiquetée (modèle/version/empreinte), acceptée ou refusée explicitement, et n'entre dans l'estimation que par l'action « Appliquer » de l'utilisateur. Le registre de mesures n'est jamais modifié par l'IA.
63 +
64 +## 7. Rendu architectural et export
65 +
66 +Murs : polygone de contour (décalage ± épaisseur/2, jonctions en onglet limitées à 4 × demi-épaisseur) rempli en noir ; boucle fermée rendue en anneau (`fill-rule: evenodd`). Ouvertures : découpe blanche à l'épaisseur du mur + vantail et arc de battement (porte) ou triple trait (fenêtre), orientés selon le segment de mur. Pièces : surface teintée, nom et aire au centroïde. Cotes : automatiques par segment de mur (côté extérieur) et manuelles (lignes d'attache, ligne de cote, repères, texte orienté lisible). Export `GET /api/metre/pages/{id}/export?format=svg|png|pdf` : SVG complet avec cartouche (projet, niveau, échelle approximative, date, auteur, mention « plan pédagogique, non scellé »), échelle graphique et flèche du nord ; PNG rasterisé à 144 dpi ; PDF Lettre paysage.
modified docs/construction/USER_GUIDE_FR.md +15 −12
@@ -2,25 +2,28 @@
2 2
3 3 > Module pédagogique du cours IMM1033 (méthodes du coût). Adresse : `https://www.uqo-eval.app/metre`. Les quantités et coûts produits sont des estimations tracées ; ils ne constituent ni une soumission d'entrepreneur ni une évaluation certifiée (OEAQ).
4 4
5 −## 1. Se connecter
5 +## 1. Accès — plateforme ouverte
6 6
7 −1. Ouvrir **Métré** dans le menu d'UQO Éval.
8 −2. Entrer le **code d'accès** remis par l'enseignant·e (ou le code enseignant / administrateur) et un **nom d'affichage** (2 caractères minimum). Aucun mot de passe ni courriel.
9 −3. Pour revenir plus tard : même code + même nom d'affichage = même compte. Déconnexion : lien « quitter » en haut à droite.
10 −
11 −Rôles : **étudiant·e** (ses projets, gabarits en lecture, devoirs de son cours), **enseignant·e** (ses projets, lecture des projets des étudiants de son cours, gabarits, recettes partagées, codes étudiants, devoirs et corrigés, administration des sources), **administrateur·rice** (tout, dont l'ingestion des sources et la QA), **professionnel·le** (comme étudiant·e, rapports sans mention « exemple académique »).
7 +Aucun mot de passe. En ouvrant **Métré**, une session est créée automatiquement (« Visiteur-XXXX », renommable dans la barre latérale). Tous les projets sont visibles et modifiables par tout le monde (laboratoire partagé) ; chaque action est attribuée à la session dans le journal. **Verrouillez** vos projets pour les mettre en lecture seule pour les autres ; **dupliquez** un gabarit ou un projet verrouillé pour travailler dessus. Les enseignant·es et administrateur·rices entrent un code (barre latérale, « code enseignant/admin ») uniquement pour l'administration (sources, devoirs, corrigés, codes) ; leur session et leurs projets sont conservés.
12 8
13 9 ## 2. Créer un projet
14 10
15 11 Tableau de bord → **+ Nouveau projet** : nom, adresse, municipalité, date d'évaluation, type de bâtiment, étages, unités (métriques ou impériales), région de prix (automatique selon la municipalité, ou code `QC-MTL`, `QC-QUE`, `QC-GAT`, `QC-LAV`…). Avec un **identifiant MAMH** (`id_provinc`), le bouton **Préremplir** copie l'adresse, le type, les étages, l'aire d'étages et la valeur du terrain au rôle (source indiquée).
16 12
17 −## 3. Plan Studio
13 +## 3. Plan Studio — dessiner comme un architecte
14 +
15 +Onglet **Plans** → **+ Nouveau plan** :
16 +
17 +- **Plan vierge à l'échelle** : largeur/hauteur en mètres, échelle (px/m), niveau (Site, Sous-sol, Rez-de-chaussée, Étage, Toit). Grille de 1 m, aimant de 10 cm, calibration exacte.
18 +- **Gabarit** : bungalow, cottage, garage déjà tracés (murs, pièces, portes, fenêtres).
19 +- **Plan de site (carte satellite)** : cherchez l'adresse (ou l'identifiant MAMH), cadrez la propriété, **Capturer** → image géoréférencée dont l'échelle est calculée exactement ; tracez le **lot**, l'**empreinte**, l'entrée, la terrasse.
20 +- **Importer** PDF, PNG, JPG (calibrer avec ⊢ sur deux points de distance connue) ou DXF (calibré automatiquement).
21 +
22 +Outils (barre de gauche, raccourcis) : **Sélection V** (glisser un objet ou un sommet) · **Déplacer H** (ou espace + glisser, molette = zoom) · **Mur ext. W** (0,30 m) · **Cloison I** (0,12 m) · **Porte D** et **Fenêtre N** (cliquer sur un mur ; la porte s'oriente le long du mur avec son battant) · **Pièce R** (polygone) · **Rectangle X** (deux clics ou longueur tapée) · **Cote M** · **Texte T** · **Escalier S** · **Colonne C** · **Empreinte F** · **Lot L** · **Ligne P** · **Calibrer K**.
23 +
24 +Dessin : clic = sommet ; **chiffres + Entrée** = longueur exacte du segment dans la direction du curseur (ortho **O** verrouille à 15°) ; cliquer sur le premier point, **Entrée**, double-clic ou clic droit = terminer ; un mur refermé sur lui-même devient une boucle fermée (murs extérieurs). **Échap** annule, **Suppr** efface, **Ctrl+Z/Y** annule/rétablit, **Ctrl+D** duplique, **0** ajuste la vue, **G** grille, **Q** aimant. Les murs sont rendus à leur épaisseur avec jonctions en onglet, les ouvertures découpent le mur, les pièces affichent leur nom et leur surface, chaque segment de mur porte sa cote.
18 25
19 −1. Onglet **Plan Studio** → **+ Importer un plan** (PDF, PNG, JPG ou **DXF ASCII**). Les pages PDF/images sont rendues dans votre navigateur puis envoyées ; un DXF est lu par le serveur (lignes, polylignes, cercles), rasterisé, **calibré automatiquement** si ses unités sont déclarées, et ses entités deviennent des objets par calque ; l'original est toujours conservé avec son empreinte.
20 −2. Choisir une page puis l'outil **Calibrer (C)** : cliquer deux points dont la distance réelle est connue (une cote), saisir la longueur et l'unité → **Calibrer**. L'échelle (px/m) et son incertitude s'affichent ; chaque calibration crée une révision.
21 −3. Dessiner : **Pièce (R)**, **Dalle (D)**, **Fondation (F)**, **Toit (T)**, **Mur (W)**, **Ouverture (O)**, **Ligne (L)**, **Aire (A)**. Clic = sommet ; clic sur le premier sommet, **Entrée** ou clic droit = fermer ; **Échap** annule ; **Suppr** retire ; **Ctrl+Z / Ctrl+Y** ; **Ctrl+D** duplique ; **+/−** zoom ; **S** aimant ; **G** grille. Les mesures en direct s'affichent pendant le tracé.
22 −4. Sélectionner un objet (outil **Sélection (V)**) pour saisir ses propriétés : hauteur d'un mur, épaisseur d'une dalle, pente d'un toit, dimensions et nombre d'une ouverture, mur dont l'ouverture se déduit.
23 −5. **Mesurer → registre** (ou **Mesurer tout**) crée les lignes du registre.
26 +Panneau de droite : propriétés de l'objet (épaisseur, hauteur, extérieur, largeur/hauteur/nombre/battement d'une ouverture, usage et hauteur de plafond d'une pièce, décalage d'une cote), quantités du plan en direct (murs extérieurs nets, longueur de murs, surface des pièces, empreinte, portes/fenêtres), liste des objets. Barre du haut : calques, cotes/étiquettes, **Mesurer tout → registre**, exports **PDF / PNG / SVG** avec cartouche (projet, niveau, échelle, date, flèche du nord, échelle graphique), renommer / dupliquer en nouveau niveau / supprimer la page.
24 27
25 28 ## 4. Registre de métré
26 29
modified e2e/metre.spec.ts +63 −86
@@ -1,42 +1,13 @@
1 −// UQO Éval — Métré : parcours complet dans un vrai navigateur (Chromium) — connexion, projet, plan PNG, calibration, dessin,
2 −// mesure, estimation, révision, PDF ; régression des pages existantes. Code d'accès lu dans .env.local (METRE_INSTRUCTOR_CODE).
1 +// UQO Éval — Métré : parcours de bout en bout dans Chromium, PLATEFORME OUVERTE (aucune connexion) — tableau de bord → projet → plan vierge →
2 +// murs à épaisseur, porte, fenêtre, pièce → mesures au registre → estimation → révision → PDF du plan et de l'estimation ; régression des pages existantes.
3 3 import { expect, test, type Page } from "playwright/test";
4 −import fs from "fs";
5 −import path from "path";
6 −import zlib from "zlib";
7 4
8 −function envCode(): string {
9 − if (process.env.METRE_E2E_CODE) return process.env.METRE_E2E_CODE;
10 − const env = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8");
11 − const m = env.match(/^METRE_INSTRUCTOR_CODE=(.+)$/m);
12 − if (!m) throw new Error("METRE_INSTRUCTOR_CODE introuvable");
13 − return m[1].trim();
14 −}
15 −
16 −/** PNG blanc minimal (sans dépendance) : un rectangle noir de 400 px simule une cote. */
17 −function makePng(w: number, h: number): Buffer {
18 − const rows: Buffer[] = [];
19 − for (let y = 0; y < h; y++) {
20 − const row = Buffer.alloc(1 + w * 3, 255); row[0] = 0;
21 − if (y >= 100 && y < 104) for (let x = 100; x < 500; x++) { row[1 + x * 3] = 0; row[2 + x * 3] = 0; row[3 + x * 3] = 0; }
22 − rows.push(row);
23 − }
24 − const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
25 − const crc = (b: Buffer) => { let c = 0xffffffff; for (const x of b) c = crcTable[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
26 − const chunk = (t: string, d: Buffer) => { const len = Buffer.alloc(4); len.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };
27 − const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
28 − return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(Buffer.concat(rows))), chunk("IEND", Buffer.alloc(0))]);
29 −}
30 −
31 −async function login(page: Page, name: string) {
5 +async function openDashboard(page: Page) {
32 6 await page.goto("/metre");
33 − await page.getByLabel(/Code d'accès|Access code/).fill(envCode());
34 − await page.getByLabel(/Nom d'affichage|Display name/).fill(name);
35 − await page.getByRole("button", { name: /Entrer|Enter/ }).click();
36 − await expect(page.getByRole("heading", { level: 1 })).toContainText(/plan à la valeur|plan to value/i, { timeout: 20_000 });
7 + await expect(page.getByRole("heading", { level: 1 })).toContainText(/Tableau de bord|Dashboard/, { timeout: 20_000 });
37 8 }
38 9
39 −test("régression : pages existantes d'UQO Éval répondent", async ({ page }) => {
10 +test("régression : pages existantes d'UQO Éval répondent et la navigation mène au Métré", async ({ page }) => {
40 11 for (const p of ["/", "/cout", "/a-vendre", "/stats", "/methodologie", "/hedonique"]) {
41 12 const r = await page.goto(p);
42 13 expect(r?.status(), p).toBe(200);
@@ -45,72 +16,78 @@ test("régression : pages existantes d'UQO Éval répondent", async ({ page }) =
45 16 await expect(page.getByRole("link", { name: /Métré|Takeoff/ }).first()).toBeVisible();
46 17 });
47 18
48 −test("parcours complet : projet → plan → calibration → mesure → estimation → révision → PDF", async ({ page }) => {
49 − await login(page, `E2E ${Date.now()}`);
19 +test("plateforme ouverte : session automatique, plan d'architecte, registre, estimation, révision, exports", async ({ page }) => {
20 + await openDashboard(page);
21 + await expect(page.getByText(/Visiteur-|session ouverte|open session/).first()).toBeVisible({ timeout: 15_000 }); // aucune connexion demandée
50 22 // projet
51 − await page.getByRole("button", { name: /Nouveau projet|New project/ }).click();
52 − await page.getByLabel(/Nom du projet|Project name/).fill("E2E bungalow");
23 + await page.getByRole("button", { name: /Nouveau projet|New project/ }).first().click();
24 + await page.getByLabel(/Nom du projet|Project name/).fill("E2E architecte");
53 25 await page.getByLabel(/Municipalité|Municipality/).fill("Gatineau");
54 − await page.getByRole("button", { name: /^Créer$|^Create$/ }).click();
26 + await page.getByRole("button", { name: /Créer et ouvrir|Create and open/ }).click();
55 27 await expect(page).toHaveURL(/\/metre\/projets\//, { timeout: 20_000 });
56 − await expect(page.getByRole("heading", { level: 1 })).toContainText("E2E bungalow");
57 − // plan PNG
58 − await page.getByRole("button", { name: /02 Plan Studio/ }).click();
59 − const png = makePng(1200, 900);
60 − await page.locator('input[type="file"]').setInputFiles({ name: "plan.png", mimeType: "image/png", buffer: png });
61 − await expect(page.locator("svg image")).toBeVisible({ timeout: 30_000 });
62 − // calibration : deux clics sur la cote (400 px = 4 m) ; le SVG est affiché à l'échelle 1 (zoom 100 %)
63 − const svg = page.locator("svg").first();
28 + // plan vierge 20 × 15 m à 40 px/m
29 + await page.getByRole("button", { name: /Plan vierge à l'échelle|Blank plan to scale/ }).click();
30 + await page.getByLabel(/Largeur|Width/).fill("20"); await page.getByLabel(/Hauteur \(m\)|Height \(m\)/).fill("15"); await page.getByLabel(/Échelle|Scale/).fill("40");
31 + await page.getByRole("button", { name: /^Créer le plan$|^Create plan$/ }).click();
32 + await expect(page.locator(".pl-canvas svg")).toBeVisible({ timeout: 20_000 });
33 + await expect(page.getByText(/px\/m/).first()).toBeVisible();
34 + // repère du canevas : la page tient dans le canevas (« ajuster ») ; on lit la transformation pour convertir m → écran
35 + const svg = page.locator(".pl-canvas svg");
36 + await page.waitForTimeout(300);
64 37 const box = (await svg.boundingBox())!;
65 − const scale = box.width / 1200;
66 − await page.getByRole("button", { name: /Calibrer|Calibrate/ }).first().click();
67 − await page.mouse.click(box.x + 100 * scale, box.y + 102 * scale);
68 − await page.mouse.click(box.x + 500 * scale, box.y + 102 * scale);
69 − await page.getByLabel(/Longueur réelle|Real length/).fill("4");
70 − await page.getByRole("button", { name: /^Calibrer$|^Calibrate$/ }).click();
71 − await expect(page.getByText(/px\/m/).first()).toBeVisible({ timeout: 15_000 });
72 − // pièce 400 × 300 px = 4 m × 3 m = 12 m² (aimant sur la grille de 0,5 m) — le panneau de calibration a disparu : recalculer la position du canevas
73 − await page.getByRole("button", { name: /Pièce|Room/ }).click();
74 − await page.keyboard.press("-"); await page.keyboard.press("-"); // zoom 64 % : le canevas tient dans la fenêtre (les clics hors fenêtre sont ignorés)
75 − await svg.scrollIntoViewIfNeeded();
76 − const box2 = (await svg.boundingBox())!;
77 − const scale2 = box2.width / 1200;
78 − for (const [x, y] of [[200, 200], [600, 200], [600, 500], [200, 500]]) await page.mouse.click(box2.x + x * scale2, box2.y + y * scale2);
79 − await page.keyboard.press("Enter");
80 − await expect(page.getByText(/Objet|Object/).first()).toBeVisible();
81 − await page.getByRole("button", { name: /Mesurer → registre|Measure → ledger/ }).click();
82 − await expect(page.getByText(/mesuré |measured /).first()).toBeVisible({ timeout: 15_000 });
38 + const t = await page.locator(".pl-canvas svg > g").getAttribute("transform");
39 + const m = t!.match(/translate\(([-\d.]+) ([-\d.]+)\) scale\(([-\d.]+)\)/)!;
40 + const tx = Number(m[1]), ty = Number(m[2]), k = Number(m[3]);
41 + const at = (xm: number, ym: number) => ({ x: box.x + tx + xm * 40 * k, y: box.y + ty + ym * 40 * k });
42 + // murs extérieurs : rectangle 10 × 8 m fermé en revenant au premier point
43 + await page.keyboard.press("w");
44 + for (const [x, y] of [[2, 2], [12, 2], [12, 10], [2, 10], [2, 2]]) { const p = at(x, y); await page.mouse.click(p.x, p.y); }
45 + await expect(page.locator(".pl-list button", { hasText: /Mur ext\.|Ext\. wall/ })).toHaveCount(1, { timeout: 10_000 });
46 + // cloison par longueur tapée : 8 m vers le bas depuis (7,2)
47 + await page.keyboard.press("i");
48 + const a = at(7, 2); await page.mouse.click(a.x, a.y);
49 + const b = at(7, 6); await page.mouse.move(b.x, b.y);
50 + await page.keyboard.type("8"); await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
51 + await expect(page.locator(".pl-list button", { hasText: /Cloison|Partition/ })).toHaveCount(1);
52 + // porte et fenêtre sur le mur extérieur
53 + await page.keyboard.press("d"); { const p = at(4, 2); await page.mouse.click(p.x, p.y); }
54 + await page.keyboard.press("n"); { const p = at(9, 2); await page.mouse.click(p.x, p.y); }
55 + await expect(page.locator(".pl-list button", { hasText: /Porte|Door/ })).toHaveCount(1);
56 + await expect(page.locator(".pl-list button", { hasText: /Fenêtre|Window/ })).toHaveCount(1);
57 + // pièce rectangle 5 × 8 m par deux clics
58 + await page.keyboard.press("x"); { const p = at(2, 2); await page.mouse.click(p.x, p.y); } { const p = at(7, 10); await page.mouse.click(p.x, p.y); }
59 + await expect(page.locator(".pl-list button", { hasText: /Pièce|Room/ })).toHaveCount(1);
60 + await expect(page.locator(".pl-status")).toContainText(/1 portes|1 doors/);
83 61 // registre
84 − await page.getByRole("button", { name: /03 Métré|03 Takeoff/ }).click();
85 − await expect(page.locator("table")).toContainText(/polygone|polygon/);
86 − await expect(page.locator("table")).toContainText("12");
62 + await page.getByRole("button", { name: /Mesurer tout → registre|Measure all → ledger/ }).click();
63 + await expect(page.locator(".pl-status")).toContainText(/mesures au registre|measurements in ledger/, { timeout: 20_000 });
64 + await page.locator(".mt-tabs button", { hasText: /^Métré$|^Takeoff$/ }).click();
65 + await expect(page.locator("table")).toContainText(/mur brut|wall gross/i);
66 + await expect(page.locator("table")).toContainText(/36/); // périmètre 36 m
67 + await expect(page.locator("table")).toContainText(/40/); // pièce 5 × 8 = 40 m²
87 68 await page.getByRole("button", { name: /^Vérifier$|^Verify$/ }).first().click();
88 − await expect(page.getByText(/✓/).first()).toBeVisible();
89 − // estimation
90 − await page.getByRole("button", { name: /05 Estimation|05 Estimate/ }).click();
69 + // estimation : toutes les mesures + hypothèse, révision, PDF
70 + await page.locator(".mt-tabs button", { hasText: /^Estimation$|^Estimate$/ }).click();
91 71 await page.getByRole("button", { name: /Créer une estimation|Create an estimate/ }).click();
92 − await expect(page.getByLabel(/^Nom$|^Name$/)).toBeVisible({ timeout: 15_000 });
93 72 await page.getByRole("button", { name: /Toutes les mesures non liées|All unlinked measurements/ }).click();
94 73 await page.getByRole("button", { name: /Ligne hypothèse|Assumption line/ }).click();
95 − await page.locator('input[placeholder="$"]').first().fill("1500");
74 + await page.locator('input[placeholder="$"]').first().fill("2500");
96 75 await expect(page.getByText(/TOTAL/)).toBeVisible();
97 − await page.waitForTimeout(1200); // autosauvegarde (debounce 450 ms) + recalcul
98 − // révision (deux invites : libellé puis scénario) et, plus bas, confirmation de suppression — un seul gestionnaire de dialogues
76 + await page.waitForTimeout(1200);
99 77 page.on("dialog", (d) => void d.accept(d.type() === "prompt" ? d.defaultValue() || "Révision E2E" : undefined));
100 78 await page.getByRole("button", { name: /Figer une révision|Freeze a revision/ }).click();
101 79 await expect(page.getByText(/r1 —/).first()).toBeVisible({ timeout: 15_000 });
102 − // PDF
103 − await page.getByRole("button", { name: /07 Rapports|07 Reports/ }).click();
80 + await page.locator(".mt-tabs button", { hasText: /^Rapports$|^Reports$/ }).click();
104 81 const href = await page.getByRole("link", { name: "PDF FR" }).first().getAttribute("href");
105 − expect(href).toMatch(/\/api\/metre\/versions\/.+format=pdf/);
106 − // téléchargé depuis la page (le cookie de session est HttpOnly + Secure : le contexte de requêtes hors navigateur ne l'envoie pas sur http)
107 − const pdf = await page.evaluate(async (u) => { const r = await fetch(u); const b = new Uint8Array(await r.arrayBuffer()); return { status: r.status, type: r.headers.get("content-type"), head: String.fromCharCode(...b.subarray(0, 4)), bytes: b.length }; }, href!);
108 − expect(pdf.status).toBe(200);
109 − expect(pdf.type).toContain("application/pdf");
110 − expect(pdf.head).toBe("%PDF");
111 − expect(pdf.bytes).toBeGreaterThan(20_000);
112 − // nettoyage : suppression du projet (logique)
113 − await page.getByRole("button", { name: /01 Projet|01 Project/ }).click();
82 + const pdf = await page.evaluate(async (u) => { const r = await fetch(u); const b = new Uint8Array(await r.arrayBuffer()); return { status: r.status, head: String.fromCharCode(...b.subarray(0, 4)), bytes: b.length }; }, href!);
83 + expect(pdf.status).toBe(200); expect(pdf.head).toBe("%PDF"); expect(pdf.bytes).toBeGreaterThan(20_000);
84 + // export du plan (cartouche)
85 + await page.locator(".mt-tabs button", { hasText: /^Plans$/ }).click();
86 + const planHref = await page.locator("a", { hasText: /^PDF$/ }).first().getAttribute("href");
87 + const planPdf = await page.evaluate(async (u) => { const r = await fetch(u); const b = new Uint8Array(await r.arrayBuffer()); return { status: r.status, head: String.fromCharCode(...b.subarray(0, 4)) }; }, planHref!);
88 + expect(planPdf.status).toBe(200); expect(planPdf.head).toBe("%PDF");
89 + // nettoyage
90 + await page.locator(".mt-tabs button", { hasText: /^Fiche$|^Sheet$/ }).click();
114 91 await page.getByRole("button", { name: /^Supprimer$|^Delete$/ }).click();
115 92 await expect(page).toHaveURL(/\/metre$/, { timeout: 15_000 });
116 93 });
added src/app/api/metre/auth/anonymous/route.ts +24 −0
@@ -0,0 +1,24 @@
1 +// UQO Éval — Métré : session anonyme automatique (plateforme ouverte, aucun mot de passe).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { HttpError, anonymousLogin, handle, sessionCookie, tokenFromRequest, userFromToken } from "@/lib/metre/auth";
4 +import { getMetreDb, metreEnabled } from "@/lib/metre/db";
5 +import { clientKey, rateLimit } from "@/lib/cost/cache";
6 +
7 +export const runtime = "nodejs";
8 +
9 +export async function POST(req: NextRequest) {
10 + return handle(async () => {
11 + if (!metreEnabled()) throw new HttpError(404, "Module Métré désactivé.");
12 + const ip = clientKey(req);
13 + const d = getMetreDb();
14 + const existing = userFromToken(d, tokenFromRequest(req));
15 + if (existing) return NextResponse.json({ user: existing });
16 + if (!rateLimit(`metre:anon:${ip}`, 30, 10 * 60_000)) throw new HttpError(429, "Trop de sessions créées — réessayez dans quelques minutes.");
17 + let name: string | null = null;
18 + try { name = ((await req.json()) as { displayName?: string }).displayName ?? null; } catch { /* corps vide */ }
19 + const r = anonymousLogin(d, { ip, userAgent: req.headers.get("user-agent"), displayName: name });
20 + const res = NextResponse.json({ user: r.user }, { status: 201 });
21 + res.headers.set("Set-Cookie", sessionCookie(r.token, r.expiresAt));
22 + return res;
23 + });
24 +}
modified src/app/api/metre/auth/login/route.ts +9 −3
@@ -1,6 +1,6 @@
1 −// UQO Éval — Métré : connexion par code d'accès (cookie de session HttpOnly).
1 +// UQO Éval — Métré : code d'accès FACULTATIF (enseignant/admin) — élève la session courante (ses projets sont conservés) ou en crée une.
2 2 import { NextRequest, NextResponse } from "next/server";
3 −import { HttpError, handle, login, readJson, sessionCookie } from "@/lib/metre/auth";
3 +import { HttpError, elevateWithCode, handle, login, readJson, sessionCookie, tokenFromRequest, userFromToken } from "@/lib/metre/auth";
4 4 import { getMetreDb, metreEnabled } from "@/lib/metre/db";
5 5 import { clientKey, rateLimit } from "@/lib/cost/cache";
6 6
@@ -12,7 +12,13 @@ export async function POST(req: NextRequest) {
12 12 const ip = clientKey(req);
13 13 if (!rateLimit(`metre:login:${ip}`, 10, 10 * 60_000)) throw new HttpError(429, "Trop de tentatives — réessayez dans 10 minutes.");
14 14 const body = await readJson<{ code?: string; displayName?: string }>(req);
15 − const r = login(getMetreDb(), { code: String(body.code ?? ""), displayName: String(body.displayName ?? ""), ip, userAgent: req.headers.get("user-agent") });
15 + const d = getMetreDb();
16 + const current = userFromToken(d, tokenFromRequest(req));
17 + if (current) {
18 + const user = elevateWithCode(d, current.id, String(body.code ?? ""));
19 + return NextResponse.json({ user });
20 + }
21 + const r = login(d, { code: String(body.code ?? ""), displayName: String(body.displayName ?? "Personnel"), ip, userAgent: req.headers.get("user-agent") });
16 22 const res = NextResponse.json({ user: r.user });
17 23 res.headers.set("Set-Cookie", sessionCookie(r.token, r.expiresAt));
18 24 return res;
modified src/app/api/metre/auth/me/route.ts +11 −2
@@ -1,6 +1,6 @@
1 −// UQO Éval — Métré : utilisateur courant (null si non connecté).
1 +// UQO Éval — Métré : session courante (GET) et renommage du nom d'affichage (PATCH).
2 2 import { NextRequest, NextResponse } from "next/server";
3 −import { handle, tokenFromRequest, userFromToken } from "@/lib/metre/auth";
3 +import { handle, readJson, renameUser, requireUser, tokenFromRequest, userFromToken } from "@/lib/metre/auth";
4 4 import { getMetreDb, metreEnabled } from "@/lib/metre/db";
5 5
6 6 export const runtime = "nodejs";
@@ -12,3 +12,12 @@ export async function GET(req: NextRequest) {
12 12 return NextResponse.json({ enabled: true, user });
13 13 });
14 14 }
15 +
16 +export async function PATCH(req: NextRequest) {
17 + return handle(async () => {
18 + const ctx = requireUser(req, { limit: { key: "rename", max: 20, windowMs: 60_000 } });
19 + const b = await readJson<{ displayName?: string }>(req);
20 + const name = renameUser(ctx.db, ctx.user.id, String(b.displayName ?? ""));
21 + return NextResponse.json({ user: { ...ctx.user, displayName: name } });
22 + });
23 +}
added src/app/api/metre/geocode/route.ts +26 −0
@@ -0,0 +1,26 @@
1 +// UQO Éval — Métré : géocodage d'une adresse québécoise (Mapbox Geocoding, jeton serveur) ou coordonnées d'une unité MAMH (?mamh=id).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { HttpError, handle, requireUser } from "@/lib/metre/auth";
4 +import { getUnit } from "@/lib/db";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function GET(req: NextRequest) {
9 + return handle(async () => {
10 + requireUser(req, { limit: { key: "geocode", max: 60, windowMs: 60_000 } });
11 + const mamh = req.nextUrl.searchParams.get("mamh");
12 + if (mamh) {
13 + const u = getUnit(mamh.replace(/\D/g, "") || mamh);
14 + if (!u || !u.lat || !u.lng) throw new HttpError(404, "Unité MAMH introuvable ou non géoréférencée.");
15 + return NextResponse.json({ results: [{ name: [u.adresse, u.municipalite].filter(Boolean).join(", "), lat: u.lat, lng: u.lng, source: "MAMH" }] });
16 + }
17 + const q = (req.nextUrl.searchParams.get("q") ?? "").trim().slice(0, 160);
18 + if (q.length < 3) throw new HttpError(400, "q (3 caractères minimum) ou mamh requis.");
19 + const { MAPBOX_TOKEN } = await import("@/lib/kamaps");
20 + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json?country=ca&language=fr&limit=5&bbox=-80,44.9,-57,63&access_token=${MAPBOX_TOKEN}`;
21 + const r = await fetch(url);
22 + if (!r.ok) throw new HttpError(502, `Géocodage indisponible (HTTP ${r.status}).`);
23 + const j = (await r.json()) as { features?: { place_name?: string; center?: [number, number] }[] };
24 + return NextResponse.json({ results: (j.features ?? []).filter((f) => f.center).map((f) => ({ name: f.place_name ?? q, lat: f.center![1], lng: f.center![0], source: "Mapbox" })) });
25 + });
26 +}
renamed src/app/api/metre/pages/[pageId]/calibrate/route.ts → src/app/api/metre/pages/[id]/calibrate/route.ts +4 −4
@@ -5,13 +5,13 @@ import { calibratePage, calibrationHistory, pageProject } from "@/lib/metre/plan
5 5
6 6 export const runtime = "nodejs";
7 7
8 −export async function GET(req: NextRequest, { params }: { params: Promise<{ pageId: string }> }) {
9 − return handle(async () => { const { pageId } = await params; const ctx = requireUser(req); const pg = pageProject(ctx.db, pageId); if (!pg) throw new HttpError(404, "Page introuvable."); requireProject(ctx, pg.projectId, "read"); return NextResponse.json({ history: calibrationHistory(ctx.db, pageId) }); });
8 +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9 + return handle(async () => { const { id: pageId } = await params; const ctx = requireUser(req); const pg = pageProject(ctx.db, pageId); if (!pg) throw new HttpError(404, "Page introuvable."); requireProject(ctx, pg.projectId, "read"); return NextResponse.json({ history: calibrationHistory(ctx.db, pageId) }); });
10 10 }
11 11
12 −export async function POST(req: NextRequest, { params }: { params: Promise<{ pageId: string }> }) {
12 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
13 13 return handle(async () => {
14 − const { pageId } = await params;
14 + const { id: pageId } = await params;
15 15 const ctx = requireUser(req);
16 16 const pg = pageProject(ctx.db, pageId);
17 17 if (!pg) throw new HttpError(404, "Page introuvable.");
added src/app/api/metre/pages/[id]/export/route.ts +23 −0
@@ -0,0 +1,23 @@
1 +// UQO Éval — Métré : export d'une page de plan avec cartouche — ?format=svg|png|pdf (&lang=fr|en).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { HttpError, handle, requireProject, requireUser } from "@/lib/metre/auth";
4 +import { exportPage, getPage } from "@/lib/metre/plans";
5 +import { getProject } from "@/lib/metre/projects";
6 +
7 +export const runtime = "nodejs";
8 +export const maxDuration = 60;
9 +
10 +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
11 + return handle(async () => {
12 + const { id } = await params;
13 + const ctx = requireUser(req, { limit: { key: "plan.export", max: 60, windowMs: 60_000 } });
14 + const page = getPage(ctx.db, id); if (!page) throw new HttpError(404, "Page introuvable.");
15 + requireProject(ctx, page.projectId, "read");
16 + const fmt = req.nextUrl.searchParams.get("format") ?? "pdf";
17 + if (fmt !== "svg" && fmt !== "png" && fmt !== "pdf") throw new HttpError(400, "format : svg | png | pdf");
18 + const project = getProject(ctx.db, page.projectId)!;
19 + const r = await exportPage(ctx, id, fmt, { projectName: project.name, author: ctx.user.displayName, imperial: project.unitSystem === "imperial", lang: req.nextUrl.searchParams.get("lang") === "en" ? "en" : "fr" });
20 + const slug = `${project.name}-${page.level ?? page.label ?? "plan"}`.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21 + return new NextResponse(new Uint8Array(r.body), { headers: { "Content-Type": r.mime, "Content-Disposition": `attachment; filename="plan-${slug}.${r.ext}"`, "Cache-Control": "private, no-store" } });
22 + });
23 +}
renamed src/app/api/metre/pages/[pageId]/image/route.ts → src/app/api/metre/pages/[id]/image/route.ts +2 −2
@@ -6,9 +6,9 @@ import { pageProject } from "@/lib/metre/plans";
6 6
7 7 export const runtime = "nodejs";
8 8
9 −export async function GET(req: NextRequest, { params }: { params: Promise<{ pageId: string }> }) {
9 +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
10 10 return handle(async () => {
11 − const { pageId } = await params;
11 + const { id: pageId } = await params;
12 12 const ctx = requireUser(req);
13 13 const pg = pageProject(ctx.db, pageId);
14 14 if (!pg) throw new HttpError(404, "Page introuvable.");
renamed src/app/api/metre/pages/[pageId]/objects/route.ts → src/app/api/metre/pages/[id]/objects/route.ts +4 −4
@@ -5,13 +5,13 @@ import { createObject, listObjects, pageProject } from "@/lib/metre/plans";
5 5
6 6 export const runtime = "nodejs";
7 7
8 −export async function GET(req: NextRequest, { params }: { params: Promise<{ pageId: string }> }) {
9 − return handle(async () => { const { pageId } = await params; const ctx = requireUser(req); const pg = pageProject(ctx.db, pageId); if (!pg) throw new HttpError(404, "Page introuvable."); requireProject(ctx, pg.projectId, "read"); return NextResponse.json({ objects: listObjects(ctx.db, pageId) }); });
8 +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9 + return handle(async () => { const { id: pageId } = await params; const ctx = requireUser(req); const pg = pageProject(ctx.db, pageId); if (!pg) throw new HttpError(404, "Page introuvable."); requireProject(ctx, pg.projectId, "read"); return NextResponse.json({ objects: listObjects(ctx.db, pageId) }); });
10 10 }
11 11
12 −export async function POST(req: NextRequest, { params }: { params: Promise<{ pageId: string }> }) {
12 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
13 13 return handle(async () => {
14 − const { pageId } = await params;
14 + const { id: pageId } = await params;
15 15 const ctx = requireUser(req, { limit: { key: "object.create", max: 600, windowMs: 60_000 } });
16 16 const pg = pageProject(ctx.db, pageId);
17 17 if (!pg) throw new HttpError(404, "Page introuvable.");
added src/app/api/metre/pages/[id]/route.ts +32 −0
@@ -0,0 +1,32 @@
1 +// UQO Éval — Métré : une page de plan — détail, renommage/niveau (PATCH), suppression (DELETE), actions (POST : duplicate | measure_all).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { HttpError, handle, readJson, requireProject, requireUser } from "@/lib/metre/auth";
4 +import { deletePage, duplicatePage, getPage, listObjects, measurePage, renamePage } from "@/lib/metre/plans";
5 +
6 +export const runtime = "nodejs";
7 +
8 +function load(req: NextRequest, id: string, need: "read" | "write") {
9 + const ctx = requireUser(req, { limit: { key: "page", max: 300, windowMs: 60_000 } });
10 + const page = getPage(ctx.db, id); if (!page) throw new HttpError(404, "Page introuvable.");
11 + requireProject(ctx, page.projectId, need);
12 + return { ctx, page };
13 +}
14 +
15 +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
16 + return handle(async () => { const { id } = await params; const { ctx, page } = load(req, id, "read"); const { imagePath, ...rest } = page; void imagePath; return NextResponse.json({ page: rest, objects: listObjects(ctx.db, id) }); });
17 +}
18 +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
19 + return handle(async () => { const { id } = await params; const { ctx } = load(req, id, "write"); const b = await readJson<{ label?: string; level?: string | null }>(req); renamePage(ctx, id, b); return NextResponse.json({ ok: true }); });
20 +}
21 +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
22 + return handle(async () => { const { id } = await params; const { ctx } = load(req, id, "write"); deletePage(ctx, id); return NextResponse.json({ ok: true }); });
23 +}
24 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
25 + return handle(async () => {
26 + const { id } = await params; const { ctx } = load(req, id, "write");
27 + const b = await readJson<{ action?: string; label?: string; level?: string | null; withObjects?: boolean }>(req);
28 + if (b.action === "duplicate") return NextResponse.json({ page: await duplicatePage(ctx, id, { label: b.label, level: b.level, withObjects: b.withObjects }) }, { status: 201 });
29 + if (b.action === "measure_all") return NextResponse.json(measurePage(ctx, id));
30 + throw new HttpError(400, "action : duplicate | measure_all");
31 + });
32 +}
added src/app/api/metre/projects/[id]/duplicate/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +// UQO Éval — Métré : dupliquer un projet (ex. partir d'un gabarit) — la copie appartient à la session courante.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { handle, readJson, requireProject, requireUser } from "@/lib/metre/auth";
4 +import { duplicateProject } from "@/lib/metre/projects";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9 + return handle(async () => {
10 + const { id } = await params; const ctx = requireUser(req, { limit: { key: "project.duplicate", max: 30, windowMs: 60_000 } });
11 + requireProject(ctx, id, "read");
12 + const b = await readJson<{ name?: string }>(req).catch(() => ({} as { name?: string }));
13 + return NextResponse.json({ project: duplicateProject(ctx, id, b.name ? String(b.name) : null) }, { status: 201 });
14 + });
15 +}
added src/app/api/metre/projects/[id]/lock/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +// UQO Éval — Métré : verrouiller / déverrouiller un projet (créateur ou personnel) — lecture seule pour les autres sessions.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { handle, readJson, requireProject, requireUser } from "@/lib/metre/auth";
4 +import { getProject, setLocked } from "@/lib/metre/projects";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9 + return handle(async () => {
10 + const { id } = await params; const ctx = requireUser(req);
11 + requireProject(ctx, id, "owner");
12 + const b = await readJson<{ locked?: boolean }>(req);
13 + setLocked(ctx, id, !!b.locked);
14 + return NextResponse.json({ project: getProject(ctx.db, id) });
15 + });
16 +}
added src/app/api/metre/projects/[id]/pages/route.ts +26 −0
@@ -0,0 +1,26 @@
1 +// UQO Éval — Métré : créer une page de plan sans fichier — vierge (échelle), gabarit (bungalow, cottage, garage) ou carte (satellite géoréférencée).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { HttpError, handle, readJson, requireProject, requireUser } from "@/lib/metre/auth";
4 +import { LEVELS, TEMPLATES, createBlankPage, createMapPage, createTemplatePage } from "@/lib/metre/plans";
5 +
6 +export const runtime = "nodejs";
7 +export const maxDuration = 60;
8 +
9 +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
10 + return handle(async () => { const { id } = await params; const ctx = requireUser(req); requireProject(ctx, id, "read"); return NextResponse.json({ levels: LEVELS, templates: Object.entries(TEMPLATES).map(([key, t]) => ({ key, fr: t.fr, en: t.en, widthM: t.widthM, heightM: t.heightM })) }); });
11 +}
12 +
13 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
14 + return handle(async () => {
15 + const { id } = await params;
16 + const ctx = requireUser(req, { limit: { key: "page.create", max: 60, windowMs: 10 * 60_000 } });
17 + requireProject(ctx, id, "write");
18 + const b = await readJson<Record<string, unknown>>(req);
19 + switch (b.kind) {
20 + case "blank": return NextResponse.json(await createBlankPage(ctx, id, { widthM: Number(b.widthM), heightM: Number(b.heightM), pxPerMeter: Number(b.pxPerMeter), label: typeof b.label === "string" ? b.label : undefined, level: typeof b.level === "string" ? b.level : null }), { status: 201 });
21 + case "template": return NextResponse.json(await createTemplatePage(ctx, id, { template: String(b.template ?? "bungalow"), level: typeof b.level === "string" ? b.level : null, pxPerMeter: Number(b.pxPerMeter) || undefined }), { status: 201 });
22 + case "map": return NextResponse.json(await createMapPage(ctx, id, { lat: Number(b.lat), lng: Number(b.lng), zoom: Number(b.zoom), label: typeof b.label === "string" ? b.label : undefined, style: typeof b.style === "string" ? b.style : undefined }), { status: 201 });
23 + default: throw new HttpError(400, "kind : blank | template | map");
24 + }
25 + });
26 +}
added src/app/metre/layout.tsx +11 −0
@@ -0,0 +1,11 @@
1 +// UQO Éval — Métré : coquille d'application (barre latérale, pleine largeur, sans bandeau ni pied de page éditorial) pour tout /metre.
2 +import { notFound } from "next/navigation";
3 +import MetreShell from "@/components/metre/MetreShell";
4 +import { metreEnabled } from "@/lib/metre/db";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +export default function MetreLayout({ children }: { children: React.ReactNode }) {
9 + if (!metreEnabled()) notFound();
10 + return <MetreShell>{children}</MetreShell>;
11 +}
modified src/app/metre/projets/[id]/page.tsx +2 −1
@@ -12,5 +12,6 @@ export default async function ProjectPage({ params, searchParams }: { params: Pr
12 12 const { id } = await params;
13 13 if (!/^[0-9a-f-]{36}$/i.test(id)) notFound();
14 14 const sp = await searchParams;
15 − return <ProjectWorkspace projectId={id} initialTab={typeof sp.tab === "string" ? sp.tab : "projet"} />;
15 + const nouveau = typeof sp.nouveau === "string" && ["blank", "template", "map", "import"].includes(sp.nouveau) ? (sp.nouveau as "blank" | "template" | "map" | "import") : null;
16 + return <ProjectWorkspace projectId={id} initialTab={typeof sp.tab === "string" ? sp.tab : "plans"} initialCreate={nouveau} />;
16 17 }
modified src/components/metre/AdminPanel.tsx +1 −2
@@ -21,8 +21,7 @@ export default function AdminPanel() {
21 21 const load = () => Promise.all([api<Overview>("/admin/sources").then(setOv), api<{ codes: Record<string, unknown>[] }>("/admin/access-codes").then((r) => setCodes(r.codes)), api<{ events: Record<string, unknown>[] }>("/admin/audit?limit=100").then((r) => setAudit(r.events))]).catch((e) => setErr(e.message));
22 22 useEffect(() => { if (me.user && (me.user.role === "admin" || me.user.role === "instructor")) void load(); }, [me.user]);
23 23 if (me.loading) return null;
24 − if (!me.user) return <LoginCard onDone={me.refresh} />;
25 − if (me.user.role !== "admin" && me.user.role !== "instructor") return <p className="py-16 text-ink-2">{fr ? "Réservé aux enseignants et administrateurs." : "Instructors and administrators only."}</p>;
24 + if (!me.user || (me.user.role !== "admin" && me.user.role !== "instructor")) return <LoginCard onDone={me.refresh} />;
26 25 const admin = me.user.role === "admin";
27 26 const run = async (fn: () => Promise<unknown>, okMsg?: string) => { setBusy(true); setErr(null); setMsg(null); try { const r = await fn(); setMsg(okMsg ?? JSON.stringify(r).slice(0, 400)); await load(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } };
28 27 return (
modified src/components/metre/CatalogueView.tsx +2 −2
@@ -3,7 +3,7 @@
3 3 import { useEffect, useState } from "react";
4 4 import Link from "next/link";
5 5 import { useLang } from "@/components/LangContext";
6 −import { api, Block, Crumbs, Field, Input, Kicker, LoginCard, money, num, Prov, Select, useMe } from "./ui";
6 +import { api, Block, Crumbs, Field, Input, Kicker, money, num, Prov, Select, useMe } from "./ui";
7 7 import { unitLabel } from "@/lib/metre/units";
8 8
9 9 interface Cat { assemblies: { code: string; nameFr: string; nameEn: string; unit: string; category: string; source: string; direct: number | null; observedShare: number | null }[]; items: { code: string; nameFr: string; nameEn: string; unit: string; price: number | null; kind: string | null }[] }
@@ -21,7 +21,7 @@ export default function CatalogueView() {
21 21 useEffect(() => { if (me.user) api<{ labour: typeof labour }>(`/labour?date=${date}`).then((r) => setLabour(r.labour)).catch(() => undefined); }, [date, me.user]);
22 22 useEffect(() => { if (!me.user) return; const t = setTimeout(() => api<{ observations: Obs[] }>(`/admin/observations?q=${encodeURIComponent(oq)}${ocls ? `&class=${ocls}` : ""}&limit=60`).then((r) => setObs(r.observations)).catch(() => setObs([])), 250); return () => clearTimeout(t); }, [oq, ocls, me.user]);
23 23 if (me.loading) return null;
24 − if (!me.user) return <LoginCard onDone={me.refresh} />;
24 + if (!me.user) return <p className="py-16 text-ink-2">…</p>;
25 25 return (
26 26 <div className="py-8">
27 27 <section className="rise"><div className="border-b-2 border-ink pb-2.5"><Crumbs items={[{ href: "/", label: "UQO Éval" }, { href: "/metre", label: fr ? "Métré" : "Takeoff" }, { label: fr ? "Catalogue" : "Catalogue" }]} /></div>
added src/components/metre/MapPicker.tsx +51 −0
@@ -0,0 +1,51 @@
1 +// UQO Éval — Métré : sélecteur de vue satellite (Mapbox GL) pour créer un plan de site géoréférencé — adresse, MAMH ou déplacement libre.
2 +"use client";
3 +import { useEffect, useRef, useState } from "react";
4 +import mapboxgl from "mapbox-gl";
5 +import "mapbox-gl/dist/mapbox-gl.css";
6 +import { MAPBOX_TOKEN } from "@/lib/kamaps";
7 +import { api, Btn, Err, Input } from "./ui";
8 +
9 +export default function MapPicker({ fr, initial, onCapture, onCancel }: { fr: boolean; initial: { lat: number; lng: number; q?: string | null; mamh?: string | null }; onCapture: (v: { lat: number; lng: number; zoom: number; label: string; style: string }) => Promise<void>; onCancel: () => void }) {
10 + const ref = useRef<HTMLDivElement>(null);
11 + const mapRef = useRef<mapboxgl.Map | null>(null);
12 + const [q, setQ] = useState(initial.q ?? "");
13 + const [res, setRes] = useState<{ name: string; lat: number; lng: number; source: string }[]>([]);
14 + const [view, setView] = useState({ lat: initial.lat, lng: initial.lng, zoom: 18.5 });
15 + const [style, setStyle] = useState<"satellite" | "streets">("satellite");
16 + const [label, setLabel] = useState("Site");
17 + const [busy, setBusy] = useState(false); const [err, setErr] = useState<string | null>(null);
18 + useEffect(() => {
19 + if (!ref.current || mapRef.current) return;
20 + mapboxgl.accessToken = MAPBOX_TOKEN;
21 + const m = new mapboxgl.Map({ container: ref.current, style: "mapbox://styles/mapbox/satellite-streets-v12", center: [initial.lng, initial.lat], zoom: 18.5, pitch: 0, bearing: 0, attributionControl: true });
22 + m.addControl(new mapboxgl.NavigationControl({ showCompass: false }), "top-right");
23 + m.addControl(new mapboxgl.ScaleControl({ unit: "metric" }), "bottom-left");
24 + m.on("moveend", () => { const c = m.getCenter(); setView({ lat: c.lat, lng: c.lng, zoom: m.getZoom() }); });
25 + mapRef.current = m;
26 + return () => { m.remove(); mapRef.current = null; };
27 + }, [initial.lat, initial.lng]);
28 + useEffect(() => { mapRef.current?.setStyle(style === "satellite" ? "mapbox://styles/mapbox/satellite-streets-v12" : "mapbox://styles/mapbox/streets-v12"); }, [style]);
29 + useEffect(() => { if (initial.mamh) api<{ results: typeof res }>(`/geocode?mamh=${encodeURIComponent(initial.mamh)}`).then((r) => { if (r.results[0]) mapRef.current?.flyTo({ center: [r.results[0].lng, r.results[0].lat], zoom: 18.5 }); }).catch(() => undefined); }, [initial.mamh]);
30 + const search = async () => { setErr(null); try { const r = await api<{ results: typeof res }>(`/geocode?q=${encodeURIComponent(q)}`); setRes(r.results); if (r.results[0]) mapRef.current?.flyTo({ center: [r.results[0].lng, r.results[0].lat], zoom: 18.5 }); } catch (e) { setErr((e as Error).message); } };
31 + const zoom = Math.min(20, Math.max(14, Math.round(view.zoom)));
32 + const mPerPx = (40075016.686 * Math.cos((view.lat * Math.PI) / 180)) / (512 * Math.pow(2, zoom)) / 2;
33 + return (
34 + <div className="space-y-3">
35 + <div className="flex flex-wrap items-end gap-2">
36 + <div className="min-w-[260px] flex-1"><Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={fr ? "Adresse (ex. 283 boul. Alexandre-Taché, Gatineau)" : "Address"} onKeyDown={(e) => { if (e.key === "Enter") void search(); }} /></div>
37 + <Btn small onClick={() => void search()}>{fr ? "Chercher" : "Search"}</Btn>
38 + <select value={style} onChange={(e) => setStyle(e.target.value as "satellite" | "streets")} className="vp-input !min-h-0 w-[140px] text-[12px]"><option value="satellite">{fr ? "Satellite" : "Satellite"}</option><option value="streets">{fr ? "Plan de rues" : "Streets"}</option></select>
39 + <Input value={label} onChange={(e) => setLabel(e.target.value)} className="!min-h-0 w-[140px] text-[12px]" placeholder={fr ? "nom de la page" : "page name"} />
40 + </div>
41 + {res.length > 1 && <div className="flex flex-wrap gap-1">{res.map((r) => <button key={r.name} type="button" className="chip vp-mono text-[10.5px] border border-[var(--line)] rounded-full px-2 py-0.5" onClick={() => mapRef.current?.flyTo({ center: [r.lng, r.lat], zoom: 18.5 })}>{r.name}</button>)}</div>}
42 + <div ref={ref} style={{ height: 420, borderRadius: 8, overflow: "hidden", border: "1px solid #d9dee2" }} />
43 + <div className="flex flex-wrap items-center justify-between gap-2">
44 + <span className="vp-mono text-[11px] text-ink-2">{view.lat.toFixed(6)}, {view.lng.toFixed(6)} · zoom {zoom} · {(mPerPx * 100).toFixed(1)} cm/px · {fr ? "image 2 560 × 1 920 px ≈" : "image 2,560 × 1,920 px ≈"} {Math.round(2560 * mPerPx)} × {Math.round(1920 * mPerPx)} m</span>
45 + <span className="flex gap-2"><Btn small onClick={onCancel}>{fr ? "Annuler" : "Cancel"}</Btn><Btn small tone="primary" disabled={busy} onClick={async () => { setBusy(true); setErr(null); try { await onCapture({ lat: view.lat, lng: view.lng, zoom, label, style }); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } }}>{busy ? "…" : fr ? "Capturer cette vue → plan de site" : "Capture this view → site plan"}</Btn></span>
46 + </div>
47 + <p className="text-[11.5px] text-ink-3">{fr ? "Cadrez la propriété (zoom 18–20 pour un lot résidentiel). L'image est géoréférencée : l'échelle est calculée exactement depuis la projection ; vous tracez ensuite le lot, l'empreinte, l'entrée, la terrasse… directement sur l'image. © Mapbox © OpenStreetMap © Maxar." : "Frame the property (zoom 18–20 for a residential lot). The image is georeferenced: the scale is computed exactly from the projection; you then trace the lot, footprint, driveway, deck… directly on the image. © Mapbox © OpenStreetMap © Maxar."}</p>
48 + <Err msg={err} />
49 + </div>
50 + );
51 +}
modified src/components/metre/MetreHome.tsx +60 −70
@@ -1,88 +1,78 @@
1 −// UQO Éval — Métré : tableau de bord client (connexion, projets, nouvelle fiche, révisions, catalogue, devoirs).
1 +// UQO Éval — Métré : tableau de bord (coquille ERP) — KPI, actions rapides, projets (tous visibles : plateforme ouverte), gabarits, révisions, devoirs, activité.
2 2 "use client";
3 −import { useEffect, useState } from "react";
3 +import { useCallback, useEffect, useState } from "react";
4 4 import Link from "next/link";
5 −import { useRouter } from "next/navigation";
5 +import { useRouter, useSearchParams } from "next/navigation";
6 6 import { useLang } from "@/components/LangContext";
7 −import { api, Block, Btn, Crumbs, dt, Err, Field, Input, Kicker, LoginCard, money, num, Select, useMe } from "./ui";
7 +import { api, Btn, dt, Err, Field, Input, money, num, Select, useMe } from "./ui";
8 8 import type { BuildingKind, Project } from "@/lib/metre/types";
9 9
10 −interface Dash { user: { id: string; displayName: string; role: string }; projects: (Project & { access: string; versions: number; lastVersionAt: string | null; ownerName: string })[]; versions: { id: string; projectId: string; number: number; label: string; total: number; createdAt: string; projectName: string }[]; catalogue: Record<string, unknown>; observations: { evidenceClass: string; n: number; last: string | null }[]; assignments: { id: string; course: string; title: string; dueAt: string | null; submissions: number; mySubmission: string | null }[] }
11 −
10 +interface Dash { user: { id: string; displayName: string; role: string }; projects: (Project & { access: string; versions: number; lastVersionAt: string | null; ownerName: string; measurements: number; pages: number; mine: boolean })[]; versions: { id: string; projectId: string; number: number; label: string; total: number; createdAt: string; projectName: string }[]; catalogue: Record<string, unknown>; observations: { evidenceClass: string; n: number; last: string | null }[]; assignments: { id: string; course: string; title: string; dueAt: string | null; submissions: number; mySubmission: string | null }[] }
12 11 const KINDS: { k: BuildingKind; fr: string; en: string }[] = [{ k: "detached", fr: "Unifamiliale détachée", en: "Detached single-family" }, { k: "semi_detached", fr: "Jumelée", en: "Semi-detached" }, { k: "row", fr: "En rangée", en: "Row" }, { k: "plex", fr: "Plex", en: "Plex" }, { k: "condo", fr: "Copropriété", en: "Condo" }, { k: "chalet", fr: "Chalet", en: "Cottage" }, { k: "commercial", fr: "Commercial", en: "Commercial" }, { k: "other", fr: "Autre", en: "Other" }];
13 12
14 13 export default function MetreHome() {
15 14 const { lang } = useLang(); const fr = lang === "fr";
16 − const me = useMe();
17 − const router = useRouter();
15 + const me = useMe(); const router = useRouter(); const sp = useSearchParams();
16 + const tab = sp.get("tab") ?? "accueil";
18 17 const [d, setD] = useState<Dash | null>(null);
19 18 const [err, setErr] = useState<string | null>(null);
20 − const [showNew, setShowNew] = useState(false);
21 − const load = () => api<Dash>("/dashboard").then(setD).catch((e) => setErr(e.message));
22 − useEffect(() => { if (me.user) void load(); }, [me.user]);
23 − if (me.loading) return <div className="py-16 vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-3">…</div>;
24 − if (!me.user) return <LoginCard onDone={me.refresh} />;
25 − const role = me.user.role;
26 − const roleLabel: Record<string, [string, string]> = { student: ["étudiant·e", "student"], instructor: ["enseignant·e", "instructor"], admin: ["administrateur·rice", "administrator"], professional: ["professionnel·le", "professional"] };
19 + const [showNew, setShowNew] = useState(sp.get("nouveau") === "plan");
20 + const [filter, setFilter] = useState("");
21 + const load = useCallback(() => api<Dash>("/dashboard").then(setD).catch((e) => setErr(e.message)), []);
22 + useEffect(() => { if (me.user) void load(); }, [me.user, load]);
23 + if (me.loading || !me.user) return <div className="py-16 vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-3">{fr ? "Ouverture de la session…" : "Opening session…"}</div>;
27 24 const cat = d?.catalogue ?? {};
25 + const projects = (d?.projects ?? []).filter((p) => !filter || `${p.name} ${p.address ?? ""} ${p.municipality ?? ""} ${p.ownerName}`.toLowerCase().includes(filter.toLowerCase()));
26 + const templates = projects.filter((p) => p.isTemplate), others = projects.filter((p) => !p.isTemplate);
27 + const dup = async (id: string) => { try { const r = await api<{ project: Project }>(`/projects/${id}/duplicate`, { method: "POST", json: {} }); router.push(`/metre/projets/${r.project.id}`); } catch (e) { setErr((e as Error).message); } };
28 + const quick = async (kind: "blank" | "template" | "map") => {
29 + try { const r = await api<{ project: Project }>("/projects", { method: "POST", json: { name: fr ? "Nouveau projet" : "New project", valuationDate: new Date().toISOString().slice(0, 10) } }); router.push(`/metre/projets/${r.project.id}?tab=plans&nouveau=${kind}`); } catch (e) { setErr((e as Error).message); }
30 + };
28 31 return (
29 − <div className="py-8">
30 − <section className="rise">
31 − <div className="flex flex-wrap items-baseline justify-between gap-4 border-b-2 border-ink pb-2.5">
32 − <Crumbs items={[{ href: "/", label: "UQO Éval" }, { label: fr ? "Métré" : "Takeoff" }]} />
33 − <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{me.user.displayName} · {fr ? roleLabel[role]?.[0] : roleLabel[role]?.[1]} · <button className="underline" onClick={() => api("/auth/logout", { method: "POST" }).then(me.refresh)}>{fr ? "quitter" : "sign out"}</button></span>
32 + <div>
33 + <div className="mt-topbar"><div><p className="kicker">{fr ? "Métré estimatif · intelligence de la construction" : "Takeoff estimating · construction intelligence"}</p><h1>{tab === "projets" ? (fr ? "Projets" : "Projects") : tab === "devoirs" ? (fr ? "Devoirs" : "Assignments") : (fr ? "Tableau de bord" : "Dashboard")}</h1></div>
34 + <div className="flex flex-wrap gap-2"><Btn tone="primary" onClick={() => setShowNew((v) => !v)}>{fr ? "+ Nouveau projet" : "+ New project"}</Btn><Link href="/metre/catalogue" className="btn btn-ghost">{fr ? "Catalogue" : "Catalogue"}</Link></div></div>
35 + <Err msg={err} />
36 + {showNew && <NewProject fr={fr} onCreated={(p) => router.push(`/metre/projets/${p.id}?tab=plans`)} onCancel={() => setShowNew(false)} />}
37 + {tab === "accueil" && (<div className="mt-grid">
38 + <div className="mt-kpis">
39 + <div className="mt-kpi"><div className="k">{fr ? "Projets" : "Projects"}</div><div className="v">{d ? others.length : "…"}</div><div className="s">{d ? `${others.filter((p) => p.mine).length} ${fr ? "à vous" : "yours"} · ${templates.length} ${fr ? "gabarits" : "templates"}` : ""}</div></div>
40 + <div className="mt-kpi"><div className="k">{fr ? "Révisions figées" : "Frozen revisions"}</div><div className="v">{d ? projects.reduce((s, p) => s + p.versions, 0) : "…"}</div><div className="s">{d?.versions[0] ? `${fr ? "dernière" : "last"} ${dt(d.versions[0].createdAt, lang)}` : ""}</div></div>
41 + <div className="mt-kpi"><div className="k">{fr ? "Mesures au registre" : "Ledger measurements"}</div><div className="v">{d ? projects.reduce((s, p) => s + p.measurements, 0) : "…"}</div><div className="s">{d ? `${projects.reduce((s, p) => s + p.pages, 0)} ${fr ? "pages de plan" : "plan pages"}` : ""}</div></div>
42 + <div className="mt-kpi"><div className="k">{fr ? "Prix observés (catalogue)" : "Observed prices (catalogue)"}</div><div className="v">{cat.available ? num(cat.observedPrices as number, lang, 0) : "—"}</div><div className="s">{cat.available ? `${cat.items} ${fr ? "articles" : "items"} · ${cat.assemblies} ${fr ? "assemblages" : "assemblies"} · ${String(cat.lastPrice ?? "")}` : ""}</div></div>
43 + <div className="mt-kpi"><div className="k">{fr ? "Main-d'œuvre APCHQ" : "APCHQ labour"}</div><div className="v">{cat.available ? num(cat.labourRates as number, lang, 0) : "—"}</div><div className="s">{cat.available ? `${fr ? "taux · en vigueur" : "rates · effective"} ${String(cat.labourLast ?? "")}` : ""}</div></div>
44 + <div className="mt-kpi"><div className="k">{fr ? "Observations SEAO / permis" : "SEAO / permit observations"}</div><div className="v">{d ? num(d.observations.reduce((s, o) => s + o.n, 0), lang, 0) : "…"}</div><div className="s">{d?.observations.map((o) => `${o.evidenceClass.replace(/_/g, " ")} ${o.n}`).join(" · ")}</div></div>
34 45 </div>
35 − <div className="mt-5 grid gap-x-10 gap-y-4 lg:grid-cols-[1.5fr_1fr]">
36 − <div>
37 − <Kicker>{fr ? "Métré estimatif — intelligence de la construction" : "Quantity takeoff — construction intelligence"}</Kicker>
38 − <h1 className="vp-display mt-2 text-[clamp(24px,3.6vw,38px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{fr ? "Du plan à la valeur, chaque chiffre tracé" : "From plan to value, every figure traced"}</h1>
39 − <p className="mt-2 max-w-2xl text-[14px] text-ink-2">{fr ? "Importez un plan, calibrez l'échelle, mesurez les éléments, liez-les à des assemblages dont les prix sont observés (APCHQ, détaillants, StatCan) ou explicitement supposés, figez une révision et reliez-la à la méthode du coût." : "Import a plan, calibrate the scale, measure elements, link them to assemblies whose prices are observed (APCHQ, retailers, StatCan) or explicitly assumed, freeze a revision and link it to the cost approach."}</p>
46 + <div className="mt-card"><h2>{fr ? "Commencer" : "Start"}</h2><div className="pl-new">
47 + <button type="button" className="mt-action" onClick={() => void quick("blank")}><b>✎ {fr ? "Dessiner un plan" : "Draw a plan"}</b><span>{fr ? "Plan vierge à l'échelle : murs, portes, fenêtres, pièces, cotations — comme un architecte." : "Blank plan to scale: walls, doors, windows, rooms, dimensions — like an architect."}</span></button>
48 + <button type="button" className="mt-action" onClick={() => void quick("template")}><b>⌂ {fr ? "Partir d'un gabarit" : "Start from a template"}</b><span>{fr ? "Bungalow, cottage, garage déjà tracés." : "Bungalow, cottage, garage already drawn."}</span></button>
49 + <button type="button" className="mt-action" onClick={() => void quick("map")}><b>🛰 {fr ? "Plan de site satellite" : "Satellite site plan"}</b><span>{fr ? "Tracez lot et empreinte sur l'image géoréférencée d'une adresse." : "Trace lot and footprint on a georeferenced image of an address."}</span></button>
50 + <button type="button" className="mt-action" onClick={() => setShowNew(true)}><b>⇪ {fr ? "Importer des plans" : "Import plans"}</b><span>{fr ? "PDF, PNG, JPG ou DXF d'un projet existant, puis calibration." : "PDF, PNG, JPG or DXF of an existing project, then calibration."}</span></button>
51 + </div></div>
52 + <div className="mt-grid mt-grid-2">
53 + <div className="mt-card"><h2>{fr ? "Projets récents" : "Recent projects"} <Link href="/metre?tab=projets" className="text-ink-3 normal-case tracking-normal">{fr ? "tous →" : "all →"}</Link></h2><ProjectsTable rows={others.slice(0, 8)} fr={fr} lang={lang} onDup={dup} /></div>
54 + <div className="space-y-3">
55 + <div className="mt-card"><h2>{fr ? "Gabarits (exemples vérifiés)" : "Templates (verified examples)"}</h2>{templates.map((p) => <div key={p.id} className="flex items-center justify-between gap-2 py-1.5 text-[13px] border-b border-[#eef1f3] last:border-0"><Link href={`/metre/projets/${p.id}`} className="font-semibold text-accent-deep hover:underline">{p.name}</Link><Btn small onClick={() => void dup(p.id)}>{fr ? "Utiliser" : "Use"}</Btn></div>)}{!templates.length && <p className="text-[12.5px] text-ink-3">—</p>}</div>
56 + <div className="mt-card"><h2>{fr ? "Dernières révisions" : "Latest revisions"}</h2>{d?.versions.slice(0, 6).map((v) => <div key={v.id} className="flex items-baseline justify-between gap-2 py-1 text-[12.5px]"><Link href={`/metre/projets/${v.projectId}?tab=rapports`} className="text-accent-deep hover:underline truncate">{v.projectName} · r{v.number}</Link><span className="vp-mono whitespace-nowrap">{money(v.total, lang)}</span></div>)}{!d?.versions.length && <p className="text-[12.5px] text-ink-3">—</p>}</div>
57 + <div className="mt-card"><h2>{fr ? "Devoirs" : "Assignments"}</h2>{d?.assignments.map((a) => <div key={a.id} className="py-1 text-[12.5px]"><b>{a.course}</b> · {a.title} <span className="vp-mono text-[10px] text-ink-3">{a.dueAt ? dt(a.dueAt, lang) : ""}{a.mySubmission ? ` · ${fr ? "remis" : "submitted"}` : ""}</span></div>)}{!d?.assignments.length && <p className="text-[12.5px] text-ink-3">{fr ? "Aucun devoir actif." : "No active assignment."}</p>}</div>
40 58 </div>
41 − <aside className="flex flex-wrap gap-2 lg:justify-end">
42 − <Btn tone="primary" onClick={() => setShowNew((v) => !v)}>{fr ? "+ Nouveau projet" : "+ New project"}</Btn>
43 − <Link href="/metre/catalogue" className="btn btn-ghost">{fr ? "Catalogue" : "Catalogue"}</Link>
44 − {(role === "instructor" || role === "admin") && <Link href="/metre/admin" className="btn btn-ghost">{fr ? "Administration" : "Administration"}</Link>}
45 − </aside>
46 59 </div>
47 − <Err msg={err} />
48 − </section>
49 − {showNew && <NewProject fr={fr} onCreated={(p) => router.push(`/metre/projets/${p.id}`)} onCancel={() => setShowNew(false)} />}
50 − <div className="mt-8 space-y-12">
51 − <Block num="01" title={fr ? "Projets" : "Projects"}>
52 − {!d ? <p className="vp-mono text-[11px] text-ink-3">…</p> : !d.projects.length ? <p className="text-[13.5px] text-ink-2">{fr ? "Aucun projet. Créez-en un pour commencer." : "No project yet. Create one to start."}</p> : (
53 − <div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Projet" : "Project"}</th><th>{fr ? "Adresse" : "Address"}</th><th>{fr ? "Type" : "Type"}</th><th>{fr ? "Date d'évaluation" : "Valuation date"}</th><th className="text-right">{fr ? "Révisions" : "Revisions"}</th><th>{fr ? "Accès" : "Access"}</th><th>{fr ? "Mis à jour" : "Updated"}</th></tr></thead><tbody>
54 − {d.projects.map((p) => (<tr key={p.id}><td><Link href={`/metre/projets/${p.id}`} className="font-semibold text-accent-deep hover:underline">{p.name}</Link>{p.isTemplate && <span className="pill pill-conf-B ml-2">{fr ? "gabarit" : "template"}</span>}<div className="vp-mono text-[10px] text-ink-3">{p.ownerName}</div></td><td className="text-[13px]">{[p.address, p.municipality].filter(Boolean).join(", ") || "—"}</td><td className="text-[13px]">{KINDS.find((k) => k.k === p.buildingKind)?.[fr ? "fr" : "en"] ?? p.buildingKind}</td><td className="vp-mono text-[12px]">{p.valuationDate}</td><td className="vp-mono text-right">{p.versions}</td><td className="vp-mono text-[11px] uppercase">{p.access}</td><td className="vp-mono text-[11px]">{dt(p.updatedAt, lang)}</td></tr>))}
55 − </tbody></table></div>
56 − )}
57 − </Block>
58 − <Block num="02" title={fr ? "Révisions récentes" : "Recent revisions"}>
59 − {!d?.versions.length ? <p className="text-[13.5px] text-ink-2">{fr ? "Aucune révision figée." : "No frozen revision yet."}</p> : (
60 − <ul className="divide-y divide-[var(--line)]">{d.versions.map((v) => (<li key={v.id} className="flex flex-wrap items-baseline justify-between gap-2 py-2 text-[13.5px]"><span><Link href={`/metre/projets/${v.projectId}?tab=rapports`} className="font-semibold text-accent-deep hover:underline">{v.projectName}</Link> · r{v.number} — {v.label}</span><span className="vp-mono text-[12px]">{money(v.total, lang)} · {dt(v.createdAt, lang)}</span></li>))}</ul>
61 − )}
62 − </Block>
63 − <Block num="03" title={fr ? "Fraîcheur du catalogue de prix" : "Price catalogue freshness"}>
64 − {!cat.available ? <p className="text-[13.5px] text-ink-2">{fr ? "Catalogue de coûts indisponible." : "Cost catalogue unavailable."}</p> : (
65 − <div className="flex flex-wrap gap-x-6 gap-y-3">
66 − <span className="stat-chip"><b>{num(cat.items as number, lang, 0)}</b> {fr ? "articles" : "items"}</span>
67 − <span className="stat-chip"><b>{num(cat.assemblies as number, lang, 0)}</b> {fr ? "assemblages" : "assemblies"}</span>
68 − <span className="stat-chip"><b>{num(cat.observedPrices as number, lang, 0)}</b> {fr ? "prix observés" : "observed prices"} · {String(cat.lastPrice ?? "—")}</span>
69 − <span className="stat-chip"><b>{num(cat.labourRates as number, lang, 0)}</b> {fr ? "taux MO" : "labour rates"} · {String(cat.labourLast ?? "—")}</span>
70 − <span className="stat-chip"><b>StatCan</b> {String(cat.indexLast ?? "—")}</span>
71 − {d?.observations.map((o) => (<span key={o.evidenceClass} className="stat-chip"><b>{num(o.n, lang, 0)}</b> {o.evidenceClass} · {o.last ?? "—"}</span>))}
72 − </div>
73 − )}
74 − <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Version du catalogue" : "Catalogue version"} : {String(cat.version ?? "—")}</p>
75 − </Block>
76 − <Block num="04" title={fr ? "Devoirs" : "Assignments"}>
77 − {!d?.assignments.length ? <p className="text-[13.5px] text-ink-2">{fr ? "Aucun devoir actif." : "No active assignment."}</p> : (
78 − <ul className="divide-y divide-[var(--line)]">{d.assignments.map((a) => (<li key={a.id} className="flex flex-wrap items-baseline justify-between gap-2 py-2 text-[13.5px]"><span><b>{a.course}</b> · {a.title}</span><span className="vp-mono text-[11px] uppercase text-ink-3">{a.dueAt ? `${fr ? "échéance" : "due"} ${dt(a.dueAt, lang)}` : ""} {a.mySubmission ? `· ${fr ? "remis" : "submitted"}` : ""} {(role !== "student") ? `· ${a.submissions} ${fr ? "remises" : "submissions"}` : ""}</span></li>))}</ul>
79 − )}
80 − </Block>
81 − </div>
60 + </div>)}
61 + {tab === "projets" && (<div className="mt-card"><h2>{fr ? "Tous les projets (plateforme ouverte : chacun peut voir et modifier ; verrouillez les vôtres pour les protéger)" : "All projects (open platform: anyone can view and edit; lock yours to protect them)"}<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={fr ? "filtrer…" : "filter…"} className="!min-h-0 max-w-[240px] !py-1 text-[12px]" /></h2><ProjectsTable rows={[...templates, ...others]} fr={fr} lang={lang} onDup={dup} /></div>)}
62 + {tab === "devoirs" && (<div className="mt-card"><h2>{fr ? "Devoirs" : "Assignments"}</h2>{d?.assignments.length ? <table className="mt-table"><thead><tr><th>{fr ? "Cours" : "Course"}</th><th>{fr ? "Titre" : "Title"}</th><th>{fr ? "Échéance" : "Due"}</th><th>{fr ? "Remises" : "Submissions"}</th></tr></thead><tbody>{d.assignments.map((a) => <tr key={a.id}><td>{a.course}</td><td>{a.title}</td><td className="vp-mono">{a.dueAt ? dt(a.dueAt, lang) : "—"}</td><td className="vp-mono">{a.submissions}{a.mySubmission ? ` · ${fr ? "la vôtre est remise" : "yours submitted"}` : ""}</td></tr>)}</tbody></table> : <p className="text-[13px] text-ink-2">{fr ? "Aucun devoir actif. Les enseignant·es les créent dans Administration ; les remises se font depuis l'onglet Rapports d'un projet." : "No active assignment. Instructors create them in Administration; submissions are made from a project's Reports tab."}</p>}</div>)}
82 63 </div>
83 64 );
84 65 }
85 66
67 +function ProjectsTable({ rows, fr, lang, onDup }: { rows: Dash["projects"]; fr: boolean; lang: string; onDup: (id: string) => void }) {
68 + if (!rows.length) return <p className="text-[13px] text-ink-2">{fr ? "Aucun projet pour l'instant." : "No project yet."}</p>;
69 + return (
70 + <div className="overflow-x-auto"><table className="mt-table"><thead><tr><th>{fr ? "Projet" : "Project"}</th><th>{fr ? "Lieu" : "Place"}</th><th>{fr ? "Type" : "Type"}</th><th className="text-right">{fr ? "Plans" : "Plans"}</th><th className="text-right">{fr ? "Mesures" : "Measures"}</th><th className="text-right">{fr ? "Révisions" : "Revisions"}</th><th>{fr ? "Par" : "By"}</th><th>{fr ? "Modifié" : "Updated"}</th><th></th></tr></thead><tbody>
71 + {rows.map((p) => <tr key={p.id}><td><Link href={`/metre/projets/${p.id}`} className="font-semibold text-accent-deep hover:underline">{p.name}</Link> {p.isTemplate && <span className="pill pill-conf-B">{fr ? "gabarit" : "template"}</span>} {p.locked && <span title={fr ? "verrouillé" : "locked"}>🔒</span>} {p.mine && !p.isTemplate && <span className="vp-mono text-[9.5px] uppercase text-accent">{fr ? "à vous" : "yours"}</span>}</td><td className="text-[12.5px]">{[p.address, p.municipality].filter(Boolean).join(", ") || "—"}</td><td className="text-[12.5px]">{KINDS.find((k) => k.k === p.buildingKind)?.[fr ? "fr" : "en"] ?? p.buildingKind}</td><td className="vp-mono text-right">{p.pages}</td><td className="vp-mono text-right">{p.measurements}</td><td className="vp-mono text-right">{p.versions}</td><td className="text-[12px] text-ink-2">{p.ownerName}</td><td className="vp-mono text-[11px]">{dt(p.updatedAt, lang)}</td><td className="whitespace-nowrap"><Link href={`/metre/projets/${p.id}?tab=plans`} className="btn btn-ghost !min-h-0 !px-2.5 !py-1 text-[11.5px]">{fr ? "Ouvrir" : "Open"}</Link> <Btn small onClick={() => onDup(p.id)}>{fr ? "Dupliquer" : "Duplicate"}</Btn></td></tr>)}
72 + </tbody></table></div>
73 + );
74 +}
75 +
86 76 function NewProject({ fr, onCreated, onCancel }: { fr: boolean; onCreated: (p: Project) => void; onCancel: () => void }) {
87 77 const [f, setF] = useState({ name: "", address: "", municipality: "", propertyId: "", valuationDate: new Date().toISOString().slice(0, 10), buildingKind: "detached", stories: 1, unitSystem: "metric", regionCode: "", notes: "" });
88 78 const [err, setErr] = useState<string | null>(null); const [busy, setBusy] = useState(false); const [pre, setPre] = useState<string | null>(null);
@@ -90,9 +80,9 @@ function NewProject({ fr, onCreated, onCancel }: { fr: boolean; onCreated: (p: P
90 80 const prefill = async () => { if (!f.propertyId) return; try { const p = await api<Record<string, unknown>>(`/prefill?id=${encodeURIComponent(f.propertyId)}`); setF((x) => ({ ...x, address: String(p.address ?? x.address), municipality: String(p.municipality ?? x.municipality), buildingKind: String(p.buildingKind ?? x.buildingKind), stories: Number(p.stories ?? x.stories), regionCode: String(p.regionCode ?? x.regionCode), name: x.name || String(p.address ?? "") })); setPre(`${fr ? "Préremplie depuis" : "Prefilled from"} ${p.source} — ${fr ? "aire d'étages" : "floor area"} ${p.grossFloorAreaM2 ?? "?"} m², ${fr ? "terrain au rôle" : "land (roll)"} ${p.landValue ?? "?"} $`); } catch (e) { setErr((e as Error).message); } };
91 81 const save = async () => { setBusy(true); setErr(null); try { const r = await api<{ project: Project }>("/projects", { method: "POST", json: { ...f, regionCode: f.regionCode || null, propertyId: f.propertyId || null } }); onCreated(r.project); } catch (e) { setErr((e as Error).message); setBusy(false); } };
92 82 return (
93 − <div className="mt-6 border border-[var(--line-strong)] bg-surface p-5">
94 − <p className="kicker">{fr ? "Nouveau projet" : "New project"}</p>
95 − <div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
83 + <div className="mt-card mb-4">
84 + <h2>{fr ? "Nouveau projet" : "New project"}</h2>
85 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
96 86 <Field label={fr ? "Identifiant MAMH (facultatif)" : "MAMH id (optional)"}><div className="flex gap-2"><Input value={f.propertyId} onChange={(e) => set("propertyId", e.target.value)} className="vp-mono" placeholder="id_provinc" /><Btn small onClick={() => void prefill()}>{fr ? "Préremplir" : "Prefill"}</Btn></div></Field>
97 87 <Field label={fr ? "Nom du projet" : "Project name"}><Input value={f.name} onChange={(e) => set("name", e.target.value)} required /></Field>
98 88 <Field label={fr ? "Date d'évaluation" : "Valuation date"}><Input type="date" value={f.valuationDate} onChange={(e) => set("valuationDate", e.target.value)} /></Field>
@@ -100,12 +90,12 @@ function NewProject({ fr, onCreated, onCancel }: { fr: boolean; onCreated: (p: P
100 90 <Field label={fr ? "Municipalité" : "Municipality"}><Input value={f.municipality} onChange={(e) => set("municipality", e.target.value)} /></Field>
101 91 <Field label={fr ? "Type de bâtiment" : "Building type"}><Select value={f.buildingKind} onChange={(e) => set("buildingKind", e.target.value)}>{KINDS.map((k) => <option key={k.k} value={k.k}>{fr ? k.fr : k.en}</option>)}</Select></Field>
102 92 <Field label={fr ? "Étages" : "Storeys"}><Input type="number" min={1} max={60} value={f.stories} onChange={(e) => set("stories", Number(e.target.value))} /></Field>
103 − <Field label={fr ? "Unités de mesure" : "Measurement units"}><Select value={f.unitSystem} onChange={(e) => set("unitSystem", e.target.value)}><option value="metric">{fr ? "Métriques (m, m², m³)" : "Metric (m, m², m³)"}</option><option value="imperial">{fr ? "Impériales (pi, pi², pi³)" : "Imperial (ft, ft², ft³)"}</option></Select></Field>
93 + <Field label={fr ? "Unités" : "Units"}><Select value={f.unitSystem} onChange={(e) => set("unitSystem", e.target.value)}><option value="metric">{fr ? "Métriques (m, m²)" : "Metric (m, m²)"}</option><option value="imperial">{fr ? "Impériales (pi, pi²)" : "Imperial (ft, sq ft)"}</option></Select></Field>
104 94 <Field label={fr ? "Région de prix (code)" : "Price region (code)"} hint="QC-MTL, QC-QUE, QC-GAT, QC-LAV…"><Input value={f.regionCode} onChange={(e) => set("regionCode", e.target.value)} className="vp-mono" placeholder={fr ? "auto (municipalité)" : "auto (municipality)"} /></Field>
105 95 </div>
106 96 {pre && <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">{pre}</p>}
107 97 <Err msg={err} />
108 − <div className="mt-4 flex gap-2"><Btn tone="primary" disabled={busy || !f.name} onClick={() => void save()}>{fr ? "Créer" : "Create"}</Btn><Btn onClick={onCancel}>{fr ? "Annuler" : "Cancel"}</Btn></div>
98 + <div className="mt-4 flex gap-2"><Btn tone="primary" disabled={busy || !f.name} onClick={() => void save()}>{fr ? "Créer et ouvrir le Plan Studio" : "Create and open Plan Studio"}</Btn><Btn onClick={onCancel}>{fr ? "Annuler" : "Cancel"}</Btn></div>
109 99 </div>
110 100 );
111 101 }
added src/components/metre/MetreShell.tsx +54 −0
@@ -0,0 +1,54 @@
1 +// UQO Éval — Métré : coquille d'application — barre latérale de modules, session anonyme automatique (plateforme ouverte), nom d'affichage, code personnel facultatif.
2 +"use client";
3 +import { useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { usePathname } from "next/navigation";
6 +import { useLang } from "@/components/LangContext";
7 +import { api, useMe } from "./ui";
8 +import "./metre.css";
9 +
10 +const NAV = [
11 + { sec: ["Travail", "Work"] },
12 + { href: "/metre", ico: "▦", fr: "Tableau de bord", en: "Dashboard", exact: true },
13 + { href: "/metre?tab=projets", ico: "▣", fr: "Projets", en: "Projects", match: "/metre/projets" },
14 + { href: "/metre?nouveau=plan", ico: "✎", fr: "Nouveau plan", en: "New plan" },
15 + { sec: ["Données", "Data"] },
16 + { href: "/metre/catalogue", ico: "☰", fr: "Catalogue de coûts", en: "Cost catalogue" },
17 + { href: "/metre/catalogue#observations", ico: "◎", fr: "Observations de prix", en: "Price observations" },
18 + { href: "/cout", ico: "⌂", fr: "Méthode du coût (MAMH)", en: "Cost approach (MAMH)" },
19 + { sec: ["Enseignement", "Teaching"] },
20 + { href: "/metre?tab=devoirs", ico: "✓", fr: "Devoirs", en: "Assignments" },
21 + { href: "/metre/admin", ico: "⚙", fr: "Administration", en: "Administration" },
22 +];
23 +
24 +export default function MetreShell({ children }: { children: React.ReactNode }) {
25 + const { lang } = useLang(); const fr = lang === "fr";
26 + const path = usePathname();
27 + const me = useMe();
28 + const [editing, setEditing] = useState(false); const [name, setName] = useState("");
29 + const [code, setCode] = useState(""); const [codeOpen, setCodeOpen] = useState(false); const [msg, setMsg] = useState<string | null>(null);
30 + useEffect(() => { document.body.classList.add("metre-app"); return () => document.body.classList.remove("metre-app"); }, []);
31 + const rename = async () => { try { await api("/auth/me", { method: "PATCH", json: { displayName: name } }); setEditing(false); me.refresh(); } catch (e) { setMsg((e as Error).message); } };
32 + const elevate = async () => { try { await api("/auth/login", { method: "POST", json: { code } }); setCodeOpen(false); setCode(""); me.refresh(); } catch (e) { setMsg((e as Error).message); } };
33 + const roleLabel: Record<string, [string, string]> = { student: ["session ouverte", "open session"], instructor: ["enseignant·e", "instructor"], admin: ["administrateur·rice", "administrator"], professional: ["professionnel·le", "professional"] };
34 + return (
35 + <div className="mt-shell">
36 + <aside className="mt-side">
37 + {NAV.map((n, i) => "sec" in n ? <div key={i} className="mt-sec">{fr ? n.sec![0] : n.sec![1]}</div> : (
38 + <Link key={n.href} href={n.href} className={(n.exact ? path === n.href : path.startsWith(n.match ?? n.href.split("?")[0].split("#")[0]) && n.href.split("?")[0].split("#")[0] !== "/metre") ? "active" : ""}><span className="ico">{n.ico}</span>{fr ? n.fr : n.en}</Link>
39 + ))}
40 + <div className="mt-foot">
41 + {me.user ? (<>
42 + {editing ? <span className="flex gap-1"><input autoFocus value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void rename(); if (e.key === "Escape") setEditing(false); }} className="w-full rounded bg-[#2a2f33] px-2 py-1 text-[12px] text-white" /><button className="text-[#2bccae]" onClick={() => void rename()}>✓</button></span>
43 + : <button className="text-left text-[#e9edee] hover:text-white" title={fr ? "Changer le nom affiché" : "Change display name"} onClick={() => { setName(me.user!.displayName); setEditing(true); }}>👤 {me.user.displayName} ✎</button>}
44 + <div>{fr ? roleLabel[me.user.role]?.[0] : roleLabel[me.user.role]?.[1]}</div>
45 + {me.user.role === "student" && (codeOpen ? <span className="flex gap-1"><input value={code} onChange={(e) => setCode(e.target.value)} placeholder={fr ? "code" : "code"} className="w-full rounded bg-[#2a2f33] px-2 py-1 text-[12px] uppercase text-white" onKeyDown={(e) => { if (e.key === "Enter") void elevate(); }} /><button className="text-[#2bccae]" onClick={() => void elevate()}>→</button></span> : <button className="text-[#8b9297] underline" onClick={() => setCodeOpen(true)}>{fr ? "code enseignant/admin" : "instructor/admin code"}</button>)}
46 + {msg && <div className="text-[#f4a261]">{msg}</div>}
47 + </>) : <div>…</div>}
48 + <div className="mt-2 text-[10px]">UQO Éval · IMM1033 · {fr ? "plateforme ouverte" : "open platform"}</div>
49 + </div>
50 + </aside>
51 + <div className="mt-main">{children}</div>
52 + </div>
53 + );
54 +}
modified src/components/metre/PlanStudio.tsx +316 −213
@@ -1,40 +1,84 @@
1 −// UQO Éval — Métré : Plan Studio — import PDF/PNG/JPG (rendu PDF côté client via pdfjs-dist), pages, calibration 2 points,
2 −// dessin vectoriel (pièces, dalles, murs, ouvertures, toits, fondations, lignes), grille/aimantation, annuler/rétablir,
3 −// raccourcis clavier, contrôles tactiles, autosauvegarde, mesure immédiate au registre.
1 +// UQO Éval — Métré : Plan Studio v2 — éditeur de plan d'architecte (murs à épaisseur, portes/fenêtres dans les murs, pièces, cotations,
2 +// niveaux, saisie des longueurs au clavier, orthogonal, aimant, zoom/pan, annuler/rétablir, export PDF/PNG/SVG) + pages vierges,
3 +// gabarits, plan de site satellite géoréférencé et import PDF/PNG/JPG/DXF. Tout alimente le registre de métré.
4 4 "use client";
5 5 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6 +import dynamic from "next/dynamic";
6 7 import type { WorkspaceData } from "./ProjectWorkspace";
7 −import { api, Block, Btn, Err, Field, Input, Select, num } from "./ui";
8 +import { api, Btn, Err, Input, num } from "./ui";
8 9 import type { Calibration, ObjectKind, PlanObject, PlanPage, Point } from "@/lib/metre/types";
9 10 import { dist, polygonAreaPx, polylineLengthPx } from "@/lib/metre/geometry";
11 +import { DEFAULT_PARTITION_THICKNESS_M, DEFAULT_WALL_HEIGHT_M, DEFAULT_WALL_THICKNESS_M, fmtArea, fmtLength, planQuantities, projectOnPolyline, snapPoint } from "@/lib/metre/plan-geometry";
12 +import { renderPlanInner } from "@/lib/metre/plan-render";
10 13
11 −type Tool = "select" | "calibrate" | ObjectKind;
12 −const TOOLS: { t: Tool; key: string; fr: string; en: string; closed: boolean }[] = [
13 − { t: "select", key: "V", fr: "Sélection", en: "Select", closed: false }, { t: "calibrate", key: "C", fr: "Calibrer", en: "Calibrate", closed: false },
14 − { t: "room", key: "R", fr: "Pièce", en: "Room", closed: true }, { t: "slab", key: "D", fr: "Dalle", en: "Slab", closed: true }, { t: "foundation", key: "F", fr: "Fondation", en: "Foundation", closed: true }, { t: "roof", key: "T", fr: "Toit", en: "Roof", closed: true },
15 − { t: "wall", key: "W", fr: "Mur", en: "Wall", closed: false }, { t: "opening", key: "O", fr: "Ouverture", en: "Opening", closed: false }, { t: "line", key: "L", fr: "Ligne", en: "Line", closed: false }, { t: "area", key: "A", fr: "Aire", en: "Area", closed: true },
14 +const MapPicker = dynamic(() => import("./MapPicker"), { ssr: false });
15 +
16 +type Tool = "select" | "pan" | "wall" | "partition" | "room" | "rect" | "door" | "window" | "dimension" | "text" | "stairs" | "column" | "footprint" | "lot" | "line" | "calibrate";
17 +interface ToolDef { t: Tool; key: string; ico: string; fr: string; en: string; kind?: ObjectKind; closed?: boolean }
18 +const TOOLS: ToolDef[] = [
19 + { t: "select", key: "V", ico: "↖", fr: "Sélection", en: "Select" }, { t: "pan", key: "H", ico: "✋", fr: "Déplacer", en: "Pan" },
20 + { t: "wall", key: "W", ico: "▬", fr: "Mur ext.", en: "Ext. wall", kind: "wall" }, { t: "partition", key: "I", ico: "┃", fr: "Cloison", en: "Partition", kind: "wall" },
21 + { t: "door", key: "D", ico: "◗", fr: "Porte", en: "Door", kind: "opening" }, { t: "window", key: "N", ico: "▭", fr: "Fenêtre", en: "Window", kind: "opening" },
22 + { t: "room", key: "R", ico: "◫", fr: "Pièce", en: "Room", kind: "room", closed: true }, { t: "rect", key: "X", ico: "▢", fr: "Rectangle", en: "Rectangle", kind: "room", closed: true },
23 + { t: "dimension", key: "M", ico: "⟷", fr: "Cote", en: "Dimension", kind: "dimension" }, { t: "text", key: "T", ico: "T", fr: "Texte", en: "Text", kind: "text" },
24 + { t: "stairs", key: "S", ico: "≣", fr: "Escalier", en: "Stairs", kind: "stairs", closed: true }, { t: "column", key: "C", ico: "■", fr: "Colonne", en: "Column", kind: "column" },
25 + { t: "footprint", key: "F", ico: "⬚", fr: "Empreinte", en: "Footprint", kind: "footprint", closed: true }, { t: "lot", key: "L", ico: "⌗", fr: "Lot", en: "Lot", kind: "lot", closed: true },
26 + { t: "line", key: "P", ico: "╱", fr: "Ligne", en: "Line", kind: "line" }, { t: "calibrate", key: "K", ico: "⊢", fr: "Calibrer", en: "Calibrate" },
16 27 ];
17 −const COLORS: Record<string, string> = { room: "#006085", slab: "#8a5a12", foundation: "#5b3a29", roof: "#b3423a", wall: "#16181b", opening: "#2bccae", line: "#4b5258", area: "#6c4fb8", annotation: "#8b9297" };
18 28 const uuid = () => crypto.randomUUID();
29 +const KIND_FR: Record<string, [string, string]> = { wall: ["Mur", "Wall"], opening: ["Ouverture", "Opening"], room: ["Pièce", "Room"], dimension: ["Cote", "Dimension"], text: ["Texte", "Text"], stairs: ["Escalier", "Stairs"], column: ["Colonne", "Column"], footprint: ["Empreinte", "Footprint"], lot: ["Lot", "Lot"], line: ["Ligne", "Line"], slab: ["Dalle", "Slab"], foundation: ["Fondation", "Foundation"], roof: ["Toit", "Roof"], area: ["Aire", "Area"], annotation: ["Note", "Note"] };
30 +
31 +type PageX = PlanPage & { filename: string };
19 32
20 −export default function PlanStudio({ d, fr, canWrite, reload }: { d: WorkspaceData; fr: boolean; canWrite: boolean; reload: () => Promise<void> }) {
21 − const pages = useMemo(() => d.plans.flatMap((f) => f.pages.map((p) => ({ ...p, filename: f.filename }))), [d.plans]);
22 − const [pageSel, setPageId] = useState<string | null>(null);
23 − const pageId = pageSel ?? pages[0]?.id ?? null;
33 +export default function PlanStudio({ d, fr, canWrite, reload, initialCreate }: { d: WorkspaceData; fr: boolean; canWrite: boolean; reload: () => Promise<void>; initialCreate?: "blank" | "template" | "map" | "import" | null }) {
34 + const pages = useMemo<PageX[]>(() => d.plans.flatMap((f) => f.pages.map((p) => ({ ...p, filename: f.filename }))), [d.plans]);
35 + const [pageSel, setPageSel] = useState<string | null>(null);
36 + const [creating, setCreating] = useState<null | "menu" | "blank" | "template" | "map" | "import">(initialCreate ?? (pages.length ? null : "menu"));
37 + const pageId = pageSel && pages.some((p) => p.id === pageSel) ? pageSel : pages[0]?.id ?? null;
24 38 const page = pages.find((p) => p.id === pageId) ?? null;
39 + const imperial = d.project.unitSystem === "imperial";
25 40 return (
26 − <div className="space-y-10">
27 − <Block num="01" title={fr ? "Plans et pages" : "Plans and pages"} aside={canWrite && <Uploader projectId={d.project.id} fr={fr} onDone={reload} />}>
28 − {!pages.length ? <p className="text-[13.5px] text-ink-2">{fr ? "Importez un PDF (plans vectoriels ou numérisés), un PNG, un JPG ou un DXF ASCII (lignes, polylignes, cercles ; calibration automatique si les unités sont déclarées). Le rendu des pages se fait dans votre navigateur ; l'original est conservé avec son empreinte SHA-256." : "Import a PDF (vector or scanned plans), a PNG, a JPG or an ASCII DXF (lines, polylines, circles; automatic calibration when units are declared). Pages are rendered in your browser; the original is kept with its SHA-256 fingerprint."}</p> : (
29 − <div className="flex flex-wrap gap-2">{pages.map((p) => (<button key={p.id} type="button" onClick={() => setPageId(p.id)} className={`vp-mono border px-3 py-1.5 text-[11px] uppercase ${p.id === pageId ? "border-accent text-accent" : "border-[var(--line)] text-ink-2"}`}>{p.filename.slice(0, 18)} · {p.label}{p.calibration ? " ●" : " ○"}</button>))}</div>
30 − )}
31 − </Block>
32 − {page && <Studio key={page.id} page={page} fr={fr} canWrite={canWrite} projectId={d.project.id} measurements={d.measurements} reload={reload} unitSystem={d.project.unitSystem} />}
41 + <div className="space-y-3">
42 + <div className="flex flex-wrap items-center justify-between gap-2">
43 + <div className="pl-levels">{pages.map((p) => <button key={p.id} type="button" className={p.id === pageId && !creating ? "active" : ""} onClick={() => { setPageSel(p.id); setCreating(null); }} title={p.filename}>{p.level ?? p.label ?? p.filename} {p.calibration ? "" : "○"}</button>)}{canWrite && <button type="button" className={creating ? "active" : ""} onClick={() => setCreating(creating ? null : "menu")}>+ {fr ? "Nouveau plan" : "New plan"}</button>}</div>
44 + {page && <span className="vp-mono text-[10.5px] uppercase text-ink-3">{page.source === "map" ? (fr ? "plan de site géoréférencé" : "georeferenced site plan") : page.source === "blank" ? (fr ? "plan vierge" : "blank plan") : page.source === "template" ? (fr ? "gabarit" : "template") : (fr ? "import" : "import")} · {page.widthPx} × {page.heightPx} px{page.calibration ? ` · ${num(page.calibration.pxPerMeter, "fr", 1)} px/m` : ` · ${fr ? "NON CALIBRÉ" : "NOT CALIBRATED"}`}</span>}
45 + </div>
46 + {creating && <NewPage mode={creating} setMode={setCreating} d={d} fr={fr} onDone={async (id) => { await reload(); if (id) setPageSel(id); setCreating(null); }} />}
47 + {page && !creating && <Editor key={page.id} page={page} fr={fr} canWrite={canWrite} projectId={d.project.id} measurements={d.measurements} reload={reload} imperial={imperial} onPageDeleted={() => setPageSel(null)} />}
48 + {!page && !creating && <div className="mt-card text-[13.5px] text-ink-2">{fr ? "Aucun plan. Créez un plan vierge, partez d'un gabarit, capturez une vue satellite ou importez un fichier." : "No plan yet. Create a blank plan, start from a template, capture a satellite view or import a file."}</div>}
33 49 </div>
34 50 );
35 51 }
36 52
37 −/* ----------------------------------------------------------- téléversement */
53 +/* ============================================================ nouvelle page */
54 +
55 +function NewPage({ mode, setMode, d, fr, onDone }: { mode: "menu" | "blank" | "template" | "map" | "import"; setMode: (m: "menu" | "blank" | "template" | "map" | "import" | null) => void; d: WorkspaceData; fr: boolean; onDone: (pageId: string | null) => Promise<void> }) {
56 + const [levels, setLevels] = useState<string[]>(["Site", "Sous-sol", "Rez-de-chaussée", "Étage", "Toit"]);
57 + const [templates, setTemplates] = useState<{ key: string; fr: string; en: string; widthM: number; heightM: number }[]>([]);
58 + const [f, setF] = useState({ widthM: 24, heightM: 16, pxPerMeter: 50, level: "Rez-de-chaussée", template: "bungalow" });
59 + const [busy, setBusy] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
60 + useEffect(() => { api<{ levels: string[]; templates: typeof templates }>(`/projects/${d.project.id}/pages`).then((r) => { setLevels(r.levels); setTemplates(r.templates); }).catch(() => undefined); }, [d.project.id]);
61 + const create = async (body: Record<string, unknown>) => { setBusy("…"); setErr(null); try { const r = await api<{ page: PlanPage }>(`/projects/${d.project.id}/pages`, { method: "POST", json: body }); await onDone(r.page.id); } catch (e) { setErr((e as Error).message); } finally { setBusy(null); } };
62 + const lvl = <label className="block"><span className="klabel">{fr ? "Niveau" : "Level"}</span><select value={f.level} onChange={(e) => setF({ ...f, level: e.target.value })} className="vp-input !min-h-0 text-[13px]">{levels.map((l) => <option key={l}>{l}</option>)}</select></label>;
63 + if (mode === "menu") return (
64 + <div className="pl-new">
65 + <button type="button" className="mt-action" onClick={() => setMode("blank")}><b>✎ {fr ? "Plan vierge à l'échelle" : "Blank plan to scale"}</b><span>{fr ? "Grille 1 m, calibration exacte ; dessinez murs, pièces, portes et fenêtres comme un architecte." : "1 m grid, exact calibration; draw walls, rooms, doors and windows like an architect."}</span></button>
66 + <button type="button" className="mt-action" onClick={() => setMode("template")}><b>⌂ {fr ? "Partir d'un gabarit" : "Start from a template"}</b><span>{fr ? "Bungalow, cottage, garage : murs, pièces et ouvertures déjà tracés, à modifier." : "Bungalow, cottage, garage: walls, rooms and openings already drawn, ready to edit."}</span></button>
67 + <button type="button" className="mt-action" onClick={() => setMode("map")}><b>🛰 {fr ? "Plan de site (carte satellite)" : "Site plan (satellite map)"}</b><span>{fr ? "Image satellite géoréférencée de la propriété ; tracez le lot, l'empreinte, l'entrée, la terrasse." : "Georeferenced satellite image of the property; trace lot, footprint, driveway, deck."}</span></button>
68 + <button type="button" className="mt-action" onClick={() => setMode("import")}><b>⇪ {fr ? "Importer PDF · PNG · JPG · DXF" : "Import PDF · PNG · JPG · DXF"}</b><span>{fr ? "Plans existants (vectoriels ou numérisés) à calibrer, DXF calibré automatiquement." : "Existing plans (vector or scanned) to calibrate; DXF calibrated automatically."}</span></button>
69 + </div>
70 + );
71 + return (
72 + <div className="mt-card">
73 + <div className="flex items-center justify-between"><h2>{mode === "blank" ? (fr ? "Plan vierge" : "Blank plan") : mode === "template" ? (fr ? "Gabarit" : "Template") : mode === "map" ? (fr ? "Plan de site" : "Site plan") : (fr ? "Importer" : "Import")}</h2><button type="button" className="vp-mono text-[10.5px] uppercase text-ink-3" onClick={() => setMode("menu")}>← {fr ? "autres options" : "other options"}</button></div>
74 + {mode === "blank" && (<div className="space-y-3"><div className="grid gap-3 sm:grid-cols-4"><label className="block"><span className="klabel">{fr ? "Largeur (m)" : "Width (m)"}</span><Input type="number" min={3} max={200} value={f.widthM} onChange={(e) => setF({ ...f, widthM: Number(e.target.value) })} className="!min-h-0" /></label><label className="block"><span className="klabel">{fr ? "Hauteur (m)" : "Height (m)"}</span><Input type="number" min={3} max={200} value={f.heightM} onChange={(e) => setF({ ...f, heightM: Number(e.target.value) })} className="!min-h-0" /></label><label className="block"><span className="klabel">{fr ? "Échelle (px/m)" : "Scale (px/m)"}</span><Input type="number" min={10} max={200} value={f.pxPerMeter} onChange={(e) => setF({ ...f, pxPerMeter: Number(e.target.value) })} className="!min-h-0" /></label>{lvl}</div><p className="text-[12px] text-ink-3">{fr ? "Page" : "Page"} {f.widthM * f.pxPerMeter} × {f.heightM * f.pxPerMeter} px · {fr ? "grille principale de 1 m, aimant de 10 cm." : "main grid 1 m, snap 10 cm."}</p><Btn tone="primary" disabled={!!busy} onClick={() => void create({ kind: "blank", ...f })}>{busy ?? (fr ? "Créer le plan" : "Create plan")}</Btn></div>)}
75 + {mode === "template" && (<div className="space-y-3"><div className="grid gap-3 sm:grid-cols-3"><label className="block sm:col-span-2"><span className="klabel">{fr ? "Gabarit" : "Template"}</span><select value={f.template} onChange={(e) => setF({ ...f, template: e.target.value })} className="vp-input !min-h-0 text-[13px]">{templates.map((t) => <option key={t.key} value={t.key}>{fr ? t.fr : t.en}</option>)}</select></label>{lvl}</div><Btn tone="primary" disabled={!!busy} onClick={() => void create({ kind: "template", template: f.template, level: f.level, pxPerMeter: 50 })}>{busy ?? (fr ? "Créer depuis le gabarit" : "Create from template")}</Btn></div>)}
76 + {mode === "map" && <MapPicker fr={fr} initial={{ lat: 45.4765, lng: -75.7013, q: [d.project.address, d.project.municipality].filter(Boolean).join(", ") || null, mamh: d.project.propertyId }} onCapture={async (v) => { await create({ kind: "map", ...v }); }} onCancel={() => setMode("menu")} />}
77 + {mode === "import" && <Uploader projectId={d.project.id} fr={fr} onDone={() => onDone(null)} />}
78 + <Err msg={err} />
79 + </div>
80 + );
81 +}
38 82
39 83 function Uploader({ projectId, fr, onDone }: { projectId: string; fr: boolean; onDone: () => Promise<void> }) {
40 84 const [busy, setBusy] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -42,247 +86,306 @@ function Uploader({ projectId, fr, onDone }: { projectId: string; fr: boolean; o
42 86 setErr(null); setBusy(fr ? "lecture…" : "reading…");
43 87 try {
44 88 const form = new FormData();
45 − if (/\.dxf$/i.test(file.name)) {
46 − // DXF ASCII : analysé et rasterisé par le serveur ; calibration exacte si $INSUNITS est présent
47 − form.append("dxf", file, file.name);
48 − setBusy(fr ? "import DXF…" : "DXF import…");
49 − const r = await api<{ objects: number; unit: string | null; skipped: Record<string, number>; calibration: unknown }>(`/projects/${projectId}/plans`, { method: "POST", body: form });
50 − setErr(r.unit ? null : (fr ? "DXF sans $INSUNITS : calibrez la page manuellement." : "DXF without $INSUNITS: calibrate the page manually."));
51 − if (Object.keys(r.skipped).length) setErr((e) => `${e ? e + " " : ""}${fr ? "Entités ignorées" : "Skipped entities"} : ${Object.entries(r.skipped).map(([k, v]) => `${k} ×${v}`).join(", ")}`);
52 − await onDone(); return;
53 − }
89 + if (/\.dxf$/i.test(file.name)) { form.append("dxf", file, file.name); setBusy(fr ? "import DXF…" : "DXF import…"); const r = await api<{ objects: number; unit: string | null; skipped: Record<string, number> }>(`/projects/${projectId}/plans`, { method: "POST", body: form }); if (!r.unit) setErr(fr ? "DXF sans $INSUNITS : calibrez la page manuellement." : "DXF without $INSUNITS: calibrate manually."); await onDone(); return; }
54 90 form.append("original", file, file.name);
55 91 if (file.type === "application/pdf" || /\.pdf$/i.test(file.name)) {
56 − const pdfjs = await import("pdfjs-dist");
57 − pdfjs.GlobalWorkerOptions.workerSrc = "/metre/pdf.worker.min.mjs";
58 − const doc = await pdfjs.getDocument({ data: await file.arrayBuffer() }).promise;
59 − const n = Math.min(doc.numPages, 60);
60 − for (let i = 1; i <= n; i++) {
61 − setBusy(`${fr ? "rendu page" : "rendering page"} ${i}/${n}`);
62 − const pg = await doc.getPage(i);
63 − const base = pg.getViewport({ scale: 1 });
64 − const scale = Math.min(3, 4000 / Math.max(base.width, base.height)); // ≤ 4 000 px de côté
65 − const vp = pg.getViewport({ scale });
66 − const canvas = document.createElement("canvas"); canvas.width = Math.round(vp.width); canvas.height = Math.round(vp.height);
67 − const ctx = canvas.getContext("2d")!; ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvas.width, canvas.height);
68 − await pg.render({ canvasContext: ctx, viewport: vp, canvas }).promise;
69 − const blob = await new Promise<Blob>((res) => canvas.toBlob((b) => res(b!), "image/png"));
70 − form.append(`page-${i}`, blob, `page-${i}.png`);
71 − }
72 − } else {
73 − const bmp = await createImageBitmap(file);
74 − const scale = Math.min(1, 4000 / Math.max(bmp.width, bmp.height));
75 − const canvas = document.createElement("canvas"); canvas.width = Math.round(bmp.width * scale); canvas.height = Math.round(bmp.height * scale);
76 − canvas.getContext("2d")!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
77 − const blob = await new Promise<Blob>((res) => canvas.toBlob((b) => res(b!), "image/png"));
78 − form.append("page-1", blob, "page-1.png");
79 − }
80 − setBusy(fr ? "téléversement…" : "uploading…");
81 − await api(`/projects/${projectId}/plans`, { method: "POST", body: form });
82 − await onDone();
92 + const pdfjs = await import("pdfjs-dist"); pdfjs.GlobalWorkerOptions.workerSrc = "/metre/pdf.worker.min.mjs";
93 + const doc = await pdfjs.getDocument({ data: await file.arrayBuffer() }).promise; const n = Math.min(doc.numPages, 60);
94 + for (let i = 1; i <= n; i++) { setBusy(`${fr ? "rendu page" : "rendering page"} ${i}/${n}`); const pg = await doc.getPage(i); const base = pg.getViewport({ scale: 1 }); const scale = Math.min(3, 4000 / Math.max(base.width, base.height)); const vp = pg.getViewport({ scale }); const canvas = document.createElement("canvas"); canvas.width = Math.round(vp.width); canvas.height = Math.round(vp.height); const ctx = canvas.getContext("2d")!; ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvas.width, canvas.height); await pg.render({ canvasContext: ctx, viewport: vp, canvas }).promise; const blob = await new Promise<Blob>((res) => canvas.toBlob((b) => res(b!), "image/png")); form.append(`page-${i}`, blob, `page-${i}.png`); }
95 + } else { const bmp = await createImageBitmap(file); const scale = Math.min(1, 4000 / Math.max(bmp.width, bmp.height)); const canvas = document.createElement("canvas"); canvas.width = Math.round(bmp.width * scale); canvas.height = Math.round(bmp.height * scale); canvas.getContext("2d")!.drawImage(bmp, 0, 0, canvas.width, canvas.height); const blob = await new Promise<Blob>((res) => canvas.toBlob((b) => res(b!), "image/png")); form.append("page-1", blob, "page-1.png"); }
96 + setBusy(fr ? "téléversement…" : "uploading…"); await api(`/projects/${projectId}/plans`, { method: "POST", body: form }); await onDone();
83 97 } catch (e) { setErr((e as Error).message); } finally { setBusy(null); }
84 98 };
85 − return (
86 − <div className="flex items-center gap-3">
87 − <label className="btn btn-primary !min-h-0 !px-3 !py-1.5 text-[12px] cursor-pointer">{busy ?? (fr ? "+ Importer un plan" : "+ Import a plan")}<input type="file" accept="application/pdf,image/png,image/jpeg,.dxf" className="hidden" disabled={!!busy} onChange={(e) => { const f = e.target.files?.[0]; if (f) void onFile(f); e.target.value = ""; }} /></label>
88 − <Err msg={err} />
89 − </div>
90 − );
99 + return (<div className="space-y-2"><label className="btn btn-primary cursor-pointer">{busy ?? (fr ? "Choisir un fichier (PDF, PNG, JPG, DXF)" : "Choose a file (PDF, PNG, JPG, DXF)")}<input type="file" accept="application/pdf,image/png,image/jpeg,.dxf" className="hidden" disabled={!!busy} onChange={(e) => { const f = e.target.files?.[0]; if (f) void onFile(f); e.target.value = ""; }} /></label><p className="text-[12px] text-ink-3">{fr ? "Les pages PDF/images sont rendues dans votre navigateur ; l'original est conservé avec son empreinte SHA-256. Calibrez ensuite avec l'outil ⊢ (deux points de distance connue)." : "PDF/image pages are rendered in your browser; the original is kept with its SHA-256. Then calibrate with the ⊢ tool (two points of known distance)."}</p><Err msg={err} /></div>);
91 100 }
92 101
93 −/* --------------------------------------------------------------- studio */
102 +/* ================================================================= éditeur */
94 103
95 −interface Draft { kind: ObjectKind; points: Point[] }
104 +interface Draft { tool: Tool; points: Point[] }
105 +interface ViewT { k: number; tx: number; ty: number }
96 106
97 −function Studio({ page, fr, canWrite, projectId, measurements, reload, unitSystem }: { page: PlanPage & { filename: string }; fr: boolean; canWrite: boolean; projectId: string; measurements: WorkspaceData["measurements"]; reload: () => Promise<void>; unitSystem: "metric" | "imperial" }) {
107 +function Editor({ page, fr, canWrite, projectId, measurements, reload, imperial, onPageDeleted }: { page: PageX; fr: boolean; canWrite: boolean; projectId: string; measurements: WorkspaceData["measurements"]; reload: () => Promise<void>; imperial: boolean; onPageDeleted: () => void }) {
98 108 const [objects, setObjects] = useState<PlanObject[]>([]);
99 109 const [cal, setCal] = useState<Calibration | null>(page.calibration);
100 − const [tool, setTool] = useState<Tool>(page.calibration ? "room" : "calibrate");
110 + const [tool, setTool] = useState<Tool>(page.calibration ? "wall" : "calibrate");
101 111 const [draft, setDraft] = useState<Draft | null>(null);
102 − const [calPts, setCalPts] = useState<Point[]>([]);
103 − const [calLen, setCalLen] = useState({ value: "", unit: unitSystem === "imperial" ? "ft" : "m" });
112 + const [typed, setTyped] = useState("");
113 + const [cursor, setCursor] = useState<Point | null>(null);
114 + const [snapKind, setSnapKind] = useState("");
104 115 const [sel, setSel] = useState<string | null>(null);
105 − const [zoom, setZoom] = useState(1);
106 − const [snap, setSnap] = useState(true);
107 − const [grid, setGrid] = useState(true);
108 − const [layer, setLayer] = useState("default");
116 + const [view, setView] = useState<ViewT>({ k: 1, tx: 0, ty: 0 });
117 + const [snap, setSnap] = useState(true); const [ortho, setOrtho] = useState(true); const [showDims, setShowDims] = useState(true); const [showLabels, setShowLabels] = useState(true); const [showGrid, setShowGrid] = useState(page.source === "blank" || page.source === "template");
109 118 const [hidden, setHidden] = useState<Set<string>>(new Set());
110 − const [err, setErr] = useState<string | null>(null);
111 − const [status, setStatus] = useState<string>("");
119 + const [calPts, setCalPts] = useState<Point[]>([]); const [calLen, setCalLen] = useState({ value: "", unit: imperial ? "ft" : "m" });
120 + const [status, setStatus] = useState(""); const [err, setErr] = useState<string | null>(null);
121 + const [drag, setDrag] = useState<{ kind: "vertex" | "object" | "pan"; id?: string; idx?: number; start: Point; orig?: Point[]; view?: ViewT } | null>(null);
122 + const [spaceDown, setSpaceDown] = useState(false);
112 123 const undo = useRef<PlanObject[][]>([]); const redo = useRef<PlanObject[][]>([]);
113 − const svgRef = useRef<SVGSVGElement>(null);
114 − const [cursor, setCursor] = useState<Point | null>(null);
124 + const wrapRef = useRef<HTMLDivElement>(null); const svgRef = useRef<SVGSVGElement>(null);
125 + const k = cal?.pxPerMeter ?? null;
115 126 void projectId;
127 + const toolDisabled = (t: Tool) => !canWrite || (t === "calibrate" && (page.source === "blank" || page.source === "template")) || (!k && t !== "calibrate" && t !== "select" && t !== "pan");
116 128
117 129 useEffect(() => { api<{ objects: PlanObject[] }>(`/pages/${page.id}/objects`).then((r) => setObjects(r.objects)).catch((e) => setErr(e.message)); }, [page.id]);
130 + const fit = useCallback(() => { const el = wrapRef.current; if (!el) return; const kk = Math.min((el.clientWidth - 20) / page.widthPx, (el.clientHeight - 20) / page.heightPx); setView({ k: kk, tx: (el.clientWidth - page.widthPx * kk) / 2, ty: (el.clientHeight - page.heightPx * kk) / 2 }); }, [page.widthPx, page.heightPx]);
131 + useEffect(() => { fit(); }, [fit]);
118 132
119 − const pushUndo = useCallback((prev: PlanObject[]) => { undo.current = [...undo.current.slice(-49), prev]; redo.current = []; }, []);
120 − const persistCreate = async (o: PlanObject) => { try { await api(`/pages/${page.id}/objects`, { method: "POST", json: o }); } catch (e) { setErr((e as Error).message); } };
121 − const persistUpdate = async (o: PlanObject) => { try { await api(`/objects/${o.id}`, { method: "PATCH", json: o }); } catch (e) { setErr((e as Error).message); } };
122 − const persistDelete = async (id: string) => { try { await api(`/objects/${id}`, { method: "DELETE" }); } catch (e) { setErr((e as Error).message); } };
133 + /* ----------------------------------------------------- persistance + annuler */
134 + const persistCreate = (o: PlanObject) => api(`/pages/${page.id}/objects`, { method: "POST", json: o }).catch((e) => setErr((e as Error).message));
135 + const persistUpdate = (o: PlanObject) => api(`/objects/${o.id}`, { method: "PATCH", json: o }).catch((e) => setErr((e as Error).message));
136 + const persistDelete = (id: string) => api(`/objects/${id}`, { method: "DELETE" }).catch((e) => setErr((e as Error).message));
137 + const pushUndo = (prev: PlanObject[]) => { undo.current = [...undo.current.slice(-59), prev]; redo.current = []; };
138 + const syncTo = (target: PlanObject[]) => { const cur = new Map(objects.map((o) => [o.id, o])); const tgt = new Map(target.map((o) => [o.id, o])); for (const o of target) { if (!cur.has(o.id)) void persistCreate(o); else if (cur.get(o.id) !== o) void persistUpdate(o); } for (const o of objects) if (!tgt.has(o.id)) void persistDelete(o.id); setObjects(target); };
139 + const doUndo = () => { const prev = undo.current.pop(); if (!prev) return; redo.current.push(objects); syncTo(prev); };
140 + const doRedo = () => { const next = redo.current.pop(); if (!next) return; undo.current.push(objects); syncTo(next); };
141 + const addObject = (o: PlanObject) => { pushUndo(objects); setObjects((xs) => [...xs, o]); setSel(o.id); void persistCreate(o); };
142 + const updateObject = (id: string, patch: Partial<PlanObject>, props?: Partial<PlanObject["props"]>) => { const o = objects.find((x) => x.id === id); if (!o) return; const n: PlanObject = { ...o, ...patch, props: { ...o.props, ...(props ?? {}) } }; pushUndo(objects); setObjects((xs) => xs.map((x) => (x.id === id ? n : x))); void persistUpdate(n); };
143 + const removeObject = (id: string) => { pushUndo(objects); const gone = objects.filter((o) => o.id === id || o.props.deductFrom === id); setObjects((xs) => xs.filter((o) => !gone.includes(o))); for (const o of gone) void persistDelete(o.id); if (sel === id) setSel(null); };
123 144
124 − /* ------------------------------------------------- coordonnées + aimant */
125 − const toPage = (e: { clientX: number; clientY: number }): Point => {
126 − const svg = svgRef.current!; const r = svg.getBoundingClientRect();
127 − const x = ((e.clientX - r.left) / r.width) * page.widthPx, y = ((e.clientY - r.top) / r.height) * page.heightPx;
128 − return snapPoint({ x: Math.max(0, Math.min(page.widthPx, x)), y: Math.max(0, Math.min(page.heightPx, y)) });
129 − };
130 − const gridStep = cal ? cal.pxPerMeter * (unitSystem === "imperial" ? 0.3048 : 0.5) : 25; // 0,5 m ou 1 pi
131 − const snapPoint = (p: Point): Point => {
132 − if (!snap) return p;
133 − const tol = 8 / zoom;
134 − // aimant aux sommets existants puis à la grille
135 − for (const o of objects) for (const q of o.points) if (dist(p, q) < tol) return { ...q };
136 − if (draft) for (const q of draft.points) if (dist(p, q) < tol) return { ...q };
137 − if (grid) return { x: Math.round(p.x / gridStep) * gridStep, y: Math.round(p.y / gridStep) * gridStep };
138 − return p;
139 − };
145 + /* ----------------------------------------------------- coordonnées et aimant */
146 + const toPage = (e: { clientX: number; clientY: number }): Point => { const r = svgRef.current!.getBoundingClientRect(); return { x: (e.clientX - r.left - view.tx) / view.k, y: (e.clientY - r.top - view.ty) / view.k }; };
147 + const gridPx = k ? k * 0.1 : null;
148 + const anchor = draft?.points.length ? draft.points[draft.points.length - 1] : null;
149 + const snapped = (p: Point) => (snap ? snapPoint(p, { objects: objects.filter((o) => !hidden.has(o.layer)), draft: draft?.points ?? [], anchor, ortho, grid: gridPx, tolPx: 9 / view.k }) : { p, kind: "free" as const });
140 150
141 − /* ------------------------------------------------------------ actions */
142 − const finishDraft = async () => {
151 + /* ----------------------------------------------------- création d'objets */
152 + const def = TOOLS.find((t) => t.t === tool)!;
153 + const layerFor = (kind: ObjectKind) => (kind === "wall" || kind === "column" ? "murs" : kind === "opening" ? "ouvertures" : kind === "room" ? "pièces" : kind === "dimension" || kind === "text" ? "cotes" : kind === "footprint" || kind === "lot" ? "site" : "default");
154 + const nameFor = (kind: ObjectKind, extra?: string) => `${extra ?? (fr ? KIND_FR[kind]?.[0] : KIND_FR[kind]?.[1]) ?? kind} ${objects.filter((o) => o.kind === kind).length + 1}`;
155 + const finishDraft = () => {
143 156 if (!draft) return;
144 − const closed = TOOLS.find((t) => t.t === draft.kind)?.closed ?? false;
145 − if (draft.points.length < (closed ? 3 : draft.kind === "opening" ? 1 : 2)) { setDraft(null); return; }
146 − const o: PlanObject = { id: uuid(), pageId: page.id, kind: draft.kind, layer, name: `${fr ? labelOf(draft.kind, true) : labelOf(draft.kind, false)} ${objects.filter((x) => x.kind === draft.kind).length + 1}`, points: draft.points, closed, props: defaultProps(draft.kind), createdAt: "", updatedAt: "" };
147 − pushUndo(objects); setObjects((xs) => [...xs, o]); setDraft(null); setSel(o.id);
148 − await persistCreate(o);
157 + const t = TOOLS.find((x) => x.t === draft.tool)!;
158 + if (draft.tool === "rect" || draft.tool === "dimension") { finishRectOrDim(draft.points); return; }
159 + const pts = draft.points; const isClosed = !!t.closed;
160 + if (pts.length < (isClosed ? 3 : 2)) { setDraft(null); setTyped(""); return; }
161 + const kind = t.kind!;
162 + const props: PlanObject["props"] = draft.tool === "wall" ? { thickness: DEFAULT_WALL_THICKNESS_M, height: DEFAULT_WALL_HEIGHT_M, exterior: true } : draft.tool === "partition" ? { thickness: DEFAULT_PARTITION_THICKNESS_M, height: DEFAULT_WALL_HEIGHT_M, exterior: false } : {};
163 + const closeWall = kind === "wall" && pts.length >= 4 && dist(pts[0], pts[pts.length - 1]) < 1e-6;
164 + addObject({ id: uuid(), pageId: page.id, kind, layer: layerFor(kind), name: nameFor(kind, draft.tool === "partition" ? (fr ? "Cloison" : "Partition") : draft.tool === "wall" ? (fr ? "Mur ext." : "Ext. wall") : undefined), points: closeWall ? pts.slice(0, -1) : pts, closed: isClosed || closeWall, props, createdAt: "", updatedAt: "" });
165 + setDraft(null); setTyped("");
166 + };
167 + const finishRectOrDim = (pts: Point[]) => {
168 + if (pts.length < 2) return;
169 + if (draft?.tool === "dimension") addObject({ id: uuid(), pageId: page.id, kind: "dimension", layer: "cotes", name: nameFor("dimension"), points: pts.slice(0, 2), closed: false, props: { offset: k ? -k * 0.5 : -30 }, createdAt: "", updatedAt: "" });
170 + if (draft?.tool === "rect") { const [a, b] = pts; addObject({ id: uuid(), pageId: page.id, kind: "room", layer: "pièces", name: nameFor("room"), points: [a, { x: b.x, y: a.y }, b, { x: a.x, y: b.y }], closed: true, props: {}, createdAt: "", updatedAt: "" }); }
171 + setDraft(null); setTyped("");
172 + };
173 + const nearestWall = (p: Point): { wall: PlanObject; point: Point } | null => { let best: { wall: PlanObject; point: Point; d: number } | null = null; for (const w of objects) { if (w.kind !== "wall") continue; const pr = projectOnPolyline(p, w.points, w.closed); if (pr && (!best || pr.dist < best.d)) best = { wall: w, point: pr.point, d: pr.dist }; } return best && best.d < 25 / view.k ? best : null; };
174 + const applyTyped = () => {
175 + if (!draft || !anchor || !k) return;
176 + const m = Number(typed.replace(",", ".")); if (!(m > 0)) { setTyped(""); return; }
177 + const len = (imperial ? m * 0.3048 : m) * k;
178 + const dir = cursor ? Math.atan2(cursor.y - anchor.y, cursor.x - anchor.x) : 0;
179 + const a = ortho ? Math.round(dir / (Math.PI / 12)) * (Math.PI / 12) : dir;
180 + const p = { x: anchor.x + Math.cos(a) * len, y: anchor.y + Math.sin(a) * len };
181 + const pts = [...draft.points, p];
182 + setTyped("");
183 + if (draft.tool === "dimension" || draft.tool === "rect") { finishRectOrDim(pts); return; }
184 + setDraft({ ...draft, points: pts });
149 185 };
150 − const onClick = (e: React.MouseEvent<SVGSVGElement>) => {
186 +
187 + /* ----------------------------------------------------- souris / tactile */
188 + const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
189 + (e.target as Element).setPointerCapture?.(e.pointerId);
190 + if (e.button === 1 || tool === "pan" || spaceDown) { setDrag({ kind: "pan", start: { x: e.clientX, y: e.clientY }, view }); return; }
191 + if (e.button !== 0) return;
192 + const raw = toPage(e);
151 193 if (!canWrite) return;
152 − const p = toPage(e);
153 − if (tool === "calibrate") { const pts = [...calPts, p].slice(-2); setCalPts(pts); return; }
154 − if (tool === "select") { setSel(null); return; }
155 − const kind = tool as ObjectKind;
156 − if (kind === "opening") { const o: PlanObject = { id: uuid(), pageId: page.id, kind, layer, name: `${fr ? "Ouverture" : "Opening"} ${objects.filter((x) => x.kind === "opening").length + 1}`, points: [p], closed: false, props: { openingWidth: 0.9, openingHeight: 2.1, count: 1, deductFrom: nearestWall(p)?.id ?? null }, createdAt: "", updatedAt: "" }; pushUndo(objects); setObjects((xs) => [...xs, o]); setSel(o.id); void persistCreate(o); return; }
157 − if (draft && draft.points.length && dist(draft.points[0], p) < 8 / zoom && draft.points.length >= 3) { void finishDraft(); return; }
158 − setDraft((dr) => (dr && dr.kind === kind ? { ...dr, points: [...dr.points, p] } : { kind, points: [p] }));
194 + if (tool === "calibrate") { setCalPts((c) => [...c, snapped(raw).p].slice(-2)); return; }
195 + if (tool === "select") {
196 + const target = (e.target as Element).closest("[data-id]")?.getAttribute("data-id") ?? null;
197 + const o = target ? objects.find((x) => x.id === target) : null;
198 + if (o && target === sel) { const vi = o.points.findIndex((p) => dist(p, raw) < 8 / view.k); setDrag(vi >= 0 ? { kind: "vertex", id: sel!, idx: vi, start: raw, orig: o.points } : { kind: "object", id: sel!, start: raw, orig: o.points }); return; }
199 + setSel(target); if (o) setDrag({ kind: "object", id: target!, start: raw, orig: o.points });
200 + return;
201 + }
202 + const sp = snapped(raw).p;
203 + if (tool === "door" || tool === "window") {
204 + const nw = nearestWall(raw); if (!nw) { setStatus(fr ? "cliquez sur un mur" : "click on a wall"); return; }
205 + const isDoor = tool === "door";
206 + addObject({ id: uuid(), pageId: page.id, kind: "opening", layer: "ouvertures", name: nameFor("opening", isDoor ? (fr ? "Porte" : "Door") : (fr ? "Fenêtre" : "Window")), points: [nw.point], closed: false, props: { openingKind: isDoor ? "door" : "window", openingWidth: isDoor ? 0.9 : 1.2, openingHeight: isDoor ? 2.1 : 1.2, count: 1, deductFrom: nw.wall.id, swing: 1 }, createdAt: "", updatedAt: "" });
207 + return;
208 + }
209 + if (tool === "text") { const t = prompt(fr ? "Texte" : "Text", ""); if (t) addObject({ id: uuid(), pageId: page.id, kind: "text", layer: "cotes", name: t.slice(0, 40), points: [sp], closed: false, props: { text: t, fontSize: k ? Math.max(11, k * 0.2) : 12 }, createdAt: "", updatedAt: "" }); return; }
210 + if (tool === "column") { addObject({ id: uuid(), pageId: page.id, kind: "column", layer: "murs", name: nameFor("column"), points: [sp], closed: false, props: { thickness: 0.3, height: DEFAULT_WALL_HEIGHT_M }, createdAt: "", updatedAt: "" }); return; }
211 + if (tool === "rect" || tool === "dimension") { const pts = [...(draft?.tool === tool ? draft.points : []), sp]; if (pts.length >= 2) { setDraft({ tool, points: pts }); setTimeout(() => finishRectOrDimDeferred(tool, pts), 0); } else setDraft({ tool, points: pts }); return; }
212 + if (draft && draft.tool === tool && draft.points.length >= 3 && dist(draft.points[0], sp) < 9 / view.k) { setDraft({ ...draft, points: def.kind === "wall" ? [...draft.points, draft.points[0]] : draft.points }); setTimeout(() => finishDraftDeferred.current?.(), 0); return; }
213 + setDraft((dr) => (dr && dr.tool === tool ? { ...dr, points: [...dr.points, sp] } : { tool, points: [sp] }));
159 214 };
160 − const nearestWall = (p: Point): PlanObject | null => { let best: PlanObject | null = null, bd = 30 / zoom; for (const o of objects) if (o.kind === "wall") for (let i = 1; i < o.points.length; i++) { const dd = distToSeg(p, o.points[i - 1], o.points[i]); if (dd < bd) { bd = dd; best = o; } } return best; };
161 − const doUndo = () => { const prev = undo.current.pop(); if (!prev) return; redo.current.push(objects); syncTo(prev); };
162 − const doRedo = () => { const next = redo.current.pop(); if (!next) return; undo.current.push(objects); syncTo(next); };
163 − const syncTo = (target: PlanObject[]) => { const cur = new Map(objects.map((o) => [o.id, o])); const tgt = new Map(target.map((o) => [o.id, o])); for (const o of target) { if (!cur.has(o.id)) void persistCreate(o); else if (cur.get(o.id) !== o) void persistUpdate(o); } for (const o of objects) if (!tgt.has(o.id)) void persistDelete(o.id); setObjects(target); };
164 − const removeSel = () => { if (!sel) return; pushUndo(objects); setObjects((xs) => xs.filter((o) => o.id !== sel)); void persistDelete(sel); setSel(null); };
165 − const duplicateSel = () => { const o = objects.find((x) => x.id === sel); if (!o) return; const c = { ...o, id: uuid(), name: `${o.name} (copie)`, points: o.points.map((p) => ({ x: p.x + 20, y: p.y + 20 })) }; pushUndo(objects); setObjects((xs) => [...xs, c]); setSel(c.id); void persistCreate(c); };
166 − const updateSel = (patch: Partial<PlanObject> | { props: PlanObject["props"] }) => { const o = objects.find((x) => x.id === sel); if (!o) return; const n = { ...o, ...patch, props: { ...o.props, ...("props" in patch ? patch.props : {}) } } as PlanObject; pushUndo(objects); setObjects((xs) => xs.map((x) => (x.id === sel ? n : x))); void persistUpdate(n); };
167 − const measure = async (id: string) => { setStatus("…"); try { const r = await api<{ measurement: { value: number; unit: string; warnings?: string[] } }>(`/objects/${id}/measure`, { method: "POST", json: {} }); setStatus(`${fr ? "mesuré" : "measured"} : ${num(r.measurement.value, fr ? "fr" : "en", 3)} ${r.measurement.unit}${r.measurement.warnings?.length ? ` — ${r.measurement.warnings.join(" ; ")}` : ""}`); await reload(); } catch (e) { setStatus((e as Error).message); } };
168 − const measureAll = async () => { for (const o of objects) if (o.kind !== "annotation") await measure(o.id); };
169 − const calibrate = async () => { if (calPts.length < 2 || !calLen.value) return; try { const r = await api<{ calibration: Calibration }>(`/pages/${page.id}/calibrate`, { method: "POST", json: { p1: calPts[0], p2: calPts[1], realLength: Number(calLen.value), unit: calLen.unit } }); setCal(r.calibration); setCalPts([]); setTool("room"); setStatus(`${fr ? "calibration rév." : "calibration rev."} ${r.calibration.revision} : ${num(r.calibration.pxPerMeter, "fr", 2)} px/m ± ${r.calibration.uncertaintyPct} %`); await reload(); } catch (e) { setErr((e as Error).message); } };
215 + const finishDraftDeferred = useRef<(() => void) | null>(null);
216 + useEffect(() => { finishDraftDeferred.current = finishDraft; });
217 + const finishRectOrDimDeferred = (t: Tool, pts: Point[]) => { if (pts.length < 2) return; if (t === "dimension") addObject({ id: uuid(), pageId: page.id, kind: "dimension", layer: "cotes", name: nameFor("dimension"), points: pts.slice(0, 2), closed: false, props: { offset: k ? -k * 0.5 : -30 }, createdAt: "", updatedAt: "" }); if (t === "rect") { const [a, b] = pts; addObject({ id: uuid(), pageId: page.id, kind: "room", layer: "pièces", name: nameFor("room"), points: [a, { x: b.x, y: a.y }, b, { x: a.x, y: b.y }], closed: true, props: {}, createdAt: "", updatedAt: "" }); } setDraft(null); setTyped(""); };
218 + const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
219 + if (drag?.kind === "pan") { setView({ k: drag.view!.k, tx: drag.view!.tx + (e.clientX - drag.start.x), ty: drag.view!.ty + (e.clientY - drag.start.y) }); return; }
220 + const raw = toPage(e);
221 + if (drag && drag.orig && drag.id) {
222 + const dx = raw.x - drag.start.x, dy = raw.y - drag.start.y;
223 + const pts = drag.kind === "vertex" ? drag.orig.map((p, i) => (i === drag.idx ? snapped({ x: p.x + dx, y: p.y + dy }).p : p)) : drag.orig.map((p) => ({ x: p.x + dx, y: p.y + dy }));
224 + setObjects((xs) => xs.map((x) => (x.id === drag.id ? { ...x, points: pts } : x)));
225 + return;
226 + }
227 + const s = snapped(raw); setCursor(s.p); setSnapKind(s.kind);
228 + };
229 + const onPointerUp = () => {
230 + if (drag && drag.kind !== "pan" && drag.id && drag.orig) { const o = objects.find((x) => x.id === drag.id); if (o && JSON.stringify(o.points) !== JSON.stringify(drag.orig)) { undo.current = [...undo.current.slice(-59), objects.map((x) => (x.id === drag.id ? { ...x, points: drag.orig! } : x))]; redo.current = []; void persistUpdate(o); } }
231 + setDrag(null);
232 + };
233 + const onWheel = (e: React.WheelEvent<SVGSVGElement>) => { const r = svgRef.current!.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; const f = e.deltaY < 0 ? 1.12 : 1 / 1.12; setView((v) => { const nk = Math.min(12, Math.max(0.05, v.k * f)); return { k: nk, tx: mx - (mx - v.tx) * (nk / v.k), ty: my - (my - v.ty) * (nk / v.k) }; }); };
234 + useEffect(() => { const el = wrapRef.current; if (!el) return; const h = (e: WheelEvent) => e.preventDefault(); el.addEventListener("wheel", h, { passive: false }); return () => el.removeEventListener("wheel", h); }, []);
170 235
171 − /* ------------------------------------------------------------ clavier */
236 + /* ----------------------------------------------------- clavier */
172 237 useEffect(() => {
173 − const h = (e: KeyboardEvent) => {
174 − if ((e.target as HTMLElement)?.tagName === "INPUT" || (e.target as HTMLElement)?.tagName === "TEXTAREA" || (e.target as HTMLElement)?.tagName === "SELECT") return;
238 + const down = (e: KeyboardEvent) => {
239 + const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
240 + if (e.key === " ") { setSpaceDown(true); e.preventDefault(); return; }
175 241 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { e.preventDefault(); if (e.shiftKey) doRedo(); else doUndo(); return; }
176 242 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { e.preventDefault(); doRedo(); return; }
177 − if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") { e.preventDefault(); duplicateSel(); return; }
178 − if (e.key === "Escape") { setDraft(null); setCalPts([]); setSel(null); return; }
179 − if (e.key === "Enter") { void finishDraft(); return; }
180 − if (e.key === "Delete" || e.key === "Backspace") { if (draft) setDraft((dr) => (dr ? { ...dr, points: dr.points.slice(0, -1) } : dr)); else removeSel(); return; }
181 − if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(6, z * 1.25)); if (e.key === "-") setZoom((z) => Math.max(0.25, z / 1.25));
182 − if (e.key.toLowerCase() === "g") setGrid((g) => !g); if (e.key.toLowerCase() === "s" && !e.ctrlKey && !e.metaKey) setSnap((s) => !s);
183 − const t = TOOLS.find((x) => x.key.toLowerCase() === e.key.toLowerCase()); if (t && !e.ctrlKey && !e.metaKey) { setTool(t.t); setDraft(null); }
243 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") { e.preventDefault(); const o = objects.find((x) => x.id === sel); if (o) addObject({ ...o, id: uuid(), name: `${o.name} (copie)`, points: o.points.map((p) => ({ x: p.x + (k ?? 50) * 0.5, y: p.y + (k ?? 50) * 0.5 })) }); return; }
244 + if (e.key === "Escape") { setDraft(null); setTyped(""); setCalPts([]); setSel(null); return; }
245 + if (e.key === "Enter") { if (typed) applyTyped(); else finishDraft(); return; }
246 + if (e.key === "Backspace") { if (typed) setTyped((t) => t.slice(0, -1)); else if (draft) setDraft((dr) => (dr ? { ...dr, points: dr.points.slice(0, -1) } : dr)); else if (sel) removeObject(sel); return; }
247 + if (e.key === "Delete") { if (sel) removeObject(sel); return; }
248 + if (/^[0-9.,]$/.test(e.key) && draft && anchor) { setTyped((t) => (t + e.key).slice(0, 8)); return; }
249 + if (e.key === "+" || e.key === "=") setView((v) => ({ ...v, k: Math.min(12, v.k * 1.2) })); if (e.key === "-") setView((v) => ({ ...v, k: Math.max(0.05, v.k / 1.2) })); if (e.key === "0") fit();
250 + if (e.key.toLowerCase() === "g") setShowGrid((g) => !g); if (e.key.toLowerCase() === "o") setOrtho((o) => !o); if (e.key.toLowerCase() === "q") setSnap((s) => !s);
251 + const t = TOOLS.find((x) => x.key.toLowerCase() === e.key.toLowerCase()); if (t && !e.ctrlKey && !e.metaKey && !e.altKey) { if (toolDisabled(t.t)) return; setTool(t.t); setDraft(null); setTyped(""); }
184 252 };
185 − window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h);
253 + const up = (e: KeyboardEvent) => { if (e.key === " ") setSpaceDown(false); };
254 + window.addEventListener("keydown", down); window.addEventListener("keyup", up);
255 + return () => { window.removeEventListener("keydown", down); window.removeEventListener("keyup", up); };
186 256 });
187 257
258 + /* ----------------------------------------------------- actions serveur */
259 + const measureAll = async () => { setStatus("…"); try { const r = await api<{ measured: number; failed: { name: string; error: string }[] }>(`/pages/${page.id}`, { method: "POST", json: { action: "measure_all" } }); setStatus(`${r.measured} ${fr ? "mesures au registre" : "measurements in ledger"}${r.failed.length ? ` · ${r.failed.length} ${fr ? "échecs" : "failed"}` : ""}`); await reload(); } catch (e) { setErr((e as Error).message); } };
260 + const measureOne = async (id: string) => { try { const r = await api<{ measurement: { value: number; unit: string } }>(`/objects/${id}/measure`, { method: "POST", json: {} }); setStatus(`${fr ? "mesuré" : "measured"} ${num(r.measurement.value, "fr", 3)} ${r.measurement.unit}`); await reload(); } catch (e) { setErr((e as Error).message); } };
261 + const calibrate = async () => { if (calPts.length < 2 || !calLen.value) return; try { const r = await api<{ calibration: Calibration }>(`/pages/${page.id}/calibrate`, { method: "POST", json: { p1: calPts[0], p2: calPts[1], realLength: Number(calLen.value), unit: calLen.unit } }); setCal(r.calibration); setCalPts([]); setTool("wall"); setStatus(`${fr ? "calibration" : "calibration"} ${num(r.calibration.pxPerMeter, "fr", 2)} px/m ± ${r.calibration.uncertaintyPct} %`); await reload(); } catch (e) { setErr((e as Error).message); } };
262 + const pageAction = async (a: "rename" | "duplicate" | "delete") => {
263 + try {
264 + if (a === "rename") { const label = prompt(fr ? "Nom du niveau / de la page" : "Level / page name", page.level ?? page.label ?? ""); if (label == null) return; await api(`/pages/${page.id}`, { method: "PATCH", json: { label, level: label } }); }
265 + if (a === "duplicate") { const label = prompt(fr ? "Nom du nouveau niveau (copie des objets)" : "New level name (objects copied)", "Étage"); if (!label) return; await api(`/pages/${page.id}`, { method: "POST", json: { action: "duplicate", label, level: label } }); }
266 + if (a === "delete") { if (!confirm(fr ? "Supprimer cette page et ses objets ?" : "Delete this page and its objects?")) return; await api(`/pages/${page.id}`, { method: "DELETE" }); onPageDeleted(); }
267 + await reload();
268 + } catch (e) { setErr((e as Error).message); }
269 + };
270 + /* ----------------------------------------------------- rendu */
188 271 const selected = objects.find((o) => o.id === sel) ?? null;
189 − const live = (o: { points: Point[]; closed: boolean; kind: ObjectKind }) => { if (!cal) return ""; const closed = TOOLS.find((t) => t.t === o.kind)?.closed; if (closed) { const m2 = polygonAreaPx(o.points) / cal.pxPerMeter ** 2; return unitSystem === "imperial" ? `${num(m2 * 10.7639, "fr", 1)} pi²` : `${num(m2, "fr", 2)} m²`; } const m = polylineLengthPx(o.points, false) / cal.pxPerMeter; return unitSystem === "imperial" ? `${num(m * 3.28084, "fr", 2)} pi` : `${num(m, "fr", 2)} m`; };
190 − const mOf = (id: string) => measurements.find((m) => m.objectId === id);
272 + const q = useMemo(() => planQuantities(objects, k), [objects, k]);
273 + const inner = useMemo(() => renderPlanInner(objects, { widthPx: page.widthPx, heightPx: page.heightPx, pxPerMeter: k, imperial, grid: false, selectedId: sel, showLabels, showDims, hiddenLayers: hidden }), [objects, page.widthPx, page.heightPx, k, imperial, sel, showLabels, showDims, hidden]);
274 + const gridSvg = useMemo(() => (showGrid && k ? renderPlanInner([], { widthPx: page.widthPx, heightPx: page.heightPx, pxPerMeter: k, imperial, grid: true }) : ""), [showGrid, k, page.widthPx, page.heightPx, imperial]);
275 + const draftLen = draft && anchor && cursor && k ? dist(anchor, cursor) / k : null;
276 + const draftPoly = draft ? [...draft.points, ...(cursor && draft.tool !== "rect" ? [cursor] : [])] : [];
191 277 const walls = objects.filter((o) => o.kind === "wall");
192 − const W = page.widthPx * zoom, Hh = page.heightPx * zoom;
278 + const layers = [...new Set(objects.map((o) => o.layer))];
279 + const mOf = (id: string) => measurements.filter((m) => m.objectId === id);
193 280
194 281 return (
195 − <div className="space-y-6">
196 − <Block num="02" title={`Plan Studio — ${page.filename} · ${page.label}`} aside={<span className="vp-mono text-[10px] uppercase text-ink-3">{cal ? `${fr ? "échelle" : "scale"} ${num(cal.pxPerMeter, "fr", 1)} px/m ± ${cal.uncertaintyPct} % (rév. ${cal.revision})` : fr ? "NON CALIBRÉE" : "NOT CALIBRATED"}</span>}>
197 − {/* barre d'outils */}
198 − <div className="flex flex-wrap items-center gap-1.5">
199 − {TOOLS.map((t) => (<button key={t.t} type="button" disabled={!canWrite} onClick={() => { setTool(t.t); setDraft(null); }} title={`${fr ? t.fr : t.en} (${t.key})`} className={`vp-mono border px-2.5 py-1.5 text-[10.5px] uppercase ${tool === t.t ? "border-accent bg-accent text-white" : "border-[var(--line)] text-ink-2"}`}><span className="inline-block h-2 w-2 mr-1 align-middle" style={{ background: COLORS[t.t] ?? "#999" }} />{fr ? t.fr : t.en} <span className="opacity-60">{t.key}</span></button>))}
200 − <span className="mx-2 h-4 w-px bg-[var(--line)]" />
201 − <Btn small onClick={doUndo} title="Ctrl+Z">↶</Btn><Btn small onClick={doRedo} title="Ctrl+Shift+Z">↷</Btn>
202 − <Btn small onClick={() => setZoom((z) => Math.max(0.25, z / 1.25))}>−</Btn><span className="vp-mono text-[11px]">{Math.round(zoom * 100)} %</span><Btn small onClick={() => setZoom((z) => Math.min(6, z * 1.25))}>+</Btn>
203 − <label className="vp-mono ml-2 flex items-center gap-1 text-[10.5px] uppercase"><input type="checkbox" checked={snap} onChange={(e) => setSnap(e.target.checked)} /> {fr ? "aimant" : "snap"} (S)</label>
204 − <label className="vp-mono flex items-center gap-1 text-[10.5px] uppercase"><input type="checkbox" checked={grid} onChange={(e) => setGrid(e.target.checked)} /> {fr ? "grille" : "grid"} (G)</label>
205 − <Field label={fr ? "calque" : "layer"}><Input value={layer} onChange={(e) => setLayer(e.target.value || "default")} className="!min-h-0 !py-0.5 w-[110px] text-[12px]" /></Field>
206 − {draft && <Btn small tone="accent" onClick={() => void finishDraft()}>{fr ? "Terminer (Entrée)" : "Finish (Enter)"}</Btn>}
207 − <Btn small onClick={() => void measureAll()} disabled={!cal || !objects.length}>{fr ? "Mesurer tout → registre" : "Measure all → ledger"}</Btn>
282 + <div className="pl-wrap">
283 + <div className="pl-tools">
284 + {TOOLS.map((t, i) => (<span key={t.t} className="contents">{(i === 2 || i === 6 || i === 8 || i === 12 || i === 15) && <span className="sep" />}<button type="button" disabled={toolDisabled(t.t)} className={tool === t.t ? "active" : ""} title={`${fr ? t.fr : t.en} (${t.key})`} onClick={() => { setTool(t.t); setDraft(null); setTyped(""); }}>{t.ico}<small>{t.key}</small></button></span>))}
285 + </div>
286 + <div className="pl-center">
287 + <div className="pl-bar">
288 + <button type="button" className={`chip ${snap ? "on" : ""}`} onClick={() => setSnap((s) => !s)}>{fr ? "aimant" : "snap"} Q</button>
289 + <button type="button" className={`chip ${ortho ? "on" : ""}`} onClick={() => setOrtho((o) => !o)}>ortho O</button>
290 + <button type="button" className={`chip ${showGrid ? "on" : ""}`} onClick={() => setShowGrid((g) => !g)}>{fr ? "grille" : "grid"} G</button>
291 + <button type="button" className={`chip ${showDims ? "on" : ""}`} onClick={() => setShowDims((v) => !v)}>{fr ? "cotes" : "dims"}</button>
292 + <button type="button" className={`chip ${showLabels ? "on" : ""}`} onClick={() => setShowLabels((v) => !v)}>{fr ? "étiquettes" : "labels"}</button>
293 + <span className="mx-1 h-4 w-px bg-[#d9dee2]" />
294 + <button type="button" className="chip" onClick={doUndo}>↶</button><button type="button" className="chip" onClick={doRedo}>↷</button>
295 + <button type="button" className="chip" onClick={() => setView((v) => ({ ...v, k: v.k / 1.2 }))}>−</button><span className="vp-mono text-[11px]">{Math.round(view.k * 100)} %</span><button type="button" className="chip" onClick={() => setView((v) => ({ ...v, k: v.k * 1.2 }))}>+</button><button type="button" className="chip" onClick={fit}>{fr ? "ajuster" : "fit"} 0</button>
296 + <span className="mx-1 h-4 w-px bg-[#d9dee2]" />
297 + {layers.map((l) => <button key={l} type="button" className={`chip ${hidden.has(l) ? "" : "on"}`} onClick={() => setHidden((h) => { const n = new Set(h); if (n.has(l)) n.delete(l); else n.add(l); return n; })}>{l}</button>)}
298 + <span className="ml-auto flex flex-wrap gap-1">
299 + {canWrite && <Btn small tone="accent" disabled={!k} onClick={() => void measureAll()}>{fr ? "Mesurer tout → registre" : "Measure all → ledger"}</Btn>}
300 + <a className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]" href={`/api/metre/pages/${page.id}/export?format=pdf`}>PDF</a><a className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]" href={`/api/metre/pages/${page.id}/export?format=png`}>PNG</a><a className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]" href={`/api/metre/pages/${page.id}/export?format=svg`}>SVG</a>
301 + {canWrite && <><Btn small onClick={() => void pageAction("rename")} title={fr ? "Renommer" : "Rename"}>✎</Btn><Btn small onClick={() => void pageAction("duplicate")} title={fr ? "Dupliquer en nouveau niveau" : "Duplicate as new level"}>⧉</Btn><Btn small onClick={() => void pageAction("delete")} title={fr ? "Supprimer la page" : "Delete page"}>🗑</Btn></>}
302 + </span>
208 303 </div>
209 304 {tool === "calibrate" && (
210 − <div className="flex flex-wrap items-end gap-3 border border-[var(--line-strong)] bg-surface p-3">
211 − <p className="text-[13px] text-ink-2 max-w-md">{fr ? "Cliquez deux points dont la distance réelle est connue (ex. une cote de 10,00 m ou 12'-0\"), puis saisissez cette longueur. Chaque calibration crée une révision ; les mesures existantes sont marquées périmées jusqu'au recalcul." : "Click two points whose real distance is known (e.g. a 10.00 m or 12'-0\" dimension), then enter that length. Each calibration creates a revision; existing measurements are flagged stale until recomputed."}</p>
212 − <span className="vp-mono text-[11px]">{calPts.length}/2 {fr ? "points" : "points"}{calPts.length === 2 ? ` · ${num(dist(calPts[0], calPts[1]), "fr", 1)} px` : ""}</span>
213 − <Field label={fr ? "Longueur réelle" : "Real length"}><Input type="number" step="0.001" min={0} value={calLen.value} onChange={(e) => setCalLen({ ...calLen, value: e.target.value })} className="!min-h-0 w-[120px]" /></Field>
214 − <Field label={fr ? "Unité" : "Unit"}><Select value={calLen.unit} onChange={(e) => setCalLen({ ...calLen, unit: e.target.value })} className="!min-h-0 w-[80px]"><option value="m">m</option><option value="ft">ft / pi</option><option value="mm">mm</option><option value="cm">cm</option><option value="in">in / po</option></Select></Field>
305 + <div className="pl-bar !bg-[#fff8e6]">
306 + <span className="text-[12px]">{fr ? "Cliquez deux points de distance connue (une cote du plan), puis la longueur :" : "Click two points of known distance (a plan dimension), then the length:"}</span>
307 + <span className="vp-mono text-[11px]">{calPts.length}/2{calPts.length === 2 ? ` · ${num(dist(calPts[0], calPts[1]), "fr", 1)} px` : ""}</span>
308 + <Input type="number" step="0.001" min={0} value={calLen.value} onChange={(e) => setCalLen({ ...calLen, value: e.target.value })} className="!min-h-0 w-[110px] !py-1" placeholder={fr ? "longueur" : "length"} />
309 + <select value={calLen.unit} onChange={(e) => setCalLen({ ...calLen, unit: e.target.value })} className="vp-input !min-h-0 w-[80px] !py-1 text-[12px]"><option value="m">m</option><option value="ft">pi / ft</option><option value="mm">mm</option><option value="cm">cm</option><option value="in">po / in</option></select>
215 310 <Btn small tone="primary" disabled={calPts.length < 2 || !calLen.value} onClick={() => void calibrate()}>{fr ? "Calibrer" : "Calibrate"}</Btn>
216 311 </div>
217 312 )}
218 − {/* canevas */}
219 − <div className="overflow-auto border border-[var(--line-strong)] bg-[#e9ebe7]" style={{ maxHeight: "72vh", touchAction: "pan-x pan-y" }}>
220 − <svg ref={svgRef} width={W} height={Hh} viewBox={`0 0 ${page.widthPx} ${page.heightPx}`} onClick={onClick} onMouseMove={(e) => setCursor(toPage(e))} onMouseLeave={() => setCursor(null)} onContextMenu={(e) => { e.preventDefault(); void finishDraft(); }} style={{ cursor: tool === "select" ? "default" : "crosshair", display: "block" }}>
221 − <image href={`/api/metre/pages/${page.id}/image`} width={page.widthPx} height={page.heightPx} />
222 − {grid && cal && <g opacity={0.25}>{Array.from({ length: Math.floor(page.widthPx / gridStep) + 1 }, (_, i) => <line key={`v${i}`} x1={i * gridStep} y1={0} x2={i * gridStep} y2={page.heightPx} stroke="#006085" strokeWidth={0.5 / zoom} />)}{Array.from({ length: Math.floor(page.heightPx / gridStep) + 1 }, (_, i) => <line key={`h${i}`} x1={0} y1={i * gridStep} x2={page.widthPx} y2={i * gridStep} stroke="#006085" strokeWidth={0.5 / zoom} />)}</g>}
223 − {objects.filter((o) => !hidden.has(o.layer)).map((o) => <Shape key={o.id} o={o} selected={o.id === sel} zoom={zoom} onSelect={() => { if (tool === "select" || tool === "opening") setSel(o.id); }} label={`${o.name}${cal ? ` · ${live(o)}` : ""}${mOf(o.id)?.stale ? " ⚠" : ""}`} />)}
224 − {draft && <g><polyline points={[...draft.points, ...(cursor ? [cursor] : [])].map((p) => `${p.x},${p.y}`).join(" ")} fill={TOOLS.find((t) => t.t === draft.kind)?.closed ? `${COLORS[draft.kind]}22` : "none"} stroke={COLORS[draft.kind]} strokeWidth={2 / zoom} strokeDasharray={`${6 / zoom} ${4 / zoom}`} />{draft.points.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={4 / zoom} fill={i === 0 ? "#fff" : COLORS[draft.kind]} stroke={COLORS[draft.kind]} strokeWidth={1.5 / zoom} />)}{cal && draft.points.length > 1 && <text x={draft.points[draft.points.length - 1].x + 8 / zoom} y={draft.points[draft.points.length - 1].y - 8 / zoom} fontSize={12 / zoom} fill={COLORS[draft.kind]} fontFamily="ui-monospace">{live({ ...draft, closed: !!TOOLS.find((t) => t.t === draft.kind)?.closed })}</text>}</g>}
225 − {calPts.map((p, i) => <g key={i}><circle cx={p.x} cy={p.y} r={6 / zoom} fill="none" stroke="#b3423a" strokeWidth={2 / zoom} /><circle cx={p.x} cy={p.y} r={1.5 / zoom} fill="#b3423a" /></g>)}
226 − {calPts.length === 2 && <line x1={calPts[0].x} y1={calPts[0].y} x2={calPts[1].x} y2={calPts[1].y} stroke="#b3423a" strokeWidth={2 / zoom} strokeDasharray={`${8 / zoom} ${4 / zoom}`} />}
227 − {cal && <g opacity={0.5}><line x1={cal.p1.x} y1={cal.p1.y} x2={cal.p2.x} y2={cal.p2.y} stroke="#2bccae" strokeWidth={1.5 / zoom} /><text x={(cal.p1.x + cal.p2.x) / 2} y={(cal.p1.y + cal.p2.y) / 2 - 6 / zoom} fontSize={11 / zoom} fill="#00465f" fontFamily="ui-monospace" textAnchor="middle">{cal.realLength} {cal.unit} (rév. {cal.revision})</text></g>}
313 + <div ref={wrapRef} className={`pl-canvas ${tool === "pan" || spaceDown ? "pan" : tool === "select" ? "select" : ""}`}>
314 + <svg ref={svgRef} width="100%" height="100%" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onWheel={onWheel} onContextMenu={(e) => { e.preventDefault(); if (draft) finishDraft(); }} onDoubleClick={() => { if (draft) finishDraft(); }} style={{ display: "block" }}>
315 + <g transform={`translate(${view.tx} ${view.ty}) scale(${view.k})`}>
316 + <rect x={-4} y={-4} width={page.widthPx + 8} height={page.heightPx + 8} fill="#000" opacity={0.08} />
317 + <image href={`/api/metre/pages/${page.id}/image`} width={page.widthPx} height={page.heightPx} />
318 + {gridSvg && <g dangerouslySetInnerHTML={{ __html: gridSvg }} />}
319 + <g dangerouslySetInnerHTML={{ __html: inner }} />
320 + {draft && draftPoly.length > 0 && (() => { const t = TOOLS.find((x) => x.t === draft.tool)!; const isWall = t.kind === "wall"; const th = (draft.tool === "wall" ? DEFAULT_WALL_THICKNESS_M : DEFAULT_PARTITION_THICKNESS_M) * (k ?? 50); return (<g pointerEvents="none">
321 + {draft.tool === "rect" && cursor && draft.points[0] ? <rect x={Math.min(draft.points[0].x, cursor.x)} y={Math.min(draft.points[0].y, cursor.y)} width={Math.abs(cursor.x - draft.points[0].x)} height={Math.abs(cursor.y - draft.points[0].y)} fill="#00608522" stroke="#006085" strokeWidth={1.5 / view.k} strokeDasharray={`${6 / view.k} ${4 / view.k}`} />
322 + : <polyline points={draftPoly.map((p) => `${p.x},${p.y}`).join(" ")} fill={t.closed ? "#00608518" : "none"} stroke={isWall ? "#1f2326" : "#006085"} strokeWidth={isWall ? th : 2 / view.k} strokeOpacity={isWall ? 0.5 : 1} strokeLinejoin="miter" strokeDasharray={isWall ? undefined : `${6 / view.k} ${4 / view.k}`} />}
323 + {draft.points.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={4 / view.k} fill={i === 0 ? "#fff" : "#006085"} stroke="#006085" strokeWidth={1.5 / view.k} />)}
324 + {draftLen != null && anchor && cursor && <text x={(anchor.x + cursor.x) / 2} y={(anchor.y + cursor.y) / 2 - 10 / view.k} fontSize={12 / view.k} fill="#006085" textAnchor="middle" fontFamily="ui-monospace" paintOrder="stroke" stroke="#fff" strokeWidth={3 / view.k}>{typed ? `${typed} ${imperial ? "pi" : "m"} ⏎` : fmtLength(draftLen, imperial)}</text>}
325 + </g>); })()}
326 + {selected && selected.points.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={5 / view.k} fill="#fff" stroke="#006085" strokeWidth={1.5 / view.k} style={{ cursor: "move" }} data-id={selected.id} />)}
327 + {cursor && tool !== "select" && tool !== "pan" && <g pointerEvents="none"><line x1={cursor.x - 8 / view.k} y1={cursor.y} x2={cursor.x + 8 / view.k} y2={cursor.y} stroke={snapKind === "vertex" || snapKind === "segment" ? "#b3423a" : "#006085"} strokeWidth={1 / view.k} /><line x1={cursor.x} y1={cursor.y - 8 / view.k} x2={cursor.x} y2={cursor.y + 8 / view.k} stroke={snapKind === "vertex" || snapKind === "segment" ? "#b3423a" : "#006085"} strokeWidth={1 / view.k} />{(snapKind === "vertex" || snapKind === "segment") && <circle cx={cursor.x} cy={cursor.y} r={6 / view.k} fill="none" stroke="#b3423a" strokeWidth={1 / view.k} />}</g>}
328 + {(tool === "door" || tool === "window") && cursor && (() => { const nw = nearestWall(cursor); return nw ? <circle cx={nw.point.x} cy={nw.point.y} r={7 / view.k} fill="#2bccae55" stroke="#2bccae" strokeWidth={1.5 / view.k} pointerEvents="none" /> : null; })()}
329 + {calPts.map((p, i) => <g key={i} pointerEvents="none"><circle cx={p.x} cy={p.y} r={6 / view.k} fill="none" stroke="#b3423a" strokeWidth={2 / view.k} /><circle cx={p.x} cy={p.y} r={1.5 / view.k} fill="#b3423a" /></g>)}
330 + {calPts.length === 2 && <line x1={calPts[0].x} y1={calPts[0].y} x2={calPts[1].x} y2={calPts[1].y} stroke="#b3423a" strokeWidth={2 / view.k} strokeDasharray={`${8 / view.k} ${4 / view.k}`} pointerEvents="none" />}
331 + {cal && page.source === "import" && <line x1={cal.p1.x} y1={cal.p1.y} x2={cal.p2.x} y2={cal.p2.y} stroke="#2bccae" strokeWidth={1.5 / view.k} opacity={0.5} pointerEvents="none" />}
332 + </g>
228 333 </svg>
229 334 </div>
230 − <div className="flex flex-wrap items-center justify-between gap-2">
231 − <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{cursor ? `x ${Math.round(cursor.x)} · y ${Math.round(cursor.y)} px` : ""} {status && <span className="ml-3 text-accent">{status}</span>}</p>
232 − <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Clic : point · clic sur le 1er point / Entrée / clic droit : fermer · Échap : annuler · Suppr : retirer · Ctrl+Z/Y · Ctrl+D dupliquer · +/− zoom" : "Click: point · click 1st point / Enter / right-click: close · Esc: cancel · Del: remove · Ctrl+Z/Y · Ctrl+D duplicate · +/− zoom"}</p>
335 + <div className="pl-status">
336 + <span>{cursor && k ? `x ${fmtLength(cursor.x / k, imperial)} · y ${fmtLength(cursor.y / k, imperial)}` : ""}{draftLen != null ? ` · ${fr ? "segment" : "segment"} ${fmtLength(draftLen, imperial)}` : ""} {typed && <b className="text-accent">⌨ {typed}</b>} {status && <span className="text-accent"> · {status}</span>}</span>
337 + <span>{fr ? `murs ${fmtLength(q.totals.wallLengthM, imperial)} · pièces ${fmtArea(q.totals.floorAreaM2, imperial)} · ${q.totals.doors} portes · ${q.totals.windows} fenêtres` : `walls ${fmtLength(q.totals.wallLengthM, imperial)} · rooms ${fmtArea(q.totals.floorAreaM2, imperial)} · ${q.totals.doors} doors · ${q.totals.windows} windows`}</span>
338 + <span>{fr ? "clic : point · 1er point / double-clic / clic droit / Entrée : fermer · chiffres + Entrée : longueur · Échap · Suppr · Ctrl+Z/Y · Ctrl+D · molette : zoom · espace+glisser : déplacer" : "click: point · 1st point / double-click / right-click / Enter: close · digits + Enter: length · Esc · Del · Ctrl+Z/Y · Ctrl+D · wheel: zoom · space+drag: pan"}</span>
233 339 </div>
234 340 <Err msg={err} />
235 − </Block>
236 − {/* calques */}
237 − <div className="flex flex-wrap items-center gap-2"><span className="klabel">{fr ? "calques" : "layers"}</span>{[...new Set(objects.map((o) => o.layer))].map((l) => (<label key={l} className="vp-mono flex items-center gap-1 text-[10.5px] uppercase"><input type="checkbox" checked={!hidden.has(l)} onChange={(e) => setHidden((h) => { const n = new Set(h); if (e.target.checked) n.delete(l); else n.add(l); return n; })} /> {l}</label>))}</div>
238 − {/* propriétés de l'objet sélectionné */}
239 − {selected && (
240 − <Block num="03" title={`${fr ? "Objet" : "Object"} — ${selected.name}`} aside={canWrite && <div className="flex gap-2"><Btn small tone="accent" disabled={!cal && selected.kind !== "opening"} onClick={() => void measure(selected.id)}>{fr ? "Mesurer → registre" : "Measure → ledger"}</Btn><Btn small onClick={duplicateSel}>{fr ? "Dupliquer" : "Duplicate"}</Btn><Btn small onClick={removeSel}>{fr ? "Supprimer" : "Delete"}</Btn></div>}>
241 − <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
242 − <Field label={fr ? "Nom" : "Name"}><Input value={selected.name} disabled={!canWrite} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, name: e.target.value } : x)))} onBlur={() => updateSel({ name: selected.name })} /></Field>
243 − <Field label={fr ? "Type" : "Kind"}><Select value={selected.kind} disabled={!canWrite} onChange={(e) => updateSel({ kind: e.target.value as ObjectKind, closed: !!TOOLS.find((t) => t.t === e.target.value)?.closed })}>{TOOLS.filter((t) => t.t !== "select" && t.t !== "calibrate").map((t) => <option key={t.t} value={t.t}>{fr ? t.fr : t.en}</option>)}</Select></Field>
244 − <Field label={fr ? "Calque" : "Layer"}><Input value={selected.layer} disabled={!canWrite} onBlur={(e) => updateSel({ layer: e.target.value || "default" })} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, layer: e.target.value } : x)))} /></Field>
245 − <Field label={fr ? "Points" : "Points"}><span className="vp-mono text-[13px]">{selected.points.length} · {live(selected)}</span></Field>
246 − {selected.kind === "wall" && <Field label={fr ? "Hauteur (m)" : "Height (m)"}><NumProp key={`${selected.id}:height:${selected.props.height ?? ""}`} o={selected} k="height" canWrite={canWrite} onChange={updateSel} /></Field>}
247 − {(selected.kind === "slab" || selected.kind === "foundation") && <Field label={fr ? "Épaisseur (m)" : "Thickness (m)"}><NumProp key={`${selected.id}:thickness:${selected.props.thickness ?? ""}`} o={selected} k="thickness" canWrite={canWrite} onChange={updateSel} /></Field>}
248 − {selected.kind === "roof" && <Field label={fr ? "Pente (po / 12)" : "Pitch (in / 12)"}><NumProp key={`${selected.id}:pitch:${selected.props.pitch ?? ""}`} o={selected} k="pitch" canWrite={canWrite} onChange={updateSel} /></Field>}
341 + </div>
342 + <aside className="pl-right">
343 + {selected ? (<>
344 + <h3>{fr ? KIND_FR[selected.kind]?.[0] : KIND_FR[selected.kind]?.[1]} — {selected.name}</h3>
345 + <div className="sec space-y-2">
346 + <label>{fr ? "Nom" : "Name"}<input value={selected.name} disabled={!canWrite} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, name: e.target.value } : x)))} onBlur={(e) => updateObject(selected.id, { name: e.target.value })} /></label>
347 + <div className="row">
348 + <label>{fr ? "Calque" : "Layer"}<input value={selected.layer} disabled={!canWrite} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, layer: e.target.value } : x)))} onBlur={(e) => updateObject(selected.id, { layer: e.target.value || "default" })} /></label>
349 + <label>{fr ? "Type" : "Kind"}<select value={selected.kind} disabled={!canWrite} onChange={(e) => updateObject(selected.id, { kind: e.target.value as ObjectKind })}>{Object.keys(KIND_FR).map((kk) => <option key={kk} value={kk}>{fr ? KIND_FR[kk][0] : KIND_FR[kk][1]}</option>)}</select></label>
350 + </div>
351 + {selected.kind === "wall" && (<><div className="row"><NumProp key={`${selected.id}:thickness:${String(selected.props.thickness ?? "")}`} label={fr ? "Épaisseur (m)" : "Thickness (m)"} o={selected} k="thickness" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { thickness: v })} /><NumProp key={`${selected.id}:height:${String(selected.props.height ?? "")}`} label={fr ? "Hauteur (m)" : "Height (m)"} o={selected} k="height" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { height: v })} /></div><label className="flex items-center gap-2"><input type="checkbox" checked={!!selected.props.exterior} disabled={!canWrite} onChange={(e) => updateObject(selected.id, {}, { exterior: e.target.checked })} className="!w-auto" /> {fr ? "mur extérieur (enveloppe)" : "exterior wall (envelope)"}</label><label className="flex items-center gap-2"><input type="checkbox" checked={selected.closed} disabled={!canWrite} onChange={(e) => updateObject(selected.id, { closed: e.target.checked })} className="!w-auto" /> {fr ? "boucle fermée" : "closed loop"}</label>
352 + <p className="text-[11px] text-ink-3">{k ? `${fmtLength(polylineLengthPx(selected.points, selected.closed) / k, imperial)} · ${fr ? "brut" : "gross"} ${fmtArea((polylineLengthPx(selected.points, selected.closed) / k) * (selected.props.height ?? DEFAULT_WALL_HEIGHT_M), imperial)} · ${objects.filter((o) => o.kind === "opening" && o.props.deductFrom === selected.id).length} ${fr ? "ouvertures" : "openings"}` : ""}</p></>)}
249 353 {selected.kind === "opening" && (<>
250 − <Field label={fr ? "Largeur (m)" : "Width (m)"}><NumProp key={`${selected.id}:openingWidth:${selected.props.openingWidth ?? ""}`} o={selected} k="openingWidth" canWrite={canWrite} onChange={updateSel} /></Field>
251 − <Field label={fr ? "Hauteur (m)" : "Height (m)"}><NumProp key={`${selected.id}:openingHeight:${selected.props.openingHeight ?? ""}`} o={selected} k="openingHeight" canWrite={canWrite} onChange={updateSel} /></Field>
252 − <Field label={fr ? "Nombre" : "Count"}><NumProp key={`${selected.id}:count:${selected.props.count ?? ""}`} o={selected} k="count" canWrite={canWrite} onChange={updateSel} /></Field>
253 − <Field label={fr ? "Déduire du mur" : "Deduct from wall"}><Select value={selected.props.deductFrom ?? ""} disabled={!canWrite} onChange={(e) => updateSel({ props: { deductFrom: e.target.value || null } })}><option value="">{fr ? "— aucun —" : "— none —"}</option>{walls.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</Select></Field>
354 + <div className="row"><label>{fr ? "Type" : "Type"}<select value={selected.props.openingKind ?? "door"} disabled={!canWrite} onChange={(e) => updateObject(selected.id, {}, { openingKind: e.target.value as "door" | "window" | "opening" })}><option value="door">{fr ? "porte" : "door"}</option><option value="window">{fr ? "fenêtre" : "window"}</option><option value="opening">{fr ? "baie libre" : "free opening"}</option></select></label><label>{fr ? "Mur" : "Wall"}<select value={selected.props.deductFrom ?? ""} disabled={!canWrite} onChange={(e) => updateObject(selected.id, {}, { deductFrom: e.target.value || null })}><option value="">—</option>{walls.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</select></label></div>
355 + <div className="row"><NumProp key={`${selected.id}:openingWidth:${String(selected.props.openingWidth ?? "")}`} label={fr ? "Largeur (m)" : "Width (m)"} o={selected} k="openingWidth" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { openingWidth: v })} /><NumProp key={`${selected.id}:openingHeight:${String(selected.props.openingHeight ?? "")}`} label={fr ? "Hauteur (m)" : "Height (m)"} o={selected} k="openingHeight" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { openingHeight: v })} /></div>
356 + <div className="row"><NumProp key={`${selected.id}:count:${String(selected.props.count ?? "")}`} label={fr ? "Nombre" : "Count"} o={selected} k="count" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { count: v })} /><label>{fr ? "Battement" : "Swing"}<select value={selected.props.swing ?? 1} disabled={!canWrite} onChange={(e) => updateObject(selected.id, {}, { swing: Number(e.target.value) })}><option value={1}>{fr ? "intérieur · charnière gauche" : "inside · left hinge"}</option><option value={-1}>{fr ? "extérieur · charnière gauche" : "outside · left hinge"}</option><option value={2}>{fr ? "intérieur · charnière droite" : "inside · right hinge"}</option><option value={-2}>{fr ? "extérieur · charnière droite" : "outside · right hinge"}</option></select></label></div>
357 + <p className="text-[11px] text-ink-3">{fr ? "Déduite du mur net une fois mesurée et vérifiée au registre." : "Deducted from the net wall once measured and verified in the ledger."}</p></>)}
358 + {(selected.kind === "room" || selected.kind === "footprint" || selected.kind === "lot" || selected.kind === "stairs" || selected.kind === "area") && (<><div className="row"><label>{fr ? "Usage" : "Use"}<input value={selected.props.usage ?? ""} disabled={!canWrite} placeholder={fr ? "chambre, cuisine…" : "bedroom, kitchen…"} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, props: { ...x.props, usage: e.target.value } } : x)))} onBlur={(e) => updateObject(selected.id, {}, { usage: e.target.value })} /></label><NumProp key={`${selected.id}:height:${String(selected.props.height ?? "")}`} label={fr ? "Hauteur plafond (m)" : "Ceiling (m)"} o={selected} k="height" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { height: v })} /></div><p className="text-[11px] text-ink-3">{k ? `${fmtArea(polygonAreaPx(selected.points) / (k * k), imperial)} · ${fr ? "périmètre" : "perimeter"} ${fmtLength(polylineLengthPx(selected.points, true) / k, imperial)}` : ""}</p></>)}
359 + {(selected.kind === "slab" || selected.kind === "foundation") && <NumProp key={`${selected.id}:thickness:${String(selected.props.thickness ?? "")}`} label={fr ? "Épaisseur (m)" : "Thickness (m)"} o={selected} k="thickness" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { thickness: v })} />}
360 + {selected.kind === "roof" && <NumProp key={`${selected.id}:pitch:${String(selected.props.pitch ?? "")}`} label={fr ? "Pente (po/12)" : "Pitch (in/12)"} o={selected} k="pitch" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { pitch: v })} />}
361 + {selected.kind === "column" && <NumProp key={`${selected.id}:thickness:${String(selected.props.thickness ?? "")}`} label={fr ? "Côté (m)" : "Side (m)"} o={selected} k="thickness" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { thickness: v })} />}
362 + {selected.kind === "dimension" && <NumProp key={`${selected.id}:offset:${String(selected.props.offset ?? "")}`} label={fr ? "Décalage (px)" : "Offset (px)"} o={selected} k="offset" canWrite={canWrite} onChange={(v) => updateObject(selected.id, {}, { offset: v })} />}
363 + {(selected.kind === "text" || selected.kind === "annotation") && <label>{fr ? "Texte" : "Text"}<input value={selected.props.text ?? ""} disabled={!canWrite} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, props: { ...x.props, text: e.target.value } } : x)))} onBlur={(e) => updateObject(selected.id, {}, { text: e.target.value })} /></label>}
364 + <label>{fr ? "Note" : "Note"}<input value={selected.props.note ?? ""} disabled={!canWrite} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, props: { ...x.props, note: e.target.value } } : x)))} onBlur={(e) => updateObject(selected.id, {}, { note: e.target.value })} /></label>
365 + {mOf(selected.id).map((m) => <p key={m.id} className="vp-mono text-[10.5px] text-ink-2">{m.formulaText} = <b>{num(m.value, "fr", 3)} {m.unit}</b>{m.stale ? <span className="text-[var(--amber)]"> ⚠</span> : m.verifiedAt ? <span className="text-accent"> ✓</span> : null}</p>)}
366 + {canWrite && <div className="flex flex-wrap gap-1 pt-1"><Btn small tone="accent" disabled={!k || selected.kind === "dimension" || selected.kind === "text"} onClick={() => void measureOne(selected.id)}>{fr ? "Mesurer" : "Measure"}</Btn><Btn small onClick={() => addObject({ ...selected, id: uuid(), name: `${selected.name} (copie)`, points: selected.points.map((p) => ({ x: p.x + (k ?? 50) * 0.5, y: p.y + (k ?? 50) * 0.5 })) })}>{fr ? "Dupliquer" : "Duplicate"}</Btn><Btn small onClick={() => removeObject(selected.id)}>{fr ? "Supprimer" : "Delete"}</Btn></div>}
367 + </div>
368 + </>) : (<>
369 + <h3>{fr ? "Quantités du plan" : "Plan quantities"}</h3>
370 + <div className="sec text-[12px] space-y-1">
371 + {!k ? <p className="text-ink-3">{fr ? "Calibrez la page pour obtenir des quantités." : "Calibrate the page to get quantities."}</p> : (<>
372 + <div className="flex justify-between"><span>{fr ? "Murs extérieurs (net)" : "Exterior walls (net)"}</span><b>{fmtArea(q.totals.extWallNetAreaM2, imperial)}</b></div>
373 + <div className="flex justify-between"><span>{fr ? "Murs (longueur)" : "Walls (length)"}</span><b>{fmtLength(q.totals.wallLengthM, imperial)}</b></div>
374 + <div className="flex justify-between"><span>{fr ? "Surface des pièces" : "Rooms area"}</span><b>{fmtArea(q.totals.floorAreaM2, imperial)}</b></div>
375 + {q.footprintM2 != null && <div className="flex justify-between"><span>{fr ? "Empreinte" : "Footprint"}</span><b>{fmtArea(q.footprintM2, imperial)}</b></div>}
376 + <div className="flex justify-between"><span>{fr ? "Portes / fenêtres" : "Doors / windows"}</span><b>{q.totals.doors} / {q.totals.windows}</b></div>
377 + {q.rooms.map((r) => <div key={r.id} className="flex justify-between text-ink-2"><span>· {r.name}</span><span>{fmtArea(r.areaM2, imperial)}</span></div>)}
254 378 </>)}
255 − <Field label={fr ? "Note" : "Note"}><Input value={selected.props.note ?? ""} disabled={!canWrite} onChange={(e) => setObjects((xs) => xs.map((x) => (x.id === sel ? { ...x, props: { ...x.props, note: e.target.value } } : x)))} onBlur={(e) => updateSel({ props: { note: e.target.value } })} /></Field>
256 379 </div>
257 − {mOf(selected.id) && <p className="vp-mono text-[11px] text-ink-2">{fr ? "Registre" : "Ledger"} : {mOf(selected.id)!.formulaText} = <b>{num(mOf(selected.id)!.value, "fr", 3)} {mOf(selected.id)!.unit}</b>{mOf(selected.id)!.stale ? <span className="text-[var(--amber)]"> ⚠ {fr ? "périmée (recalibration)" : "stale (recalibration)"}</span> : null}{mOf(selected.id)!.verifiedAt ? <span className="text-accent"> ✓ {fr ? "vérifiée" : "verified"}</span> : null}</p>}
258 − {selected.kind === "opening" && <p className="text-[12.5px] text-ink-2">{fr ? "Une ouverture n'est déduite du mur net qu'une fois mesurée ET vérifiée dans le registre (onglet Métré) ; elle ne peut être déduite que d'un seul mur." : "An opening is deducted from the net wall only once measured AND verified in the ledger (Takeoff tab); it can be deducted from a single wall only."}</p>}
259 − </Block>
260 − )}
380 + </>)}
381 + <h3>{fr ? "Objets" : "Objects"} ({objects.length})</h3>
382 + <div className="pl-list">{objects.map((o) => <button key={o.id} type="button" className={o.id === sel ? "sel" : ""} onClick={() => { setSel(o.id); setTool("select"); }}><span>{(fr ? KIND_FR[o.kind]?.[0] : KIND_FR[o.kind]?.[1]) ?? o.kind} · {o.name}</span><span className="vp-mono text-[10px] text-ink-3">{mOf(o.id).some((m) => m.verifiedAt) ? "✓" : mOf(o.id).length ? "•" : ""}</span></button>)}{!objects.length && <p className="px-3 py-2 text-[12px] text-ink-3">{fr ? "Dessinez des murs (W), posez des portes (D) et des fenêtres (N), nommez les pièces (R)." : "Draw walls (W), place doors (D) and windows (N), name rooms (R)."}</p>}</div>
383 + </aside>
261 384 </div>
262 385 );
263 386 }
264 387
265 −function NumProp({ o, k, canWrite, onChange }: { o: PlanObject; k: keyof PlanObject["props"]; canWrite: boolean; onChange: (p: { props: PlanObject["props"] }) => void }) {
388 +function NumProp({ o, k, label, canWrite, onChange }: { o: PlanObject; k: keyof PlanObject["props"]; label: string; canWrite: boolean; onChange: (v: number | undefined) => void }) {
266 389 const [v, setV] = useState(String(o.props[k] ?? ""));
267 − return <Input type="number" step="0.01" min={0} value={v} disabled={!canWrite} onChange={(e) => setV(e.target.value)} onBlur={() => onChange({ props: { [k]: v === "" ? undefined : Number(v) } as PlanObject["props"] })} className="vp-mono" />;
390 + return <label>{label}<input type="number" step="0.01" value={v} disabled={!canWrite} onChange={(e) => setV(e.target.value)} onBlur={() => onChange(v === "" ? undefined : Number(v))} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} /></label>;
268 391 }
269 −
270 −function Shape({ o, selected, zoom, onSelect, label }: { o: PlanObject; selected: boolean; zoom: number; onSelect: () => void; label: string }) {
271 − const c = o.props.color ?? COLORS[o.kind] ?? "#333";
272 − const pts = o.points.map((p) => `${p.x},${p.y}`).join(" ");
273 − const sw = (selected ? 3 : 2) / zoom;
274 − const anchor = o.points[0];
275 − return (
276 − <g onClick={(e) => { e.stopPropagation(); onSelect(); }} style={{ cursor: "pointer" }}>
277 − {o.kind === "opening" ? <g><rect x={anchor.x - 8 / zoom} y={anchor.y - 8 / zoom} width={16 / zoom} height={16 / zoom} fill={`${c}aa`} stroke={selected ? "#16181b" : c} strokeWidth={sw} transform={`rotate(45 ${anchor.x} ${anchor.y})`} /></g>
278 − : o.closed ? <polygon points={pts} fill={`${c}${selected ? "44" : "22"}`} stroke={selected ? "#16181b" : c} strokeWidth={sw} />
279 − : <polyline points={pts} fill="none" stroke={selected ? "#16181b" : c} strokeWidth={sw * 1.5} strokeLinecap="round" />}
280 − {selected && o.points.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={4 / zoom} fill="#fff" stroke="#16181b" strokeWidth={1.2 / zoom} />)}
281 − <text x={anchor.x + 6 / zoom} y={anchor.y - 6 / zoom} fontSize={11 / zoom} fill={c} fontFamily="ui-monospace" stroke="#fff" strokeWidth={3 / zoom} paintOrder="stroke">{label}</text>
282 − </g>
283 − );
284 −}
285 −
286 −function labelOf(k: ObjectKind, fr: boolean): string { const t = TOOLS.find((x) => x.t === k); return t ? (fr ? t.fr : t.en) : k; }
287 −function defaultProps(k: ObjectKind): PlanObject["props"] { switch (k) { case "wall": return { height: 2.44 }; case "slab": return { thickness: 0.1 }; case "foundation": return { thickness: 0.2 }; case "roof": return { pitch: 6 }; default: return {}; } }
288 −function distToSeg(p: Point, a: Point, b: Point): number { const l2 = (b.x - a.x) ** 2 + (b.y - a.y) ** 2; if (!l2) return dist(p, a); const t = Math.max(0, Math.min(1, ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2)); return dist(p, { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) }); }
modified src/components/metre/ProjectWorkspace.tsx +25 −25
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
4 4 import Link from "next/link";
5 5 import { useRouter } from "next/navigation";
6 6 import { useLang } from "@/components/LangContext";
7 −import { api, Block, Btn, Crumbs, dt, Err, Field, Input, Kicker, LoginCard, money, Select, Textarea, useMe } from "./ui";
7 +import { api, Block, Btn, Crumbs, dt, Err, Field, Input, money, Select, Textarea, useMe } from "./ui";
8 8 import PlanStudio from "./PlanStudio";
9 9 import TakeoffLedger from "./TakeoffLedger";
10 10 import EstimateEditor from "./EstimateEditor";
@@ -18,49 +18,49 @@ export interface WorkspaceData {
18 18 estimates: { id: string; name: string; versions: number; updatedAt: string }[]; versions: Omit<EstimateVersion, "result" | "lines">[]; valuations: ValuationCase[]; assemblies: CustomAssembly[]; locations: { code: string; nameFr: string; nameEn: string; materialFactor: number; labourFactor: number; equipmentFactor: number }[];
19 19 }
20 20
21 −const TABS = [["projet", "Projet", "Project"], ["plans", "Plan Studio", "Plan Studio"], ["metre", "Métré", "Takeoff"], ["assemblages", "Assemblages", "Assemblies"], ["estimation", "Estimation", "Estimate"], ["valeur", "Valeur", "Value"], ["rapports", "Rapports", "Reports"]] as const;
21 +const TABS = [["plans", "Plans", "Plans"], ["metre", "Métré", "Takeoff"], ["estimation", "Estimation", "Estimate"], ["assemblages", "Assemblages", "Assemblies"], ["valeur", "Valeur", "Value"], ["rapports", "Rapports", "Reports"], ["projet", "Fiche", "Sheet"]] as const;
22 22
23 −export default function ProjectWorkspace({ projectId, initialTab }: { projectId: string; initialTab: string }) {
23 +export default function ProjectWorkspace({ projectId, initialTab, initialCreate }: { projectId: string; initialTab: string; initialCreate?: "blank" | "template" | "map" | "import" | null }) {
24 24 const { lang } = useLang(); const fr = lang === "fr";
25 25 const me = useMe();
26 + const router = useRouter();
26 27 const [d, setD] = useState<WorkspaceData | null>(null);
27 28 const [err, setErr] = useState<string | null>(null);
28 − const [tab, setTab] = useState<string>(TABS.some((t) => t[0] === initialTab) ? initialTab : "projet");
29 + const [tab, setTab] = useState<string>(TABS.some((t) => t[0] === initialTab) ? initialTab : "plans");
29 30 const reload = useCallback(() => api<WorkspaceData>(`/projects/${projectId}`).then((x) => { setD(x); setErr(null); }).catch((e) => setErr(e.message)), [projectId]);
30 31 useEffect(() => { if (me.user) void reload(); }, [me.user, reload]);
31 32 useEffect(() => { const u = new URL(window.location.href); u.searchParams.set("tab", tab); window.history.replaceState(null, "", u.toString()); }, [tab]);
32 − if (me.loading) return <div className="py-16 vp-mono text-[11px] uppercase text-ink-3">…</div>;
33 − if (!me.user) return <LoginCard onDone={me.refresh} />;
33 + if (me.loading || (!d && !err)) return <div className="py-16 vp-mono text-[11px] uppercase text-ink-3">{fr ? "Chargement…" : "Loading…"}</div>;
34 + if (!me.user) return <p className="py-16 text-ink-2">{fr ? "Session indisponible — rechargez la page." : "Session unavailable — reload the page."}</p>;
34 35 if (err && !d) return <div className="py-16"><p className="vp-display text-[22px] font-bold">{fr ? "Projet inaccessible" : "Project unavailable"}</p><p className="mt-2 text-ink-2">{err}</p><Link href="/metre" className="btn btn-ghost mt-4">← {fr ? "Retour" : "Back"}</Link></div>;
35 − if (!d) return <div className="py-16 vp-mono text-[11px] uppercase text-ink-3">{fr ? "Chargement…" : "Loading…"}</div>;
36 + if (!d) return null;
36 37 const canWrite = d.access !== "read";
37 38 const p = d.project;
39 + const dup = async () => { try { const r = await api<{ project: Project }>(`/projects/${p.id}/duplicate`, { method: "POST", json: {} }); router.push(`/metre/projets/${r.project.id}`); } catch (e) { setErr((e as Error).message); } };
40 + const lock = async () => { try { await api(`/projects/${p.id}/lock`, { method: "POST", json: { locked: !p.locked } }); await reload(); } catch (e) { setErr((e as Error).message); } };
38 41 return (
39 − <div className="py-8">
40 − <section className="rise">
41 − <div className="flex flex-wrap items-baseline justify-between gap-4 border-b-2 border-ink pb-2.5">
42 − <Crumbs items={[{ href: "/", label: "UQO Éval" }, { href: "/metre", label: fr ? "Métré" : "Takeoff" }, { label: p.name }]} />
43 − <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{d.access} · {me.user.displayName}</span>
44 − </div>
45 − <div className="mt-5 grid gap-x-10 gap-y-3 lg:grid-cols-[1.5fr_1fr]">
46 − <div><Kicker>{fr ? "Projet de métré" : "Takeoff project"}</Kicker><h1 className="vp-display mt-2 text-[clamp(22px,3.4vw,34px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{p.name}</h1><p className="mt-1 text-[14px] text-ink-2">{[p.address, p.municipality].filter(Boolean).join(", ") || (fr ? "Sans adresse" : "No address")} · {fr ? "évaluation au" : "valued on"} {p.valuationDate}</p></div>
47 − <div className="flex flex-wrap gap-x-5 gap-y-1 lg:justify-end"><span className="stat-chip"><b>{d.plans.reduce((s, f) => s + f.pages.length, 0)}</b> {fr ? "pages" : "pages"}</span><span className="stat-chip"><b>{d.measurements.length}</b> {fr ? "mesures" : "measures"}</span><span className="stat-chip"><b>{d.versions.length}</b> {fr ? "révisions" : "revisions"}</span></div>
42 + <div>
43 + <div className="mt-topbar">
44 + <div><Crumbs items={[{ href: "/metre", label: fr ? "Métré" : "Takeoff" }, { href: "/metre?tab=projets", label: fr ? "Projets" : "Projects" }, { label: p.name }]} /><h1 className="mt-1">{p.name} {p.isTemplate && <span className="pill pill-conf-B align-middle">{fr ? "gabarit" : "template"}</span>} {p.locked && <span className="pill pill-conf-C align-middle">🔒 {fr ? "verrouillé" : "locked"}</span>}</h1><p className="text-[13px] text-ink-2">{[p.address, p.municipality].filter(Boolean).join(", ") || (fr ? "Sans adresse" : "No address")} · {fr ? "évaluation au" : "valued on"} {p.valuationDate} · {fr ? "par" : "by"} {d.members.length ? d.members[0].displayName : ""}</p></div>
45 + <div className="flex flex-wrap items-center gap-2">
46 + <span className="stat-chip"><b>{d.plans.reduce((s, f) => s + f.pages.length, 0)}</b> {fr ? "plans" : "plans"}</span><span className="stat-chip"><b>{d.measurements.length}</b> {fr ? "mesures" : "measures"}</span><span className="stat-chip"><b>{d.versions.length}</b> {fr ? "révisions" : "revisions"}</span>
47 + <Btn small onClick={() => void dup()}>{fr ? "Dupliquer" : "Duplicate"}</Btn>
48 + {d.access === "owner" && !p.isTemplate && <Btn small onClick={() => void lock()}>{p.locked ? (fr ? "Déverrouiller" : "Unlock") : (fr ? "Verrouiller" : "Lock")}</Btn>}
49 + {!canWrite && <span className="vp-mono text-[10px] uppercase text-ink-3">{fr ? "lecture seule — dupliquez pour modifier" : "read-only — duplicate to edit"}</span>}
48 50 </div>
49 − <ol className="vp-mono mt-5 flex flex-wrap gap-x-1 gap-y-1 border-y border-[var(--line)] py-1 text-[10.5px] uppercase tracking-[0.08em]">
50 − {TABS.map(([k, f, e], i) => (<li key={k}><button type="button" onClick={() => setTab(k)} className={`px-3 py-2 ${tab === k ? "border-b-2 border-accent text-ink" : "text-ink-2 hover:text-accent"}`}><b className="text-accent">0{i + 1}</b> {fr ? f : e}</button></li>))}
51 − </ol>
52 − <Err msg={err} />
53 − </section>
54 − <div className="mt-8">
51 + </div>
52 + <div className="mt-tabs">{TABS.map(([k, f, e]) => <button key={k} type="button" onClick={() => setTab(k)} className={tab === k ? "active" : ""}>{fr ? f : e}</button>)}</div>
53 + <Err msg={err} />
54 + <div>
55 55 {tab === "projet" && <ProjectTab d={d} fr={fr} lang={lang} canWrite={canWrite} reload={reload} />}
56 − {tab === "plans" && <PlanStudio d={d} fr={fr} canWrite={canWrite} reload={reload} />}
56 + {tab === "plans" && <PlanStudio d={d} fr={fr} canWrite={canWrite} reload={reload} initialCreate={initialCreate} />}
57 57 {tab === "metre" && <TakeoffLedger d={d} fr={fr} lang={lang} canWrite={canWrite} reload={reload} />}
58 58 {tab === "assemblages" && <AssemblyEditor d={d} fr={fr} lang={lang} canWrite={canWrite} reload={reload} />}
59 59 {tab === "estimation" && <EstimateEditor d={d} fr={fr} lang={lang} canWrite={canWrite} reload={reload} />}
60 60 {tab === "valeur" && <ValuationPanel d={d} fr={fr} lang={lang} canWrite={canWrite} reload={reload} />}
61 61 {tab === "rapports" && <ReportsTab d={d} fr={fr} lang={lang} />}
62 62 </div>
63 − <p className="vp-mono mt-10 border-l-2 border-accent pl-4 text-[11px] uppercase leading-relaxed tracking-[0.05em] text-ink-2">{fr ? "Laboratoire pédagogique — les quantités et coûts présentés sont des estimations tracées ; ils ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ)." : "Teaching laboratory — quantities and costs shown are traced estimates; they constitute neither a contractor's bid nor a certified professional appraisal (OEAQ)."}</p>
63 + <p className="vp-mono mt-8 border-l-2 border-accent pl-4 text-[10.5px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? "Laboratoire pédagogique — les quantités et coûts présentés sont des estimations tracées ; ils ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ)." : "Teaching laboratory — quantities and costs shown are traced estimates; they constitute neither a contractor's bid nor a certified professional appraisal (OEAQ)."}</p>
64 64 </div>
65 65 );
66 66 }
@@ -75,7 +75,7 @@ function ProjectTab({ d, fr, lang, canWrite, reload }: { d: WorkspaceData; fr: b
75 75 const del = async () => { if (!confirm(fr ? "Supprimer ce projet (réversible par un administrateur) ?" : "Delete this project (recoverable by an administrator)?")) return; try { await api(`/projects/${p.id}`, { method: "DELETE" }); router.push("/metre"); } catch (e) { setErr((e as Error).message); } };
76 76 return (
77 77 <div className="space-y-12">
78 − <Block num="01" title={fr ? "Fiche du projet" : "Project sheet"} aside={canWrite && <div className="flex gap-2"><Btn small tone="primary" onClick={() => void save()}>{ok ? "✓" : fr ? "Enregistrer" : "Save"}</Btn>{d.access === "owner" && <Btn small onClick={() => void del()}>{fr ? "Supprimer" : "Delete"}</Btn>}</div>}>
78 + <Block num="01" title={fr ? "Fiche du projet" : "Project sheet"} aside={canWrite && <div className="flex gap-2"><Btn small tone="primary" onClick={() => void save()}>{ok ? "✓" : fr ? "Enregistrer" : "Save"}</Btn>{d.access === "owner" && !p.isTemplate && <Btn small onClick={() => void del()}>{fr ? "Supprimer" : "Delete"}</Btn>}</div>}>
79 79 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
80 80 <Field label={fr ? "Nom" : "Name"}><Input value={f.name} disabled={!canWrite} onChange={(e) => setF({ ...f, name: e.target.value })} /></Field>
81 81 <Field label={fr ? "Adresse" : "Address"}><Input value={f.address ?? ""} disabled={!canWrite} onChange={(e) => setF({ ...f, address: e.target.value })} /></Field>
added src/components/metre/metre.css +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified src/components/metre/ui.tsx +19 −5
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/metre/auth.ts +53 −24
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/metre/db.ts +8 −1
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/metre/measure.ts +25 −5
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/metre/plan-geometry.ts +223 −0
Large diff (223 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/metre/plan-render.ts +131 −0
Large diff (131 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/metre/plan.test.ts +98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/metre/plans.ts +235 −6
Large diff (272 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/metre/projects.ts +44 −19
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/metre/services.test.ts +23 −12
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/metre/types.ts +19 −1
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme