function MainApp({data,soldeInitial,savedFiches,savedCrm,savedMouvements,savedArchRequests,savedReconciliations,savedReceptions,onReset,loggedUser,onLogout,initialVersion,initialDomainVersions,onVersionChange,onDomainVersionChange,dirtyRef}) {
  const [projects,setProjects]=useState(data.projects);
  const [rows,setRows]=useState(data.rows);
  const [values,setValues]=useState(data.values);
  const [fiches,setFiches]=useState(()=>{
    if(savedFiches) return savedFiches;
    const f={};data.projects.forEach(p=>{f[p.id]=EMPTY_FICHE();});return f;
  });
  const [crm,setCrm]=useState(()=>{
    const c=savedCrm||{};
    return {entreprises:[],clients:[],cgps:[],banques:[],comptes:[],...c};
  });
  // ── Sprint T/M/R (étape 1) : registre des mouvements (table SQL) ──────────
  // Remplace l'ancien state `journal` (blob app_misc.journal, gelé en archive).
  // Hook dédié dès le départ (règle anti-obésité MainApp) : état + CRUD réseau
  // vivent dans public/hooks/useMouvements.js. saveFlux/deleteFlux gardent
  // leur nom historique → les consommateurs (FluxFormModal, logMouvementAuto,
  // FichesProgrammes, Notifications) sont branchés sans changement.
  const {mouvements,saveFlux,deleteFlux}=useMouvements({initial:savedMouvements});
  const [reconciliations,setReconciliations]=useState(()=>savedReconciliations||{});
  const [receptions,setReceptions]=useState(()=>savedReceptions||[]);
  const [deleteProjId,setDeleteProjId]=useState(null);

  // ── Sprint A4 (étape 4) : versions par domaine + save granulaire ──────────
  // initialDomainVersions provient du GET /api/state enrichi (cf. App
  // component). _versionRef garde la version globale (app_state.updated_at)
  // pour le PUT global rétro-compat utilisé par receptions (résiduel A4).
  const _planVerRef       = useRef((initialDomainVersions && initialDomainVersions.plan)   || null);
  const _fichesVerRef     = useRef((initialDomainVersions && initialDomainVersions.fiches) || null);
  const _crmVerRef        = useRef((initialDomainVersions && initialDomainVersions.crm)    || null);
  const _miscVerRef       = useRef((initialDomainVersions && initialDomainVersions.misc)   || null);
  const _versionRef       = useRef(initialVersion || null);
  const _receptionsTimer  = useRef(null);
  const _receptionsMounted= useRef(false);
  const _receptionsConflict = useRef(false);

  // ── 3 hooks granulaires (le 4e, 'misc', est déclaré APRÈS archRequests) ──
  // payload mémoïsé pour éviter de retrigger le useEffect interne à chaque
  // render (React compare par identité de référence sur le deps array).
  const planPayload = React.useMemo(
    () => ({ projects, rows, values }),
    [projects, rows, values]
  );
  useDomainSave({
    domain:'plan', payloadKey:'plan',
    payload: planPayload,
    versionRef:_planVerRef, dirtyRef,
    onVersionChange: onDomainVersionChange,
  });
  useDomainSave({
    domain:'fiches', payloadKey:'fiches',
    payload: fiches,
    versionRef:_fichesVerRef, dirtyRef,
    onVersionChange: onDomainVersionChange,
  });
  useDomainSave({
    domain:'crm', payloadKey:'crm',
    payload: crm,
    versionRef:_crmVerRef, dirtyRef,
    onVersionChange: onDomainVersionChange,
  });

  /* ── Attestations : suivi des demandes envoyées à l'architecte ── */
  const [archRequests,setArchRequests]=useState(()=>Array.isArray(savedArchRequests)?savedArchRequests:[]);

  // ── Sprint A4 (étape 4) : useDomainSave('misc') ──────────────────────────
  // Placé APRÈS la déclaration de archRequests (TDZ avec const, contournée
  // historiquement par hoisting Babel-standalone, mais on garde l'ordre
  // logique pour ne pas perpétuer le piège).
  // T/M/R étape 1 : `journal` retiré du payload misc (le registre mouvements a
  // ses propres routes ; le blob app_misc.journal est une archive gelée).
  const miscPayload = React.useMemo(
    () => ({ soldeInitial, archRequests, reconciliations }),
    [soldeInitial, archRequests, reconciliations]
  );
  useDomainSave({
    domain:'misc', payloadKey:'misc',
    payload: miscPayload,
    versionRef:_miscVerRef, dirtyRef,
    onVersionChange: onDomainVersionChange,
  });

  // ── receptions : résiduel A4, transite par le PUT global /api/state ──────
  // (sprint Réception futur le migrera en granulaire). Le PUT global envoie
  // le state complet car syncTables fait des DELETE inconditionnels sur
  // projects (cf. routes/state.js §syncTables) — passer un payload partiel
  // wiperait toute la base.
  useEffect(() => {
    if (!_receptionsMounted.current) { _receptionsMounted.current = true; return; }
    if (dirtyRef) dirtyRef.current = true;
    clearTimeout(_receptionsTimer.current);
    _receptionsTimer.current = setTimeout(async () => {
      try {
        const resp = await fetch('/api/state', {
          method:'PUT',
          headers:{'Content-Type':'application/json'},
          body: JSON.stringify({
            data:{projects,rows,values,fiches,soldeInitial,crm,archRequests,reconciliations,receptions},
            version:_versionRef.current,
          }),
        });
        if (resp.status === 409) {
          if (_receptionsConflict.current) return;
          _receptionsConflict.current = true;
          alert("Un autre utilisateur a modifié les données en même temps que vous.\n\nPour éviter tout écrasement, la page va se recharger avec la dernière version.\n\nVos modifications les plus récentes (non encore sauvegardées) pourraient être perdues.");
          window.location.reload();
          return;
        }
        if (resp.status === 401) {
          alert("Votre session a expiré. La page va se recharger pour vous reconnecter.");
          window.location.reload();
          return;
        }
        if (resp.ok) {
          const body = await resp.json();
          if (body && body.version) {
            _versionRef.current = body.version;
            if (onVersionChange) onVersionChange(body.version);
          }
          if (dirtyRef) dirtyRef.current = false;
        } else {
          console.error('[receptions PUT global] HTTP', resp.status);
        }
      } catch (e) {
        console.error('[receptions PUT global] Erreur réseau :', e);
      }
    }, 1200);
  }, [receptions]);
  const [pendingAttestations,setPendingAttestations]=useState([]);
  const [showAttestationList,setShowAttestationList]=useState(false);
  const [attestationAppel,setAttestationAppel]=useState(null);

  useEffect(()=>{
    const checkAttestations=async()=>{
      try{
        const r=await fetch('/api/agent/attestations');
        if(r.ok){const data=await r.json();if(Array.isArray(data))setPendingAttestations(data);}
      }catch(e){}
    };
    checkAttestations();
    const t=setInterval(checkAttestations,86400000);
    return ()=>clearInterval(t);
  },[]);

  const handleArchitecteEmailSent=(info)=>{
    setArchRequests(reqs=>[...reqs.filter(r=>!(r.projId===info.projId&&r.lotIdx===info.lotIdx)),info]);
  };

  const [user,setUser]=useState("Direction");
  const [page,setPage]=useState(()=>{try{return sessionStorage.getItem('tresoimmo_page')||'plan';}catch{return 'plan';}});
  useEffect(()=>{try{sessionStorage.setItem('tresoimmo_page',page);}catch{}},[page]);
  const [fichePage,setFichePage]=useState(null);
  const [openMenu,setOpenMenu]=useState(null);

  /* ── Site internet : compteur de demandes de réservation en attente (badge onglet) ── */
  const [siteResaCount,setSiteResaCount]=useState(0);
  useEffect(()=>{
    let stop=false,es;
    const load=()=>fetch('/api/site-admin/reservations/count').then(r=>r.ok?r.json():null).then(d=>{if(!stop&&d)setSiteResaCount(d.count||0);}).catch(()=>{});
    load();
    try{es=new EventSource('/api/events');es.addEventListener('cgp-reservation',load);}catch(_){}
    const poll=setInterval(load,120000);
    return()=>{stop=true;clearInterval(poll);if(es)es.close();};
  },[]);
  /* ── Feature Notifications : extraite dans public/hooks/useNotifications.js (D1 c) ──
     La grappe d'états + loadNotifs + les 2 effets vivent dans le hook ; MainApp
     consomme et passe le tout à NotificationsView comme avant. */
  const {
    notifications,setNotifications,newNotifCount,setNewNotifCount,
    notifLoading,setNotifLoading,notifError,setNotifError,
    notifFilterBal,setNotifFilterBal,notifFilterCat,setNotifFilterCat,
    notifShowAll,setNotifShowAll,notifPdfModal,setNotifPdfModal,
    analyzingIds,setAnalyzingIds,editingFacture,setEditingFacture,
    reglerModal,setReglerModal,agentRunning,setAgentRunning,loadNotifs,
  }=useNotifications(page);
  const [mouvementModal,setMouvementModal]=useState(null); // capture d'un mouvement depuis l'édition d'une cellule réelle

  // ── Pilotage centralisé du statut commercial (déménagé de FichesProgrammes) ──
  // openStatutModal(projId,lotIdx) ouvre la fenêtre de changement de statut depuis
  // n'importe quel onglet (Suivi des signatures = pilotage ; Programmes = lecture seule).
  // Les 3 fenêtres de suite (LIA à OPTION, notaire à LIA, courtoisie à COMPROMIS)
  // sont rendues ici, une seule fois. Pas d'appel de fonds au passage VENDU/LIVRE.
  const [statutModal,setStatutModal]=useState(null);                   // {projId,lotIdx}
  const [liaModal,setLiaModal]=useState(null);                         // {projId,lotIdx}
  const [notaireModal,setNotaireModal]=useState(null);                 // {projId,lotIdx}
  const [compromisEmailModal,setCompromisEmailModal]=useState(null);   // {projId,lotIdx}
  const openStatutModal=(projId,lotIdx)=>setStatutModal({projId,lotIdx});
  // « Valider l'option » : ouvre la fenêtre LIA à la demande (découplé du passage en OPTION).
  const openLiaModal=(projId,lotIdx)=>setLiaModal({projId,lotIdx});

  /* ── handleCellSave : mutation partagée Plan + Notifications ── */
  const setValue=useCallback((rowId,wk,montant)=>{
    setValues(vs=>{
      const idx=vs.findIndex(v=>v.rowId===rowId&&v.weekIso===wk);
      if(montant===null) return vs.filter((_,i)=>i!==idx);
      if(idx>=0) return vs.map((v,i)=>i===idx?{...v,montant}:v);
      return [...vs,{rowId,weekIso:wk,montant}];
    });
  },[]);

  const handleCellSave=useCallback((rowId,weekIso,newVal,redistribution,additive)=>{
    if(additive){
      // Mode additif (règlement de factures) : on AJOUTE au montant déjà présent
      // dans la cellule au lieu de l'écraser. Plusieurs factures sur la même
      // ligne/semaine se cumulent. Updater fonctionnel → robuste si on règle
      // plusieurs factures à la suite (toujours l'état le plus récent).
      setValues(vs=>{
        const idx=vs.findIndex(v=>v.rowId===rowId&&v.weekIso===weekIso);
        const existing=idx>=0?(Number(vs[idx].montant)||0):0;
        const sum=existing+newVal;
        if(sum===0) return idx>=0?vs.filter((_,i)=>i!==idx):vs;
        if(idx>=0) return vs.map((v,i)=>i===idx?{...v,montant:sum}:v);
        return [...vs,{rowId,weekIso,montant:sum}];
      });
    } else {
      setValue(rowId,weekIso,newVal);
    }
    if(redistribution){
      setValues(vs=>{
        const idx=vs.findIndex(v=>v.rowId===rowId&&v.weekIso===redistribution.weekIso);
        const existing=idx>=0?vs[idx].montant:0;
        const adjusted=existing+redistribution.adjustment;
        if(adjusted===0){
          return idx>=0?vs.filter((_,i)=>i!==idx):vs;
        }
        if(idx>=0) return vs.map((v,i)=>i===idx?{...v,montant:adjusted}:v);
        return [...vs,{rowId,weekIso:redistribution.weekIso,montant:adjusted}];
      });
    }
  },[setValue]);

  const updateFiche=(projId,field,val)=>{
    if(field==="ville") setProjects(ps=>ps.map(p=>p.id===projId?{...p,ville:val}:p));
    if(field==="nomProgramme") setProjects(ps=>ps.map(p=>p.id===projId?{...p,nom:val}:p));
    setFiches(f=>{
      const curr=f[projId]||EMPTY_FICHE();
      const updated={...curr,[field]:val};
      if(field==="nbLots"){
        const n=parseInt(val)||0;
        const currentLots=curr.lots||[];
        if(n>currentLots.length){
          const extra=Array.from({length:n-currentLots.length},()=>EMPTY_LOT());
          updated.lots=[...currentLots,...extra];
        } else {
          updated.lots=currentLots.slice(0,n);
        }
      }
      return {...f,[projId]:updated};
    });
    if(field==="nbLots"){
      const n=parseInt(val)||0;
      setRows(rs=>{
        const projLotRows=rs.filter(r=>r.projetId===projId&&/^Lot \d+$/.test(r.label));
        const currentCount=projLotRows.length;
        if(n>currentCount){
          const newLotRows=Array.from({length:n-currentCount},(_,i)=>({id:uid(),projetId:projId,label:`Lot ${currentCount+i+1}`,statut:"F"}));
          return [...rs,...newLotRows];
        } else if(n<currentCount){
          let kept=0;
          return rs.filter(r=>{
            if(r.projetId===projId&&/^Lot \d+$/.test(r.label)){kept++;return kept<=n;}
            return true;
          });
        }
        return rs;
      });
    }
  };

  const createProgramme=({ville,nom})=>{
    const id=uid();
    const colorIdx=projects.filter(p=>!p.isGlobal).length;
    setProjects(ps=>[...ps,{id,ville:ville.trim(),nom:nom.trim(),isGlobal:false,color:PROJ_COLORS[colorIdx%PROJ_COLORS.length]}]);
    setFiches(f=>({...f,[id]:{...EMPTY_FICHE(),statut:"En cours",ville:ville.trim(),nomProgramme:nom.trim()}}));
    const newRows=COUT_ROWS_LABELS.map(label=>({id:uid(),projetId:id,label,statut:"F",hidden:label.toLowerCase()==="marge blue"}));
    setRows(rs=>[...rs,...newRows]);
    setFichePage(id);
  };

  const deleteProgramme=(projId)=>{
    const projRowIds=rows.filter(r=>r.projetId===projId).map(r=>r.id);
    setProjects(ps=>ps.filter(p=>p.id!==projId));
    setFiches(f=>{const nf={...f};delete nf[projId];return nf;});
    setRows(rs=>rs.filter(r=>r.projetId!==projId));
    setValues(vs=>vs.filter(v=>!projRowIds.includes(v.rowId)));
    setDeleteProjId(null);
    if(fichePage===projId) setFichePage(null);
  };

  /* ── Helpers flux prévisionnels ────────────────────────────────────────────
     maxWeek        : renvoie la plus tardive de deux ISO-week strings
     computeLotPrices : calcule prixFinal / foncier / travaux par lot (+ achatFNI
                        et prixTotalHorsMarge au niveau programme)
     buildCgpFluxes  : recalcul complet de la row "Marge CGP" pour un programme.
                        Pour chaque lot : flux individuel daté selon son statut
                        (COMPROMIS+17 / LIA+20 / OPTION+22), montant = retroMontantR
                        ou prixFinal*margeCGPpct% en fallback.
                        Lots LIBRE : CGP résiduel réparti 80/20 aux dates foncier. */

  const maxWeek=(w1,w2)=>(!w1?w2:!w2?w1:w1>=w2?w1:w2);

  const computeLotPrices=(fiche)=>{
    const lots=fiche.lots||[];
    const devisTravaux=toN(fiche.devisTravaux);
    const euribor=toN(fiche.euribor),montantCredit=toN(fiche.montantCredit);
    const creditMensuel=montantCredit*(euribor/100+0.025)/12;
    const creditTotal=creditMensuel*8;
    const moe=devisTravaux*0.08;
    const prixDenormandieCalc=toN(fiche.prixAchat)-toN(fiche.montantCommerceRdc);
    const achatFNI=prixDenormandieCalc*1.025+toN(fiche.dossierBanque)+creditTotal;
    const montantTravaux=moe+toN(fiche.dp)+toN(fiche.pc)+toN(fiche.plaquette)+toN(fiche.deplacements)+toN(fiche.avocat)+toN(fiche.hommeArt)+toN(fiche.geometre)+toN(fiche.diagnostics)+toN(fiche.loyerMeuble)+toN(fiche.doTrc)+toN(fiche.gfa)+devisTravaux;
    const prixTotalHorsMarge=achatFNI+montantTravaux;
    const totalMargePct=(toN(fiche.margeBluePct)+toN(fiche.margeCGPpct))/100;
    const caOperation=totalMargePct<1?prixTotalHorsMarge/(1-totalMargePct):prixTotalHorsMarge;
    const totalSP=lots.reduce((s,l)=>s+toN(l.surface)+Math.min(toN(l.ext)/2,8),0);
    const lotsP1=lots.map(l=>{
      const sp=toN(l.surface)+Math.min(toN(l.ext)/2,8);
      const bc=totalSP>0?caOperation*sp/totalSP:0;
      return bc*(1+toN(l.ponderationPct||"")/100);
    });
    const totPP=lotsP1.reduce((s,v)=>s+v,0);
    return lots.map((l,idx)=>{
      const prixPondere=lotsP1[idx];
      const calc=totPP>0?prixPondere*caOperation/totPP:prixPondere;
      const prixFinal=l.prixFinalO!==""?toN(l.prixFinalO):calc;
      const foncier=prixTotalHorsMarge>0?prixFinal*achatFNI/prixTotalHorsMarge:0;
      const travaux=prixFinal-foncier;
      return {prixFinal,foncier,travaux,achatFNI,prixTotalHorsMarge};
    });
  };

  /* Budget travaux par lot et par programme, pour l'écran « Appels de fonds »
     (calcul du « reste à appeler »). Source identique à l'onglet programme :
     travaux RÉEL = prixReel − foncierReel quand prixReel est saisi ; sinon repli
     sur le montant des TRAVAUX prévisionnels (lc.travaux). lots.travaux_reel en
     base est vide → on recalcule ici comme le fait FichesProgrammes.
     Résultat : { [programmeId]: { [lotId 1-based]: travaux } }. */
  const lotsTravaux=React.useMemo(()=>{
    const out={};
    Object.keys(fiches||{}).forEach(progId=>{
      const fiche=fiches[progId];
      if(!fiche||!Array.isArray(fiche.lots)) return;
      const lcs=computeLotPrices(fiche);
      const m={};
      fiche.lots.forEach((lot,idx)=>{
        const lc=lcs[idx]||{};
        const prixReel=toN(lot.prixReel);
        const foncierReel=(lot.foncierReelO!==""&&lot.foncierReelO!=null)
          ?toN(lot.foncierReelO)
          :prixReel>0
            ?(lc.prixTotalHorsMarge>0?prixReel*lc.achatFNI/lc.prixTotalHorsMarge:0)
            :lc.foncier;
        const travauxReel=prixReel>0?prixReel-foncierReel:(lc.travaux||0);
        m[String(idx+1)]=travauxReel;
      });
      out[progId]=m;
    });
    return out;
  },[fiches]);

  /* Devis travaux par lot = part du DEVIS TRAVAUX du programme (fiche.devisTravaux)
     répartie au prorata de la SURFACE du lot. Base « EG » pour le bloc « Ce qu'on
     attend » (≠ travaux réel utilisé côté client). NB : on part bien du devis, pas
     d'un montant dérivé du prix de vente (computeLotPrices.travaux inclut la marge).
     { [programmeId]: { [lotId 1-based]: devisTravauxLot } }. */
  const lotsDevis=React.useMemo(()=>{
    const out={};
    Object.keys(fiches||{}).forEach(progId=>{
      const fiche=fiches[progId];
      if(!fiche||!Array.isArray(fiche.lots)) return;
      const devis=toN(fiche.devisTravaux);
      const totalSurf=fiche.lots.reduce((s,l)=>s+toN(l.surface),0);
      const m={};
      fiche.lots.forEach((lot,idx)=>{
        const part=totalSurf>0?toN(lot.surface)/totalSurf:0;
        m[String(idx+1)]=devis*part;
      });
      out[progId]=m;
    });
    return out;
  },[fiches]);

  /* Mapping statut → {searchStatut pour historiqueStatut, offsetWeeks foncier} */
  const STATUT_PIVOT={
    COMPROMIS:{s:'COMPROMIS',o:17},AV10:{s:'COMPROMIS',o:17},AV40:{s:'COMPROMIS',o:17},
    AV70:{s:'COMPROMIS',o:17},AV95:{s:'COMPROMIS',o:17},VENDU:{s:'COMPROMIS',o:17},
    LIVRE:{s:'COMPROMIS',o:17},LIA:{s:'LIA',o:20},OPTION:{s:'OPTION',o:22},
  };

  const buildCgpFluxes=(projId,ficheArg)=>{
    const fiche=ficheArg||fiches[projId]||EMPTY_FICHE();
    const lots=fiche.lots||[];
    const dateAchat=fiche.dateAcquisition;
    if(!dateAchat||lots.length===0) return [];
    const projRows=rows.filter(r=>r.projetId===projId);
    const cgpRow=projRows.find(r=>r.label.toLowerCase().includes("marge cgp"));
    if(!cgpRow) return [];
    const margeCGPpct=toN(fiche.margeCGPpct);
    const lotPrices=computeLotPrices(fiche);
    const nb80=Math.ceil(lots.length*0.8);
    const fluxes=[];
    let libreEarlyCgp=0,libreLateCgp=0;
    lots.forEach((lot,idx)=>{
      const statut=lot.statutCommercial||'LIBRE';
      const pivotInfo=STATUT_PIVOT[statut]||(isAvancement(statut)?{s:'COMPROMIS',o:17}:undefined);
      if(!pivotInfo){
        // Lot LIBRE : accumule pour le forecast 80/20
        const cgpEst=lotPrices[idx].prixFinal*(margeCGPpct/100);
        if(idx<nb80) libreEarlyCgp+=cgpEst; else libreLateCgp+=cgpEst;
        return;
      }
      // Lot avec statut connu : flux individuel daté
      const histEntry=[...(lot.historiqueStatut||[])].reverse().find(h=>h.to===pivotInfo.s);
      const pivotDate=histEntry?.date||dateAchat;
      const cgpAmount=toN(lot.retroMontantR)>0
        ?toN(lot.retroMontantR)
        :lotPrices[idx].prixFinal*(margeCGPpct/100);
      const weekIso=shiftWeek(pivotDate,pivotInfo.o);
      if(weekIso) fluxes.push({rowId:cgpRow.id,weekIso,montant:-Math.abs(cgpAmount)});
    });
    // Lots LIBRE : forecast réparti 80/20 aux mêmes dates que le foncier
    // dateDebutTravaux-4sem et dateDebutTravaux+4sem (cohérent avec generateFlows)
    const dateDebutTravauxCgp=fiche.dateDebutTravaux||shiftWeek(dateAchat,39);
    const w80=shiftWeek(dateDebutTravauxCgp,-4),w20=shiftWeek(dateDebutTravauxCgp,4);
    if(libreEarlyCgp>0&&w80) fluxes.push({rowId:cgpRow.id,weekIso:w80,montant:-Math.abs(libreEarlyCgp)});
    if(libreLateCgp>0&&w20)  fluxes.push({rowId:cgpRow.id,weekIso:w20,montant:-Math.abs(libreLateCgp)});
    // Dédoublonnage : si deux lots sont en COMPROMIS la même semaine, on somme leurs CGP
    // pour éviter les violations PRIMARY KEY (row_id, week_iso) à la sauvegarde.
    const cgpByWeek={};
    fluxes.forEach(f=>{
      if(cgpByWeek[f.weekIso]!==undefined){cgpByWeek[f.weekIso].montant+=f.montant;}
      else{cgpByWeek[f.weekIso]={...f};}
    });
    return Object.values(cgpByWeek);
  };

  const generateFlows=(projId)=>{
    const fiche=fiches[projId]||EMPTY_FICHE();
    if(!fiche.dateAcquisition) return;
    const dateAchat=fiche.dateAcquisition;
    const dateDebutTravaux=fiche.dateDebutTravaux||shiftWeek(dateAchat,39);
    const devisTravaux=toN(fiche.devisTravaux);
    const euribor=toN(fiche.euribor),montantCredit=toN(fiche.montantCredit);
    const creditMensuel=montantCredit*(euribor/100+0.025)/12;
    const creditTotal=creditMensuel*8;
    const moe=devisTravaux*0.08;
    const prixDenormandieCalc=toN(fiche.prixAchat)-toN(fiche.montantCommerceRdc);
    const achatFNI=prixDenormandieCalc*1.025+toN(fiche.dossierBanque)+creditTotal;
    const montantTravaux=moe+toN(fiche.dp)+toN(fiche.pc)+toN(fiche.plaquette)+toN(fiche.deplacements)+toN(fiche.avocat)+toN(fiche.hommeArt)+toN(fiche.geometre)+toN(fiche.diagnostics)+toN(fiche.loyerMeuble)+toN(fiche.doTrc)+toN(fiche.gfa)+devisTravaux;
    const prixTotalHorsMarge=achatFNI+montantTravaux;
    const totalMargePct=(toN(fiche.margeBluePct)+toN(fiche.margeCGPpct))/100;
    const caOperation=totalMargePct<1?prixTotalHorsMarge/(1-totalMargePct):prixTotalHorsMarge;
    const dontMargeCGP=caOperation*(toN(fiche.margeCGPpct)/100);
    const lots=fiche.lots||[];
    const nb80=Math.ceil(lots.length*0.8);
    const totalSP=lots.reduce((s,l)=>s+toN(l.surface)+Math.min(toN(l.ext)/2,8),0);
    const lotsPass1=lots.map(l=>{
      const sp=toN(l.surface)+Math.min(toN(l.ext)/2,8);
      const prixBrutCalc=totalSP>0?caOperation*sp/totalSP:0;
      const ponderPct=toN(l.ponderationPct||"");
      return {prixBrutCalc,prixPondere:prixBrutCalc*(1+ponderPct/100)};
    });
    const totalPrixPondere=lotsPass1.reduce((s,lp)=>s+lp.prixPondere,0);
    const lotsCalcLocal=lots.map((l,idx)=>{
      const {prixBrutCalc,prixPondere}=lotsPass1[idx];
      const prixFinalCalc=totalPrixPondere>0?prixPondere*caOperation/totalPrixPondere:prixBrutCalc;
      const prixFinal=l.prixFinalO!==""?toN(l.prixFinalO):prixFinalCalc;
      const foncier=prixTotalHorsMarge>0?prixFinal*achatFNI/prixTotalHorsMarge:0;
      const travaux=prixFinal-foncier;
      return {prixFinal,foncier,travaux};
    });
    const projRowsBase=rows.filter(r=>r.projetId===projId);
    const existingLabels=new Set(projRowsBase.map(r=>r.label.toLowerCase()));
    const missingRows=COUT_ROWS_LABELS
      .filter(label=>!existingLabels.has(label.toLowerCase()))
      .map(label=>({id:uid(),projetId:projId,label,statut:"F",hidden:label.toLowerCase()==="marge blue"}));
    if(missingRows.length>0) setRows(rs=>[...rs,...missingRows]);
    const projRows=[...projRowsBase,...missingRows];
    const findRow=(...kws)=>projRows.find(r=>kws.some(kw=>r.label.toLowerCase().includes(kw.toLowerCase())));
    const dpRow=findRow("dp"); const pcRow=findRow("pc"); const plaqueRow=findRow("plaquette");
    const geoRow=findRow("géomètre","geometre"); const diagRow=findRow("diagnostic");
    const dossierRow=findRow("dossier banque"); const avocatRow=findRow("avocat");
    const hommeRow=findRow("homme de l'art","homme art");
    const creditRow=findRow("crédit mensuel","credit mensuel");
    const gfaRow=findRow("gfa"); const doTrcRow=findRow("trc");
    const travauxRow=findRow("travaux"); const moeRow=findRow("moe");
    const deplRow=findRow("déplacement");
    const loyerRow=findRow("loyer meublé","loyer meuble");
    const cgpRow=findRow("marge cgp"); const margeBlueRow=findRow("marge blue");
    const lotRows=projRows.filter(r=>/^Lot \d+$/.test(r.label));
    const dontMargeBlueFlow=caOperation*(toN(fiche.margeBluePct)/100);
    const newValues=[];
    const addFlow=(row,weekIso,montant)=>{if(row&&weekIso)newValues.push({rowId:row.id,weekIso,montant:montant||0});};
    const pre3m=shiftWeek(dateAchat,-13);
    if(dpRow)    addFlow(dpRow,    pre3m, -Math.abs(toN(fiche.dp)));
    if(pcRow)    addFlow(pcRow,    pre3m, -Math.abs(toN(fiche.pc)));
    if(plaqueRow)addFlow(plaqueRow,pre3m, -Math.abs(toN(fiche.plaquette)));
    if(geoRow)   addFlow(geoRow,   pre3m, -Math.abs(toN(fiche.geometre)));
    if(diagRow)  addFlow(diagRow,  pre3m, -Math.abs(toN(fiche.diagnostics)));
    if(deplRow)  addFlow(deplRow,  pre3m, -Math.abs(toN(fiche.deplacements)));
    if(dossierRow) addFlow(dossierRow, dateAchat, -Math.abs(toN(fiche.dossierBanque)));
    const post1m=shiftWeek(dateAchat,4);
    if(avocatRow) addFlow(avocatRow, post1m, -Math.abs(toN(fiche.avocat)));
    if(hommeRow)  addFlow(hommeRow,  post1m, -Math.abs(toN(fiche.hommeArt)));
    for(let i=0;i<8;i++){
      if(creditRow) addFlow(creditRow, shiftWeek(dateAchat,i*4), -Math.abs(creditMensuel));
    }
    // CGP : flux par lot (prixFinal×margeCGPpct%), répartis 80/20 aux mêmes dates que le foncier
    // (on paye les CGP quand on vend le foncier) : dateDebutTravaux-4sem et dateDebutTravaux+4sem
    const margeCGPpctG=toN(fiche.margeCGPpct);
    let cgpEarlyG=0,cgpLateG=0;
    lotsCalcLocal.forEach((lc,idx)=>{
      const cgpLot=lc.prixFinal*(margeCGPpctG/100);
      if(idx<nb80) cgpEarlyG+=cgpLot; else cgpLateG+=cgpLot;
    });
    if(cgpRow&&cgpEarlyG>0) addFlow(cgpRow,shiftWeek(dateDebutTravaux,-4),-Math.abs(cgpEarlyG));
    if(cgpRow&&cgpLateG>0)  addFlow(cgpRow,shiftWeek(dateDebutTravaux,4),-Math.abs(cgpLateG));
    // Marge Blue : en totalité au dernier palier des travaux (offset 27)
    if(margeBlueRow) addFlow(margeBlueRow,shiftWeek(dateDebutTravaux,27),-Math.abs(dontMargeBlueFlow));
    const pre1mTrav=shiftWeek(dateDebutTravaux,-4);
    if(gfaRow)   addFlow(gfaRow,   pre1mTrav, -Math.abs(toN(fiche.gfa)));
    if(doTrcRow) addFlow(doTrcRow, pre1mTrav, -Math.abs(toN(fiche.doTrc)));
    TRAVAUX_SCHEDULE.forEach(({offsetWeeks,pct})=>{
      if(travauxRow) addFlow(travauxRow, shiftWeek(dateDebutTravaux,offsetWeeks), -Math.abs(devisTravaux*pct));
      if(moeRow)     addFlow(moeRow,     shiftWeek(dateDebutTravaux,offsetWeeks), -Math.abs(moe*pct));
    });
    const loyerMeubleMontant=toN(fiche.loyerMeuble);
    if(loyerMeubleMontant!==0){
      for(let i=0;i<7;i++){
        if(loyerRow) addFlow(loyerRow, shiftWeek(dateDebutTravaux,i*4), loyerMeubleMontant/7);
      }
    }
    lots.forEach((lot,idx)=>{
      const lc=lotsCalcLocal[idx];
      const lotRow=lotRows.find(r=>r.label===`Lot ${idx+1}`);
      if(!lotRow) return;
      const isFirst80=idx<nb80;
      // Foncier : dateDebutTravaux-4sem pour les 80%, dateDebutTravaux+4sem pour les 20%
      const saleDate=shiftWeek(dateDebutTravaux,isFirst80?-4:4);
      addFlow(lotRow, saleDate, Math.abs(lc.foncier));
      const travOffset=isFirst80?0:13;
      TRAVAUX_SCHEDULE.forEach(({offsetWeeks,pct})=>{
        addFlow(lotRow, shiftWeek(dateDebutTravaux,offsetWeeks+travOffset), Math.abs(lc.travaux*pct));
      });
    });
    const affectedRowIds=new Set([
      dpRow?.id,pcRow?.id,plaqueRow?.id,geoRow?.id,diagRow?.id,
      dossierRow?.id,avocatRow?.id,hommeRow?.id,creditRow?.id,
      gfaRow?.id,doTrcRow?.id,travauxRow?.id,moeRow?.id,
      deplRow?.id,loyerRow?.id,cgpRow?.id,margeBlueRow?.id,
      ...lotRows.map(r=>r.id)
    ].filter(Boolean));
    setValues(vs=>{
      const kept=vs.filter(v=>{
        if(!affectedRowIds.has(v.rowId)) return true;
        const r=rows.find(x=>x.id===v.rowId);
        return r&&r.statut==="R";
      });
      return [...kept,...newValues];
    });
  };

  const handleLotStatutChange=(projId,lotIdx,newStatut,date,clientNom,note,indivisionData,clientId)=>{
    setFiches(f=>{
      const fiche=f[projId]||EMPTY_FICHE();
      const lots=[...(fiche.lots||[])];
      const lot={...lots[lotIdx]};
      const oldStatut=lot.statutCommercial||"LIBRE";
      lot.historiqueStatut=[...(lot.historiqueStatut||[]),{date,from:oldStatut,to:newStatut,clientNom,note}];
      lot.statutCommercial=newStatut;
      if(clientNom) lot.clientNom=clientNom;
      // Client choisi dans le CRM : on enregistre l'identifiant (fiabilise la LIA et l'aval).
      if(clientId){
        lot.clientId=clientId;
        // Auto-proposition du CGP : si le client CRM a un CGP rattaché et qu'aucun
        // CGP n'est encore posé sur le lot, on le remplit (nom + id).
        if(!lot.cgpId){
          const cli=((crm&&crm.clients)||[]).find(c=>c&&c.id===clientId);
          if(cli&&cli.cgpId){
            const g=((crm&&crm.cgps)||[]).find(x=>x&&x.id===cli.cgpId);
            if(g){
              lot.cgpId=g.id;
              lot.cgpNom=(g.societe?g.societe+" – ":"")+`${g.prenom||""} ${g.nom||""}`.trim();
            }
          }
        }
      }
      // Sprint Indivision (étape 2) : enregistrement type d'acquisition au passage en OPTION.
      // indivisionData est null pour les autres statuts.
      if(newStatut==="OPTION" && indivisionData){
        lot.typeAcquisition       = indivisionData.typeAcquisition || "seul";
        lot.coAcheteurClientId    = indivisionData.coAcheteurClientId || "";
        lot.quotePartPrincipalPct = indivisionData.quotePartPrincipalPct!=null
          ? indivisionData.quotePartPrincipalPct
          : 50;
      }
      // Passage en LIBRE = nouveau cycle : on remet le lot "comme jamais commercialisé".
      // On efface toutes les dates du cycle (+ leurs couleurs), on détache client & CGP,
      // et on réinitialise le type d'acquisition. L'historique (historiqueStatut) garde la
      // trace des cycles passés. (Décision Pierre 2026-06-20.)
      if(newStatut==="LIBRE"){
        // ⚠️ Vider (="") et NON delete : Suivi des signatures retombe sur une date
        // dérivée de historiqueStatut quand le champ est ABSENT. En laissant la clé
        // présente mais vide, la « saisie vide » prime → la case reste blanche.
        ["dateOption","dateOptionExpire","dateLiaEnvoyee","dateLiaSignee","dateLiaRelance",
         "dateCompromisEnvoye","dateCompromisSigne","dateVendu","dateLivre"
        ].forEach(k=>{ lot[k]=""; });
        lot.dateSource={};
        lot.clientId=""; lot.clientNom="";
        lot.cgpId="";    lot.cgpNom="";
        lot.typeAcquisition="seul";
        lot.coAcheteurClientId="";
        lot.quotePartPrincipalPct=50;
      }
      lots[lotIdx]=lot;
      return {...f,[projId]:{...fiche,lots}};
    });
    // [Débranché le 2026-06-18, décision Pierre] Le passage en COMPROMIS ne crée
    // PLUS automatiquement les flux (foncier compromis+17, travaux 5 tranches) ni ne
    // recalcule la marge CGP. La saisie se fait à la main dans le plan. La bascule
    // prévisionnel→réel reste au passage en VENDU ; le bouton 🔄 (updateLotReelFlows)
    // reste disponible pour recalculer un lot ponctuellement.
    if(newStatut==="VENDU"){
      setRows(rs=>rs.map(r=>r.projetId===projId&&r.label===`Lot ${lotIdx+1}`?{...r,statut:"R"}:r));
    }
  };

  // Suivi des signatures : met à jour une date libre sur un lot (dateOption,
  // dateOptionExpire, dateLiaEnvoyee, dateLiaSignee, dateCompromisEnvoye,
  // dateCompromisSigne, dateVendu, dateLivre). Stockée dans le blob lot.data,
  // persistée par le save granulaire 'fiches'. Aucun effet de bord sur le plan.
  // 5e arg facultatif `source` ('auto' = posée par l'appli, 'manual' = saisie/modifiée
  // par l'utilisateur). On le mémorise dans lot.dateSource[field] pour pouvoir colorer
  // les cases dans Suivi des signatures (bleu = auto, ambre = manuel, gris = inconnu).
  const handleLotDateChange=(projId,lotIdx,field,value,source)=>{
    setFiches(f=>{
      const fiche=f[projId]||EMPTY_FICHE();
      const lots=[...(fiche.lots||[])];
      if(!lots[lotIdx]) return f;
      const updated={...lots[lotIdx],[field]:value};
      if(source) updated.dateSource={...(lots[lotIdx].dateSource||{}),[field]:source};
      lots[lotIdx]=updated;
      return {...f,[projId]:{...fiche,lots}};
    });
  };

  const updateLotReelFlows=(projId,lotIdx)=>{
    const fiche=fiches[projId]||EMPTY_FICHE();
    const lots=fiche.lots||[];
    const lot=lots[lotIdx];
    if(!lot) return;
    const prixReel=toN(lot.prixReel);
    if(prixReel<=0) return;
    const dateAchat=fiche.dateAcquisition;
    const dateDebutTravaux=fiche.dateDebutTravaux||(dateAchat?shiftWeek(dateAchat,39):null);
    if(!dateAchat) return;
    const lotPrices=computeLotPrices(fiche);
    const lc=lotPrices[lotIdx];
    if(!lc) return;
    const projRows=rows.filter(r=>r.projetId===projId);
    const lotRow=projRows.find(r=>r.label===`Lot ${lotIdx+1}`);
    const cgpRow=projRows.find(r=>r.label.toLowerCase().includes("marge cgp"));
    if(!lotRow) return;
    // Déterminer la date pivot selon le statut du lot
    // COMPROMIS+ → date du passage en COMPROMIS + 17 sem
    // LIA         → date du passage en LIA + 20 sem
    // OPTION      → date du passage en OPTION + 22 sem
    // Fallback    → aujourd'hui (pivot immédiat)
    const statut=lot.statutCommercial||'LIBRE';
    const pivotInfo=STATUT_PIVOT[statut]||(isAvancement(statut)?{s:'COMPROMIS',o:17}:undefined);
    let foncierDate;
    if(pivotInfo){
      const histEntry=[...(lot.historiqueStatut||[])].reverse().find(h=>h.to===pivotInfo.s);
      const pivotDate=histEntry?.date||todayIso;
      foncierDate=shiftWeek(pivotDate,pivotInfo.o);
    } else {
      // LIBRE ou statut inconnu : foncier daté à aujourd'hui
      foncierDate=todayIso;
    }
    // Foncier réel
    const foncierReel=lot.foncierReelO!==""&&lot.foncierReelO!=null
      ?toN(lot.foncierReelO)
      :lc.prixTotalHorsMarge>0?prixReel*lc.achatFNI/lc.prixTotalHorsMarge:0;
    const travauxReel=prixReel-foncierReel;
    // Travaux : max(foncier+3, dateDebutTravaux+3)
    const travStart=maxWeek(
      shiftWeek(foncierDate,3),
      shiftWeek(dateDebutTravaux||todayIso,3)
    );
    const travPcts=TRAVAUX_SCHEDULE.map(s=>s.pct);
    const newVals=[];
    if(foncierDate) newVals.push({rowId:lotRow.id,weekIso:foncierDate,montant:Math.abs(foncierReel)});
    [0,6,12,18,24].forEach((off,i)=>{
      const wk=shiftWeek(travStart,off);
      if(wk) newVals.push({rowId:lotRow.id,weekIso:wk,montant:Math.abs(travauxReel*travPcts[i])});
    });
    // CGP : recalcul complet de la row pour tout le programme
    const cgpFluxes=buildCgpFluxes(projId,fiche);
    // Mise à jour : supprime TOUS les flux du lot (F et R) + tous les flux CGP, puis réinsère.
    // IMPORTANT : on supprime aussi les R pour éviter les doublons PRIMARY KEY (row_id, week_iso)
    // quand updateLotReelFlows est appelé sur un lot déjà VENDU/LIVRE dont les flux R sont aux
    // mêmes semaines que les nouveaux flux calculés.
    setValues(vs=>{
      const kept=vs.filter(v=>{
        if(v.rowId===lotRow.id) return false; // supprime TOUT pour ce lot (F et R)
        if(cgpRow&&v.rowId===cgpRow.id) return false;
        return true;
      });
      return [...kept,...newVals,...cgpFluxes];
    });
    console.log("[TrésoImmo] Flux mis à jour pour Lot "+(lotIdx+1)+": prixReel="+prixReel+", foncierDate="+foncierDate+", travStart="+travStart);
  };

  /* ── Registre des mouvements (T/M/R étape 1) ── */
  // saveFlux/deleteFlux viennent du hook useMouvements (déclaré en tête de
  // MainApp) : écriture SQL immédiate, liée à la case (rowId+weekIso). Le
  // mouvement ENREGISTRE seulement (pas d'écriture dans le plan : c'est
  // l'édition de la cellule qui fait foi, cf. handleCellSave).
  // Ouvre la modale « mouvement » pré-remplie depuis l'édition d'une cellule réelle.
  // prefill : {programmeId, rowId, weekIso, montant, montantPrecedent, __redist}
  // Date pré-remplie = aujourd'hui SEULEMENT si aujourd'hui tombe dans la
  // semaine de la case (sinon vide : la date doit être choisie dans la semaine,
  // cf. validation FluxFormModal — demande Pierre, recette étape 1).
  const openMouvementModal=(prefill)=>{
    const m={
      ...EMPTY_FLUX(), source:"manuel",
      datePaiement:new Date().toISOString().slice(0,10),
      ...prefill,
    };
    if(m.weekIso){
      const fin=weekEndIso(m.weekIso);
      if(m.datePaiement<m.weekIso||m.datePaiement>fin) m.datePaiement="";
    }
    setMouvementModal(m);
  };
  // Log AUTOMATIQUE d'un mouvement (sans modale) : règlement de facture, appel payé…
  // L'appelant fournit les détails connus (destinataire, montant, etc.).
  const logMouvementAuto=(partial)=>saveFlux({
    ...EMPTY_FLUX(),
    datePaiement:new Date().toISOString().slice(0,10),
    ...partial,
  });

  /* ── Helpers de style (header) ── */
  const btnS=(bg,sm)=>({background:bg||"#2563eb",color:"#fff",border:"none",borderRadius:7,padding:sm?"4px 10px":"8px 16px",cursor:"pointer",fontSize:sm?11:13,fontWeight:600});

  const navItems=[
    {id:"plan",label:"📋 Plan hebdo"},
    {id:"programmes",label:"🏘 Programmes"},
    {id:"crm",label:"👥 CRM"},
    {id:"journal",label:"📒 Journal"},
    {id:"reconciliation",label:"⚖️ Réconciliation"},
    {id:"reception",label:"🏠 Réception"},
    {id:"notifications",label:"🔔 Notifications",badge:newNotifCount||null},
    {id:"appels",label:"💶 Appels de fonds"},
    {id:"signatures",label:"✍️ Suivi des signatures"},
    {id:"contenu-site",label:"🌐 Contenu site"},
    {id:"reservations-site",label:"📥 Réservations site",badge:siteResaCount||null},
  ];

  // Regroupement de la barre d'onglets en menus déroulants (sinon trop serré).
  // Notifications reste un onglet visible (alerte permanente). Chaque groupe affiche
  // la somme des pastilles de ses onglets sur son en-tête, même menu fermé.
  const navById=Object.fromEntries(navItems.map(i=>[i.id,i]));
  const navGroups=[
    {key:"treso",   label:"💰 Trésorerie", ids:["plan","reconciliation","journal","appels"]},
    {key:"commerce",label:"🏘 Commercial", ids:["programmes","crm","signatures"]},
    {key:"chantier",label:"🏠 Chantier",   ids:["reception"]},
    {key:"site",    label:"🌐 Site web",   ids:["contenu-site","reservations-site"]},
  ];
  const NAV_FLAT_IDS=["notifications"];
  const navBadge=v=>(<span style={{background:"#dc2626",color:"#fff",borderRadius:10,padding:"0 6px",fontSize:10,fontWeight:700,lineHeight:"16px",minWidth:16,textAlign:"center",marginLeft:4}}>{v}</span>);

  return (
    <div style={{fontFamily:"system-ui,sans-serif",background:C.bg,color:C.text,minHeight:"100vh"}}>
      {/* Header */}
      <div style={{background:C.card,borderBottom:"1px solid "+C.border,padding:"10px 16px",display:"flex",alignItems:"center",justifyContent:"space-between",position:"sticky",top:0,zIndex:30}}>
        <div style={{display:"flex",alignItems:"center",gap:20}}>
          <span style={{fontWeight:800,fontSize:16}}>🏗 TrésoImmo</span>
          {openMenu&&<div onClick={()=>setOpenMenu(null)} style={{position:"fixed",inset:0,zIndex:35}}/>}
          {navGroups.map(g=>{
            const items=g.ids.map(id=>navById[id]).filter(it=>it&&!it.hidden);
            if(!items.length) return null;
            const active=items.some(it=>it.id===page);
            const gBadge=items.reduce((s,it)=>s+(it.badge||0),0);
            const open=openMenu===g.key;
            return (
              <div key={g.key} style={{position:"relative",zIndex:open?40:"auto"}}>
                <button onClick={()=>setOpenMenu(open?null:g.key)}
                  style={{background:"none",border:"none",color:active?C.accent:C.muted,fontSize:13,fontWeight:active?700:400,cursor:"pointer",borderBottom:active?"2px solid "+C.accent:"2px solid transparent",padding:"4px 0",display:"flex",alignItems:"center",gap:4}}>
                  {g.label}<span style={{fontSize:9,opacity:.7}}>{open?"▲":"▼"}</span>
                  {gBadge>0&&navBadge(gBadge)}
                </button>
                {open&&(
                  <div style={{position:"absolute",top:"100%",left:0,marginTop:6,background:C.card,border:"1px solid "+C.border,borderRadius:8,boxShadow:"0 8px 24px #00000033",zIndex:40,minWidth:190,padding:4}}>
                    {items.map(it=>(
                      <button key={it.id} onClick={()=>{setPage(it.id);setFichePage(null);setOpenMenu(null);}}
                        style={{display:"flex",width:"100%",alignItems:"center",gap:4,background:page===it.id?C.bg:"none",border:"none",color:page===it.id?C.accent:C.text,fontSize:13,fontWeight:page===it.id?700:400,cursor:"pointer",padding:"8px 10px",borderRadius:6,textAlign:"left",whiteSpace:"nowrap"}}>
                        {it.label}
                        {it.badge&&navBadge(it.badge)}
                      </button>
                    ))}
                  </div>
                )}
              </div>
            );
          })}
          {NAV_FLAT_IDS.map(id=>navById[id]).filter(Boolean).map(item=>(
            <button key={item.id} onClick={()=>{setPage(item.id);setFichePage(null);}}
              style={{background:"none",border:"none",color:page===item.id?C.accent:C.muted,fontSize:13,fontWeight:page===item.id?700:400,cursor:"pointer",borderBottom:page===item.id?"2px solid "+C.accent:"2px solid transparent",padding:"4px 0",display:"flex",alignItems:"center",gap:4}}>
              {item.label}
              {item.badge&&<span style={{background:"#dc2626",color:"#fff",borderRadius:10,padding:"0 6px",fontSize:10,fontWeight:700,lineHeight:"16px",minWidth:16,textAlign:"center"}}>{item.badge}</span>}
            </button>
          ))}
        </div>
        <div style={{display:"flex",gap:8,alignItems:"center"}}>
          {pendingAttestations.length>0&&(
            <button onClick={()=>setShowAttestationList(true)}
              style={{background:"#f59e0b",color:"#fff",border:"none",borderRadius:8,padding:"4px 12px",fontSize:12,fontWeight:700,cursor:"pointer",animation:"pulse 2s infinite"}}
              title="Des attestations d'avancement ont été reçues — cliquer pour envoyer l'appel de fonds">
              🔔 {pendingAttestations.length} attestation{pendingAttestations.length>1?"s":""}
            </button>
          )}
          <span style={{fontSize:12,color:C.muted,background:"#f1f5f9",borderRadius:6,padding:"4px 10px",fontWeight:600}}>👤 {loggedUser||user}</span>
          <button onClick={onReset} style={btnS("#334155",true)}>↩ Import</button>
          {onLogout&&<button onClick={onLogout} style={{...btnS("#64748b",true),fontSize:11}}>⎋ Déco</button>}
        </div>
      </div>

      {/* ═══ PLAN HEBDO ═══ */}
      {page==="plan"&&(
        <PlanTresorerieView
          projects={projects}
          rows={rows}
          values={values}
          fiches={fiches}
          soldeInitial={soldeInitial}
          setValues={setValues}
          setRows={setRows}
          setProjects={setProjects}
          handleCellSave={handleCellSave}
          openMouvementModal={openMouvementModal}
          onNavigateToFiche={id=>{setFichePage(id);setPage("programmes");}}
        />
      )}

      {/* ═══ RÉCONCILIATION ═══ */}
      {page==="reconciliation"&&(
        <ReconciliationPage
          projects={projects}
          rows={rows}
          values={values}
          reconciliations={reconciliations}
          setReconciliations={setReconciliations}
          loggedUser={loggedUser}
        />
      )}

      {/* ═══ NOTIFICATIONS ═══ */}
      {page==="notifications"&&(
        <NotificationsView
          notifications={notifications}
          setNotifications={setNotifications}
          notifLoading={notifLoading}
          setNotifLoading={setNotifLoading}
          notifError={notifError}
          setNotifError={setNotifError}
          notifFilterBal={notifFilterBal}
          setNotifFilterBal={setNotifFilterBal}
          notifFilterCat={notifFilterCat}
          setNotifFilterCat={setNotifFilterCat}
          notifShowAll={notifShowAll}
          setNotifShowAll={setNotifShowAll}
          notifPdfModal={notifPdfModal}
          setNotifPdfModal={setNotifPdfModal}
          analyzingIds={analyzingIds}
          setAnalyzingIds={setAnalyzingIds}
          editingFacture={editingFacture}
          setEditingFacture={setEditingFacture}
          reglerModal={reglerModal}
          setReglerModal={setReglerModal}
          agentRunning={agentRunning}
          setAgentRunning={setAgentRunning}
          loadNotifs={loadNotifs}
          fiches={fiches}
          projects={projects}
          rows={rows}
          values={values}
          handleCellSave={handleCellSave}
          logMouvementAuto={logMouvementAuto}
          crm={crm}
        />
      )}

      {/* ═══ PROGRAMMES ═══ */}
      {page==="programmes"&&(
        fichePage?(
          <FicheDetail
            proj={projects.find(p=>p.id===fichePage)}
            fiche={fiches[fichePage]||EMPTY_FICHE()}
            onUpdate={(field,val)=>updateFiche(fichePage,field,val)}
            onBack={()=>setFichePage(null)}
            soldeFinOp={values.filter(v=>{const r=rows.find(r=>r.id===v.rowId);return r&&r.projetId===fichePage&&!r.hidden;}).reduce((s,v)=>s+v.montant,0)}
            rows={rows}
            values={values}
            crm={crm}
            onGenerateFlows={generateFlows}
            onLotStatutChange={(lotIdx,newStatut,date,clientNom,note,indivisionData)=>handleLotStatutChange(fichePage,lotIdx,newStatut,date,clientNom,note,indivisionData)}
            onArchitecteEmailSent={handleArchitecteEmailSent}
            onUpdateLotReelFlows={(lotIdx)=>updateLotReelFlows(fichePage,lotIdx)}
          />
        ):(
          <ProgrammesView projects={projects} fiches={fiches} values={values} rows={rows} onSelect={id=>setFichePage(id)} onCreate={createProgramme} onDelete={id=>setDeleteProjId(id)}/>
        )
      )}

      {/* ═══ CRM ═══ */}
      {page==="crm"&&<CRMView crm={crm} setCrm={setCrm}/>}

      {/* ═══ JOURNAL DE FLUX ═══ */}
      {page==="journal"&&<JournalFluxView journal={mouvements} onSave={saveFlux} onDelete={deleteFlux} projects={projects} rows={rows} crm={crm} fiches={fiches}/>}

      {/* ═══ RÉCEPTION ═══ */}
      {page==="reception"&&<ReceptionPage
        projects={projects} fiches={fiches} crm={crm} receptions={receptions} setReceptions={setReceptions}
        onLivraison={(programmeId, lotIdx)=>{
          handleLotStatutChange(programmeId, lotIdx, "LIVRE", new Date().toISOString().slice(0,10), null, "Passage en LIVRE — réception validée");
        }}
      />}

      {/* ═══ APPELS DE FONDS ═══ */}
      {page==="appels"&&<AppelsFondsView C={C} projects={projects} fiches={fiches} crm={crm} rows={rows} values={values} onLotStatutChange={handleLotStatutChange} lotsTravaux={lotsTravaux} lotsDevis={lotsDevis}/>}

      {/* ═══ SUIVI DES SIGNATURES ═══ */}
      {page==="signatures"&&<SuiviSignaturesView C={C} projects={projects} fiches={fiches} crm={crm} onUpdateLotDate={handleLotDateChange} openStatutModal={openStatutModal} onLotStatutChange={handleLotStatutChange} openLiaModal={openLiaModal}/>}

      {/* ═══ SITE INTERNET (nouveaux onglets) ═══ */}
      {page==="contenu-site"&&<ContenuSiteView C={C} projects={projects} fiches={fiches}/>}
      {page==="reservations-site"&&<SiteReservationsView C={C}/>}


      {/* Modale suppression programme */}
      {deleteProjId&&(
        <DeleteProgrammeModal
          proj={projects.find(p=>p.id===deleteProjId)||{nom:"ce programme"}}
          onConfirm={()=>deleteProgramme(deleteProjId)}
          onClose={()=>setDeleteProjId(null)}
        />
      )}

      {/* ── Pilotage du statut commercial (centralisé, ouvert via openStatutModal) ── */}
      {statutModal&&(()=>{
        const {projId,lotIdx}=statutModal;
        const fiche=fiches[projId]||EMPTY_FICHE();
        const lot=(fiche.lots||[])[lotIdx];
        if(!lot) return null;
        return (
          <HistoriqueStatutModal
            lot={lot}
            lotIdx={lotIdx}
            crm={crm}
            onSave={(li,newStatut,date,clientNom,note,indivisionData,clientId)=>{
              handleLotStatutChange(projId,li,newStatut,date,clientNom,note,indivisionData,clientId);
              // Fenêtres de suite par statut. Pas d'appel de fonds à VENDU/LIVRE
              // (le foncier est appelé par le notaire — décision 2026-06-18).
              // OPTION n'ouvre PLUS la LIA automatiquement : l'envoi se fait via le
              // bouton « Valider l'option » dans Suivi des signatures (décision 2026-06-18).
              if(newStatut==="LIA") setNotaireModal({projId,lotIdx:li});
              else if(newStatut==="COMPROMIS") setCompromisEmailModal({projId,lotIdx:li});
              setStatutModal(null);
            }}
            onClose={()=>setStatutModal(null)}
          />
        );
      })()}
      {liaModal&&(()=>{
        const {projId,lotIdx}=liaModal;
        const proj=projects.find(p=>p.id===projId);
        const fiche=fiches[projId]||EMPTY_FICHE();
        const lot=(fiche.lots||[])[lotIdx];
        if(!proj||!lot) return null;
        const lc=computeLotPrices(fiche)[lotIdx]||{};
        return <LIAModal lot={lot} lotIdx={lotIdx} proj={proj} fiche={fiche} lc={lc} crm={crm}
          onSent={()=>handleLotDateChange(projId,lotIdx,'dateLiaEnvoyee',new Date().toISOString().slice(0,10),'auto')}
          onClose={()=>setLiaModal(null)}/>;
      })()}
      {notaireModal&&(()=>{
        const {projId,lotIdx}=notaireModal;
        const proj=projects.find(p=>p.id===projId);
        const fiche=fiches[projId]||EMPTY_FICHE();
        const lot=(fiche.lots||[])[lotIdx];
        if(!proj||!lot) return null;
        const lc=computeLotPrices(fiche)[lotIdx]||{};
        return <NotaireEmailModal lot={lot} lotIdx={lotIdx} proj={proj} fiche={fiche} lc={lc} crm={crm}
          onSent={()=>handleLotDateChange(projId,lotIdx,'dateLiaSignee',new Date().toISOString().slice(0,10),'auto')}
          onClose={()=>setNotaireModal(null)}/>;
      })()}
      {compromisEmailModal&&(()=>{
        const {projId,lotIdx}=compromisEmailModal;
        const proj=projects.find(p=>p.id===projId);
        const fiche=fiches[projId]||EMPTY_FICHE();
        const lot=(fiche.lots||[])[lotIdx];
        if(!proj||!lot) return null;
        return <CompromisEmailModal lot={lot} lotIdx={lotIdx} proj={proj} fiche={fiche} crm={crm} onClose={()=>setCompromisEmailModal(null)}/>;
      })()}

      {/* ── Capture d'un mouvement (édition d'une cellule réelle du plan) ── */}
      {mouvementModal&&(
        <FluxFormModal
          flux={mouvementModal}
          projects={projects} rows={rows} crm={crm} fiches={fiches}
          lockPlanContext
          onSave={(form)=>{
            // 1) la cellule éditée fait foi → on l'écrit dans le plan
            handleCellSave(form.rowId, form.weekIso, toN(form.montant), mouvementModal.__redist||null);
            // 2) on enregistre le mouvement au registre (lié à la case, T/M/R étape 1)
            const {__redist, ...entry}=form;
            saveFlux(entry);
            setMouvementModal(null);
          }}
          onClose={()=>setMouvementModal(null)}
        />
      )}

      {/* ── Modale liste des attestations reçues de l'architecte ── */}
      {showAttestationList&&pendingAttestations.length>0&&(
        <div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.5)",zIndex:3000,display:"flex",alignItems:"center",justifyContent:"center",padding:16}} onClick={()=>setShowAttestationList(false)}>
          <div style={{background:"#fff",borderRadius:16,padding:28,width:600,maxWidth:"96vw",maxHeight:"85vh",overflowY:"auto",boxShadow:"0 8px 40px #0003",border:"1px solid #e2e8f0"}} onClick={e=>e.stopPropagation()}>
            <div style={{fontWeight:800,fontSize:17,marginBottom:4}}>🔔 Attestations d'avancement reçues</div>
            <div style={{fontSize:12,color:"#64748b",marginBottom:16}}>L'architecte a répondu. Vous pouvez maintenant envoyer l'appel de fonds au client.</div>
            {pendingAttestations.map((att,i)=>{
              const req=archRequests.find(r=>r.ref===att.ref)||att;
              const proj=req.projId?projects.find(p=>p.id===req.projId):null;
              const projNom=proj?(proj.ville||proj.nom):req.programmeNom||att.ref||'Programme inconnu';
              return (
                <div key={i} style={{background:"#f8fafc",borderRadius:10,padding:14,marginBottom:10,border:"1px solid #e2e8f0"}}>
                  <div style={{fontWeight:700,fontSize:13,marginBottom:4}}>
                    <span style={{background:"#fef9c3",color:"#713f12",borderRadius:4,padding:"1px 6px",fontSize:11,fontWeight:700,marginRight:8}}>{req.pct||'?'}%</span>
                    {projNom} — Lot {(req.lotIdx!==undefined?req.lotIdx+1:att.ref)||'?'}
                  </div>
                  <div style={{fontSize:11,color:"#64748b",marginBottom:10}}>
                    {att.receivedAt&&<span>Reçue le {new Date(att.receivedAt).toLocaleString('fr-FR')}</span>}
                    {att.emailFrom&&<span style={{marginLeft:8}}>— de {att.emailFrom}</span>}
                    {att.ref&&<span style={{marginLeft:8,fontFamily:"monospace",fontSize:10,color:"#94a3b8"}}>[{att.ref}]</span>}
                  </div>
                  <div style={{display:"flex",gap:8}}>
                    {req.projId!==undefined&&req.lotIdx!==undefined&&req.callIdx!==undefined?(
                      <button onClick={()=>{setAttestationAppel(req);setShowAttestationList(false);}}
                        style={{background:"#2563eb",color:"#fff",border:"none",borderRadius:8,padding:"7px 16px",fontSize:13,fontWeight:700,cursor:"pointer"}}>
                        📧 Envoyer l'appel de fonds au client →
                      </button>
                    ):(
                      <span style={{fontSize:12,color:"#94a3b8",fontStyle:"italic"}}>
                        Programme non identifié automatiquement — naviguez vers le lot concerné et utilisez le bouton 📧 Appel de fonds.
                      </span>
                    )}
                    <button onClick={async()=>{
                      const remaining=pendingAttestations.filter((_,j)=>j!==i);
                      setPendingAttestations(remaining);
                      if(remaining.length===0) await fetch('/api/agent/attestations',{method:'DELETE'}).catch(()=>{});
                    }} style={{background:"#f1f5f9",border:"1px solid #e2e8f0",borderRadius:8,padding:"7px 12px",fontSize:12,cursor:"pointer",color:"#64748b"}}>
                      ✓ Ignorer
                    </button>
                  </div>
                </div>
              );
            })}
            <div style={{display:"flex",justifyContent:"flex-end",marginTop:8}}>
              <button onClick={()=>setShowAttestationList(false)} style={{background:"#f1f5f9",border:"1px solid #e2e8f0",borderRadius:8,padding:"8px 18px",fontSize:13,cursor:"pointer"}}>Fermer</button>
            </div>
          </div>
        </div>
      )}

      {/* ── AppelFondsModal déclenché depuis une attestation ── */}
      {attestationAppel&&(()=>{
        const aproj=projects.find(p=>p.id===attestationAppel.projId);
        const afiche=fiches[attestationAppel.projId]||EMPTY_FICHE();
        const alot=(afiche.lots||[])[attestationAppel.lotIdx];
        if(!aproj||!alot) return null;
        return (
          <AppelFondsModal
            lot={alot}
            lotIdx={attestationAppel.lotIdx}
            proj={aproj}
            fiche={afiche}
            lc={{}}
            rows={rows}
            values={values}
            crm={crm}
            callIdxOverride={attestationAppel.callIdx}
            onClose={async()=>{
              const remaining=pendingAttestations.filter(a=>a.ref!==attestationAppel.ref);
              setPendingAttestations(remaining);
              if(remaining.length===0) await fetch('/api/agent/attestations',{method:'DELETE'}).catch(()=>{});
              setArchRequests(reqs=>reqs.filter(r=>!(r.projId===attestationAppel.projId&&r.lotIdx===attestationAppel.lotIdx)));
              setAttestationAppel(null);
            }}
          />
        );
      })()}
    </div>
  );
}

