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 (proposals, projets)
+export const MAF_INTERCALAIRE_OPTIONS = MAF_INTERCALAIRE_ORDER.map((id) => ({
+ id,
+ name: `${id.replace('_', ' ')} — ${MAF_INTERCALAIRE_LABELS[id]}`,
+}));
+
+// Taux T de la mission de maîtrise d'œuvre (intercalaire jaune uniquement — § 2.1 de la circulaire)
+export const TAUX_MISSION_OPTIONS = [
+ { value: 30, label: 'Mission limitée au projet architectural (30%)' },
+ { value: 60, label: 'Mission de conception générale sans direction (60%)' },
+ { value: 100, label: 'Mission complète ou assimilable (100%)' },
+ { value: 110, label: 'Mission complète élargie (110%)' },
+];
+
// ─── Calcul de la part d'intérêt pondérée depuis fee_distribution ─────────────
interface FeeDistributionMission {
diff --git a/src/pages/MafDeclaration.tsx b/src/pages/MafDeclaration.tsx
index 123fd9e..5d14941 100644
--- a/src/pages/MafDeclaration.tsx
+++ b/src/pages/MafDeclaration.tsx
@@ -11,17 +11,14 @@ import {
import type { MafProjectData, MafIntercalaire, Project } from '../types';
import {
computeAssiette, computeCotisation,
- MAF_TAUX_FIXES, MAF_INTERCALAIRE_LABELS,
+ MAF_TAUX_FIXES, MAF_INTERCALAIRE_LABELS, MAF_INTERCALAIRE_ORDER,
+ TAUX_MISSION_OPTIONS,
computePartInteret,
} from '../lib/mafUtils';
// ─── Constants ────────────────────────────────────────────────────────────────
-const INTERCALAIRES: MafIntercalaire[] = [
- 'jaune', 'vert', 'ami', 'grand_chantier',
- 'violet', 'orange_clair', 'orange_fonce', 'bleu',
- 'rose', 'tabac', 'gris', 'puc',
-];
+const INTERCALAIRES: MafIntercalaire[] = MAF_INTERCALAIRE_ORDER;
const INTERCALAIRE_DESCRIPTIONS: Record = {
jaune: 'Missions complètes ou partielles de maîtrise d\'œuvre (formule M × T × P). Chantiers ouverts ou en cours.',
@@ -38,13 +35,6 @@ const INTERCALAIRE_DESCRIPTIONS: Record = {
puc: 'Police Unique de Chantier — chantiers couverts par une PUC souscrite par le maître d\'ouvrage.',
};
-const TAUX_MISSION_OPTIONS = [
- { value: 30, label: 'Mission limitée au projet architectural (30%)' },
- { value: 60, label: 'Mission de conception générale sans direction (60%)' },
- { value: 100, label: 'Mission complète ou assimilable (100%)' },
- { value: 110, label: 'Mission complète élargie (110%)' },
-];
-
const SISMICITE_OPTIONS = ['Très faible', 'Faible', 'Modérée', 'Moyenne', 'Forte'];
const RETRAIT_ARGILES_OPTIONS = ['Non exposée', 'Faible', 'Moyen', 'Fort'];
const TYPE_MOA_OPTIONS = ['Privé', 'Public', 'Copropriété'];
@@ -208,6 +198,12 @@ function EntryForm({ entry, intercalaire, year, projects, tauxContratPermil, onS
— Aucun projet lié —
{projects.map(p => {p.name} {p.client ? `— ${p.client}` : ''} )}
+ {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)} />
+
+ Type de mission (circulaire MAF)
+ setProject(prev => prev ? ({
+ ...prev,
+ maf_intercalaire: (e.target.value || undefined) as any,
+ taux_mission: e.target.value === 'jaune' ? prev.taux_mission : undefined,
+ }) : null)}
+ >
+ Select Type de mission
+ {MAF_INTERCALAIRE_OPTIONS.map(o => {o.name} )}
+
+
+ {project.maf_intercalaire === 'jaune' && (
+
+ Taux de la mission (T)
+ setProject(prev => prev ? ({...prev, taux_mission: e.target.value ? Number(e.target.value) : undefined}) : null)}
+ >
+ Select Taux
+ {TAUX_MISSION_OPTIONS.map(o => {o.label} )}
+
+
+ )}
+ 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;