/* ======================================================================== SENTINEL — Pages Toutes les vues principales, exposées via window pour le router ======================================================================== */ const { useState, useEffect, useMemo, useRef } = React; const { AppCtx, useApp, I, PageHeader, Card, EmptyState, MetricRow, Kpi, Spark, Pill, Status, Modal, Tabs, } = window; // Types de bien : enumeration Stream Estate 0..6 (cf. modules/collecte.py, // TYPES_BIEN). Valeur = nom canonique stocke en base, libelle = affichage. const TYPES_BIEN = [ ['appartement', 'Appartement'], ['maison', 'Maison'], ['immeuble', 'Immeuble'], ['parking', 'Parking'], ['bureau', 'Bureaux'], ['terrain', 'Terrain'], ['local commercial', 'Local commercial'], ]; const _typeBienLabel = (v) => { if (!v) return '—'; const t = TYPES_BIEN.find(([k]) => k === String(v).toLowerCase()); return t ? t[1] : v; }; const _typeBienOptions = () => TYPES_BIEN.map(([v, l]) => ); // Achat / location (lot location pro). En location, `prix` est le loyer // publie par le portail (mensuel en residentiel). const TRANSACTIONS = [['vente', 'Achat'], ['location', 'Location']]; const _isLocation = (x) => !!x && (x.transaction || 'vente') === 'location'; const _transactionLabel = (x, achat = 'Achat') => (_isLocation(x) ? 'Location' : achat); const _prixLabel = (a) => (_isLocation(a) ? 'Loyer' : 'Prix'); const _prixTexte = (a) => { if (!a || a.prix == null || a.prix === '') return '—'; const s = Number(a.prix).toLocaleString('fr-FR') + ' €'; return _isLocation(a) ? s + ' /mois' : s; }; const _eur2 = (v) => Number(v || 0).toFixed(2).replace('.', ',') + ' €'; // Résumé lisible d'un passage de collecte (reçues / nouvelles / déjà connues / coût) const _collecteResume = (c, m, n) => { const init = (c.collecte_initiale || []).length ? ` Collecte initiale : ${c.collecte_initiale.map((t) => _transactionLabel({ transaction: t }).toLowerCase()).join(', ')}.` : ''; return `Collecte ${c.mode === 'complet' ? 'complète' : 'OK'} : ${c.recus || 0} reçues, ${c.inseres || 0} nouvelles, ${c.skips || 0} déjà connues (≈ ${_eur2(c.cout_estime_eur)}). ${m.matches || 0} matches, ${n} notifs.${init}`; }; const fmt = { num: (n) => n == null ? '—' : n.toLocaleString('fr-FR').replace(/\u202F/g, ' '), eur: (n) => n == null ? '—' : n.toLocaleString('fr-FR').replace(/\u202F/g, ' ') + ' €', days: (n) => n + ' jour' + (n > 1 ? 's' : ''), pct: (n) => n + ' %', // Remplace la source generique "stream_estate" (techno de collecte interne) // par un libelle neutre "web" pour ne pas exposer la provenance aux users. // Les vraies sources publiques (leboncoin, seloger...) restent affichees. source: (s) => s === 'stream_estate' ? 'web' : (s || '—'), }; /* ============================================================ */ /* DASHBOARD */ /* Phase 1 : branche sur les vraies APIs via window.SentinelAPI. */ /* Endpoints utilises : */ /* GET /api/dashboard/stats -> KPIs */ /* GET /api/dashboard/matches-today -> Table matches */ /* GET /api/dashboard/activity -> Liste activite */ /* GET /api/dashboard/rappels -> Alert strip */ /* GET /api/dashboard/annonces-par-jour -> Chart 7j */ /* ============================================================ */ const DashboardPage = () => { const { toast, goto, isMobile } = useApp(); const [stats, setStats] = useState(null); const [rawMatches, setRawMatches] = useState(null); const [rawActivity, setRawActivity] = useState(null); const [rawRappels, setRawRappels] = useState(null); const [annoncesParJour, setAnnoncesParJour] = useState(null); const [loaded, setLoaded] = useState(false); // Paquet G : onboarding checklist + bannière quota pack const [onboarding, setOnboarding] = useState({ tele: null, hasClient: null, hasCollecte: null }); const [myPack, setMyPack] = useState(null); useEffect(() => { const api = window.SentinelAPI; if (!api) { setLoaded(true); return; } const safe = (p) => p.catch((e) => { console.warn('[Dashboard] fetch err:', e); return null; }); Promise.all([ safe(api.fetchAuth('/api/dashboard/stats')), safe(api.fetchAuth('/api/dashboard/matches-today')), safe(api.fetchAuth('/api/dashboard/activity')), safe(api.fetchAuth('/api/dashboard/rappels')), safe(api.fetchAuth('/api/dashboard/annonces-par-jour')), // Paquet G : onboarding (3 etapes) + pack quota safe(api.fetchAuth('/api/cabinet/telegram')), safe(api.fetchAuth('/api/clients?limit=1')), safe(api.fetchAuth('/api/collecte/status')), safe(api.fetchAuth('/api/me/pack')), ]).then(([s, m, a, r, ap, tele, cli, col, pack]) => { setStats((s && s.data) || null); setRawMatches((m && m.data) || []); setRawActivity((a && a.data) || []); setRawRappels((r && r.data) || []); setAnnoncesParJour((ap && ap.data) || []); setOnboarding({ tele: !!(tele && tele.data && tele.data.configured), hasClient: !!(cli && Array.isArray(cli.data) && cli.data.length > 0), hasCollecte: !!(col && col.data && col.data.derniere_collecte), }); setMyPack((pack && pack.data) || null); setLoaded(true); }); }, []); // ─── Transformations vers le shape attendu par les composants ─────── const matches = (rawMatches || []).slice(0, 5).map((m) => { const a = m.annonce || {}; const c = m.client || {}; const bienParts = []; if (a.pieces) bienParts.push('T' + a.pieces); else if (a.type_bien) bienParts.push(_typeBienLabel(a.type_bien)); if (a.ville) bienParts.push(a.ville); return { score: m.score, client: c.nom || '—', bien: bienParts.join(' ') || '—', prix: a.prix, transaction: a.transaction, surface: a.surface, ville: a.ville || '—', time: m.notifie ? 'envoyé' : 'nouveau', }; }); const activity = (rawActivity || []).map((a) => { const tagMap = { annonce: 'Annonce', match_parfait: 'Match', geo_manuel: 'Géo' }; const tag = tagMap[a.type] || a.type || '—'; let time = '—'; if (a.date) { const d = new Date(a.date); time = d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); } return { tag, text: a.label, time, em: a.type === 'match_parfait' }; }); const firstRappel = (rawRappels || [])[0] || null; const nbRappels = (rawRappels || []).length; const rappelNom = firstRappel ? [firstRappel.prenom, firstRappel.nom].filter(Boolean).join(' ') || firstRappel.email || '—' : null; const chartData = (annoncesParJour || []).map((d) => d.annonces || 0); const chartLabels = (annoncesParJour || []).map((d) => d.date ? d.date.slice(5) /* MM-DD depuis YYYY-MM-DD */ : '—' ); // KPIs : helpers d'affichage du delta const deltaStr = (pct) => { if (pct == null) return '—'; if (pct === 0) return '0 vs hier'; return (pct > 0 ? '+' : '') + pct + ' % vs hier'; }; const deltaDir = (pct) => (pct == null || pct === 0 ? 'flat' : pct > 0 ? 'up' : 'down'); /* Chantier 07 : sur telephone, « Aujourd'hui » remplace le tableau de bord : 2 KPI cote a cote, matches du jour, relances Paul. Listes 2 colonnes. */ if (isMobile) { const kpiMatches = stats ? stats.matches_parfaits : null; const kpiAnn = stats ? stats.annonces_aujourdhui : null; const tier = (sc) => (sc >= 80 ? 'high' : sc >= 50 ? 'mid' : 'low'); return (
{fmt.num(matches.length)} nouveau{matches.length > 1 ? 'x' : ''} match{matches.length > 1 ? 's' : ''} · {fmt.num(nbRappels)} relance{nbRappels > 1 ? 's' : ''} à faire : <> Chargement…} />
{!loaded ? : matches.length === 0 ? : (
{matches.map((m, i) => ( ))}
)}
{!loaded ? : nbRappels === 0 ? : (
{(rawRappels || []).slice(0, 5).map((r, i) => { const nom = [r.prenom, r.nom].filter(Boolean).join(' ') || r.email || '—'; return ( ); })}
)}
); } return (
Données à jour {stats ? <> · {fmt.num(stats.clients_actifs || 0)} client{(stats.clients_actifs || 0) > 1 ? 's' : ''} actif{(stats.clients_actifs || 0) > 1 ? 's' : ''} · {fmt.num(stats.matches_parfaits || 0)} match{(stats.matches_parfaits || 0) > 1 ? 's' : ''} parfait{(stats.matches_parfaits || 0) > 1 ? 's' : ''} aujourd'hui : null} : <> Chargement…} actions={<> } />
{/* Paquet G : onboarding checklist 3 etapes (parite V1). S'affiche tant qu'une etape au moins n'est pas faite. Telegram configure + 1 client cree + 1 collecte lancee. */} {loaded && (onboarding.tele === false || onboarding.hasClient === false || onboarding.hasCollecte === false) && (
Demarrage
Termine la configuration de ton cabinet
{[onboarding.tele, onboarding.hasClient, onboarding.hasCollecte].filter(Boolean).length}/3
{[ { ok: onboarding.tele, label:'Configurer Telegram', desc:'Recevoir les matches parfaits en push.', target:'parametres' }, { ok: onboarding.hasClient, label:'Creer ton 1er client', desc:'Saisir des criteres pour declencher les matches.', target:'clients' }, { ok: onboarding.hasCollecte, label:'Lancer ta 1re collecte', desc:'Charger des annonces depuis Stream Estate.', target:'outils' }, ].map((s, i) => (
goto(s.target)} style={{cursor:'pointer', padding:'12px 14px', border:'1px solid var(--color-border)', borderRadius:4, background: s.ok ? 'var(--color-surface-2)' : 'transparent'}}>
{s.ok ? '✓' : (i + 1)} {s.label}
{s.desc}
))}
)} {/* Paquet G : banniere quota pack (parite V1). S'affiche si on approche/depasse le quota matches du pack courant. */} {loaded && myPack && myPack.quota_matches && (() => { const q = myPack.quota_matches; // Backend renvoie {used, limit, pct, exceeded} (ou shape similaire). // On affiche si pct > 70 ou si exceeded. const limit = q.limit != null ? q.limit : (q.quota_matches_mois || null); const used = q.used != null ? q.used : (q.n_matches_mois || 0); if (limit == null || limit <= 0) return null; const pct = Math.round((used / limit) * 100); if (pct < 70) return null; const tone = pct >= 100 ? 'danger' : pct >= 90 ? 'warning' : 'info'; const packName = (myPack.pack && (myPack.pack.label || myPack.pack.name)) || 'Starter'; return ( = 100 ? 'var(--color-danger)' : 'var(--color-warning)')}}>
Pack {packName} {pct >= 100 ? 'Quota mensuel atteint' : `Quota mensuel a ${pct} %`} — {used} / {limit} matches.
); })()} {/* AI suggestions card — donnees reelles (/api/dashboard/suggestions) */} {window.AISuggestions && { if (s && s.goto) goto(s.goto); toast(`Action : ${s.cta}`,'success'); }} />} {/* Alert strip — premier client a recontacter (s'il y en a) */} {firstRappel && (
À recontacter {nbRappels === 1 ? '1 client sans interaction depuis plus de 14 jours.' : `${nbRappels} clients sans interaction depuis plus de 14 jours.`}
{rappelNom} {firstRappel.jours_sans_contact != null && ( {firstRappel.jours_sans_contact} j sans contact )}
{firstRappel.email && ( Email )}
)} {/* KPI grid (branche /api/dashboard/stats) */}
{/* Detail grid : matches + activite */}
goto('annonces')}> Voir tout} flush> {!loaded ? ( ) : matches.length === 0 ? ( goto('annonces')}>Voir les annonces} /> ) : (
{matches.map((m, i) => ( ))}
ScoreClientBienPrix SurfaceVilleStatut
{m.client} {m.bien} {_prixTexte(m)} {m.surface != null ? m.surface + ' m²' : '—'} {m.ville} {m.time}
)}
{!loaded ? ( ) : activity.length === 0 ? ( ) : activity.map((a, i) => (
{a.tag} {a.em ? {a.text} : a.text} {a.time}
))}
{/* Heatmap activity (mocke pour l'instant) */} {window.Heatmap && ( )} {/* Charts */}
{chartData.length > 0 ? : (loaded ? : ) } goto('rapports')}>Voir rapports }>
); }; const ScoreBar = ({ score }) => { if (score == null) return ; const tier = score >= 80 ? 'high' : score >= 50 ? 'mid' : ''; return (
{score} %
); }; const ChartArea = ({ data, labels }) => { const w = 600, h = 200; const pad = { t: 12, r: 16, b: 24, l: 32 }; const innerW = w - pad.l - pad.r; const innerH = h - pad.t - pad.b; const max = Math.max(...data, 6); const yTicks = [0, 2, 4, 6]; const pts = data.map((v, i) => { const x = pad.l + (i / (data.length - 1)) * innerW; const y = pad.t + innerH - (v / max) * innerH; return { x, y, v }; }); const lineD = pts.map((p, i) => (i === 0 ? `M ${p.x},${p.y}` : `L ${p.x},${p.y}`)).join(' '); const fillD = `${lineD} L ${pts[pts.length-1].x},${pad.t + innerH} L ${pad.l},${pad.t + innerH} Z`; return (
{yTicks.map((t, i) => { const y = pad.t + innerH - (t / max) * innerH; return ; })} {yTicks.map((t, i) => { const y = pad.t + innerH - (t / max) * innerH; return {t}; })} {pts.map((p, i) => ( ))} {labels.map((l, i) => { const x = pad.l + (i / (labels.length - 1)) * innerW; return {l}; })}
); }; /* ============================================================ */ /* CLIENTS */ /* Phase 2 : branche sur GET /api/clients + actions CRUD : */ /* POST /api/clients (Nouveau) */ /* GET /api/clients/{id} (Détail) */ /* PUT /api/clients/{id} (Édition) */ /* DELETE /api/clients/{id} (Supprimer) */ /* GET /api/clients/{id}/matches (matches du client) */ /* GET /api/clients/export.csv (Export) */ /* POST /api/clients/import.csv (Import) */ /* ============================================================ */ // Mapper : ClientOut backend -> shape attendu par la table existante const _mapClient = (c) => { const cr = c.criteres || {}; const unite = _isLocation(cr) ? ' € /mois' : ' €'; const budget = cr.budget_min != null && cr.budget_max != null ? `${cr.budget_min.toLocaleString('fr-FR')} → ${cr.budget_max.toLocaleString('fr-FR')}${unite}` : cr.budget_max != null ? `≤ ${cr.budget_max.toLocaleString('fr-FR')}${unite}` : cr.budget_min != null ? `≥ ${cr.budget_min.toLocaleString('fr-FR')}${unite}` : '—'; const criteres = [ _isLocation(cr) ? 'Location' : null, cr.pieces_min ? `${cr.pieces_min}P` : null, cr.surface_min ? `≥ ${cr.surface_min} m²` : null, cr.type_bien, ].filter(Boolean).join(' · ') || '—'; const lastContactDays = c.date_derniere_interaction ? Math.floor((Date.now() - new Date(c.date_derniere_interaction).getTime()) / (1000 * 60 * 60 * 24)) : null; const villesStr = (cr.villes && cr.villes.length) ? cr.villes.join(', ') : '—'; // Chantier 03 : dans la table, un seul montant (le max) et des criteres // courts « 4 p. · Ronchin, Lille » ; la fourchette complete reste dans la fiche. const budgetMax = cr.budget_max != null ? `${cr.budget_max.toLocaleString('fr-FR')}${unite}` : cr.budget_min != null ? `≥ ${cr.budget_min.toLocaleString('fr-FR')}${unite}` : '—'; const criteresCourts = [ _isLocation(cr) ? 'Location' : null, cr.pieces_min ? `${cr.pieces_min} p.` : null, cr.type_bien ? _typeBienLabel(cr.type_bien) : null, (cr.villes && cr.villes.length) ? cr.villes.join(', ') : null, ].filter(Boolean).join(' · ') || '—'; return { id: c.id, raw: c, // on garde l'original pour edition name: `${c.prenom || ''} ${c.nom || ''}`.trim() || c.email || `Client #${c.id}`, email: c.email || '—', telephone: c.telephone || '', budget, budgetMax, critères: criteres, critèresCourts: criteresCourts, villes: villesStr, lastContact: lastContactDays, agent: c.agent_id || '—', state: c.statut || 'prospect', }; }; /* Chantier 03 : indicateur de fraicheur du dernier contact */ const Fresh = ({ days }) => { if (days == null) return ; const tone = days <= 3 ? 'ok' : days <= 10 ? 'warn' : 'late'; return {days} j; }; /* Menu ⋯ d'une ligne de table */ const RowMenu = ({ items }) => { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [open]); return ( {open && ( {items.map((it) => ( ))} )} ); }; /* Chantier 03 : barre d'actions groupees (selection par cases a cocher) */ const ClientsBulkBar = ({ count, onClear, onExport, onAssign, onDelete }) => { if (!count) return null; return (
{count} sélectionné{count > 1 ? 's' : ''}
); }; /* Chantier 07 : indicateur de position du kanban (une colonne par ecran) */ const KanbanDots = () => { const [idx, setIdx] = useState(0); const [n, setN] = useState(0); useEffect(() => { const el = document.querySelector('.kanban'); if (!el) return; const cols = el.querySelectorAll('.kanban__col'); setN(cols.length); const on = () => { const w = cols[0] ? cols[0].offsetWidth + 12 : 1; setIdx(Math.round(el.scrollLeft / w)); }; el.addEventListener('scroll', on, { passive: true }); on(); return () => el.removeEventListener('scroll', on); }, []); if (n < 2) return null; return ; }; const _freshTone = (days) => (days == null ? 'none' : days <= 3 ? 'ok' : days <= 10 ? 'warn' : 'late'); const ClientsPage = () => { const { toast, isMobile } = useApp(); // Chantier 07 : feuille de detail (mobile) + declencheur « Nouveau client » // depuis le bouton flottant (evenement ou drapeau de session). const [sheetClient, setSheetClient] = useState(null); useEffect(() => { const on = () => setShowCreate(true); window.addEventListener('sentinel:new-client', on); try { if (sessionStorage.getItem('sentinel.clients.new') === '1') { sessionStorage.removeItem('sentinel.clients.new'); setShowCreate(true); } } catch (_) {} return () => window.removeEventListener('sentinel:new-client', on); }, []); const sendMatches = async (c) => { if (!c.raw || !c.raw.email) { toast('Pas d\'email renseigné pour ce client.', 'warning'); return; } try { const r = await window.SentinelAPI.fetchAuth(`/api/clients/${c.id}/matches`); const ms = (r && r.data) || []; if (!ms.length) { toast('Aucun match à envoyer pour l\'instant.', 'warning'); return; } const top = [...ms].sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 5); const lignes = top.map((m) => { const a = m.annonce || {}; return `- ${a.titre || a.ville || 'Annonce'}${a.ville ? ' (' + a.ville + ')' : ''} : ${_prixTexte(a)}${a.url ? ' — ' + a.url : ''}`; }); const body = `Bonjour ${c.raw.prenom || ''},\n\nVoici les biens qui correspondent à votre recherche :\n\n${lignes.join('\n')}\n\nJe reste disponible pour organiser des visites.\n`; window.location.href = 'mailto:' + encodeURIComponent(c.raw.email) + '?subject=' + encodeURIComponent('Vos biens sélectionnés') + '&body=' + encodeURIComponent(body); } catch (e) { toast('Échec : ' + (e.message || e), 'error'); } }; const [view, setView] = useState('liste'); const [tab, setTab] = useState('tous'); const [q, setQ] = useState(''); const [clients, setClients] = useState(null); // null = loading const [showCreate, setShowCreate] = useState(false); const [detailClient, setDetailClient] = useState(null); // {raw, mapped} ou null const [editClient, setEditClient] = useState(null); // Paquet G : filtre par agent + modale delete stylee const [agentFilter, setAgentFilter] = useState(''); const [agents, setAgents] = useState([]); const [deleteCandidate, setDeleteCandidate] = useState(null); const [deleting, setDeleting] = useState(false); // Chantier 03 : selection par cases a cocher -> actions groupees const [selected, setSelected] = useState(() => new Set()); const [assignOpen, setAssignOpen] = useState(false); const [assignTo, setAssignTo] = useState(''); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [bulkBusy, setBulkBusy] = useState(false); const reload = () => { if (!window.SentinelAPI) { setClients([]); return; } setClients(null); setSelected(new Set()); window.SentinelAPI.fetchAuth('/api/clients') .then((r) => setClients((r.data || []).map(_mapClient))) .catch((e) => { console.warn('[Clients] fetch err', e); setClients([]); toast('Erreur de chargement clients.', 'error'); }); }; const toggleSel = (id) => setSelected((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); const toggleAll = (ids) => setSelected((prev) => (ids.every((id) => prev.has(id)) ? new Set() : new Set(ids))); const selectedClients = () => (clients || []).filter((c) => selected.has(c.id)); const exportSelection = () => { const rows = selectedClients(); if (!rows.length) return; const esc = (v) => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; const head = ['nom', 'prenom', 'email', 'telephone', 'statut', 'budget_min', 'budget_max', 'transaction', 'type_bien', 'villes', 'agent']; const lines = [head.join(';')].concat(rows.map((c) => { const cr = c.raw.criteres || {}; return [c.raw.nom, c.raw.prenom, c.raw.email, c.raw.telephone, c.state, cr.budget_min, cr.budget_max, cr.transaction || 'vente', cr.type_bien, (cr.villes || []).join('|'), c.raw.agent_id].map(esc).join(';'); })); const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `clients-selection-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast(`${rows.length} client${rows.length > 1 ? 's' : ''} exporté${rows.length > 1 ? 's' : ''}.`, 'success'); }; const runAssign = async () => { const rows = selectedClients(); if (!rows.length) return; setBulkBusy(true); let ok = 0; for (const c of rows) { try { await window.SentinelAPI.fetchAuth(`/api/clients/${c.id}`, { method: 'PUT', body: { agent_id: assignTo || null } }); ok++; } catch (e) { /* on continue, bilan a la fin */ } } setBulkBusy(false); setAssignOpen(false); toast(`${ok}/${rows.length} client${rows.length > 1 ? 's' : ''} assigné${ok > 1 ? 's' : ''}.`, ok === rows.length ? 'success' : 'warning'); reload(); }; const runBulkDelete = async () => { const rows = selectedClients(); if (!rows.length) return; setBulkBusy(true); let ok = 0; for (const c of rows) { try { await window.SentinelAPI.fetchAuth(`/api/clients/${c.id}`, { method: 'DELETE' }); ok++; } catch (e) { /* bilan a la fin */ } } setBulkBusy(false); setBulkDeleteOpen(false); toast(`${ok}/${rows.length} client${rows.length > 1 ? 's' : ''} supprimé${ok > 1 ? 's' : ''}.`, ok === rows.length ? 'success' : 'warning'); reload(); }; useEffect(() => { reload(); }, []); // Paquet G : charge les agents du cabinet pour le filtre dropdown. useEffect(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/cabinet/users') .then((r) => setAgents(Array.isArray(r.data) ? r.data : [])) .catch(() => {}); }, []); const list = clients || []; const counts = useMemo(() => { const c = { tous: list.length, prospect: 0, actif: 0, chaud: 0, signe: 0, perdu: 0 }; list.forEach((x) => { if (c[x.state] != null) c[x.state]++; }); return c; }, [list]); const filtered = useMemo(() => { let l = list; if (tab !== 'tous') l = l.filter((c) => c.state === tab); // Paquet G : filtre par agent (compare agent_id avec email/agent_id de l'agent). if (agentFilter) { l = l.filter((c) => (c.agent || '').toLowerCase() === agentFilter.toLowerCase()); } if (q) { const ql = q.toLowerCase(); l = l.filter((c) => c.name.toLowerCase().includes(ql) || c.email.toLowerCase().includes(ql) || c.villes.toLowerCase().includes(ql) ); } return l; }, [list, tab, q, agentFilter]); // Paquet G : modale delete stylee (vs confirm() natif). const handleDelete = (c) => setDeleteCandidate(c); const confirmDelete = async () => { if (!deleteCandidate) return; setDeleting(true); try { await window.SentinelAPI.fetchAuth(`/api/clients/${deleteCandidate.id}`, { method: 'DELETE' }); toast(`${deleteCandidate.name} supprimé.`, 'success'); setDeleteCandidate(null); reload(); } catch (e) { toast('Échec de la suppression : ' + (e.message || e), 'error'); } finally { setDeleting(false); } }; const handleExport = async () => { try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; const r = await fetch('/api/clients/export.csv', { headers: { Authorization: 'Bearer ' + token } }); if (!r.ok) throw new Error('HTTP ' + r.status); const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `clients-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast('Export CSV téléchargé.', 'success'); } catch (e) { toast('Échec export CSV : ' + (e.message || e), 'error'); } }; // Paquet E : import CSV passe par une modale de confirmation avec // checkbox Patrimoine batch (consentement RGPD pour TOUS les clients // du fichier). Sans cette etape, on ne peut plus prouver le consentement // legalement pour un lot import en masse. const [importFile, setImportFile] = useState(null); const [importAutoPat, setImportAutoPat] = useState(false); const [importing, setImporting] = useState(false); const onCsvSelected = (e) => { const file = e.target.files && e.target.files[0]; e.target.value = ''; // reset pour pouvoir re-importer le meme fichier if (!file) return; setImportAutoPat(false); // toujours decoche par defaut (defense en profondeur) setImportFile(file); }; const runImport = async () => { if (!importFile) return; setImporting(true); try { const fd = new FormData(); fd.append('file', importFile); if (importAutoPat) fd.append('auto_patrimoine', 'true'); const r = await window.SentinelAPI.fetchAuth('/api/clients/import.csv', { method: 'POST', body: fd }); // Lot 0 audit : le backend renvoie n_imported / n_skipped / errors // (pas `created`) -> le toast affichait toujours "0 client importé" // et poussait a re-importer (doublons). Les lignes ignorees sont // desormais montrees. const d = (r && r.data) || {}; const n = d.n_imported || 0; const nErr = d.total_errors || (d.errors ? d.errors.length : 0); const msg = importAutoPat ? `${n} client${n > 1 ? 's' : ''} importé${n > 1 ? 's' : ''} + Patrimoine activé.` : `${n} client${n > 1 ? 's' : ''} importé${n > 1 ? 's' : ''}.`; toast(msg, n > 0 ? 'success' : 'warning'); if (nErr > 0) { const first = (d.errors || []).slice(0, 3).join(' · '); toast(`${nErr} ligne${nErr > 1 ? 's' : ''} ignorée${nErr > 1 ? 's' : ''}${first ? ' : ' + first : ''}`, 'warning'); } setImportFile(null); reload(); } catch (err) { toast('Échec import CSV : ' + (err.message || err), 'error'); } finally { setImporting(false); } }; const importInputRef = useRef(null); /* Deplacement kanban : update optimiste -> PATCH backend -> rollback via reload si echec. Partage par les deux kanbans (DnD et fallback). */ const moveClient = async (clientId, newState) => { setClients((prev) => (prev || []).map((c) => c.id === clientId ? { ...c, state: newState, raw: { ...c.raw, statut: newState } } : c)); try { await window.SentinelAPI.fetchAuth(`/api/clients/${clientId}/statut`, { method: 'PATCH', body: { statut: newState } }); toast(`Statut → ${newState}`, 'success'); } catch (e) { toast('Echec deplacement : ' + (e.message || e), 'error'); reload(); } }; return (
1 ? 's' : ''}`) : 'Gestion des clients et de leurs critères de recherche.'} actions={isMobile ? : <> } />
setQ(e.target.value)} />
{/* Paquet G : filtre agent (parite V1, peuple via /api/cabinet/users). */} {agents.length > 0 && ( )}
{!isMobile && }
{view === 'liste' && ( setSelected(new Set())} onExport={exportSelection} onAssign={() => { setAssignTo(''); setAssignOpen(true); }} onDelete={() => setBulkDeleteOpen(true)} /> )} {clients === null ? ( ) : view === 'liste' ? ( {filtered.length === 0 ? ( setShowCreate(true)}> Nouveau client} /> ) : ( {filtered.map(c => ( { if (isMobile) setSheetClient(c); }}> ))}
0 && filtered.every((c) => selected.has(c.id))} onChange={() => toggleAll(filtered.map((c) => c.id))} /> ClientStatutEmailCritères Budget maxDernier contact
toggleSel(c.id)} aria-label={`Sélectionner ${c.name}`} /> { if (!isMobile) setDetailClient(c); }}>{c.name} {c.email} {c.critèresCourts}{isMobile && c.lastContact != null ? ` · ${c.lastContact} j` : ''} {c.budgetMax}
setDetailClient(c) }, { label: 'Éditer', onClick: () => setEditClient(c) }, { label: 'Supprimer…', danger: true, onClick: () => handleDelete(c) }, ]} />
)}
) : ( window.KanbanDnD /* onMove branche la PERSISTANCE : avant, KanbanDnD ne recevait rien et le deplacement n'etait jamais sauvegarde (retour a l'ancienne colonne au rechargement). */ ? : )} {isMobile && view === 'kanban' && clients !== null && } {/* Chantier 07 : feuille de detail mobile (au-dessus de la tab bar) */} setSheetClient(null)} title={sheetClient ? sheetClient.name : ''} aside={sheetClient ? : null} rows={sheetClient ? [ { k: 'Budget max', v: sheetClient.budgetMax, mono: true }, { k: 'Recherche', v: sheetClient.critèresCourts }, { k: 'Dernier contact', v: sheetClient.lastContact != null ? `${sheetClient.lastContact} j` : '—', style: sheetClient.lastContact > 10 ? { color: 'var(--color-danger)' } : undefined }, ] : []} actions={sheetClient ? <> { if (!sheetClient.telephone) { e.preventDefault(); toast('Pas de téléphone renseigné.', 'warning'); } }}> Appeler : null}> {sheetClient && } {/* Modal Nouveau client */} setShowCreate(false)} title="Nouveau client" onSubmit={async (payload) => { try { await window.SentinelAPI.fetchAuth('/api/clients', { method: 'POST', body: payload }); toast('Client créé.', 'success'); setShowCreate(false); reload(); } catch (e) { toast('Échec création : ' + (e.message || e), 'error'); } }} /> {/* Modal Édition */} setEditClient(null)} title={editClient ? `Éditer ${editClient.name}` : ''} initial={editClient && editClient.raw} onSubmit={async (payload) => { try { await window.SentinelAPI.fetchAuth(`/api/clients/${editClient.id}`, { method: 'PUT', body: payload }); toast('Client mis à jour.', 'success'); setEditClient(null); reload(); } catch (e) { toast('Échec mise à jour : ' + (e.message || e), 'error'); } }} /> {/* Modal Détail */} setDetailClient(null)} onEdit={() => { setEditClient(detailClient); setDetailClient(null); }} onChanged={(updated) => { // Refresh la liste + le detailClient courant pour que les sections // (statut, notes, matches) refletent le dernier state sans fermer. reload(); if (updated && detailClient && detailClient.id === updated.id) { setDetailClient({ ...detailClient, raw: { ...detailClient.raw, ...updated }, state: updated.statut || detailClient.state }); } }} /> {/* Paquet E : modale confirmation import CSV avec consentement Patrimoine batch. Sans cette etape, on ne peut plus prouver le consentement RGPD pour un lot importe en masse (preuve legale CNIL pour TOUS les clients du fichier). */} { if (!importing) setImportFile(null); }} title="Import CSV — partage Patrimoine ?" footer={<> }>

Tu vas importer {importFile ? importFile.name : '—'}.

Tu peux activer le partage Patrimoine pour TOUS les clients de ce fichier en une seule opération. Sans cette case, les clients seront importés normalement (tu pourras activer le partage individuellement plus tard depuis chaque fiche).

{/* Chantier 03 : actions groupees */} { if (!bulkBusy) setAssignOpen(false); }} title={`Assigner ${selected.size} client${selected.size > 1 ? 's' : ''}`} footer={<> }>
L'agent assigné reçoit les notifications de matches de ces clients.
{ if (!bulkBusy) setBulkDeleteOpen(false); }} title={`Supprimer ${selected.size} client${selected.size > 1 ? 's' : ''} ?`} footer={<> }>

Les clients sélectionnés, leurs critères et leurs matches seront perdus.

Cette action est définitive. Les traces de consentement Patrimoine restent pour l'audit RGPD.

{/* Paquet G : modale "Supprimer ce client" stylee (remplace confirm() natif). */} { if (!deleting) setDeleteCandidate(null); }} title="Supprimer ce client ?" footer={<> }> {deleteCandidate && (<>

Tu vas supprimer {deleteCandidate.name}.

Cette action est définitive. Le client, ses critères et ses matches seront perdus. Si le client a donné son consentement Patrimoine, la trace du consentement reste pour l'audit RGPD.

)}
); }; const ClientStatePill = ({ state }) => { const map = { prospect: { tone:'neutral', label:'Prospect' }, actif: { tone:'info', label:'Actif' }, chaud: { tone:'warning', label:'Chaud', pulse:true }, signe: { tone:'success', label:'Signé' }, perdu: { tone:'danger', label:'Perdu' }, }; const cfg = map[state] || map.prospect; return {cfg.label}; }; /* Paquet I : Kanban + drag&drop natif HTML5. La carte est draggable, la colonne destination ecoute dragover (preventDefault pour autoriser le drop) + drop -> onMove(clientId, newState). Le parent ClientsPage appelle PATCH /api/clients/{id}/statut puis reload. Update optimiste cote front. */ const ClientsKanban = ({ clients, onMove }) => { const cols = [ { id:'prospect', label:'Prospect' }, { id:'actif', label:'Actif' }, { id:'chaud', label:'Chaud' }, { id:'signe', label:'Signé' }, { id:'perdu', label:'Perdu' }, ]; const [dragOver, setDragOver] = useState(null); // colonne survolee const [draggingId, setDraggingId] = useState(null); const onDragStart = (e, c) => { setDraggingId(c.id); if (e.dataTransfer) { e.dataTransfer.setData('text/plain', String(c.id)); e.dataTransfer.effectAllowed = 'move'; } }; const onDragEnd = () => { setDraggingId(null); setDragOver(null); }; const onDragOver = (e, colId) => { e.preventDefault(); // requis pour activer le drop if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; if (dragOver !== colId) setDragOver(colId); }; const onDragLeave = (e, colId) => { // Evite le flicker quand on traverse les cartes enfants if (e.currentTarget.contains(e.relatedTarget)) return; if (dragOver === colId) setDragOver(null); }; const onDrop = (e, colId) => { e.preventDefault(); const clientId = parseInt((e.dataTransfer && e.dataTransfer.getData('text/plain')) || '0', 10); setDragOver(null); setDraggingId(null); if (!clientId || !onMove) return; const c = clients.find((x) => x.id === clientId); if (!c || c.state === colId) return; // pas de move si meme colonne onMove(clientId, colId); }; return (
{cols.map(col => { const items = clients.filter(c => c.state === col.id); const isHover = dragOver === col.id; return (
onDragOver(e, col.id)} onDragLeave={(e) => onDragLeave(e, col.id)} onDrop={(e) => onDrop(e, col.id)} >
{col.label} {items.length}
{items.length === 0 ? (
) : items.map(c => (
onDragStart(e, c)} onDragEnd={onDragEnd} style={{cursor: onMove ? 'grab' : 'default', opacity: draggingId === c.id ? 0.4 : 1}} > {c.name} {c.villes} {c.budget}
{c.critères} {/* Paquet C : guard pour eviter "nullj" quand date_derniere_interaction est NULL. */} 14 ? 'warning' : 'neutral'}>{c.lastContact != null ? c.lastContact + 'j' : '—'}
))}
); })}
); }; /* ============================================================ */ /* CLIENT FORM MODAL — Create + Edit (reutilisable) */ /* Phase 2 : controlled inputs, pre-fill via prop `initial`, */ /* submit construit un payload conforme a ClientCreate / Update. */ /* ============================================================ */ const _emptyForm = () => ({ prenom: '', nom: '', email: '', telephone: '', agent_id: '', source_acquisition: '', transaction: 'vente', type_bien: '', pieces_min: '', budget_min: '', budget_max: '', surface_min: '', villes: '', options: [], // Paquet E : checkbox RGPD Patrimoine, n'est envoyee qu'en creation. auto_patrimoine: false, }); const ClientFormModal = ({ open, onClose, title, initial, onSubmit }) => { const [form, setForm] = useState(_emptyForm()); const [saving, setSaving] = useState(false); const isCreate = !initial; // checkbox Patrimoine visible uniquement en mode "Nouveau" useEffect(() => { if (!open) return; if (initial) { const cr = initial.criteres || {}; setForm({ prenom: initial.prenom || '', nom: initial.nom || '', email: initial.email || '', telephone: initial.telephone || '', agent_id: initial.agent_id || '', source_acquisition: initial.source_acquisition || '', type_bien: cr.type_bien || '', transaction: cr.transaction || 'vente', pieces_min: cr.pieces_min != null ? String(cr.pieces_min) : '', budget_min: cr.budget_min != null ? String(cr.budget_min) : '', budget_max: cr.budget_max != null ? String(cr.budget_max) : '', surface_min: cr.surface_min != null ? String(cr.surface_min) : '', villes: (cr.villes || []).join(', '), options: cr.options || [], }); } else { setForm(_emptyForm()); } setSaving(false); }, [open, initial]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const toggleOpt = (opt) => setForm((f) => ({ ...f, options: (f.options || []).includes(opt) ? f.options.filter((o) => o !== opt) : [...(f.options || []), opt], })); const handleSubmit = async (e) => { if (e) e.preventDefault(); if (!form.prenom.trim() || !form.nom.trim()) { alert('Prénom et nom sont obligatoires.'); return; } const intOr = (v) => { const s = String(v || '').trim(); if (!s) return null; const n = parseInt(s, 10); return isNaN(n) ? null : n; }; const villesList = (form.villes || '') .split(/[,;]/).map((s) => s.trim()).filter(Boolean); // Paquet E : refuse l'auto_patrimoine si pas de contact (email/tel), // le backend ignorerait silencieusement sinon (routes/clients.py:344). if (isCreate && form.auto_patrimoine && !form.email.trim() && !form.telephone.trim()) { alert('Pour activer le partage Patrimoine, renseigne au moins un email ou un téléphone.'); return; } const payload = { prenom: form.prenom.trim(), nom: form.nom.trim(), email: form.email.trim() || null, telephone: form.telephone.trim() || null, agent_id: form.agent_id.trim() || null, source_acquisition: form.source_acquisition.trim() || null, criteres: { type_bien: form.type_bien || null, transaction: form.transaction || 'vente', pieces_min: intOr(form.pieces_min), budget_min: intOr(form.budget_min), budget_max: intOr(form.budget_max), surface_min: intOr(form.surface_min), villes: villesList, options: form.options || [], }, }; // Paquet E : flag uniquement en creation (en edition, le consentement // se gere depuis la section Patrimoine de la modale detail). if (isCreate && form.auto_patrimoine) payload.auto_patrimoine = true; setSaving(true); try { await onSubmit(payload); } finally { setSaving(false); } }; if (!open) return null; return ( }>
Informations personnelles
set('prenom', e.target.value)} required />
set('nom', e.target.value)} required />
set('email', e.target.value)} />
set('telephone', e.target.value)} />
set('agent_id', e.target.value)} placeholder="vide = vous-même" /> L'agent reçoit les notifications de matches.
Critères de recherche
set('budget_min', e.target.value)} />
set('budget_max', e.target.value)} />
set('pieces_min', e.target.value)} />
set('surface_min', e.target.value)} />
set('villes', e.target.value)} placeholder="Lille, Ronchin, Tourcoing" />
{['Jardin','Garage','Terrasse','Balcon','Cave','Ascenseur','Piscine'].map((opt) => { const checked = (form.options || []).includes(opt); return ( ); })}
{/* Paquet E : checkbox RGPD Patrimoine uniquement en mode "Nouveau". En edition, le consentement se gere depuis la section Patrimoine de la modale detail (evite la double UI). */} {isCreate && (
)}
); }; /* ============================================================ */ /* CLIENT DETAIL MODAL — lecture seule + historique matches */ /* ============================================================ */ /* Paquet D : ClientDetailModal CRM complet (parite V1) - Contact bar : boutons Appeler / SMS / Email pre-remplis - Pipeline editable : dropdown statut + save -> PATCH /api/clients/{id}/statut - Notes : input add + render historique (anti-chrono) -> PATCH /api/clients/{id}/notes - Dropdown statut par match -> PATCH /api/clients/matches/{id}/statut - Bouton Export RGPD JSON -> GET /api/rgpd/clients/{id}/export */ const CLIENT_STATUTS = ['prospect', 'actif', 'chaud', 'signe', 'perdu']; const MATCH_STATUTS = ['nouveau', 'vu', 'interesse', 'visite', 'achete', 'perdu']; const MATCH_STATUT_TONE = { nouveau: 'neutral', vu: 'neutral', interesse: 'info', visite: 'info', achete: 'success', perdu: 'danger' }; const ClientDetailModal = ({ client, onClose, onEdit, onChanged }) => { const { toast } = useApp(); const [matches, setMatches] = useState(null); const [statutDraft, setStatutDraft] = useState(''); const [savingStatut, setSavingStatut] = useState(false); const [newNote, setNewNote] = useState(''); const [savingNote, setSavingNote] = useState(false); const [exporting, setExporting] = useState(false); // Paquet E : section Patrimoine const [patrimoine, setPatrimoine] = useState(null); // {actif, date_consentement?, email_contact?, telephone_contact?, texte_a_afficher} const [showPatActivate, setShowPatActivate] = useState(false); const [patForm, setPatForm] = useState({ email: '', telephone: '', confirme: false }); const [savingPat, setSavingPat] = useState(false); const reloadPat = (clientId) => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth(`/api/patrimoine/consentement/${clientId}`) .then((r) => setPatrimoine((r && r.data) || null)) .catch(() => setPatrimoine(null)); }; // Reload matches + reset form state quand le client change. useEffect(() => { if (!client) { setMatches(null); setStatutDraft(''); setNewNote(''); setPatrimoine(null); return; } setStatutDraft((client.raw && client.raw.statut) || 'prospect'); setNewNote(''); setPatrimoine(null); setShowPatActivate(false); if (!window.SentinelAPI) { setMatches([]); return; } setMatches(null); window.SentinelAPI.fetchAuth(`/api/clients/${client.id}/matches`) .then((r) => setMatches(r.data || [])) .catch(() => setMatches([])); reloadPat(client.id); }, [client && client.id]); if (!client) return null; const c = client.raw || {}; const cr = c.criteres || {}; const fmtDate = (d) => d ? new Date(d).toLocaleDateString('fr-FR', { day:'2-digit', month:'short', year:'numeric' }) : '—'; const fmtDateTime = (d) => d ? new Date(d).toLocaleString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'; const saveStatut = async () => { if (!statutDraft || statutDraft === c.statut) return; setSavingStatut(true); try { const r = await window.SentinelAPI.fetchAuth(`/api/clients/${client.id}/statut`, { method: 'PATCH', body: { statut: statutDraft } }); toast(`Statut → ${statutDraft}`, 'success'); if (onChanged) onChanged({ ...c, statut: (r.data && r.data.statut) || statutDraft }); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSavingStatut(false); } }; const addNote = async () => { const text = (newNote || '').trim(); if (!text) return; setSavingNote(true); try { const r = await window.SentinelAPI.fetchAuth(`/api/clients/${client.id}/notes`, { method: 'PATCH', body: { text } }); toast('Note ajoutee.', 'success'); setNewNote(''); if (onChanged) onChanged({ ...c, notes: (r.data && r.data.notes) || c.notes }); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSavingNote(false); } }; const changeMatchStatut = async (m, newStatut) => { try { await window.SentinelAPI.fetchAuth(`/api/clients/matches/${m.id}/statut`, { method: 'PATCH', body: { statut: newStatut } }); // Mise a jour optimiste cote modal setMatches((arr) => (arr || []).map((x) => x.id === m.id ? { ...x, statut: newStatut } : x)); toast(`Match #${m.id} → ${newStatut}`, 'success'); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; // Paquet E : ouvrir la sous-modale d'activation Patrimoine. const openPatActivate = () => { setPatForm({ email: c.email || '', telephone: c.telephone || '', confirme: false, }); setShowPatActivate(true); }; const submitPatrimoine = async () => { if (!patForm.confirme) { toast('Coche la case de consentement avant d enregistrer.', 'warning'); return; } if (!patForm.email.trim() && !patForm.telephone.trim()) { toast('Email ou telephone obligatoire pour activer le partage.', 'warning'); return; } setSavingPat(true); try { await window.SentinelAPI.fetchAuth('/api/patrimoine/consentement', { method: 'POST', body: { client_id: client.id, email_contact: patForm.email.trim() || null, telephone_contact: patForm.telephone.trim() || null, }, }); toast('Consentement Patrimoine enregistre.', 'success'); setShowPatActivate(false); reloadPat(client.id); } catch (e) { // Backend renvoie 409 si un consentement actif existe deja toast('Echec : ' + (e.message || e), 'error'); } finally { setSavingPat(false); } }; const revokePatrimoine = async () => { if (!confirm('Retirer le consentement Patrimoine ?\n\nLe partage avec le conseiller en gestion de patrimoine sera desactive immediatement. La trace du consentement initial reste pour l audit RGPD.')) return; setSavingPat(true); try { await window.SentinelAPI.fetchAuth(`/api/patrimoine/consentement/${client.id}`, { method: 'DELETE' }); toast('Consentement retire.', 'success'); reloadPat(client.id); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSavingPat(false); } }; const exportRgpd = async () => { setExporting(true); try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; const r = await fetch(`/api/rgpd/clients/${client.id}/export`, { headers: { Authorization: 'Bearer ' + token } }); if (!r.ok) throw new Error('HTTP ' + r.status); const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const safeName = (c.nom || 'client').toLowerCase().replace(/[^a-z0-9]+/g, '-'); a.download = `rgpd-${safeName}-${client.id}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast('Export RGPD telecharge.', 'success'); } catch (e) { toast('Echec export : ' + (e.message || e), 'error'); } finally { setExporting(false); } }; // Parse les notes (anti-chrono, separe par \n\n). Chaque entree est // typiquement "[2026-05-09 14:30] auteur: texte..." - on coupe au 1er ":" // apres le timestamp pour separer header / corps. const parsedNotes = (() => { if (!c.notes) return []; return c.notes.split(/\n\s*\n+/).map((blk) => blk.trim()).filter(Boolean).map((blk, i) => { const m = blk.match(/^\[([^\]]+)\]\s*([^:]+):\s*([\s\S]*)$/); if (m) return { idx: i, when: m[1], who: m[2].trim(), text: m[3].trim() }; return { idx: i, when: null, who: null, text: blk }; }); })(); return ( }> {/* Contact bar : actions rapides un clic */}
{c.telephone && (<> 📞 Appeler 💬 SMS )} {c.email && ✉ Email} {!c.telephone && !c.email && Aucune coordonnée enregistrée.}
Coordonnées
{c.email} : '—'} /> {c.telephone} : '—'} />
{/* Pipeline commercial editable */}
Pipeline commercial
{/* Notes : add + historique */}
Notes ({parsedNotes.length})
setNewNote(e.target.value)} placeholder="Ex : Appel - intéressé par le T3 de Ronchin, recontact mardi" onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addNote(); } }} />
{parsedNotes.length === 0 ? (
Aucune note pour l'instant.
) : (
{parsedNotes.map((n) => (
{n.when && (
{n.when}{n.who ? ' · ' + n.who : ''}
)}
{n.text}
))}
)}
{/* Paquet E : section Patrimoine RGPD — etat du consentement + actions */}
Partage Patrimoine (RGPD)
{patrimoine === null ? (
Chargement…
) : patrimoine.actif ? (
Consentement actif Depuis le {fmtDateTime(patrimoine.date_consentement)} {patrimoine.email_contact ? ` · contact ${patrimoine.email_contact}` : ''} {patrimoine.telephone_contact ? ` · ${patrimoine.telephone_contact}` : ''}
) : (
Non actif Active le partage uniquement si le client a explicitement accepté d'être contacté par un conseiller en gestion de patrimoine partenaire (preuve légale RGPD).
)}
Critères de recherche
{(cr.options && cr.options.length > 0) && (
Options · {cr.options.join(' · ')}
)}
Historique des matches{matches && matches.length > 0 ? ` (${matches.length})` : ''}
{matches === null ? ( ) : matches.length === 0 ? ( ) : ( {matches.slice(0, 20).map((m) => { const a = m.annonce || {}; return ( ); })}
ScoreAnnoncePrixStatutDate
{a.titre || `${a.ville || ''}`.trim() || `Annonce #${m.annonce_id}`}
{a.url && {fmt.source(a.source)} ↗}
{_prixTexte(a)} {m.date_match ? new Date(m.date_match).toLocaleDateString('fr-FR') : '—'}
)}
{/* Paquet E : sous-modale activation Patrimoine RGPD. Stocke IP + texte exact backend pour preuve legale CNIL. */} {showPatActivate && ( { if (!savingPat) setShowPatActivate(false); }} title="Activer le partage Patrimoine" footer={<> }>

Tu vas partager les coordonnées de {client.name} avec un conseiller en gestion de patrimoine partenaire. Le client doit avoir donné son accord verbal explicite avant.

setPatForm({...patForm, email: e.target.value})} placeholder="optionnel si telephone renseigne" />
setPatForm({...patForm, telephone: e.target.value})} placeholder="optionnel si email renseigne" />
Texte de consentement
{patrimoine && patrimoine.texte_a_afficher ? patrimoine.texte_a_afficher : 'Le client accepte d être contacté par un conseiller en gestion de patrimoine partenaire pour évaluation de son projet immobilier.'}
)} ); }; /* ============================================================ */ /* ANNONCE DETAIL MODAL */ /* ============================================================ */ const Detail = ({ label, value, big, mono }) => (
{label}
{value}
); /* ============================================================ */ /* ANNONCES */ /* ============================================================ */ // Niveau geo (0/4 = exact, 1-3 = approx) -> pill const _geoTier = (lat, lng, niveau) => { if (lat == null || lng == null) return { tone: 'none', label: '—' }; if (niveau === 0 || niveau === 4) return { tone: 'exact', label: 'Exact' }; return { tone: 'approx', label: 'Approx' }; }; /* Chantier 04 : « il y a 12 min » pour la sous-ligne des resultats */ const _relTime = (iso) => { if (!iso) return null; const d = new Date(iso); if (isNaN(d.getTime())) return null; const sec = Math.max(0, (Date.now() - d.getTime()) / 1000); if (sec < 60) return "à l'instant"; const m = Math.floor(sec / 60); if (m < 60) return `il y a ${m} min`; const h = Math.floor(m / 60); if (h < 24) return `il y a ${h} h`; const j = Math.floor(h / 24); if (j < 30) return `il y a ${j} j`; return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' }); }; const _FILTER_LABELS = { source: 'Source', transaction: 'Transaction', type_bien: 'Type', prix_min: 'Prix min', prix_max: 'Prix max', ville: 'Ville', geolocalise: 'Géoloc.', score_min: 'Score' }; const _filterChipLabel = (k, v) => { switch (k) { case 'transaction': return _transactionLabel({ transaction: v }); case 'type_bien': return _typeBienLabel(v); case 'prix_min': return `≥ ${Number(v).toLocaleString('fr-FR')} €`; case 'prix_max': return `≤ ${Number(v).toLocaleString('fr-FR')} €`; case 'geolocalise': return v === 'true' ? 'géolocalisées' : 'non géolocalisées'; case 'score_min': return `≥ ${v} %`; case 'source': return fmt.source(v); default: return v; } }; const AnnoncesPage = () => { const { toast, user, goto } = useApp(); const [annonces, setAnnonces] = useState(null); const [detail, setDetail] = useState(null); // {id, ...} dans la liste const [collecting, setCollecting] = useState(false); // Chantier 04 : barre unique (recherche, filtres en drawer, tri, export, // menu ⋯), vues enregistrees (localStorage), chips de filtres actifs. const [q, setQ] = useState(''); const [sort, setSort] = useState('score'); const [drawerOpen, setDrawerOpen] = useState(false); const [vues, setVues] = useState(() => { try { return JSON.parse(localStorage.getItem('sentinel.annonces.vues') || '[]'); } catch (_) { return []; } }); const [saveVueOpen, setSaveVueOpen] = useState(false); const [vueName, setVueName] = useState(''); useEffect(() => { if (!drawerOpen) return; const k = (e) => { if (e.key === 'Escape') setDrawerOpen(false); }; document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, [drawerOpen]); // Filtres (controlled inputs) const [filters, setFilters] = useState({ source: '', transaction: '', type_bien: '', prix_min: '', prix_max: '', ville: '', geolocalise: '', score_min: '', }); const [appliedFilters, setAppliedFilters] = useState(filters); const setF = (k, v) => setFilters((f) => ({ ...f, [k]: v })); const resetFilters = () => { const empty = { source: '', transaction: '', type_bien: '', prix_min: '', prix_max: '', ville: '', geolocalise: '', score_min: '' }; setFilters(empty); setAppliedFilters(empty); }; const applyFilters = () => setAppliedFilters(filters); const reload = () => { if (!window.SentinelAPI) { setAnnonces([]); return; } setAnnonces(null); const qs = new URLSearchParams(); Object.entries(appliedFilters).forEach(([k, v]) => { if (v === '' || v == null) return; qs.append(k, v); }); qs.append('limit', '200'); window.SentinelAPI.fetchAuth('/api/annonces?' + qs.toString()) .then((r) => setAnnonces(r.data || [])) .catch((e) => { console.warn('[Annonces] fetch err', e); setAnnonces([]); toast('Erreur chargement annonces.', 'error'); }); }; useEffect(reload, [appliedFilters]); const list = annonces || []; const activeFilters = Object.entries(appliedFilters).filter(([, v]) => v !== '' && v != null); const removeFilter = (k) => { const next = { ...appliedFilters, [k]: '' }; setFilters(next); setAppliedFilters(next); }; const persistVues = (arr) => { setVues(arr); try { localStorage.setItem('sentinel.annonces.vues', JSON.stringify(arr)); } catch (_) {} }; const sameFilters = (a, b) => JSON.stringify(a) === JSON.stringify(b); const activeVue = vues.find((v) => sameFilters(v.filters, appliedFilters) && (v.sort || 'score') === sort && (v.q || '') === q); const applyVue = (v) => { setFilters(v.filters); setAppliedFilters(v.filters); setSort(v.sort || 'score'); setQ(v.q || ''); }; const saveVue = () => { const label = vueName.trim(); if (!label) return; persistVues([...vues, { id: Date.now(), label, filters: appliedFilters, sort, q }]); setSaveVueOpen(false); setVueName(''); toast('Vue enregistrée.', 'success'); }; const removeVue = (id) => persistVues(vues.filter((v) => v.id !== id)); const visible = useMemo(() => { let l = annonces || []; if (q) { const ql = q.toLowerCase(); l = l.filter((a) => [a.titre, a.ville, a.code_postal, a.source, a.type_bien].filter(Boolean).some((x) => String(x).toLowerCase().includes(ql))); } const by = { score: (a, b) => (b.score_max ?? -1) - (a.score_max ?? -1), date: (a, b) => new Date(b.date_publication || b.date_collecte || 0) - new Date(a.date_publication || a.date_collecte || 0), prix_asc: (a, b) => (a.prix ?? Infinity) - (b.prix ?? Infinity), prix_desc: (a, b) => (b.prix ?? -1) - (a.prix ?? -1), surface: (a, b) => (b.surface ?? -1) - (a.surface ?? -1), }; return [...l].sort(by[sort] || by.score); }, [annonces, q, sort]); const exportCsv = () => { if (!visible.length) return; const esc = (v) => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; const head = ['titre', 'type_bien', 'transaction', 'prix', 'surface', 'pieces', 'ville', 'code_postal', 'score_max', 'source', 'url']; const lines = [head.join(';')].concat(visible.map((a) => head.map((k) => esc(a[k])).join(';'))); const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const el = document.createElement('a'); el.href = url; el.download = `annonces-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(el); el.click(); el.remove(); URL.revokeObjectURL(url); toast(`${visible.length} annonce${visible.length > 1 ? 's' : ''} exportée${visible.length > 1 ? 's' : ''}.`, 'success'); }; // Paquet I : modales stylees vs confirm() natif (parite V1 annonces.html). const [deleteOne, setDeleteOne] = useState(null); // annonce a supprimer (unitaire) const [deletingOne, setDeletingOne] = useState(false); const [showBulkWipe, setShowBulkWipe] = useState(false); const [bulkWipeConfirm, setBulkWipeConfirm] = useState(''); // texte tape (V1 attend "SUPPRIMER") const [wipingAll, setWipingAll] = useState(false); const handleDelete = (a) => setDeleteOne(a); const confirmDeleteOne = async () => { if (!deleteOne) return; setDeletingOne(true); try { await window.SentinelAPI.fetchAuth(`/api/annonces/${deleteOne.id}`, { method: 'DELETE' }); toast('Annonce supprimée.', 'success'); setDeleteOne(null); reload(); } catch (e) { toast('Échec suppression : ' + (e.message || e), 'error'); } finally { setDeletingOne(false); } }; const handleDeleteAll = () => { if (!user.is_superadmin) { toast('Réservé super-admin.', 'error'); return; } setBulkWipeConfirm(''); setShowBulkWipe(true); }; const confirmBulkWipe = async () => { if (bulkWipeConfirm !== 'SUPPRIMER') { toast('Tape SUPPRIMER (majuscules) pour confirmer.', 'warning'); return; } setWipingAll(true); try { // Lot 0 audit : le backend exige ?confirm=YES_I_AM_SURE (400 sinon). await window.SentinelAPI.fetchAuth('/api/annonces?confirm=YES_I_AM_SURE', { method: 'DELETE' }); toast('Toutes les annonces supprimées.', 'success'); setShowBulkWipe(false); reload(); } catch (e) { toast('Échec : ' + (e.message || e), 'error'); } finally { setWipingAll(false); } }; const handleCollecte = async () => { if (!(user.is_cabinet_admin || user.is_superadmin)) { toast('Réservé au compte cabinet (action facturée Stream Estate).', 'error'); return; } if (!confirm('Lancer une collecte Stream Estate maintenant ?\n\n(action facturée, pipeline complet : collecte → matching → notifications)')) return; setCollecting(true); try { const r = await window.SentinelAPI.fetchAuth('/api/collecte/run', { method: 'POST', body: { notifier: true } }); const c = (r.data && r.data.collecte) || {}; const m = (r.data && r.data.matching) || {}; const n = (r.data && r.data.notifications_envoyees) || 0; // Backend : {recus, inseres, skips, erreurs, ignorees_hors_zone, zones, // transactions, mode, collecte_initiale, cout_estime_eur} // et {annonces_traitees, matches, matches_parfaits, bons_matches}. toast(_collecteResume(c, m, n), 'success', 9000); reload(); } catch (e) { toast('Échec collecte : ' + (e.message || e), 'error'); } finally { setCollecting(false); } }; // Collecte complète (8 sept 2026) : rattrapage du stock Stream Estate sans // fenêtre de date, paginé, plafonné par transaction. On sonde d'abord le // volume disponible (0,01 € par transaction) pour afficher le coût réel. const [collectingFull, setCollectingFull] = useState(false); const handleCollecteComplete = async () => { if (!user.is_superadmin) { toast('Réservé au super-admin (action facturée).', 'error'); return; } setCollectingFull(true); try { const v = await window.SentinelAPI.fetchAuth('/api/collecte/volume'); const d = (v && v.data) || {}; const entrees = Object.entries(d.transactions || {}); const lignes = entrees.map(([t, x]) => { const dispo = x.disponibles == null ? 'volume inconnu' : (x.disponibles >= 10000 ? '≥ ' : '') + Number(x.disponibles).toLocaleString('fr-FR') + ' disponibles'; const suite = x.a_rattraper ? `jusqu'à ${x.plafond} récupérées` : 'stock suffisant, ignorée'; return `${_transactionLabel({ transaction: t })} (${(x.villes || []).join(', ') || 'toutes villes'}) : ${dispo}, ${x.en_base} déjà en base → ${suite}`; }); if (!entrees.some(([, x]) => x.a_rattraper)) { alert(`Rien à rattraper : chaque transaction a déjà au moins ${d.plafond_par_transaction} annonces en base.\n\n` + lignes.join('\n')); return; } const msg = `Stream Estate pour vos critères, par transaction :\n` + lignes.join('\n') + `\n\nCollecte complète : ${_eur2(d.cout_complet_max_eur)} maximum (0,01 € par annonce), puis matching et notifications. Lancer ?`; if (!confirm(msg)) return; const r = await window.SentinelAPI.fetchAuth('/api/collecte/run', { method: 'POST', body: { notifier: true, mode: 'complet' } }); const c = (r.data && r.data.collecte) || {}; const m = (r.data && r.data.matching) || {}; const n = (r.data && r.data.notifications_envoyees) || 0; toast(_collecteResume(c, m, n), 'success', 9000); reload(); } catch (e) { toast('Échec collecte complète : ' + (e.message || e), 'error'); } finally { setCollectingFull(false); } }; const handleGeolocalize = async (a) => { try { await window.SentinelAPI.fetchAuth(`/api/annonces/${a.id}/geolocalize`, { method: 'POST' }); toast('Géolocalisation relancée.', 'success'); reload(); } catch (e) { toast('Échec géoloc : ' + (e.message || e), 'error'); } }; return (
1 ? 's' : ''}${(activeFilters.length || q) ? ' (filtrés)' : ''}`} actions={<> {(user.is_cabinet_admin || user.is_superadmin) && ( )} } /> {/* Barre unique 30 px : recherche · Filtres (n) · ... · tri · export · ⋯ */}
setQ(e.target.value)} />
{(user.is_cabinet_admin || user.is_superadmin) && ( )}
{/* Vues enregistrees : chips auto-largeur, vue active = bordure blanche */}
Vues {vues.map((v) => ( ))}
{activeFilters.length > 0 && (
{activeFilters.map(([k, v]) => ( ))}
)} {annonces === null ? ( ) : visible.length === 0 ? ( Collecter maintenant : null) : } /> ) : ( {visible.map((a) => { const geo = _geoTier(a.lat, a.lng, a.geo_niveau); const sub = [ fmt.source(a.source), a.surface != null ? a.surface + ' m²' : null, a.pieces != null ? a.pieces + ' p.' : null, _relTime(a.date_publication || a.date_collecte), geo.tone === 'approx' ? 'géoloc. approx.' : geo.tone === 'none' ? 'non géolocalisée' : null, ].filter(Boolean).join(' · '); const tier = a.score_max == null ? null : a.score_max >= 80 ? 'high' : a.score_max >= 50 ? 'mid' : 'low'; return (
setDetail(a)} onKeyDown={(e) => { if (e.key === 'Enter') setDetail(a); }}> {a.photos && a.photos[0] ? { e.target.style.display = 'none'; }} /> : null} {a.titre || a.ville || `Annonce #${a.id}`} — {_typeBienLabel(a.type_bien)}{a.ville ? ', ' + a.ville : ''} {sub} {_prixTexte(a)} {tier ? {a.score_max} % : } e.stopPropagation()}> {a.url && } {(user.is_cabinet_admin || user.is_superadmin) && ( )}
); })}
)} {/* Drawer de filtres (ferme par defaut) */} {drawerOpen && (
setDrawerOpen(false)}>
)} setSaveVueOpen(false)} title="Enregistrer la vue" footer={<> }>
setVueName(e.target.value)} placeholder="ex. Lille · ≤ 500 k€" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') saveVue(); }} />
La vue mémorise les filtres, le tri et la recherche actuels.
setDetail(null)} onGeolocalize={handleGeolocalize} onDelete={(a) => { handleDelete(a); setDetail(null); }} /> {/* Paquet I : modale suppression unitaire (vs confirm() natif). */} { if (!deletingOne) setDeleteOne(null); }} title="Supprimer cette annonce ?" footer={<> }> {deleteOne && (<>

Tu vas supprimer l'annonce {deleteOne.titre || deleteOne.ville || '#' + deleteOne.id}.

L'annonce est partagee entre tous les cabinets (collecte mutualisee). Sa suppression affecte les matches existants pour tous les utilisateurs.

)}
{/* Paquet I : modale bulk wipe (super-admin). Demande de taper "SUPPRIMER" en majuscules pour valider, parite V1 (annonces.html). */} { if (!wipingAll) setShowBulkWipe(false); }} title="⚠ Supprimer TOUTES les annonces ?" footer={<> }>