function App() {
  const [loggedUser,setLoggedUser]=useState(null);
  const [displayName,setDisplayName]=useState('');
  const [appState,setAppState]=useState(null);
  const [stateVersion,setStateVersion]=useState(null);
  // Sprint A4 (étape 4) : versions par domaine remontées par GET /api/state
  // enrichi. Propagées à MainApp via initialDomainVersions pour initialiser
  // les 4 versionRefs des hooks useDomainSave.
  const [domainVersions,setDomainVersions]=useState(null);
  const [remountKey,setRemountKey]=useState(0);
  const dirtyRef=useRef(false);
  // D7 (option A) + filtre par page (2026-07-03) : les refreshs SSE reçus pendant
  // une saisie OU concernant une page NON affichée sont mis EN ATTENTE ici.
  // Contenu = Set des portées modifiées ('plan'/'fiches'/'crm'/'misc'/'_global').
  // Appliqués dès que la saisie est finie ET que la page affichée est concernée
  // (flush 1,5 s dans l'effet SSE — couvre aussi le changement de page).
  const pendingRefreshRef=useRef(new Set());
  const stateVersionRef=useRef(null);
  // Sprint A4 (étape 5) : version-ref miroir de domainVersions, mutée à la fois
  // par le bootstrap (useEffect ci-dessous) et par les saves locaux (callback
  // handleDomainVersionChange câblé dans MainApp). Source de vérité pour
  // l'anti-ping-pong du listener SSE 'domain-updated'.
  const domainVersionsRef=useRef(null);
  useEffect(()=>{ stateVersionRef.current = stateVersion; },[stateVersion]);
  useEffect(()=>{ domainVersionsRef.current = domainVersions ? {...domainVersions} : {}; },[domainVersions]);

  // Sprint A4 (étape 5) : callback appelé par chaque useDomainSave après un
  // save 200 OK. Mute domainVersionsRef SANS re-render (évite la boucle
  // setState→re-render→re-save). C'est cette mutation qui permet au listener
  // SSE de skip notre propre echo (version reçue === version ref locale).
  const handleDomainVersionChange = useCallback((domain, newVersion) => {
    if (!domainVersionsRef.current) domainVersionsRef.current = {};
    domainVersionsRef.current[domain] = newVersion;
  }, []);

  useEffect(()=>{
    fetch('/api/me')
      .then(r=>r.json())
      .then(d=>{
        setLoggedUser(d.user||false);
        if(d.displayName) setDisplayName(d.displayName);
      })
      .catch(()=>setLoggedUser(false));
  },[]);

  useEffect(()=>{
    if(!loggedUser){setAppState(null);setStateVersion(null);setDomainVersions(null);return;}
    fetch('/api/state')
      .then(r=>{
        if(r.status===401){setLoggedUser(false);return null;}
        return r.json();
      })
      .then(resp=>{
        if(resp===null) return;
        const s  = (resp && typeof resp==='object' && 'data' in resp) ? resp.data : resp;
        const v  = (resp && typeof resp==='object' && 'version' in resp) ? resp.version : null;
        const dv = (resp && typeof resp==='object' && 'domainVersions' in resp) ? resp.domainVersions : null;
        setStateVersion(v);
        setDomainVersions(dv);
        if(s&&s.projects) setAppState(s);
        else setAppState(false);
      })
      .catch(()=>setAppState(false));
  },[loggedUser]);

  useEffect(()=>{
    if(!loggedUser) return;
    let es;
    try { es = new EventSource('/api/events'); }
    catch(e){ console.warn('SSE indisponible:', e); return; }

    // D7 (option A) — vrai = l'utilisateur saisit dans un champ (input/textarea/
    // select/contentEditable). Tant que c'est vrai, on DIFFÈRE le refresh : un
    // refetch+remount fermerait la fenêtre de saisie et jetterait ce qui est tapé.
    const isEditing = ()=>{
      const el = document.activeElement;
      if(!el) return false;
      const t = el.tagName;
      return t==='INPUT' || t==='TEXTAREA' || t==='SELECT' || el.isContentEditable;
    };
    // ── Filtre par page (2026-07-03) ─────────────────────────────────────────
    // Un changement reçu par SSE ne déclenche un rechargement QUE si la portée
    // modifiée est affichée par la page en cours. Sinon il reste en attente
    // (pendingRefreshRef) et sera appliqué quand l'utilisateur ira sur une page
    // concernée (le flush 1,5 s relit la page courante à chaque tick).
    // Portées : les 4 domaines A4 + '_global' = event 'state-updated' sans
    // domaine (réceptions PUT global, agent email, appels EG).
    // Mapping volontairement GÉNÉREUX (une page y figure dès qu'elle AFFICHE des
    // données du domaine) : en cas de doute on recharge — jamais l'inverse.
    const DOMAIN_PAGES = {
      plan:    ['plan','reconciliation','programmes'],
      fiches:  ['programmes','signatures','appels','reception','contenu-site','reservations-site','notifications'],
      crm:     ['crm','programmes','signatures','reception','notifications','appels'],
      misc:    ['plan','journal','reconciliation','notifications'],
      mouvements: ['plan','journal','reconciliation','notifications'], // T/M/R étape 1

      _global: ['reception','notifications','appels','programmes','signatures'],
    };
    const currentPage = ()=>{ try{ return sessionStorage.getItem('tresoimmo_page')||'plan'; }catch{ return 'plan'; } };
    const pageConcerned = (scope)=>{
      const pages = DOMAIN_PAGES[scope];
      if(!pages) return true; // portée inconnue → prudence, on recharge
      return pages.includes(currentPage());
    };
    // Applique réellement le rafraîchissement : refetch global (data + version +
    // les 4 domainVersions) puis remount de MainApp (ré-initialise les versionRefs).
    // Le refetch étant GLOBAL, il couvre toutes les portées en attente → purge du Set.
    const runRefresh = ()=>{
      pendingRefreshRef.current = new Set();
      fetch('/api/state')
        .then(r=>r.ok ? r.json() : null)
        .then(resp=>{
          if(!resp || !resp.data || !resp.data.projects) return;
          setAppState(resp.data);
          setStateVersion(resp.version);
          if ('domainVersions' in resp) setDomainVersions(resp.domainVersions);
          setRemountKey(k=>k+1);
        })
        .catch(()=>{});
    };
    // Tente d'appliquer les refreshs en attente : rien en attente → no-op ;
    // saisie/save en vol → on attend ; aucune portée en attente ne concerne la
    // page affichée → on attend (ça flushera au changement de page).
    const attemptFlush = ()=>{
      const pending = pendingRefreshRef.current;
      if(!pending || pending.size===0) return;
      if(dirtyRef.current || isEditing()) return;
      let concerned=false; pending.forEach(s=>{ if(pageConcerned(s)) concerned=true; });
      if(concerned) runRefresh();
    };
    // Demande de refresh pour une portée : mise en attente puis tentative
    // immédiate (le cas nominal « page concernée, pas de saisie » recharge
    // tout de suite, comme avant).
    const requestRefresh = (scope)=>{
      pendingRefreshRef.current.add(scope||'_global');
      attemptFlush();
    };

    const onUpdate = (ev)=>{
      try{
        const payload = JSON.parse(ev.data||'{}');
        const v = payload && payload.version;
        // Ignore ma propre action (origin = mon email) : j'ai déjà la donnée en
        // local, inutile de recharger (sinon remount qui ferme mes fenêtres).
        if(payload && payload.origin && payload.origin === loggedUser) return;
        if(!v) return;
        if(v === stateVersionRef.current) return;
        requestRefresh('_global'); // event sans domaine → portée globale
      }catch{}
    };
    es.addEventListener('state-updated', onUpdate);

    // Sprint A4 (étape 5) : SSE granulaire par domaine.
    // Émis par chaque PUT /api/state/:domain (cf. routes/state.js
    // handleDomainPut). Permet aux autres onglets de refresh leur vue sans
    // attendre un PUT global (qui n'arrive plus que pour receptions/import
    // depuis A4-4).
    //
    // Anti-ping-pong : on skip si la version reçue ≤ celle stockée localement
    // dans domainVersionsRef.current[domain]. Cette ref est mise à jour :
    //   - au bootstrap (useEffect sur domainVersions)
    //   - après chaque save local 200 OK (callback handleDomainVersionChange
    //     passé en prop à MainApp puis aux 4 useDomainSave)
    // Donc notre propre echo SSE arrive avec une version déjà connue → skip.
    //
    // Garde-fou dirtyRef (global, option A) : on skip aussi si un save local
    // est en flight, pour ne pas écraser des modifs utilisateur non encore
    // sauvegardées.
    const onDomainUpdate = (ev)=>{
      try{
        const payload = JSON.parse(ev.data||'{}');
        const domain  = payload && payload.domain;
        const version = payload && payload.version;
        const localVer = domainVersionsRef.current && domainVersionsRef.current[domain];
        // Ignore ma propre action (origin = mon email) : j'ai déjà la donnée en
        // local, inutile de recharger (sinon remount qui ferme mes fenêtres).
        if(payload && payload.origin && payload.origin === loggedUser) return;
        if(!domain || !version) return;
        if(localVer && version <= localVer) return;  // echo plus ancien (anti-ping-pong A4-5)
        // Refetch global (data + version + 4 domainVersions en une requête). Plus
        // simple qu'un refetch ciblé car les states (crm, fiches, …) vivent dans
        // MainApp. requestRefresh DIFFÈRE si saisie en cours (D7) ou si le
        // domaine ne concerne pas la page affichée (filtre par page).
        requestRefresh(domain);
      }catch{}
    };
    es.addEventListener('domain-updated', onDomainUpdate);

    // D7 + filtre par page — flush des refreshs différés : dès que la saisie est
    // terminée, qu'aucun save local n'est en vol ET que la page affichée est
    // concernée par une portée en attente. Poll léger (1,5 s) → la vue se met à
    // jour juste après la fin de saisie OU juste après un changement de page.
    const flushTimer = setInterval(attemptFlush, 1500);

    es.onerror = ()=>{ /* no-op */ };
    return ()=>{ try{ es.close(); }catch{} clearInterval(flushTimer); };
  },[loggedUser]);

  const handleImport=async(data,solde)=>{
    await fetch('/api/state',{method:'DELETE'});
    const state={...data,soldeInitial:solde,fiches:{},crm:{entreprises:[],clients:[],cgps:[],banques:[],comptes:[]},journal:[]};
    const putResp = await fetch('/api/state',{
      method:'PUT',
      headers:{'Content-Type':'application/json'},
      body:JSON.stringify({data:state, version:null})
    });
    try {
      const b = await putResp.json();
      if (b && b.version) setStateVersion(b.version);
    } catch {}
    setAppState(state);
    setShowImportOverlay(false);
  };

  const [showImportOverlay,setShowImportOverlay]=React.useState(false);
  const handleReset=()=>setShowImportOverlay(true);

  const handleLogout=async()=>{
    await fetch('/api/logout',{method:'POST'});
    setLoggedUser(false);
    setAppState(null);
  };

  if(loggedUser===null) return (
    <div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100vh',flexDirection:'column',gap:16,color:'#64748b'}}>
      <div style={{width:40,height:40,border:'4px solid #e2e8f0',borderTopColor:'#2563eb',borderRadius:'50%',animation:'spin 0.8s linear infinite'}}/>
      <style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
      <div>Chargement de TrésoImmo…</div>
    </div>
  );

  if(!loggedUser) return <LoginScreen/>;

  if(appState===null) return (
    <div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100vh',flexDirection:'column',gap:16,color:'#64748b'}}>
      <div style={{width:40,height:40,border:'4px solid #e2e8f0',borderTopColor:'#2563eb',borderRadius:'50%',animation:'spin 0.8s linear infinite'}}/>
      <style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
      <div>Chargement de TrésoImmo…</div>
    </div>
  );

  if(!appState||showImportOverlay) return <ImportScreen onImport={handleImport} onCancel={appState&&showImportOverlay?()=>setShowImportOverlay(false):null}/>;
  return <MainApp
    key={remountKey}
    data={{projects:appState.projects,rows:appState.rows,values:appState.values}}
    soldeInitial={appState.soldeInitial||0}
    savedFiches={appState.fiches||null}
    savedCrm={appState.crm||null}
    savedMouvements={appState.mouvements||[]}
    savedArchRequests={appState.archRequests||[]}
    savedReconciliations={appState.reconciliations||{}}
    savedReceptions={appState.receptions||[]}
    onReset={handleReset}
    loggedUser={displayName||loggedUser}
    onLogout={handleLogout}
    initialVersion={stateVersion}
    initialDomainVersions={domainVersions}
    onVersionChange={setStateVersion}
    onDomainVersionChange={handleDomainVersionChange}
    dirtyRef={dirtyRef}
  />;
}

ReactDOM.render(<App/>, document.getElementById("root"));
