Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 37 additions & 13 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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({
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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");
Expand Down
14 changes: 11 additions & 3 deletions src/components/LocationMaps.tsx
Original file line number Diff line number Diff line change
@@ -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('');
Expand Down Expand Up @@ -52,7 +60,7 @@ export const GeoportailMap = ({ address, banId }: { address: string; banId?: str
if (error) return <div className="w-full h-full flex items-center justify-center text-red-400 text-sm">{error}</div>;
if (!coords) return <div className="w-full h-full flex items-center justify-center text-zinc-400 text-sm">Enter a valid address to see the map</div>;

return <MapLibreCadastre lat={coords.lat} lon={coords.lon} />;
return <MapLibreCadastre lat={coords.lat} lon={coords.lon} onParcelSelect={onParcelSelect} />;
};

export const GoogleMap = ({ address }: { address: string }) => {
Expand Down
120 changes: 110 additions & 10 deletions src/components/MapLibreCadastre.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -13,30 +23,85 @@ const MAP_STYLE = (lon: number, lat: number): any => ({
tileSize: 256,
attribution: '&copy; 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: '&copy; 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<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const marker = useRef<maplibregl.Marker | null>(null);
const [contextLost, setContextLost] = useState(false);
const [zoom, setZoom] = useState(17);
const hoveredId = useRef<number | string | null>(null);
const selectedId = useRef<number | string | null>(null);
const fetchAbort = useRef<AbortController | null>(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;
Expand All @@ -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
Expand Down
49 changes: 39 additions & 10 deletions src/hooks/useMafCost.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,66 @@
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);
const partInteret = partFromFee ?? project.part_interet ?? 100;

const A = montantCumulFinAnnee ?? project.construction_cost ?? 0;
const B = montantCumulAnneePrecedente ?? 0;
const honorairesHt = proposal?.amount ?? project.remuneration ?? 0;

return computeMafCost({
intercalaire,
Expand All @@ -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]);
Expand Down
Loading
Loading