Action irreversible, globale (tous cabinets).

Toutes les annonces collectees, leurs matches et enrichissements DVF associes seront perdus. La prochaine collecte recharge depuis Stream Estate, mais l'historique est perdu.

setBulkWipeConfirm(e.target.value)} placeholder="SUPPRIMER" autoFocus />
); }; /* AnnonceDetailModalV2 : modal de detail branchee (remplace AnnonceDetailModal et AnnonceDetailFull du proto qui etaient 100 % mockes). */ const AnnonceDetailModalV2 = ({ annonce, onClose, onGeolocalize, onDelete }) => { const { user, toast, goto } = useApp(); const [full, setFull] = useState(null); const [enriching, setEnriching] = useState(false); useEffect(() => { if (!annonce) { setFull(null); return; } setFull(null); window.SentinelAPI.fetchAuth(`/api/annonces/${annonce.id}`) .then((r) => setFull(r.data || annonce)) .catch(() => setFull(annonce)); }, [annonce]); if (!annonce) return null; const a = full || annonce; const geo = _geoTier(a.lat, a.lng, a.geo_niveau); const handleEnrichDVF = async () => { setEnriching(true); try { await window.SentinelAPI.fetchAuth(`/api/annonces/${a.id}/enrich-dvf`, { method: 'POST' }); toast('Enrichissement DVF déclenché.', 'success'); } catch (e) { toast('Échec DVF : ' + (e.message || e), 'error'); } finally { setEnriching(false); } }; return ( {a.url && Voir source} {(user.is_cabinet_admin || user.is_superadmin) && ( )} }>
Informations
{a.description && (

{a.description}

)}
Géolocalisation
Non géolocalisée : {geo.label}} />
{(user.is_cabinet_admin || user.is_superadmin) && (
)}
{/* Clients matchés — donnees reelles via GET /api/annonces/{id}.matches (clients DU CABINET courant, isoles par l'API). */}
Clients matchés{full && Array.isArray(a.matches) ? ` (${a.matches.length})` : ''}
{!full ? (
Chargement…
) : !Array.isArray(a.matches) || a.matches.length === 0 ? (
Aucun client de votre cabinet ne correspond à cette annonce.
) : ( {a.matches.map((m) => { const cl = m.client || {}; return ( ); })}
ScoreClient
{cl.nom || `Client #${cl.id}`}
{cl.email || '—'}
{m.is_locked ? Verrouillé : }
)}
{a.photos && a.photos.length > 0 && ( <>
Photos ({a.photos.length})
{a.photos.slice(0, 6).map((src, i) => ( ))}
)} ); }; /* ============================================================ */ /* CARTE — MapLibre GL JS, branchee sur /api/annonces */ /* + filtre client (via /api/clients/{id}/matches) + checkbox */ /* masquer non-geolocalisees + bouton Appliquer + toggle sidebar */ /* + toggle mode carte : dark / light / 3D batiments */ /* ============================================================ */ // Fonds de carte Dark / Blanc : styles VECTORIELS OpenFreeMap (tiles.openfreemap.org, // deja autorise par la CSP : connect-src pour tuiles/glyphs/sprites, img-src). // Gratuit, sans cle, sans limite, usage commercial autorise. Remplace les tuiles // raster CARTO (dark_all / light_all) qui affichent depuis sept. 2026 un filigrane // "API KEY REQUIRED" : CARTO exige une cle (usage non commercial) et retire le raster. const _VECTOR_DARK = 'https://tiles.openfreemap.org/styles/dark'; const _VECTOR_LIGHT = 'https://tiles.openfreemap.org/styles/positron'; // ESRI World Imagery (satellite + aerien). Gratuit, no API key, attribution // requise. URL format ArcGIS : {z}/{y}/{x} et non {z}/{x}/{y}. const _RASTER_SATELLITE = [ 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', ]; function _rasterStyle(tiles, attribution) { return { version: 8, sources: { base: { type: 'raster', tiles, tileSize: 256, attribution: attribution || '© OpenStreetMap' }, }, layers: [{ id: 'base-layer', type: 'raster', source: 'base' }], }; } // Styles indexes par couleur seulement. Le mode 3D est decorrele : c'est // un toggle independant qui change juste le pitch (+ ajoute les batiments // Overpass). Avantage : passer 2D <-> 3D ne recharge plus les tuiles -> // transitions fluides, et on peut combiner toutes les options // (Dark+2D, Dark+3D, Blanc+2D, Blanc+3D). const _MAP_STYLES = { dark: _VECTOR_DARK, light: _VECTOR_LIGHT, satellite: _rasterStyle(_RASTER_SATELLITE, '© Esri · Maxar · Earthstar Geographics'), }; // ─── Overpass : fetch OSM buildings du viewport ────────────────────────── // Gratuit, no API key, rate-limite (~10 req/min). On limite a zoom >= 14 // et debounce les moveend pour eviter le flood. Cache cote client par bbox // quantize (limite les requetes identiques). const _OVERPASS_URL = 'https://overpass-api.de/api/interpreter'; const _OVERPASS_CACHE = new Map(); // key = bbox quantized -> Promise function _parseHeight(tags) { if (!tags) return 12; const h = tags.height || tags['building:height']; if (h) { const n = parseFloat(String(h).replace(',', '.')); if (!isNaN(n) && n > 0) return n; } const levels = tags['building:levels']; if (levels) { const n = parseFloat(String(levels).replace(',', '.')); if (!isNaN(n) && n > 0) return n * 3.2; // ~3.2m par etage } return 12; // default 3-4 etages } function _waysToGeoJSON(elements) { const features = []; for (const el of elements) { if (el.type !== 'way' || !el.geometry || el.geometry.length < 4) continue; const coords = el.geometry.map((p) => [p.lon, p.lat]); // Fermer le polygone si pas deja if (coords[0][0] !== coords[coords.length - 1][0] || coords[0][1] !== coords[coords.length - 1][1]) { coords.push(coords[0]); } features.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: { height: _parseHeight(el.tags), min_height: 0 }, }); } return { type: 'FeatureCollection', features }; } async function _fetchOverpassBuildings(bbox) { // bbox = {south, west, north, east} const key = [bbox.south.toFixed(3), bbox.west.toFixed(3), bbox.north.toFixed(3), bbox.east.toFixed(3)].join(','); if (_OVERPASS_CACHE.has(key)) return _OVERPASS_CACHE.get(key); const query = ( '[out:json][timeout:25];' + '(way["building"](' + bbox.south + ',' + bbox.west + ',' + bbox.north + ',' + bbox.east + '););' + 'out body geom;' ); const promise = fetch(_OVERPASS_URL, { method: 'POST', body: 'data=' + encodeURIComponent(query), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) .then((r) => r.ok ? r.json() : Promise.reject(new Error('Overpass HTTP ' + r.status))) .then((data) => _waysToGeoJSON(data.elements || [])) .catch((e) => { console.warn('[Overpass] err', e); return { type: 'FeatureCollection', features: [] }; }); _OVERPASS_CACHE.set(key, promise); // Expire le cache apres 10 min pour ne pas garder des polygones obsoletes setTimeout(() => _OVERPASS_CACHE.delete(key), 10 * 60 * 1000); return promise; } // Ajoute / retire la layer fill-extrusion + source GeoJSON sur la map. // `enabled=true` = on synchronise les buildings avec le viewport courant. // `enabled=false` = on retire tout. function _apply3DBuildingsOverpass(map, enabled) { const SOURCE_ID = 'sentinel-3d-buildings'; const LAYER_ID = 'sentinel-3d-buildings-layer'; if (!enabled) { if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID); if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); return Promise.resolve(); } if (map.getZoom() < 14) { // Trop dezoome : pas de buildings (Overpass refuserait ou serait lent) if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID); if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); return Promise.resolve(); } const b = map.getBounds(); const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }; return _fetchOverpassBuildings(bbox).then((geojson) => { if (map.getSource(SOURCE_ID)) { map.getSource(SOURCE_ID).setData(geojson); } else { map.addSource(SOURCE_ID, { type: 'geojson', data: geojson }); map.addLayer({ id: LAYER_ID, type: 'fill-extrusion', source: SOURCE_ID, paint: { 'fill-extrusion-color': '#34D399', 'fill-extrusion-opacity': 0.55, 'fill-extrusion-height': ['get', 'height'], 'fill-extrusion-base': ['get', 'min_height'], }, }); } }); } const CartePage = () => { const { toast, isMobile } = useApp(); const [mapQ, setMapQ] = useState(''); const [allAnnonces, setAllAnnonces] = useState(null); const [clients, setClients] = useState([]); const [clientMatchedIds, setClientMatchedIds] = useState(null); // Set d'annonce_id si filtre client actif // pending = ce que l'utilisateur modifie. applied = ce qui est applique sur la carte. const [pending, setPending] = useState({ client_id: '', type_bien: '', score_min: 70, masquer_non_geo: true }); const [applied, setApplied] = useState(pending); // Chantier 07 : sur telephone le panneau est une feuille, fermee par defaut const [sidebarOpen, setSidebarOpen] = useState(() => !(window.matchMedia && window.matchMedia('(max-width: 767px)').matches)); const [mapColor, setMapColor] = useState('dark'); // dark | light const [map3D, setMap3D] = useState(false); // toggle 3D batiments const mapEl = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); // Fetch annonces + clients au mount useEffect(() => { if (!window.SentinelAPI) { setAllAnnonces([]); return; } setAllAnnonces(null); // On fetch les annonces SANS filtre geo, pour pouvoir basculer la checkbox sans refetch window.SentinelAPI.fetchAuth('/api/annonces?limit=500') .then((r) => setAllAnnonces(r.data || [])) .catch((e) => { console.warn('[Carte] fetch err', e); setAllAnnonces([]); toast('Erreur chargement annonces.', 'error'); }); window.SentinelAPI.fetchAuth('/api/clients') .then((r) => setClients(r.data || [])) .catch(() => setClients([])); }, []); // Quand le filtre client applique change : fetch les matches du client pour // obtenir la liste des annonce_id qui matchent ce client. useEffect(() => { if (!applied.client_id) { setClientMatchedIds(null); return; } if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth(`/api/clients/${applied.client_id}/matches`) .then((r) => { const ids = new Set((r.data || []).map((m) => m.annonce_id)); setClientMatchedIds(ids); }) .catch(() => { setClientMatchedIds(new Set()); toast('Erreur chargement matches client.', 'error'); }); }, [applied.client_id]); // Filtrage client-side selon `applied` const filtered = useMemo(() => { let l = allAnnonces || []; if (mapQ) { const ql = mapQ.toLowerCase(); l = l.filter((a) => [a.ville, a.code_postal, a.titre].filter(Boolean).some((x) => String(x).toLowerCase().includes(ql))); } if (applied.masquer_non_geo) l = l.filter((a) => a.lat != null && a.lng != null); if (applied.type_bien) l = l.filter((a) => a.type_bien === applied.type_bien); if (applied.score_min > 0) l = l.filter((a) => (a.score_max || 0) >= applied.score_min); if (applied.client_id && clientMatchedIds) l = l.filter((a) => clientMatchedIds.has(a.id)); return l; }, [allAnnonces, applied, clientMatchedIds]); // Init MapLibre GL au mount (style initial = dark) useEffect(() => { if (typeof maplibregl === 'undefined' || !mapEl.current || mapRef.current) return; mapRef.current = new maplibregl.Map({ container: mapEl.current, style: _MAP_STYLES[mapColor], center: [3.0573, 50.6292], // Lille (note : MapLibre = [lng, lat]) zoom: 9, pitch: map3D ? 60 : 0, attributionControl: { compact: true }, }); mapRef.current.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right'); return () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; markersRef.current = []; } }; }, []); // Switch de couleur : recharge le style raster (Dark <-> Blanc). En 3D, // les batiments Overpass sont re-appliques apres le re-style. useEffect(() => { const map = mapRef.current; if (!map) return; map.setStyle(_MAP_STYLES[mapColor]); map.once('idle', () => _apply3DBuildingsOverpass(map, map3D)); }, [mapColor]); // Toggle 3D : juste un easeTo pitch + add/remove buildings + listeners // moveend pour suivre le viewport. NE recharge PAS les tuiles -> fluide. useEffect(() => { const map = mapRef.current; if (!map) return; map.easeTo({ pitch: map3D ? 60 : 0, duration: 600 }); _apply3DBuildingsOverpass(map, map3D); if (!map3D) return; let timer = null; const onMove = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => _apply3DBuildingsOverpass(map, true), 800); }; map.on('moveend', onMove); map.on('zoomend', onMove); return () => { if (timer) clearTimeout(timer); map.off('moveend', onMove); map.off('zoomend', onMove); }; }, [map3D]); // Render markers quand la liste filtree change (markers DOM = survivent au setStyle) useEffect(() => { if (!mapRef.current || typeof maplibregl === 'undefined') return; markersRef.current.forEach((m) => m.remove()); markersRef.current = []; if (!filtered.length) return; const validCoords = []; filtered.forEach((a) => { if (a.lat == null || a.lng == null) return; const score = a.score_max || 0; const tier = score >= 100 ? 'exact' : score >= 70 ? 'approx' : score > 0 ? 'low' : 'none'; const color = tier === 'exact' ? '#34D399' : tier === 'approx' ? '#F0A44A' : tier === 'low' ? '#888' : '#444'; const size = score >= 70 ? 16 : 12; // Marker DOM custom (rond avec border) const el = document.createElement('div'); el.style.cssText = 'width:' + size + 'px;height:' + size + 'px;background:' + color + ';border:2px solid #0A0A0A;border-radius:50%;cursor:pointer;box-shadow:0 0 0 1px rgba(255,255,255,.1);'; const titre = a.titre || a.ville || ('Annonce #' + a.id); const prix = _prixTexte(a); const surf = a.surface ? a.surface + ' m²' : '—'; const sc = a.score_max != null ? a.score_max + ' %' : '—'; // Lot 0 audit : popup construite en DOM (textContent), jamais en HTML // concatene. Titre / ville / URL viennent de la collecte externe et les // annonces sont mutualisees entre cabinets : un seul titre piege // s'executait chez tout le monde (setHTML = innerHTML). L'URL source // n'est acceptee qu'en http(s). const safeUrl = (a.url && /^https?:\/\//i.test(String(a.url))) ? String(a.url) : null; const pop = document.createElement('div'); pop.style.cssText = 'font-family:Inter,sans-serif;min-width:200px'; const popTitle = document.createElement('div'); popTitle.style.cssText = 'font-weight:600;font-size:13px;margin-bottom:6px;color:#0A0A0A'; popTitle.textContent = titre; const popBody = document.createElement('div'); popBody.style.cssText = 'font-size:11px;color:#555;line-height:1.5'; const addLine = (txt) => { popBody.appendChild(document.createTextNode(txt)); popBody.appendChild(document.createElement('br')); }; addLine(prix + ' · ' + surf); addLine((a.ville || '') + (a.code_postal ? ' · ' + a.code_postal : '')); popBody.appendChild(document.createTextNode('Score : ')); const popScore = document.createElement('strong'); popScore.textContent = sc; popBody.appendChild(popScore); popBody.appendChild(document.createElement('br')); if (safeUrl) { const popLink = document.createElement('a'); popLink.href = safeUrl; popLink.target = '_blank'; popLink.rel = 'noopener'; popLink.style.cssText = 'color:#34D399;font-size:11px'; popLink.textContent = 'source ↗'; popBody.appendChild(popLink); } pop.appendChild(popTitle); pop.appendChild(popBody); const marker = new maplibregl.Marker({ element: el, anchor: 'center' }) .setLngLat([a.lng, a.lat]) .setPopup(new maplibregl.Popup({ offset: 18, closeButton: false }).setDOMContent(pop)) .addTo(mapRef.current); markersRef.current.push(marker); validCoords.push([a.lng, a.lat]); }); // Auto-fit bounds sur tous les markers if (validCoords.length > 1) { const bounds = validCoords.reduce( (b, c) => b.extend(c), new maplibregl.LngLatBounds(validCoords[0], validCoords[0]) ); mapRef.current.fitBounds(bounds, { padding: 60, maxZoom: 13, duration: 600 }); } else if (validCoords.length === 1) { mapRef.current.flyTo({ center: validCoords[0], zoom: 13, duration: 600 }); } }, [filtered]); const setP = (k, v) => setPending((p) => ({ ...p, [k]: v })); const applyAll = () => setApplied(pending); const isPendingDirty = JSON.stringify(pending) !== JSON.stringify(applied); return (
{sidebarOpen && ( )}
{!sidebarOpen && !isMobile && ( )} {isMobile && (() => { const nActive = [applied.client_id, applied.type_bien].filter(Boolean).length + (applied.score_min !== 70 ? 1 : 0) + (applied.masquer_non_geo ? 0 : 1); const nMatches = filtered.filter((a) => a.score_max != null && a.score_max >= 70).length; return (<>
setMapQ(e.target.value)} />
{allAnnonces === null ? 'Chargement…' : `${filtered.length} annonce${filtered.length > 1 ? 's' : ''} dans la zone`} ● {nMatches} match{nMatches > 1 ? 'es' : ''}
Match
70–99
< 70
Sans match
); })()}
); }; /* ============================================================ */ /* OUTILS (géo) */ /* ============================================================ */ /* ============================================================ */ /* OUTILS — Collecte + 4 outils geo branches sur backend */ /* ============================================================ */ const OutilsPage = () => { const { toast, user } = useApp(); const canCollect = !!(user.is_cabinet_admin || user.is_superadmin); // Etat collecte const [collecteStatus, setCollecteStatus] = useState(null); const [collecteZones, setCollecteZones] = useState(null); const [collecting, setCollecting] = useState(false); // Tool 1 : EXIF const [exifFile, setExifFile] = useState(null); const [exifResult, setExifResult] = useState(null); const [exifBusy, setExifBusy] = useState(false); // Tool 2 : Geocode const [geocodeAdresse, setGeocodeAdresse] = useState(''); const [geocodeResult, setGeocodeResult] = useState(null); const [geocodeBusy, setGeocodeBusy] = useState(false); // Tool 3 : Reverse geocode const [reverseLat, setReverseLat] = useState('50.6292'); const [reverseLng, setReverseLng] = useState('3.0573'); const [reverseResult, setReverseResult] = useState(null); const [reverseBusy, setReverseBusy] = useState(false); // Tool 4 : Parse adresse const [parseTexte, setParseTexte] = useState('Belle maison au 12 rue Victor Hugo, 59000 Lille, idéale pour famille.'); const [parseResult, setParseResult] = useState(null); const [parseBusy, setParseBusy] = useState(false); // Charger statut + zones collecte useEffect(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/collecte/status') .then((r) => setCollecteStatus(r.data || {})) .catch(() => setCollecteStatus({})); window.SentinelAPI.fetchAuth('/api/collecte/zones') .then((r) => setCollecteZones(r.data || {})) .catch(() => setCollecteZones({})); }, []); const handleCollecte = async () => { if (!canCollect) { toast('Réservé compte cabinet.', 'error'); return; } if (!confirm('Lancer une collecte Stream Estate (action facturée) ?')) return; setCollecting(true); try { const r = await window.SentinelAPI.fetchAuth('/api/collecte/run', { method: 'POST', body: { notifier: true } }); const c = (r.data && r.data.collecte) || {}; const m = (r.data && r.data.matching) || {}; const n = (r.data && r.data.notifications_envoyees) || 0; // Audit V2 : backend renvoie {recus, inseres, skips, erreurs, ignorees_hors_zone, zones} // et {annonces_traitees, matches, matches_parfaits, bons_matches}. toast(`Collecte OK : ${c.inseres || 0} annonces, ${m.matches || 0} matches, ${n} notifs.`, 'success'); // Refresh status window.SentinelAPI.fetchAuth('/api/collecte/status').then((r2) => setCollecteStatus(r2.data || {})).catch(() => {}); } catch (e) { toast('Échec collecte : ' + (e.message || e), 'error'); } finally { setCollecting(false); } }; const runExif = async () => { if (!exifFile) { toast('Sélectionne une photo.', 'warning'); return; } setExifBusy(true); setExifResult(null); try { // Lot 0 audit : la route attend le champ multipart `photo` (pas `file`) // -> 422 systematique avant. const fd = new FormData(); fd.append('photo', exifFile); const r = await window.SentinelAPI.fetchAuth('/api/tools/exif-photo', { method: 'POST', body: fd }); setExifResult(r.data || {}); if (r.data && r.data.lat != null) toast(`GPS trouvé : ${r.data.lat.toFixed(4)} · ${r.data.lng.toFixed(4)}`, 'success'); else toast('Aucune coordonnée GPS dans cette photo.', 'warning'); } catch (e) { toast('Échec EXIF : ' + (e.message || e), 'error'); } finally { setExifBusy(false); } }; const runGeocode = async () => { if (!geocodeAdresse.trim()) { toast('Saisis une adresse.', 'warning'); return; } setGeocodeBusy(true); setGeocodeResult(null); try { const r = await window.SentinelAPI.fetchAuth('/api/tools/geocode', { method: 'POST', body: { adresse: geocodeAdresse } }); setGeocodeResult(r.data || {}); if (r.data && r.data.lat != null) toast(`${r.data.lat.toFixed(4)} N, ${r.data.lng.toFixed(4)} E`, 'success'); else toast('Adresse introuvable.', 'warning'); } catch (e) { toast('Échec géocodage : ' + (e.message || e), 'error'); } finally { setGeocodeBusy(false); } }; const runReverse = async () => { const lat = parseFloat(reverseLat); const lng = parseFloat(reverseLng); if (isNaN(lat) || isNaN(lng)) { toast('Coordonnées invalides.', 'warning'); return; } setReverseBusy(true); setReverseResult(null); try { const r = await window.SentinelAPI.fetchAuth(`/api/tools/reverse-geocode?lat=${lat}&lng=${lng}`); setReverseResult(r.data || {}); // Audit V2 : backend renvoie {found, label, street, postcode, city, citycode}, pas {adresse}. if (r.data && r.data.found && r.data.label) toast(r.data.label, 'success'); else toast('Aucune adresse trouvée.', 'warning'); } catch (e) { toast('Échec : ' + (e.message || e), 'error'); } finally { setReverseBusy(false); } }; const runParse = async () => { if (!parseTexte.trim()) { toast('Saisis un texte.', 'warning'); return; } setParseBusy(true); setParseResult(null); try { const r = await window.SentinelAPI.fetchAuth('/api/tools/parse-adresse', { method: 'POST', body: { texte: parseTexte } }); setParseResult(r.data || {}); if (r.data && r.data.adresse) toast(`Trouvé : ${r.data.adresse}`, 'success'); else toast('Aucune adresse détectée dans le texte.', 'warning'); } catch (e) { toast('Échec : ' + (e.message || e), 'error'); } finally { setParseBusy(false); } }; return (
{/* Section Collecte */}

Collecte d'annonces

Stream Estate (mutualisé tous cabinets) · pipeline complet collecte → matching → notifications.

{canCollect && ( )}
Statut
{collecteStatus === null ? '—' : collecteStatus.derniere_collecte ? new Date(collecteStatus.derniere_collecte).toLocaleString('fr-FR') : 'Aucune collecte récente'}
Total annonces
{collecteStatus && collecteStatus.total_annonces != null ? fmt.num(collecteStatus.total_annonces) : '—'}
Zones ciblées
{collecteZones?.count != null ? `${collecteZones.count} ville${collecteZones.count > 1 ? 's' : ''}` : '—'}
{collecteZones?.villes && collecteZones.villes.length > 0 && (
Villes : {collecteZones.villes.slice(0, 20).join(' · ')} {collecteZones.villes.length > 20 && ` · +${collecteZones.villes.length - 20}`}
)}
{/* Section Outils geo */}
{/* Tool 1 : EXIF */}
01 · Photo → GPS

Lire les EXIF d'une photo.

Récupère les coordonnées GPS dans les métadonnées EXIF. Les messageries (WhatsApp, Telegram, iMessage) suppriment souvent ces données — utilise le fichier original.

{/* display:block inline ecrasait le display:grid de .dropzone -> titre et description se chevauchaient */} {exifResult && (
{exifResult.lat != null ? <>GPS : {exifResult.lat.toFixed(6)} · {exifResult.lng.toFixed(6)} : <>Aucune coordonnée GPS dans les EXIF.}
)}
{/* Tool 2 : Geocode */}
02 · Adresse → GPS

Géocoder une adresse française.

Nominatim (OSM) + fallback Base Adresse Nationale. Précision typique : ~10 m.

setGeocodeAdresse(e.target.value)} placeholder="12 rue de Paris, Lille" />
{geocodeResult && (
{geocodeResult.lat != null ? <>GPS : {geocodeResult.lat.toFixed(6)} · {geocodeResult.lng.toFixed(6)} {/* Paquet C : backend renvoie `source` (nominatim/ban), pas `precision`. */} {geocodeResult.source && <> · source {geocodeResult.source}} : <>Adresse introuvable.}
)}
{/* Tool 3 : Reverse geocode */}
03 · GPS → Adresse

