From 097dc3942ec68552a63a080de62e9a7fab70d649 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:10:54 +0000 Subject: [PATCH 1/4] Switch cadastral map to interactive vector parcels instead of raster WMTS Replace the raster CADASTRALPARCELS.PARCELS WMTS overlay in MapLibreCadastre with a clickable GeoJSON vector layer, matching the vector-tile UX seen on gorenove.fr. /api/cadastre/parcel now accepts a bbox (in addition to the existing lon/lat point mode) and returns parcel geometry, so the map can fetch and render all parcels in view and let users click one to select it (onParcelSelect callback, threaded through GeoportailMap). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XmfoTz7co9CshaNAFf6QD3 --- server.ts | 35 ++++++-- src/components/LocationMaps.tsx | 14 +++- src/components/MapLibreCadastre.tsx | 120 +++++++++++++++++++++++++--- 3 files changed, 148 insertions(+), 21 deletions(-) diff --git a/server.ts b/server.ts index ec1d7f6..540eb69 100644 --- a/server.ts +++ b/server.ts @@ -4265,14 +4265,30 @@ async function startServer() { app.get("/api/cadastre/parcel", async (req, res) => { try { - const { lon, lat } = req.query; - console.log(`[Cadastre] Lookup request: lon=${lon}, lat=${lat}`); - - if (!lon || !lat) { - return res.status(400).json({ error: "Missing longitude or latitude parameters" }); + const { lon, lat, bbox } = req.query; + + let geom: { type: string; coordinates: any }; + if (bbox) { + const parts = String(bbox).split(',').map(Number); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) { + return res.status(400).json({ error: "Invalid bbox parameter, expected minLon,minLat,maxLon,maxLat" }); + } + const [minLon, minLat, maxLon, maxLat] = parts; + geom = { + type: 'Polygon', + coordinates: [[ + [minLon, minLat], [maxLon, minLat], [maxLon, maxLat], [minLon, maxLat], [minLon, minLat], + ]], + }; + console.log(`[Cadastre] Lookup request: bbox=${bbox}`); + } else if (lon && lat) { + geom = { type: 'Point', coordinates: [Number(lon), Number(lat)] }; + console.log(`[Cadastre] Lookup request: lon=${lon}, lat=${lat}`); + } else { + return res.status(400).json({ error: "Missing longitude/latitude or bbox parameters" }); } - const apiUrl = `https://apicarto.ign.fr/api/cadastre/parcelle?geom=%7B%22type%22%3A%22Point%22%2C%22coordinates%22%3A%5B${lon}%2C${lat}%5D%7D`; + const apiUrl = `https://apicarto.ign.fr/api/cadastre/parcelle?geom=${encodeURIComponent(JSON.stringify(geom))}&_limit=1000`; console.log(`[Cadastre] Fetching from IGN: ${apiUrl}`); const response = await fetchWithTimeout(apiUrl, { @@ -4326,19 +4342,22 @@ async function startServer() { console.log(`[Cadastre] Mapping: IGN=${p.idu} -> Etalab=${id15} (URL Commune: ${urlCommune}, INSEE: ${p.code_insee})`); return { + type: 'Feature', + geometry: f.geometry, properties: { id: id15, section: id15.substring(8, 10), numero: id15.substring(10), prefixe: id15.substring(5, 8), commune: urlCommune, - insee: p.code_insee || urlCommune + insee: p.code_insee || urlCommune, + contenance: p.contenance } }; }); console.log(`[Cadastre] Success: Found ${mappedFeatures.length} parcels`); - res.json({ features: mappedFeatures }); + res.json({ type: 'FeatureCollection', features: mappedFeatures }); } catch (error: any) { if (error.name === 'AbortError') { console.error("[Cadastre] Request timed out"); diff --git a/src/components/LocationMaps.tsx b/src/components/LocationMaps.tsx index d27340f..7e3301f 100644 --- a/src/components/LocationMaps.tsx +++ b/src/components/LocationMaps.tsx @@ -1,8 +1,16 @@ import { useState, useEffect } from 'react'; -import { MapLibreCadastre } from './MapLibreCadastre'; +import { MapLibreCadastre, CadastreParcel } from './MapLibreCadastre'; import { cn } from '../lib/utils'; -export const GeoportailMap = ({ address, banId }: { address: string; banId?: string }) => { +export const GeoportailMap = ({ + address, + banId, + onParcelSelect, +}: { + address: string; + banId?: string; + onParcelSelect?: (parcel: CadastreParcel) => void; +}) => { const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -52,7 +60,7 @@ export const GeoportailMap = ({ address, banId }: { address: string; banId?: str if (error) return
{error}
; if (!coords) return
Enter a valid address to see the map
; - return ; + return ; }; export const GoogleMap = ({ address }: { address: string }) => { diff --git a/src/components/MapLibreCadastre.tsx b/src/components/MapLibreCadastre.tsx index a702a44..f28c736 100644 --- a/src/components/MapLibreCadastre.tsx +++ b/src/components/MapLibreCadastre.tsx @@ -4,6 +4,16 @@ import 'maplibre-gl/dist/maplibre-gl.css'; const CADASTRE_MIN_ZOOM = 13; +export interface CadastreParcel { + id: string; + section: string; + numero: string; + prefixe: string; + commune: string; + insee: string; + contenance?: number; +} + const MAP_STYLE = (lon: number, lat: number): any => ({ version: 8, sources: { @@ -13,30 +23,85 @@ const MAP_STYLE = (lon: number, lat: number): any => ({ tileSize: 256, attribution: '© OpenStreetMap contributors', }, - cadastre: { - type: 'raster', - tiles: [ - 'https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&LAYER=CADASTRALPARCELS.PARCELS&STYLE=normal&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', - ], - tileSize: 256, - attribution: '© IGN', - minzoom: CADASTRE_MIN_ZOOM, + parcelles: { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + generateId: true, }, }, layers: [ { id: 'osm-layer', type: 'raster', source: 'osm' }, - { id: 'cadastre-layer', type: 'raster', source: 'cadastre', paint: { 'raster-opacity': 0.7 }, minzoom: CADASTRE_MIN_ZOOM }, + { + id: 'parcelles-fill', + type: 'fill', + source: 'parcelles', + minzoom: CADASTRE_MIN_ZOOM, + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': [ + 'case', + ['boolean', ['feature-state', 'selected'], false], 0.35, + ['boolean', ['feature-state', 'hover'], false], 0.2, + 0.05, + ], + }, + }, + { + id: 'parcelles-line', + type: 'line', + source: 'parcelles', + minzoom: CADASTRE_MIN_ZOOM, + paint: { + 'line-color': ['case', ['boolean', ['feature-state', 'selected'], false], '#f59e0b', '#3b82f6'], + 'line-width': ['case', ['boolean', ['feature-state', 'selected'], false], 2.5, 1], + }, + }, ], center: [lon, lat], zoom: 17, }); -export const MapLibreCadastre = ({ lat, lon }: { lat: number; lon: number }) => { +export const MapLibreCadastre = ({ + lat, + lon, + onParcelSelect, +}: { + lat: number; + lon: number; + onParcelSelect?: (parcel: CadastreParcel) => void; +}) => { const mapContainer = useRef(null); const map = useRef(null); const marker = useRef(null); const [contextLost, setContextLost] = useState(false); const [zoom, setZoom] = useState(17); + const hoveredId = useRef(null); + const selectedId = useRef(null); + const fetchAbort = useRef(null); + const onParcelSelectRef = useRef(onParcelSelect); + onParcelSelectRef.current = onParcelSelect; + + const fetchParcelles = useCallback((instance: maplibregl.Map) => { + if (instance.getZoom() < CADASTRE_MIN_ZOOM) return; + const source = instance.getSource('parcelles') as maplibregl.GeoJSONSource | undefined; + if (!source) return; + + fetchAbort.current?.abort(); + const controller = new AbortController(); + fetchAbort.current = controller; + + const b = instance.getBounds(); + const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].join(','); + + fetch(`/api/cadastre/parcel?bbox=${bbox}`, { signal: controller.signal }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data?.features) source.setData(data); + }) + .catch((err) => { + if (err.name !== 'AbortError') console.warn('[MapLibreCadastre] parcel fetch failed', err); + }); + }, []); const initMap = useCallback(() => { if (!mapContainer.current) return; @@ -58,9 +123,44 @@ export const MapLibreCadastre = ({ lat, lon }: { lat: number; lon: number }) => .addTo(instance); setZoom(Math.round(instance.getZoom())); setTimeout(() => instance.resize(), 100); + fetchParcelles(instance); }); instance.on('zoomend', () => setZoom(Math.round(instance.getZoom()))); + instance.on('moveend', () => fetchParcelles(instance)); + + instance.on('mousemove', 'parcelles-fill', (e) => { + if (!e.features?.length) return; + const id = e.features[0].id; + if (id === undefined || id === hoveredId.current) return; + if (hoveredId.current !== null) { + instance.setFeatureState({ source: 'parcelles', id: hoveredId.current }, { hover: false }); + } + hoveredId.current = id; + instance.setFeatureState({ source: 'parcelles', id }, { hover: true }); + instance.getCanvas().style.cursor = 'pointer'; + }); + + instance.on('mouseleave', 'parcelles-fill', () => { + if (hoveredId.current !== null) { + instance.setFeatureState({ source: 'parcelles', id: hoveredId.current }, { hover: false }); + hoveredId.current = null; + } + instance.getCanvas().style.cursor = ''; + }); + + instance.on('click', 'parcelles-fill', (e) => { + if (!e.features?.length) return; + const feature = e.features[0]; + const id = feature.id; + if (id === undefined) return; + if (selectedId.current !== null) { + instance.setFeatureState({ source: 'parcelles', id: selectedId.current }, { selected: false }); + } + selectedId.current = id; + instance.setFeatureState({ source: 'parcelles', id }, { selected: true }); + onParcelSelectRef.current?.(feature.properties as CadastreParcel); + }); // Without a listener, MapLibre's own fallback is to print any internal // error (WebGL context loss included) via console.error — which Sentry From 57c9bc276ba55cd2cb8b1de63304266b77840acf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:11:18 +0000 Subject: [PATCH 2/4] Refresh package-lock.json after local npm install Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XmfoTz7co9CshaNAFf6QD3 --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index d52b813..eb54566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20111,6 +20111,7 @@ "license": "SEE LICENSE IN LICENSE", "peerDependencies": { "@google/genai": ">=1.0.0", + "@sentry/node": ">=10.0.0", "@supabase/supabase-js": ">=2.0.0", "@tabler/icons-react": ">=3.0.0", "express": ">=4.0.0", From d354675200781059f1fa1cc0476998241b91eea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:34:07 +0000 Subject: [PATCH 3/4] Add MAF mission-type selection to proposals and projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devis and projets can now pick the mission type from the MAF activity circular (intercalaire jaune/vert/ami/... and, for maîtrise d'œuvre, the taux T 30/60/100/110%). The choice carries over from an accepted proposal to its project, and the MAF declaration plugin now warns when a linked project's declared mission type doesn't match the selected intercalaire tab. Shared MAF_INTERCALAIRE_OPTIONS/TAUX_MISSION_OPTIONS constants moved to mafUtils.ts to avoid duplicating them per page. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XmfoTz7co9CshaNAFf6QD3 --- server.ts | 15 ++++++++---- src/lib/mafUtils.ts | 21 ++++++++++++++++ src/pages/MafDeclaration.tsx | 22 +++++++---------- src/pages/ProjectDetail.tsx | 30 +++++++++++++++++++++++ src/pages/Proposals.tsx | 19 ++++++++++++++ src/types.ts | 5 ++++ supabase/migrate_add_maf_mission_type.sql | 12 +++++++++ 7 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 supabase/migrate_add_maf_mission_type.sql diff --git a/server.ts b/server.ts index 540eb69..cd53911 100644 --- a/server.ts +++ b/server.ts @@ -2461,7 +2461,8 @@ async function startServer() { adresse_terrain, cp_ville_terrain, ban_id_terrain, city_code_terrain, ref_cadastrale, zone_plu, surface_parcelle, nom_etablissement, avant_trav, apres_trav, type_et_cat, type_projet, categorie_projet, surface_plancher, surface_plancher_ext, surface_erp, - surface_ert, effectif_public, effectif_personnel, ind, date_modification + surface_ert, effectif_public, effectif_personnel, ind, date_modification, + maf_intercalaire, taux_mission, part_interet } = req.body; if (!name || !client) return res.status(400).json({ error: "Name and client are required" }); // Generate project code: YYNNN @@ -2482,7 +2483,8 @@ async function startServer() { adresse_terrain, cp_ville_terrain, ban_id_terrain, city_code_terrain, ref_cadastrale, zone_plu, surface_parcelle, nom_etablissement, avant_trav, apres_trav, type_et_cat, type_projet, categorie_projet, surface_plancher, surface_plancher_ext, surface_erp, - surface_ert, effectif_public, effectif_personnel, ind, date_modification + surface_ert, effectif_public, effectif_personnel, ind, date_modification, + maf_intercalaire, taux_mission, part_interet }); if (pe) throw pe; if (cotraitants_list?.length) { @@ -2522,7 +2524,8 @@ async function startServer() { adresse_terrain, cp_ville_terrain, ban_id_terrain, city_code_terrain, ref_cadastrale, zone_plu, surface_parcelle, nom_etablissement, avant_trav, apres_trav, type_et_cat, type_projet, categorie_projet, surface_plancher, surface_plancher_ext, surface_erp, - surface_ert, effectif_public, effectif_personnel, ind, date_modification + surface_ert, effectif_public, effectif_personnel, ind, date_modification, + maf_intercalaire, taux_mission, part_interet } = req.body; if (!name || !client) return res.status(400).json({ error: "Name and client are required" }); const { error: ue } = await supabaseAdmin.from('projects').update({ @@ -2535,7 +2538,8 @@ async function startServer() { adresse_terrain, cp_ville_terrain, ban_id_terrain, city_code_terrain, ref_cadastrale, zone_plu, surface_parcelle, nom_etablissement, avant_trav, apres_trav, type_et_cat, type_projet, categorie_projet, surface_plancher, surface_plancher_ext, surface_erp, - surface_ert, effectif_public, effectif_personnel, ind, date_modification + surface_ert, effectif_public, effectif_personnel, ind, date_modification, + maf_intercalaire, taux_mission, part_interet }).eq('id', id).eq('tenant_id', tenantId); if (ue) throw ue; // Update related lists (delete + reinsert) @@ -3482,7 +3486,8 @@ async function startServer() { surface_plancher: p.surface_plancher, surface_plancher_ext: p.surface_plancher_ext, surface_erp: p.surface_erp, surface_ert: p.surface_ert, effectif_public: p.effectif_public, effectif_personnel: p.effectif_personnel, - ind: p.ind, date_modification: p.date_modification + ind: p.ind, date_modification: p.date_modification, + maf_intercalaire: p.maf_intercalaire, taux_mission: p.taux_mission, part_interet: p.part_interet }); if (projErr) throw projErr; // Copy specialties to cotraitants diff --git a/src/lib/mafUtils.ts b/src/lib/mafUtils.ts index 88c10f2..38622d1 100644 --- a/src/lib/mafUtils.ts +++ b/src/lib/mafUtils.ts @@ -31,6 +31,27 @@ export const MAF_INTERCALAIRE_LABELS: Record = { puc: 'Police Unique de Chantier (PUC)', }; +// Ordre d'affichage des intercalaires (reprend l'ordre de la circulaire MAF) +export const MAF_INTERCALAIRE_ORDER: MafIntercalaire[] = [ + 'jaune', 'vert', 'ami', 'grand_chantier', + 'violet', 'orange_clair', 'orange_fonce', 'bleu', + 'rose', 'tabac', 'gris', 'puc', +]; + +// Options {id, name} prêtes à l'emploi pour un + {linkedProject?.maf_intercalaire && linkedProject.maf_intercalaire !== intercalaire && ( +

+ + Ce projet est déclaré en type de mission « {MAF_INTERCALAIRE_LABELS[linkedProject.maf_intercalaire]} » ({linkedProject.maf_intercalaire.replace('_', ' ')}) — vérifiez que l'intercalaire {intercalaire} est le bon. +

+ )} {!isHonoraires && ( diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index 316a9e6..cf08412 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -72,6 +72,7 @@ import SiteReports from '../components/SiteReports'; import MilestoneGantt from '../components/MilestoneGantt'; import { ProTab } from '../components/pro/ProTab'; import Situations from './Situations'; +import { MAF_INTERCALAIRE_OPTIONS, TAUX_MISSION_OPTIONS } from '../lib/mafUtils'; import { Card, CardHeader, CardBody } from '../components/ui/Card'; import { StatTile, StatTileColor } from '../components/ui/StatTile'; import { PillTabs, PillTabItem } from '../components/ui/PillTabs'; @@ -2480,6 +2481,35 @@ export default function ProjectDetail() { setProject(prev => prev ? ({...prev, type_et_cat: v}) : null)} /> setProject(prev => prev ? ({...prev, type_projet: v}) : null)} /> setProject(prev => prev ? ({...prev, categorie_projet: v}) : null)} /> +
+ + +
+ {project.maf_intercalaire === 'jaune' && ( +
+ + +
+ )} + setProject(prev => prev ? ({...prev, part_interet: v ? Number(v) : undefined}) : null)} /> diff --git a/src/pages/Proposals.tsx b/src/pages/Proposals.tsx index dc03456..da321b5 100644 --- a/src/pages/Proposals.tsx +++ b/src/pages/Proposals.tsx @@ -19,6 +19,7 @@ import { InfoPanelBoundary } from '../components/InfoPanelBoundary'; import MilestoneGantt from '../components/MilestoneGantt'; import { MobileAccordionTable } from '../components/MobileAccordionTable'; import { ProposalGenerator } from '../components/ProposalGenerator'; +import { MAF_INTERCALAIRE_OPTIONS, TAUX_MISSION_OPTIONS } from '../lib/mafUtils'; import { saveAs } from 'file-saver'; @@ -171,6 +172,8 @@ export default function Proposals() { effectif_personnel: '', ind: 'A', date_modification: new Date().toLocaleDateString('fr-FR'), + maf_intercalaire: undefined, + taux_mission: undefined, specialties_list: [], fee_distribution: JSON.stringify({ missions: DEFAULT_MISSIONS.map(m => ({ @@ -859,6 +862,22 @@ export default function Proposals() { setNewProposal(prev => ({...prev, type_et_cat: v}))} /> setNewProposal(prev => ({...prev, type_projet: v}))} /> setNewProposal(prev => ({...prev, categorie_projet: v}))} /> + setNewProposal(prev => ({ ...prev, maf_intercalaire: v || undefined, taux_mission: v === 'jaune' ? prev.taux_mission : undefined }))} + /> + {newProposal.maf_intercalaire === 'jaune' && ( + ({ id: o.value, name: o.label }))} + value={newProposal.taux_mission} + onChange={(v: any) => setNewProposal(prev => ({ ...prev, taux_mission: v ? Number(v) : undefined }))} + /> + )} diff --git a/src/types.ts b/src/types.ts index 2deaa94..dd87452 100644 --- a/src/types.ts +++ b/src/types.ts @@ -179,6 +179,7 @@ export interface Project { mission_bim?: boolean; type_moa?: string; nature_travaux_maf?: string; + maf_intercalaire?: MafIntercalaire; taux_mission?: number; part_interet?: number; } @@ -488,6 +489,10 @@ export interface Proposal { effectif_personnel?: string; ind?: string; date_modification?: string; + // MAF — type de mission (circulaire d'activités) + maf_intercalaire?: MafIntercalaire; + taux_mission?: number; + part_interet?: number; specialties_list?: ProposalSpecialty[]; fee_distribution?: string; // JSON string for reactgrid data diff --git a/supabase/migrate_add_maf_mission_type.sql b/supabase/migrate_add_maf_mission_type.sql new file mode 100644 index 0000000..b21410e --- /dev/null +++ b/supabase/migrate_add_maf_mission_type.sql @@ -0,0 +1,12 @@ +-- Migration : Type de mission MAF sur proposals/projects +-- Permet de choisir le type de mission (intercalaire de la circulaire MAF) +-- dès le devis, puis de le reporter sur le projet et de le pré-remplir dans +-- le plugin Déclaration MAF. + +ALTER TABLE proposals + ADD COLUMN IF NOT EXISTS maf_intercalaire TEXT, + ADD COLUMN IF NOT EXISTS taux_mission NUMERIC(5,2), + ADD COLUMN IF NOT EXISTS part_interet NUMERIC(5,2); + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS maf_intercalaire TEXT; From 62c04fbd10962466da92c99c22c0a4d3d890a38c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:41:55 +0000 Subject: [PATCH 4/4] Show the estimated MAF cost impact of the chosen mission type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useMafCost/MafCostBadge existed but were never wired into the UI, and the hook only guessed the intercalaire heuristically from categorie_projet — ignoring the explicit maf_intercalaire choice just added to proposals and projects. Make the explicit choice take priority over the heuristic, pass honoraires HT through for the non-maîtrise-d'œuvre intercalaires (which price on fees rather than M×T×P), and surface the badge live next to the mission-type selectors on both the devis and the project page. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XmfoTz7co9CshaNAFf6QD3 --- src/hooks/useMafCost.ts | 49 +++++++++++++++++++++++++++++-------- src/pages/ProjectDetail.tsx | 10 ++++++++ src/pages/Proposals.tsx | 15 ++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/hooks/useMafCost.ts b/src/hooks/useMafCost.ts index 4517cb1..2afd83f 100644 --- a/src/hooks/useMafCost.ts +++ b/src/hooks/useMafCost.ts @@ -1,31 +1,58 @@ import { useMemo } from 'react'; -import type { Project, Proposal, MafCostResult, MafIntercalaire } from '../types'; +import type { MafCostResult, MafIntercalaire } from '../types'; import { computeMafCost, computePartInteret } from '../lib/mafUtils'; +// Structural subset shared by Project and Proposal — lets this hook be used +// from either a project or an in-progress devis without casting. +interface MafCostSource { + categorie_projet?: string; + taux_mission?: number; + part_interet?: number; + surface_plancher?: string; + maf_intercalaire?: MafIntercalaire; + construction_cost?: number; + remuneration?: number; +} + +interface MafFeeSource { + fee_distribution?: string; + amount?: number; +} + interface UseMafCostParams { - project: Project | null | undefined; - proposal?: Proposal | null; + project: MafCostSource | null | undefined; + proposal?: MafFeeSource | null; mafEnabled?: boolean; tauxContratPermil?: number; montantCumulFinAnnee?: number; montantCumulAnneePrecedente?: number; } +const INTERCALAIRES_HONORAIRES: MafIntercalaire[] = ['violet', 'orange_clair', 'orange_fonce', 'bleu', 'rose', 'tabac', 'gris', 'puc']; + export function useMafCost(params: UseMafCostParams): MafCostResult | null { const { project, proposal, mafEnabled, tauxContratPermil = 0, montantCumulFinAnnee, montantCumulAnneePrecedente } = params; return useMemo(() => { if (!mafEnabled || !project) return null; - // Déduire l'intercalaire depuis le type de bâtiment et la mission - const cat = (project.categorie_projet ?? '').toLowerCase(); - const isMaisonInd = cat.includes('maison') && cat.includes('individuelle'); const tauxMission = project.taux_mission ?? 100; - const isPermisOnly = tauxMission === 30; - let intercalaire: MafIntercalaire = 'jaune'; - if (isMaisonInd && isPermisOnly) intercalaire = 'vert'; - else if (isMaisonInd && tauxMission >= 60) intercalaire = 'ami'; + // Le type de mission choisi explicitement sur le projet (ou hérité du devis) + // prime toujours sur la déduction heuristique ci-dessous. + let intercalaire: MafIntercalaire; + if (project.maf_intercalaire) { + intercalaire = project.maf_intercalaire; + } else { + // Déduire l'intercalaire depuis le type de bâtiment et la mission + const cat = (project.categorie_projet ?? '').toLowerCase(); + const isMaisonInd = cat.includes('maison') && cat.includes('individuelle'); + const isPermisOnly = tauxMission === 30; + + intercalaire = 'jaune'; + if (isMaisonInd && isPermisOnly) intercalaire = 'vert'; + else if (isMaisonInd && tauxMission >= 60) intercalaire = 'ami'; + } // Part d'intérêt : depuis fee_distribution si disponible, sinon depuis project const partFromFee = computePartInteret(proposal?.fee_distribution ?? null); @@ -33,6 +60,7 @@ export function useMafCost(params: UseMafCostParams): MafCostResult | null { const A = montantCumulFinAnnee ?? project.construction_cost ?? 0; const B = montantCumulAnneePrecedente ?? 0; + const honorairesHt = proposal?.amount ?? project.remuneration ?? 0; return computeMafCost({ intercalaire, @@ -42,6 +70,7 @@ export function useMafCost(params: UseMafCostParams): MafCostResult | null { partInteret, surfacePlancher: project.surface_plancher, categorieProjet: project.categorie_projet, + honorairesHt: INTERCALAIRES_HONORAIRES.includes(intercalaire) ? honorairesHt : undefined, tauxContratPermil, }); }, [project, proposal, mafEnabled, tauxContratPermil, montantCumulFinAnnee, montantCumulAnneePrecedente]); diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index cf08412..d4b01ed 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -73,6 +73,9 @@ import MilestoneGantt from '../components/MilestoneGantt'; import { ProTab } from '../components/pro/ProTab'; import Situations from './Situations'; import { MAF_INTERCALAIRE_OPTIONS, TAUX_MISSION_OPTIONS } from '../lib/mafUtils'; +import { useMafCost } from '../hooks/useMafCost'; +import { useSettings } from '../hooks/useSettings'; +import { MafCostBadge } from '../components/MafCostBadge'; import { Card, CardHeader, CardBody } from '../components/ui/Card'; import { StatTile, StatTileColor } from '../components/ui/StatTile'; import { PillTabs, PillTabItem } from '../components/ui/PillTabs'; @@ -189,6 +192,8 @@ export default function ProjectDetail() { const { t } = useTranslation(); const [project, setProject] = useState(null); + const { settings } = useSettings(); + const mafCost = useMafCost({ project, mafEnabled: !!(settings as any)?.maf_enabled, tauxContratPermil: parseFloat((settings as any)?.maf_taux_contrat_permil ?? 0) }); useEffect(() => { if (project) { @@ -2511,6 +2516,11 @@ export default function ProjectDetail() { )} setProject(prev => prev ? ({...prev, part_interet: v ? Number(v) : undefined}) : null)} /> + {mafCost && ( +
+ +
+ )}
diff --git a/src/pages/Proposals.tsx b/src/pages/Proposals.tsx index da321b5..fcd0dfc 100644 --- a/src/pages/Proposals.tsx +++ b/src/pages/Proposals.tsx @@ -20,6 +20,9 @@ import MilestoneGantt from '../components/MilestoneGantt'; import { MobileAccordionTable } from '../components/MobileAccordionTable'; import { ProposalGenerator } from '../components/ProposalGenerator'; import { MAF_INTERCALAIRE_OPTIONS, TAUX_MISSION_OPTIONS } from '../lib/mafUtils'; +import { useMafCost } from '../hooks/useMafCost'; +import { useSettings } from '../hooks/useSettings'; +import { MafCostBadge } from '../components/MafCostBadge'; import { saveAs } from 'file-saver'; @@ -193,6 +196,13 @@ export default function Proposals() { const [newProposal, setNewProposal] = useState>(initialProposalState); const [costMode, setCostMode] = useState<'manual' | 'ratio'>('manual'); const [submitError, setSubmitError] = useState(null); + const { settings } = useSettings(); + const mafCost = useMafCost({ + project: newProposal, + proposal: newProposal, + mafEnabled: !!(settings as any)?.maf_enabled, + tauxContratPermil: parseFloat((settings as any)?.maf_taux_contrat_permil ?? 0), + }); useEffect(() => { const loadData = async () => { @@ -879,6 +889,11 @@ export default function Proposals() { /> )}
+ {mafCost && ( +
+ +
+ )} {/* Section 5: Surfaces & Capacity */}