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", diff --git a/server.ts b/server.ts index ec1d7f6..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 @@ -4265,14 +4270,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 +4347,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 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/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..d4b01ed 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -72,6 +72,10 @@ 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 { 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'; @@ -188,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) { @@ -2480,7 +2486,41 @@ 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)} /> + {mafCost && ( +
+ +
+ )}
diff --git a/src/pages/Proposals.tsx b/src/pages/Proposals.tsx index dc03456..fcd0dfc 100644 --- a/src/pages/Proposals.tsx +++ b/src/pages/Proposals.tsx @@ -19,6 +19,10 @@ 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 { useMafCost } from '../hooks/useMafCost'; +import { useSettings } from '../hooks/useSettings'; +import { MafCostBadge } from '../components/MafCostBadge'; import { saveAs } from 'file-saver'; @@ -171,6 +175,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 => ({ @@ -190,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 () => { @@ -859,7 +872,28 @@ 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 }))} + /> + )}
+ {mafCost && ( +
+ +
+ )} {/* Section 5: Surfaces & Capacity */} 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;