Géocodage inverse.

Retrouve l'adresse postale la plus proche de coordonnées GPS données.

setReverseLat(e.target.value)} />
setReverseLng(e.target.value)} />
{reverseResult && (
{reverseResult.found && reverseResult.label ? {reverseResult.label} : 'Aucune adresse trouvée à ces coordonnées.'}
)}
{/* Tool 4 : Parse adresse */}
04 · Texte → Adresse extraite

Extraire une adresse d'un texte libre.

Détecte un pattern d'adresse française dans un texte libre (description d'annonce, email client, etc.).

{parseResult && (
{parseResult.adresse ? <>Adresse : {parseResult.adresse} : 'Aucune adresse détectée.'}
)}
); }; /* ============================================================ */ /* PHOTOLOC — agent : upload photo -> top-N candidats GPS */ /* Endpoints : */ /* GET /api/photoloc/zones -> zones ready dispo */ /* POST /api/photoloc/search -> multipart photo + zone_id */ /* ============================================================ */ const PhotoLocPage = () => { const { toast } = useApp(); const [zones, setZones] = useState([]); const [zoneId, setZoneId] = useState(''); const [file, setFile] = useState(null); const [phase, setPhase] = useState('empty'); // empty | analyzing | result const [result, setResult] = useState(null); const [selectedRank, setSelectedRank] = useState(0); const [mapColor, setMapColor] = useState('dark'); // dark | light const [map3D, setMap3D] = useState(false); // toggle 3D batiments const mapEl = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); useEffect(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/photoloc/zones') .then((r) => { const zs = r.data || []; setZones(zs); if (zs.length > 0) setZoneId((prev) => prev || String(zs[0].id)); }) .catch(() => toast('Erreur chargement zones PhotoLoc.', 'error')); }, []); const launch = async () => { if (!file) { toast('Selectionne une photo.', 'warning'); return; } if (!zoneId) { toast('Selectionne une zone.', 'warning'); return; } setPhase('analyzing'); try { const fd = new FormData(); fd.append('photo', file); fd.append('zone_id', zoneId); fd.append('top_k', '5'); const r = await window.SentinelAPI.fetchAuth('/api/photoloc/search', { method: 'POST', body: fd }); setResult(r.data || {}); setSelectedRank(0); setPhase('result'); } catch (e) { toast('Echec recherche : ' + (e.message || e), 'error'); setPhase('empty'); } }; const reset = () => { setPhase('empty'); setResult(null); }; const currentZone = zones.find((z) => String(z.id) === zoneId); const matches = (result && result.matches) || []; // Lot D audit : abstention du service (aucun candidat verifie). const abstained = !!(result && result.abstained); // Init MapLibre GL en mode result useEffect(() => { if (phase !== 'result' || typeof maplibregl === 'undefined' || !mapEl.current || mapRef.current) return; mapRef.current = new maplibregl.Map({ container: mapEl.current, style: _MAP_STYLES[mapColor], center: [3.0573, 50.6292], zoom: 12, pitch: map3D ? 60 : 0, attributionControl: false, }); mapRef.current.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right'); return () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; markersRef.current = []; } }; }, [phase]); // Switch couleur : recharge le style raster useEffect(() => { const map = mapRef.current; if (!map) return; map.setStyle(_MAP_STYLES[mapColor]); map.once('idle', () => _apply3DBuildingsOverpass(map, map3D)); }, [mapColor]); // Toggle 3D : pitch + buildings + listeners useEffect(() => { const map = mapRef.current; if (!map) return; map.easeTo({ pitch: map3D ? 60 : 0, duration: 600 }); _apply3DBuildingsOverpass(map, map3D); if (!map3D) return; let timer = null; const onMove = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => _apply3DBuildingsOverpass(map, true), 800); }; map.on('moveend', onMove); map.on('zoomend', onMove); return () => { if (timer) clearTimeout(timer); map.off('moveend', onMove); map.off('zoomend', onMove); }; }, [map3D]); const onSelectFromMap = (i) => setSelectedRank(i); // Update markers (DOM, survivent au setStyle) useEffect(() => { if (!mapRef.current || typeof maplibregl === 'undefined') return; markersRef.current.forEach((m) => m.remove()); markersRef.current = []; if (!matches.length) return; const coords = []; matches.forEach((m, i) => { const isTop = i === selectedRank; const color = isTop ? '#34D399' : '#F0A44A'; const size = isTop ? 22 : 16; const el = document.createElement('div'); el.style.cssText = 'width:' + size + 'px;height:' + size + 'px;background:' + color + ';border:2px solid #0A0A0A;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#000;font-size:10px;font-weight:600;box-shadow:0 0 0 1px rgba(255,255,255,.15);'; el.textContent = String(m.rank); el.addEventListener('click', (e) => { e.stopPropagation(); onSelectFromMap(i); }); const marker = new maplibregl.Marker({ element: el, anchor: 'center' }) .setLngLat([m.lng, m.lat]) .setPopup(new maplibregl.Popup({ offset: 18, closeButton: false }).setHTML( '
Rang ' + m.rank + ' - ' + Math.round((m.score || 0) * 100) + ' %
' + m.lat.toFixed(5) + ', ' + m.lng.toFixed(5) + '
' )) .addTo(mapRef.current); markersRef.current.push(marker); coords.push([m.lng, m.lat]); }); if (coords.length > 1) { const bounds = coords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0])); mapRef.current.fitBounds(bounds, { padding: 80, maxZoom: 16, duration: 600 }); } else if (coords.length === 1) { mapRef.current.flyTo({ center: coords[0], zoom: 16, duration: 600 }); } }, [matches, selectedRank]); const photoPreview = useMemo(() => file ? URL.createObjectURL(file) : null, [file]); useEffect(() => () => { if (photoPreview) URL.revokeObjectURL(photoPreview); }, [photoPreview]); return (
{phase === 'empty' && ( )} {phase === 'analyzing' && ( )} {phase === 'result' && ( )}
); }; const PhotoLocBar = ({ phase, zones, zoneId, onChangeZone, onReset, mapColor, onChangeMapColor, map3D, onChangeMap3D }) => (
{phase === 'empty' && 'Photo seule, sans metadonnees'} {phase === 'analyzing' && <>Analyse en cours} {phase === 'result' && <>Resultats prets}
PhotoLoc Geolocalisation IA
{phase === 'result' && onChangeMapColor && (
onChangeMap3D(v === '3d')} options={[ { value: '2d', label: '2D' }, { value: '3d', label: '3D' }, ]} />
)}
Zone
{phase !== 'empty' && }
); const PhotoLocEmptyV2 = ({ zones, zoneId, onChangeZone, file, onChangeFile, onLaunch }) => { const noZones = zones.length === 0; return (

Chaque pixel
est un indice.

Aucune metadonnee requise. Une seule photo suffit : le service identifie le batiment en comparant la facade aux milliers de batiments indexes de la zone.

{noZones ? (
Aucune zone PhotoLoc prete pour l'instant. Demande au super-admin de lancer un scan via PhotoLoc DEV.
) : ( <>
Pas d'EXIF requis Pas de monument requis Latence ~10-30s, jusqu'a 3 min au premier appel (demarrage du GPU)
)}
); }; const PhotoLocAnalyzingV2 = ({ zone }) => (

Analyse en cours, zone {(zone && zone.nom_zone) || ''}

Recoupement multi-critères sur notre base géolocalisée propriétaire

Première analyse de la zone : jusqu'à 60 s

); // Helper : construire une URL Google Maps a partir des coords (fallback si // l'API ne renvoie pas google_maps_url). const _gmapsUrl = (lat, lng) => 'https://www.google.com/maps/search/?api=1&query=' + lat + ',' + lng; // Lot D audit : etiquette de verification geometrique d'un candidat. const _plVerifTag = (m) => { if (!m) return null; if (m.verified === true) return verifie; if (m.geometry_status === 'rejected') return rejete; return non verifie; }; const PhotoLocResultV2 = ({ matches, selectedRank, onSelect, zone, photoPreview, fileName, service, durationMs, mapEl, abstained }) => { // Lot A audit : le backend renvoie desormais 502/503 (toast) quand le // service est en panne ou refuse la requete ; ici on n'arrive donc que // pour un vrai "aucun candidat" ou un resultat de simulation. const simulation = service === 'mock'; if (!matches.length) { return (

