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>
38 changed files +1,777 −505
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 | ||
| 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