Aucun candidat trouve

{simulation ? 'Mode simulation (PHOTOLOC_MOCK_MODE) : le service reel n\'a pas ete interroge.' : 'La photo ne matche aucun batiment indexe dans cette zone. Essaie une autre zone ou une autre photo.'}

); } const top = matches[selectedRank] || matches[0]; const topPct = Math.round((top.score || 0) * 100); const isLow = topPct < 30; return (
= 70 ? 'var(--color-success)' : 'var(--color-warning)'} strokeWidth="2" strokeDasharray={(topPct/100)*88 + ' 88'} strokeLinecap="round" transform="rotate(-90 18 18)"/> {topPct} % Rang {top.rank} {_plVerifTag(top)}
{abstained && (
Aucun candidat n'a passe la verification geometrique : resultats non fiables, a confirmer visuellement.
)}
  • Coordonnees : {top.lat.toFixed(5)}, {top.lng.toFixed(5)}
  • {top.building_id &&
  • Building ID : {top.building_id}
  • } {top.match_count != null &&
  • {top.match_count} matchs dans le cluster spatial
  • } {isLow &&
  • Confiance faible (<30%), verifier visuellement
  • }
Voir sur Google Maps
{simulation && ( MODE SIMULATION — coordonnees fictives, service reel non interroge )} © OpenFreeMap · © OpenStreetMap, {simulation ? 'simulation' : (service || '-')}, {durationMs ? Math.round(durationMs) + ' ms' : '-'}
); }; /* ============================================================ */ /* ESTIMATION — branche sur POST /api/estimation/estimate */ /* + geocodage de l'adresse via POST /api/tools/geocode (Nominatim) */ /* ============================================================ */ const EstimationPage = () => { const { toast } = useApp(); const [form, setForm] = useState({ adresse: '', lat: '', lng: '', type_bien: 'Appartement', surface: '', pieces: '', rayon: 500, }); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); const [geocoding, setGeocoding] = useState(false); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); // Geocoder l'adresse si lat/lng vides const ensureCoords = async () => { if (form.lat && form.lng) return { lat: parseFloat(form.lat), lng: parseFloat(form.lng) }; if (!form.adresse.trim()) return null; setGeocoding(true); try { const r = await window.SentinelAPI.fetchAuth('/api/tools/geocode', { method: 'POST', body: { adresse: form.adresse } }); if (r.data && r.data.lat != null) { set('lat', String(r.data.lat)); set('lng', String(r.data.lng)); return { lat: r.data.lat, lng: r.data.lng }; } toast('Adresse non geocodable.', 'error'); return null; } catch (e) { toast('Echec geocodage : ' + (e.message || e), 'error'); return null; } finally { setGeocoding(false); } }; const estimate = async () => { if (!form.surface || parseInt(form.surface, 10) <= 0) { toast('Saisis une surface.', 'warning'); return; } if (!form.type_bien) { toast('Selectionne un type de bien.', 'warning'); return; } const coords = await ensureCoords(); if (!coords) return; setBusy(true); setResult(null); try { const payload = { lat: coords.lat, lng: coords.lng, type_bien: form.type_bien, surface: parseInt(form.surface, 10), rayon: parseInt(form.rayon, 10) || 500, }; if (form.pieces) payload.pieces = parseInt(form.pieces, 10); const r = await window.SentinelAPI.fetchAuth('/api/estimation/estimate', { method: 'POST', body: payload }); setResult(r.data || {}); if (r.data && r.data.estimation) { const n = (r.data.comparables && r.data.comparables.length) || 0; toast(`Estimation calculee sur ${n} comparable${n > 1 ? 's' : ''} DVF.`, 'success'); } else if (r.data && r.data.error) { toast(r.data.error, 'warning'); } } catch (e) { toast('Echec estimation : ' + (e.message || e), 'error'); } finally { setBusy(false); } }; const est = result && result.estimation; const comparables = (result && result.comparables) || []; return (
Bien a estimer
set('adresse', e.target.value)} placeholder="Ex : 12 rue Esquermoise, Lille" /> Geocodee automatiquement via Nominatim + BAN au lancement.
set('lat', e.target.value)} placeholder="optionnel" />
set('lng', e.target.value)} placeholder="optionnel" />
set('surface', e.target.value)} />
set('pieces', e.target.value)} />
{result && !est && result.error && (
{result.error}
{result.hint &&
{result.hint}
}
)} {est && ( <>
{fmt.eur(est.median)} Mediane
P25
{fmt.eur(est.low)}
P75
{fmt.eur(est.high)}
{/* Lot 0 audit : cles reelles de POST /api/estimation/estimate (estimation.low/median/high/confidence, price_per_sqm_avg, market_trend_12m = "+3.2%" | null, comparables.price / price_per_sqm / sold_at). Avant : tout affichait "—". */} {result.price_per_sqm_avg != null && (
Prix moyen / m² : {fmt.eur(result.price_per_sqm_avg)}
)}
{comparables.length > 0 && ( {comparables.slice(0, 20).map((c, i) => ( ))}
DateTypeSurface Valeur €/m² Distance
{c.sold_at || '—'} {c.type_local || '—'} {c.surface != null ? c.surface + ' m²' : '—'} {c.price ? fmt.eur(c.price) : '—'} {c.price_per_sqm != null ? fmt.eur(c.price_per_sqm) : '—'} {c.distance_m != null ? c.distance_m + ' m' : '—'}
)} )}
); }; /* ============================================================ */ /* LEGAL */ /* ============================================================ */ const LegalPage = () => { const [view, setView] = useState('hub'); if (view === 'mandats') return setView('hub')} />; if (view === 'bons') return setView('hub')} />; if (view === 'diagnostics') return setView('hub')} />; return ; }; const LegalHub = ({ onOpen }) => { // Paquet C : compteurs reels (avant hardcodes count:1, 0, 2, 2). // 4 fetch parallels au mount, chaque resultat alimente un compteur. // Si un endpoint echoue (403, network), le compteur reste null // (affiche "—" plutot qu'un chiffre faux). const [counts, setCounts] = useState({ mandats: null, bons: null, diagnostics: null, coffre: null }); useEffect(() => { if (!window.SentinelAPI) return; const api = window.SentinelAPI; const safe = (p) => p.catch(() => null); Promise.all([ safe(api.fetchAuth('/api/mandats')), safe(api.fetchAuth('/api/bons-visite')), safe(api.fetchAuth('/api/diagnostics/expiring?days_ahead=365')), safe(api.fetchAuth('/api/documents')), ]).then(([m, b, d, c]) => { setCounts({ mandats: m && Array.isArray(m.data) ? m.data.length : null, bons: b && Array.isArray(b.data) ? b.data.length : null, diagnostics: d && d.data && typeof d.data.n_total === 'number' ? d.data.n_total : null, coffre: c && Array.isArray(c.data) ? c.data.length : null, }); }); }, []); const tiles = [ { id:'mandats', glyph:'file', count: counts.mandats, title:'Mandats', desc:'Auto-générés conformes ALUR. Signature électronique eIDAS simple sur place.' }, { id:'bons', glyph:'home', count: counts.bons, title:'Bons de visite', desc:'Preuve juridique opposable. Anti court-circuit visiteur → achat hors agence.' }, { id:'diagnostics', glyph:'clock', count: counts.diagnostics, title:'Diagnostics', desc:'DPE, amiante, plomb, gaz, électricité. Suivi validités avec alerte 30 jours.' }, { id:'coffre', glyph:'lock', count: counts.coffre, title:'Coffre-fort', desc:'Documents légaux scannés (CNI, RIB, RCP, copies signées). 20 MB par fichier.' }, ]; return (
{tiles.map(t => { const Glyph = I[t.glyph]; return (
onOpen(t.id === 'coffre' ? 'hub' : t.id)}>
{t.title}{t.count != null ? t.count : '—'}
{t.count === 0 ? Aucun pour l'instant. : null}{t.desc}
Ouvrir
); })}
); }; /* MANDATS — branche sur /api/mandats */ const MandatsView = ({ onBack }) => { const { toast } = useApp(); const [mandats, setMandats] = useState(null); const [showNew, setShowNew] = useState(false); const [types, setTypes] = useState({}); const reload = () => { if (!window.SentinelAPI) { setMandats([]); return; } setMandats(null); window.SentinelAPI.fetchAuth('/api/mandats') .then((r) => setMandats(r.data || [])) .catch(() => { setMandats([]); toast('Erreur chargement mandats.', 'error'); }); window.SentinelAPI.fetchAuth('/api/mandats/types') .then((r) => { // Backend renvoie [{code, label, duree_defaut_jours}], on indexe par code. const raw = (r && r.data) || []; const dict = {}; (Array.isArray(raw) ? raw : []).forEach((t) => { if (t && t.code) dict[t.code] = t; }); setTypes(dict); }).catch(() => {}); }; useEffect(() => { reload(); }, []); const list = mandats || []; const totalCount = list.length; // Audit V2 : backend renvoie `status` en MAJUSCULES (DRAFT/EN_ATTENTE_SIGNATURE/SIGNE/EXPIRE/RESILIE), // pas `statut` en minuscules. Cf modules/mandats.py:64-68. const brouillonsCount = list.filter((m) => m.status === 'DRAFT' || m.status === 'EN_ATTENTE_SIGNATURE').length; const signesCount = list.filter((m) => m.status === 'SIGNE').length; // Map status -> label humain affichable dans la Pill. const STATUS_LABEL = { DRAFT: 'Brouillon', EN_ATTENTE_SIGNATURE: 'A signer', SIGNE: 'Signe', EXPIRE: 'Expire', RESILIE: 'Resilie', }; const downloadPdf = async (m) => { try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; const r = await fetch('/api/mandats/' + m.id + '/pdf', { headers: { Authorization: 'Bearer ' + token } }); if (!r.ok) throw new Error('HTTP ' + r.status); const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'mandat-' + (m.numero || m.id) + '.pdf'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (e) { toast('Echec PDF : ' + (e.message || e), 'error'); } }; const sign = async (m) => { if (!confirm('Signer le mandat ' + (m.numero || '#' + m.id) + ' ?\n\nApres signature il ne peut plus etre edite.')) return; try { await window.SentinelAPI.fetchAuth('/api/mandats/' + m.id + '/sign', { method: 'POST' }); toast('Mandat signe.', 'success'); reload(); } catch (e) { toast('Echec signature : ' + (e.message || e), 'error'); } }; const resilier = async (m) => { const motif = prompt('Motif de resiliation (optionnel) :'); try { await window.SentinelAPI.fetchAuth('/api/mandats/' + m.id + '/resilier', { method: 'POST', body: { motif: motif || null } }); toast('Mandat resilie.', 'success'); reload(); } catch (e) { toast('Echec resiliation : ' + (e.message || e), 'error'); } }; const remove = async (m) => { if (!confirm('Supprimer le mandat ' + (m.numero || '#' + m.id) + ' ?\n\nCette action est definitive.')) return; try { await window.SentinelAPI.fetchAuth('/api/mandats/' + m.id, { method: 'DELETE' }); toast('Mandat supprime.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; return (
← Juridique} title="Mandats" sub="Generation automatique avec mentions ALUR. PDF pret a signer en 30 secondes." actions={} /> 1 ? 's' : ''} · ${fmt.num(signesCount)} signé${signesCount > 1 ? 's' : ''}`} flush className="table-card"> {mandats === null ? ( ) : list.length === 0 ? ( setShowNew(true)}> Nouveau mandat} /> ) : ( {list.map((m) => { // Paquet I : prefere honoraires_montant_ttc direct du backend // (calcule au moment de la creation, peut diverger si on edit // pct sans toucher le prix). Fallback sur recalcul cote front. const honoraires = m.honoraires_montant_ttc != null ? m.honoraires_montant_ttc : (m.prix_vente && m.honoraires_pct ? Math.round(m.prix_vente * m.honoraires_pct / 100) : null); // Audit V2 : statut backend en MAJUSCULES. const statutTone = m.status === 'SIGNE' ? 'success' : m.status === 'RESILIE' ? 'danger' : m.status === 'EXPIRE' ? 'warning' : m.status === 'EN_ATTENTE_SIGNATURE' ? 'info' : 'neutral'; const isSignable = m.status === 'DRAFT' || m.status === 'EN_ATTENTE_SIGNATURE'; return ( ); })}
TypeMandantBien Prix Honor. DebutFinStatut
{m.numero || m.id}
{(types && types[m.type] && types[m.type].label) || m.type_label || m.type}
{m.exclusivite && Exclusif}
{[m.mandant_prenom, m.mandant_nom].filter(Boolean).join(' ').trim() || '—'} {m.bien_adresse || '—'} {m.prix_vente ? fmt.eur(m.prix_vente) : '—'} {honoraires ? fmt.eur(honoraires) : '—'} {m.date_debut || '—'} {m.date_fin || '—'} {STATUS_LABEL[m.status] || m.status || '—'} {isSignable && } {m.status === 'SIGNE' && }
)}
setShowNew(false)} types={types} onCreated={() => { setShowNew(false); reload(); }} toast={toast} />
); }; const MandatFormModal = ({ open, onClose, types, onCreated, toast }) => { const [form, setForm] = useState({ type: '', date_debut: new Date().toISOString().slice(0, 10), duree_jours: '', mandant_prenom: '', mandant_nom: '', mandant_email: '', mandant_telephone: '', mandant_adresse: '', bien_adresse: '', bien_type: '', bien_surface: '', bien_pieces: '', bien_description: '', prix_vente: '', honoraires_pct: '5.0', honoraires_charge: 'VENDEUR', exclusivite: false, preavis_jours: '15', notes: '', }); const [saving, setSaving] = useState(false); useEffect(() => { if (!open) return; // Reset + pre-fill type avec le premier disponible const firstType = Object.keys(types || {})[0] || ''; setForm((f) => ({ ...f, type: firstType })); setSaving(false); }, [open, types]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const intOr = (v) => { const s = String(v || '').trim(); if (!s) return null; const n = parseInt(s, 10); return isNaN(n) ? null : n; }; const floatOr = (v) => { const s = String(v || '').trim(); if (!s) return null; const n = parseFloat(s); return isNaN(n) ? null : n; }; const submit = async () => { if (!form.type) { toast('Selectionne un type de mandat.', 'warning'); return; } if (!form.mandant_nom.trim()) { toast('Nom du mandant requis.', 'warning'); return; } if (!form.date_debut) { toast('Date de debut requise.', 'warning'); return; } const payload = { type: form.type, date_debut: form.date_debut, duree_jours: intOr(form.duree_jours), mandant_nom: form.mandant_nom.trim(), mandant_prenom: form.mandant_prenom.trim() || null, mandant_email: form.mandant_email.trim() || null, mandant_telephone: form.mandant_telephone.trim() || null, mandant_adresse: form.mandant_adresse.trim() || null, bien_adresse: form.bien_adresse.trim() || null, bien_type: form.bien_type.trim() || null, bien_surface: intOr(form.bien_surface), bien_pieces: intOr(form.bien_pieces), bien_description: form.bien_description.trim() || null, prix_vente: intOr(form.prix_vente), honoraires_pct: floatOr(form.honoraires_pct), honoraires_charge: form.honoraires_charge, exclusivite: !!form.exclusivite, preavis_jours: intOr(form.preavis_jours) || 15, notes: form.notes.trim() || null, }; setSaving(true); try { await window.SentinelAPI.fetchAuth('/api/mandats', { method: 'POST', body: payload }); toast('Mandat cree.', 'success'); onCreated(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; if (!open) return null; return ( }>
Type et dates
{/* Paquet I : help-tips parite V1 (mandats.html). */} VENTE_SIMPLE : vendeur libre · VENTE_EXCLUSIF : seul ton agence · VENTE_SEMI_EXCLUSIF : ton agence + le vendeur · RECHERCHE : pour acheteur.
set('date_debut', e.target.value)} />
set('duree_jours', e.target.value)} placeholder="default selon type" /> Vide = duree par defaut selon type (90j vente, 365j recherche, ALUR 3 mois mini renouvelable).
Cocher pour les types VENTE_EXCLUSIF / VENTE_SEMI_EXCLUSIF (revoque le droit du vendeur ou autres agences pendant la duree).
Mandant
set('mandant_prenom', e.target.value)} />
set('mandant_nom', e.target.value)} />
set('mandant_email', e.target.value)} />
set('mandant_telephone', e.target.value)} />
set('mandant_adresse', e.target.value)} />
Bien (laisser vide pour un mandat de recherche)
set('bien_adresse', e.target.value)} />
set('bien_surface', e.target.value)} />
set('bien_pieces', e.target.value)} />
Prix et honoraires
set('prix_vente', e.target.value)} />
set('honoraires_pct', e.target.value)} /> Taux TTC. Moyenne marche FR : 3 a 7 % pour la vente.
Qui paie la commission a la signature. Doit etre coherent avec le mandat affiche (visible sur le portail si vendeur paie).
set('preavis_jours', e.target.value)} /> Delai mini pour denoncer le mandat. Standard ALUR : 15 jours.