/* ======================================================================== SENTINEL — Remaining pages : Rapports, Paramètres, Administration ======================================================================== */ const { useState: _us, useEffect: _ue, useMemo: _um, useRef: _ur, useApp: _useApp, I: _I, PageHeader: _PH, Card: _Card, EmptyState: _ES, MetricRow: _MR, Kpi: _Kpi, Pill: _Pill, Tabs: _Tabs, Status: _S, Modal: _Modal, Skel: _Skel, fmt: _fmt, ScoreBar: _SB, } = window; /* ============================================================ */ /* RAPPORTS */ /* ============================================================ */ const RapportsPage = () => { const { toast } = _useApp(); const [range, setRange] = _us('90'); const [data, setData] = _us(null); _ue(() => { if (!window.SentinelAPI) { setData({}); return; } setData(null); const api = window.SentinelAPI; const days = parseInt(range, 10); const safe = (p) => p.catch(() => null); Promise.all([ safe(api.fetchAuth('/api/dashboard/sources-stats?days=' + days)), safe(api.fetchAuth('/api/dashboard/geoloc-stats?days=' + days)), safe(api.fetchAuth('/api/dashboard/annonces-par-jour')), safe(api.fetchAuth('/api/dashboard/top-clients?limit=5&days=' + days)), safe(api.fetchAuth('/api/dashboard/top-villes?limit=5&days=' + days)), safe(api.fetchAuth('/api/dashboard/score-distribution?days=' + days)), safe(api.fetchAuth('/api/dashboard/team-performance?days=' + days)), safe(api.fetchAuth('/api/dashboard/client-sources?days=' + days)), ]).then(([sources, geoloc, dailyAnnonces, topClients, topVilles, scoreDist, teamPerf, clientSources]) => { // Adapter aux shapes reelles du backend (voir routes/dashboard.py) : // - geoloc : objet {total, exact, approx, echec} // - scoreDist : array [{bucket: "0-9", count: N}] (pas {buckets}) // - teamPerf : objet {users:[...], totals:{n_users, n_clients, // n_nouveaux_periode, n_matches_periode, n_ventes}} // - clientSources: objet {sources:[{source, n}], n_total, ...} const cs = (clientSources && clientSources.data) || {}; setData({ sources: (sources && sources.data) || [], geoloc: (geoloc && geoloc.data) || { total: 0, exact: 0, approx: 0, echec: 0 }, dailyAnnonces: (dailyAnnonces && dailyAnnonces.data) || [], topClients: (topClients && topClients.data) || [], topVilles: (topVilles && topVilles.data) || [], scoreDist: Array.isArray(scoreDist && scoreDist.data) ? scoreDist.data : [], teamPerf: (teamPerf && teamPerf.data) || { users: [], totals: {} }, clientSources: Array.isArray(cs.sources) ? cs.sources : [], }); }).catch(() => setData({ // Fix 7.9 : meme shape que le succes -- sinon data.sources.length etc. // crash dans le body (l'ecran noir des Rapports en cas de network KO). sources: [], geoloc: { total: 0, exact: 0, approx: 0, echec: 0 }, dailyAnnonces: [], topClients: [], topVilles: [], scoreDist: [], teamPerf: { users: [], totals: {} }, clientSources: [], })); }, [range]); const loading = data === null; const sourceMax = data && data.sources && data.sources.length ? Math.max(...data.sources.map((s) => s.count || 0), 1) : 1; const villeMax = data && data.topVilles && data.topVilles.length ? Math.max(...data.topVilles.map((v) => v.count || 0), 1) : 1; const scoreMax = data && Array.isArray(data.scoreDist) && data.scoreDist.length ? Math.max(...data.scoreDist.map((b) => b.count || 0), 1) : 1; const geo = data && data.geoloc ? data.geoloc : { total: 0, exact: 0, approx: 0, echec: 0 }; const totalGeoloc = (geo.exact || 0) + (geo.approx || 0) + (geo.echec || 0); // Paquet G : Export CSV cote front (pas d'endpoint backend dedie). // BOM UTF-8 prefixe pour qu'Excel detecte l'encodage (sinon "Cécile" -> "Cécile"). const _csvEscape = (v) => { const s = String(v == null ? '' : v); if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; return s; }; const _downloadCsv = (filename, header, rows) => { const lines = [header.join(',')]; rows.forEach((r) => lines.push(r.map(_csvEscape).join(','))); const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }; const today = () => new Date().toISOString().slice(0, 10); const exportSourcesCsv = () => _downloadCsv(`sources-${today()}.csv`, ['source', 'count'], (data.sources || []).map((s) => [_fmt.source(s.source), s.count])); const exportTopClientsCsv = () => _downloadCsv(`top-clients-${today()}.csv`, ['nom', 'nb_matches'], (data.topClients || []).map((c) => [c.nom, c.nb_matches])); const exportTopVillesCsv = () => _downloadCsv(`top-villes-${today()}.csv`, ['ville', 'count'], (data.topVilles || []).map((v) => [v.ville, v.count])); return (
<_PH title="Rapports" sub="Indicateurs de collecte, de matching et de performance equipe." actions={<> <_Tabs value={range} onChange={setRange} options={[ {value:'1', label:'24h'}, {value:'7', label:'7j'}, {value:'30', label:'30j'}, {value:'90', label:'90j'}, ]} /> } />

Performance collecte

<_Card title={'Annonces par source (' + range + 'j)'} actions={(data && data.sources && data.sources.length > 0) && }> {loading ? <_Skel kind="lines" n={4} /> : data.sources.length === 0 ? <_ES compact icon="annonces" title="Aucune source sur la période" desc="Lance une collecte pour alimenter ce rapport." /> :
{data.sources.map((s) => (
{_fmt.source(s.source)} · {s.count}
))}
} <_Card title="Taux de geolocalisation" padded> {loading ? <_Skel kind="lines" n={4} /> : }
<_Card title="Annonces collectees par jour" meta="7 derniers jours" flush style={{marginBottom:24}}> {loading ? <_Skel kind="lines" n={4} /> : data.dailyAnnonces.length === 0 ? <_ES compact icon="rapports" title="Pas encore de données" desc="Le graphique se remplit au fil des collectes." /> : d.annonces || 0)} /> }

Performance matching

<_Card title="Top 5 clients (matches)" flush actions={(data && data.topClients && data.topClients.length > 0) && }> {loading ? <_Skel kind="lines" n={4} /> : {data.topClients.length === 0 ? ( ) : data.topClients.map((c) => { const max = data.topClients[0].nb_matches || 1; return ( ); })}
ClientMatches
Aucun client actif sur la periode
{c.nom} {c.nb_matches}
} <_Card title="Top 5 villes (annonces)" flush actions={(data && data.topVilles && data.topVilles.length > 0) && }> {loading ? <_Skel kind="lines" n={4} /> : {data.topVilles.length === 0 ? ( ) : data.topVilles.map((v) => ( ))}
VilleAnnonces
Aucune ville sur la periode
{v.ville} {v.count}
}
<_Card title="Distribution des scores de match" meta={data && Array.isArray(data.scoreDist) ? (data.scoreDist.reduce((s, b) => s + (b.count || 0), 0) + ' matches') : ''} padded style={{marginBottom:24}}> {loading ? <_Skel kind="lines" n={4} /> : !data.scoreDist || data.scoreDist.length === 0 ? <_ES compact icon="check" title="Aucun match sur la période" desc="Les scores apparaissent dès que des annonces correspondent à des clients." /> :
{data.scoreDist.map((d) => { const lo = d.bucket ? parseInt(d.bucket, 10) : 0; const tier = lo >= 90 ? 'high' : lo >= 70 ? 'mid' : 'low'; const n = d.count || 0; return (
{n > 0 && {n}}
{d.bucket || (lo + '–' + (lo + 9))}
); })}
}

Performance equipe

<_Kpi label="Equipe" value={loading ? '…' : _fmt.num((data.teamPerf.totals || {}).n_users || 0)} meta="Utilisateurs actifs" /> <_Kpi label="Nouveaux clients" value={loading ? '…' : _fmt.num((data.teamPerf.totals || {}).n_nouveaux_periode || 0)} meta={'Depuis ' + range + ' jours'} /> <_Kpi label="Matches generes" value={loading ? '…' : _fmt.num((data.teamPerf.totals || {}).n_matches_periode || 0)} meta={'Depuis ' + range + ' jours'} /> <_Kpi label="Ventes (signes)" value={loading ? '…' : _fmt.num((data.teamPerf.totals || {}).n_ventes || 0)} meta="Depuis toujours" />
<_Card flush style={{marginBottom:24}}> {loading ? <_Skel kind="lines" n={4} /> : !data.teamPerf.users || data.teamPerf.users.length === 0 ? <_ES compact icon="clients" title="Aucun agent sur la période" desc="Ajoute des agents dans Paramètres › Équipe pour suivre leur performance." /> : <> {/* Paquet I : nouvelle col Pipeline (parite V1) — breakdown par statut. */} {data.teamPerf.users.map((a) => { const p = a.pipeline || {}; return ( ); })}
AgentClients Nouveaux Matches VentesPipeline
{a.nom || a.email || '—'}
{a.email || ''}
{_fmt.num(a.n_clients || 0)} {_fmt.num(a.n_nouveaux_periode || 0)} {_fmt.num(a.n_matches_periode || 0)} {_fmt.num(a.n_ventes || 0)} {/* Format : "prospect 4 · actif 2 · chaud 1 · signe 3 · perdu 0" en compact. */} {Object.entries(p).filter(([, n]) => n > 0).map(([k, n]) => k[0] + n).join(' · ') || '—'}
{/* Paquet I : footer "team-orphan-note" (parite V1) — clients dont l'agent_id ne matche aucun User. */} {data.teamPerf.n_orphan_clients > 0 && (
+{data.teamPerf.n_orphan_clients} client(s) orphelin(s) — agent_id ne matche aucun user du cabinet (a re-attribuer).
)} }

D'ou viennent vos clients

<_Card title="Provenance (graphique)" padded> {loading ? <_Skel kind="lines" n={4} /> : ({ label: s.source || 'Non precise', value: s.n || 0, color: 'var(--color-info)', }))} /> } <_Card title="Detail par source" flush> {loading ? <_Skel kind="lines" n={4} /> : {(!data.clientSources || data.clientSources.length === 0) ? ( ) : (() => { const tot = data.clientSources.reduce((s, x) => s + (x.n || 0), 0) || 1; return data.clientSources.map((s) => ( )); })()}
SourceClients%
Aucune source renseignee
{s.source || 'Non precise'} {s.n || 0} {Math.round((s.n || 0) / tot * 100)} %
}
); }; /* Donut + simple line chart */ const Donut = ({ data }) => { const total = data.reduce((s, d) => s + d.value, 0); let acc = 0; const segs = data.map(d => { const pct = d.value / total; const start = acc; acc += pct; return { ...d, pct, start, end: acc }; }); const polar = (a) => { const x = 100 + Math.cos((a - 0.25) * 2 * Math.PI) * 90; const y = 100 + Math.sin((a - 0.25) * 2 * Math.PI) * 90; return [x, y]; }; return (
{segs.map((s, i) => { if (s.pct === 1) { return ( ); } const [x1, y1] = polar(s.start); const [x2, y2] = polar(s.end); const large = s.pct > 0.5 ? 1 : 0; return ( ); })}
{segs.map((s, i) => (
{s.label} {s.value} {Math.round(s.pct * 100)} %
))}
); }; const ChartLine = ({ data }) => { const w = 1000, h = 160; const pad = { l: 24, r: 16, t: 16, b: 28 }; const innerW = w - pad.l - pad.r; const innerH = h - pad.t - pad.b; const max = Math.max(...data, 6); const pts = data.map((v, i) => ({ x: pad.l + (i / (data.length - 1)) * innerW, y: pad.t + innerH - (v / max) * innerH, 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`; const labels = ['-90j','-75j','-60j','-45j','-30j','-15j','aujourd\'hui']; return (
{[0,0.25,0.5,0.75,1].map(t => )} {labels.map((l, i) => { const x = pad.l + (i / (labels.length - 1)) * innerW; return {l}; })}
); }; /* ============================================================ */ /* PARAMÈTRES */ /* ============================================================ */ /* ParametresPage — Phase 5E : toutes les cards branchees au backend */ /* Chantier 08 : choix du theme (sombre · clair · systeme), persistant. */ const _ThemeChoice = () => { const { themePref, setThemePref, theme, toggleTheme } = _useApp(); const pref = themePref || theme; const choices = setThemePref ? [['dark', 'Sombre'], ['light', 'Clair'], ['system', 'Système']] : [['dark', 'Sombre'], ['light', 'Clair']]; const pick = (v) => { if (setThemePref) setThemePref(v); else if (v !== theme) toggleTheme(); }; return (
{choices.map(([v, l]) => ( ))}
« Système » suit le réglage clair / sombre de ton appareil.
); }; const _AccountCard = () => { const { toast } = _useApp(); const [form, setForm] = _us({ nom: '', email: '', password: '', current_password: '' }); const [origEmail, setOrigEmail] = _us(''); const [origNom, setOrigNom] = _us(''); const [saving, setSaving] = _us(false); _ue(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/auth/me') .then((r) => { const u = (r && r.data) || {}; setForm({ nom: u.nom || '', email: u.email || '', password: '', current_password: '' }); setOrigEmail(u.email || ''); setOrigNom(u.nom || ''); }) .catch(() => toast('Erreur chargement compte.', 'error')); }, []); const reset = () => setForm({ nom: origNom, email: origEmail, password: '', current_password: '' }); // Lot 1 audit : changer l'email ou le mot de passe exige le mot de passe // actuel (sinon un token vole suffisait a detourner le compte). Un // changement de mot de passe invalide les autres sessions : le backend // renvoie un nouveau token pour celle-ci, qu'on stocke. const sensitive = !!form.password || (form.email.trim().toLowerCase() !== (origEmail || '').toLowerCase()); const save = async () => { if (sensitive && !form.current_password) { toast('Saisis ton mot de passe actuel pour changer l\'email ou le mot de passe.', 'warning'); return; } setSaving(true); try { const body = { nom: form.nom, email: form.email }; if (form.password) body.password = form.password; if (sensitive) body.current_password = form.current_password; const r = await window.SentinelAPI.fetchAuth('/api/auth/me', { method: 'PUT', body }); if (r && r.data && r.data.token) { try { localStorage.setItem('sentinel.token', r.data.token); } catch (e) { /* storage indisponible */ } } toast('Compte sauvegarde.', 'success'); setOrigEmail(form.email.trim().toLowerCase()); setForm((f) => ({ ...f, password: '', current_password: '' })); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; return ( <_Card title="Mon compte" padded>
setForm({ ...form, nom: e.target.value })} />
setForm({ ...form, email: e.target.value })} />
setForm({ ...form, password: e.target.value })} />
{sensitive && (
setForm({ ...form, current_password: e.target.value })} />
)}
<_ThemeChoice />
); }; const _BrandingCard = () => { const { toast } = _useApp(); const [logoUrl, setLogoUrl] = _us(''); const [saving, setSaving] = _us(false); _ue(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/cabinet/branding') .then((r) => { const d = (r && r.data) || {}; setLogoUrl(d.logo_url || ''); }) .catch(() => {}); }, []); const save = async (url) => { setSaving(true); try { await window.SentinelAPI.fetchAuth('/api/cabinet/branding', { method: 'PUT', body: { logo_url: url || null } }); toast('Branding mis a jour.', 'success'); setLogoUrl(url); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; return ( <_Card title="Identite cabinet" padded style={{marginBottom:16}}>

Personnalise l'apparence de Sentinel pour ton equipe : remplace le logo dans la barre de navigation. L'image doit etre hebergee sur une URL publique.

setLogoUrl(e.target.value)} placeholder="https://exemple.fr/logo.png" />
); }; const _TelegramCard = () => { const { toast } = _useApp(); const [data, setData] = _us(null); const [token, setToken] = _us(''); const [chatId, setChatId] = _us(''); const [saving, setSaving] = _us(false); const reload = () => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/cabinet/telegram') .then((r) => { const d = (r && r.data) || {}; // Audit V2 : backend renvoie {configured, telegram_token_masked, telegram_chat_id}. setData(d); setToken(d.telegram_token_masked || ''); setChatId(d.telegram_chat_id || ''); }) .catch(() => {}); }; _ue(() => { reload(); }, []); const save = async () => { setSaving(true); try { // Audit V2 : backend attend {telegram_token, telegram_chat_id} (pas {token, chat_id}). const body = { telegram_chat_id: chatId.trim() || null }; // n'envoie le token que s'il a ete modifie (pas masque) if (token && !token.includes('...')) body.telegram_token = token.trim() || null; await window.SentinelAPI.fetchAuth('/api/cabinet/telegram', { method: 'PUT', body }); toast('Telegram mis a jour.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; const discover = async () => { // Audit V2 : backend exige body {telegram_token} (necessaire pour appeler // l'API Telegram getUpdates). Sans token saisi, on n'envoie pas la requete. const t = (token || '').trim(); if (!t || t.includes('...')) { toast('Renseigne d abord le token Telegram, puis clique Auto.', 'warning'); return; } try { const r = await window.SentinelAPI.fetchAuth('/api/cabinet/telegram/discover-chat-id', { method: 'POST', body: { telegram_token: t }, }); const id = (r && r.data && r.data.chat_id) || ''; if (id) { setChatId(String(id)); toast('Chat ID detecte : ' + id, 'success'); } else { toast('Aucun chat detecte. Envoie /start a ton bot d abord.', 'warning'); } } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; const test = async () => { try { await window.SentinelAPI.fetchAuth('/api/cabinet/telegram/test', { method: 'POST' }); toast('Message test envoye sur Telegram.', 'success'); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; // Audit V2 : backend expose `configured` (bool calcule) plutot que `actif`. const actif = !!(data && data.configured); return ( <_Card title="Notifications Telegram" padded style={{marginBottom:16}}>
Statut
<_S state={actif ? 'ready' : 'pending'} label={actif ? 'Actif' : 'Inactif'} /> (cabinet)
Chat ID actuel
{(data && data.telegram_chat_id) || '—'}

Pour notifier ton cabinet en prive : cree un bot via @BotFather, recupere son token, envoie /start a ton bot, puis renseigne les 2 champs ci-dessous.

setToken(e.target.value)} placeholder="123456:ABC-DEF..." />
setChatId(e.target.value)} style={{flex:1}} />
Astuce : saisis le token, envoie un message a ton bot, puis clique Auto.
); }; const _AgentsCard = () => { const { toast } = _useApp(); const [users, setUsers] = _us(null); const [me, setMe] = _us(null); const [showNew, setShowNew] = _us(false); const reload = () => { if (!window.SentinelAPI) { setUsers([]); return; } setUsers(null); window.SentinelAPI.fetchAuth('/api/cabinet/users') .then((r) => setUsers(r.data || [])) .catch(() => { setUsers([]); toast('Erreur chargement agents.', 'error'); }); window.SentinelAPI.fetchAuth('/api/auth/me').then((r) => setMe(r.data || null)).catch(() => {}); }; _ue(() => { reload(); }, []); const promote = async (u) => { const becomeCab = !u.is_cabinet_admin; if (!confirm(becomeCab ? 'Promouvoir ' + u.email + ' en compte cabinet ?' : 'Retirer le role cabinet de ' + u.email + ' ?')) return; try { await window.SentinelAPI.fetchAuth('/api/cabinet/users/' + u.id, { method: 'PATCH', body: { is_cabinet_admin: becomeCab } }); toast(becomeCab ? u.email + ' promu cabinet.' : u.email + ' redevenu agent.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; const remove = async (u) => { if (!confirm('Supprimer ' + u.email + ' ?\n\nSon compte et ses clients seront detaches.')) return; try { await window.SentinelAPI.fetchAuth('/api/cabinet/users/' + u.id, { method: 'DELETE' }); toast('Agent supprime.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; return ( <> <_Card title="Agents du cabinet" actions={} flush style={{marginBottom:16}}>
Cree des comptes pour tes agents. Un agent simple gere ses clients et consulte les annonces. Promus en "compte cabinet", ils ont les memes droits que toi.
{users === null ?
Chargement…
: (users || []).length === 0 ?
Aucun agent
: {users.map((u) => { const isMe = me && u.id === me.id; // Audit V2 : CabinetUserOut n'expose pas is_groupe_admin (champ // present uniquement sur /auth/me). On retombe sur 3 roles ici. const role = u.is_superadmin ? 'Super-admin' : u.is_cabinet_admin ? 'Cabinet' : 'Agent'; const tone = u.is_superadmin ? 'warning' : u.is_cabinet_admin ? 'info' : 'neutral'; return ( ); })}
EmailNomRoleActif
{u.email} {isMe && (vous)} {u.nom || '—'} <_Pill tone={tone}>{role} <_Pill tone={u.actif ? 'success' : 'neutral'} dot>{u.actif ? 'Oui' : 'Non'} {!isMe && !u.is_superadmin && (<> )}
} <_NewAgentModal open={showNew} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); reload(); }} toast={toast} /> ); }; const _NewAgentModal = ({ open, onClose, onCreated, toast }) => { const [form, setForm] = _us({ email: '', nom: '', password: '', is_cabinet_admin: false }); const [saving, setSaving] = _us(false); _ue(() => { if (open) { setForm({ email: '', nom: '', password: '', is_cabinet_admin: false }); setSaving(false); } }, [open]); const submit = async () => { if (!form.email.trim() || !form.password) { toast('Email et mot de passe requis.', 'warning'); return; } setSaving(true); try { await window.SentinelAPI.fetchAuth('/api/cabinet/users', { method: 'POST', body: { email: form.email.trim(), nom: form.nom.trim() || null, password: form.password, is_cabinet_admin: !!form.is_cabinet_admin, }}); toast('Agent cree.', 'success'); onCreated(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; if (!open) return null; return ( <_Modal open={open} onClose={onClose} title="Nouvel agent" footer={<> }>
setForm({ ...form, email: e.target.value })} />
setForm({ ...form, nom: e.target.value })} />
setForm({ ...form, password: e.target.value })} />
); }; const _CollecteCard = () => { const { toast } = _useApp(); const [status, setStatus] = _us(null); const [running, setRunning] = _us(false); const reload = () => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/collecte/status') .then((r) => setStatus(r.data || {})).catch(() => {}); }; _ue(() => { reload(); }, []); const launch = async () => { if (!confirm('Lancer une collecte maintenant ?')) return; setRunning(true); try { const r = await window.SentinelAPI.fetchAuth('/api/collecte/run', { method: 'POST', body: { notifier: true } }); const stats = (r && r.data && r.data.collecte) || {}; // Audit V2 : backend renvoie `inseres` (annonces inserees en base), pas `nb_nouvelles`. toast('Collecte terminee : ' + (stats.inseres || 0) + ' nouvelles annonces.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setRunning(false); } }; const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('fr-FR') : '—'; const s = status || {}; // Audit V2 : backend renvoie cle_configuree (bool) et intervalle_min (int en minutes), // pas api_key_configured / cron_interval. const apiOk = !!s.cle_configuree; const fmtInterval = (min) => { if (!min) return '—'; if (min >= 1440 && min % 1440 === 0) { const j = min / 1440; return j === 1 ? '1 jour' : j + ' jours'; } if (min >= 60 && min % 60 === 0) { const h = min / 60; return h === 1 ? '1 heure' : h + ' heures'; } return min + ' min'; }; return ( <_Card title="Collecte d'annonces" padded style={{marginBottom:16}}>
Cle API Stream Estate
<_S state={apiOk ? 'ready' : 'failed'} label={apiOk ? 'Configuree' : 'Non configuree'} />
Intervalle cron
{fmtInterval(s.intervalle_min)}
Derniere collecte
{fmtDate(s.derniere_collecte)}
Total annonces en base
{_fmt.num(s.total_annonces || 0)}

Lance la collecte à la demande pour ne consommer du quota que quand tu en as besoin.

); }; const _GeolocCard = () => { const { toast } = _useApp(); const [running, setRunning] = _us(false); const relancer = async () => { if (!confirm('Relancer le pipeline geoloc 4 niveaux sur les annonces sans coordonnees ?')) return; setRunning(true); try { const r = await window.SentinelAPI.fetchAuth('/api/admin/relancer-geoloc', { method: 'POST' }); // Audit V2 : backend renvoie {annonces_traitees, geolocalisees, echecs}, pas {relancees}. const d = (r && r.data) || {}; const ok = d.geolocalisees || 0; const total = d.annonces_traitees || 0; toast(ok + ' / ' + total + ' annonce(s) geolocalisee(s).', 'success'); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setRunning(false); } }; return ( <_Card title="Geolocalisation" padded style={{marginBottom:16}}>

Relance le pipeline 4 niveaux sur toutes les annonces sans coordonnees. Les annonces validees manuellement (niveau 4) ne sont pas modifiees.

); }; const _DataCard = () => { const { toast } = _useApp(); const [exporting, setExporting] = _us(false); const exportJson = async () => { setExporting(true); try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; const r = await fetch('/api/admin/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; a.download = 'immosentinel-export-' + new Date().toISOString().slice(0, 10) + '.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast('Export telecharge.', 'success'); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setExporting(false); } }; return ( <_Card title="Donnees" padded style={{marginBottom:16}}>

Sauvegarde manuelle JSON (clients, annonces, matches, DVF). A conserver avant toute mise a jour majeure.

); }; const _DangerZoneCard = () => { const { toast } = _useApp(); const [deleting, setDeleting] = _us(false); const wipe = async () => { if (!confirm('SUPPRIMER DEFINITIVEMENT tous les clients, annonces, matches et DVF ?')) return; if (!confirm('Confirmer : les comptes utilisateurs sont preserves mais TOUTES les donnees du cabinet seront perdues.')) return; setDeleting(true); try { // Audit V2 : backend exige ?confirm=YES_I_AM_SURE pour eviter wipes accidentels. await window.SentinelAPI.fetchAuth('/api/admin/data?confirm=YES_I_AM_SURE', { method: 'DELETE' }); toast('Donnees supprimees.', 'success'); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setDeleting(false); } }; return (
<_I.trash /> Zone de danger

Supprime definitivement tous les clients, criteres, annonces, matches et DVF. Les comptes utilisateurs sont preserves. Action irreversible.

); }; const _MyDataRgpdCard = () => { const { toast } = _useApp(); const [exporting, setExporting] = _us(false); const exportMe = async () => { setExporting(true); try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; // Lot 0 audit : la route est /api/rgpd/me/export (404 avant). const r = await fetch('/api/rgpd/me/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; a.download = 'mes-donnees-rgpd-' + new Date().toISOString().slice(0, 10) + '.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast('Donnees personnelles telechargees.', 'success'); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setExporting(false); } }; return ( <_Card title="Mes donnees (RGPD)" padded>

Telecharge une copie complete de tes donnees personnelles (profil + connexions + cabinet de rattachement) au format JSON. Conforme aux articles 15 (acces) et 20 (portabilite) du RGPD.

Pour supprimer ton compte ou exercer un autre droit RGPD : contact@immosentinel.fr

); }; /* Chantier 05 : navigation verticale gauche, ouverture sur Cabinet, abonnement en dernier (pack courant + quotas, autres packs replies). */ const _FEATURE_LABELS = { carte_interactive: 'Carte interactive', donnees_dvf: 'Données DVF & estimation', module_legal: 'Module juridique', geoloc_photo: 'Géolocalisation photo (IA)', exclusivite_zone: 'Exclusivité de zone', }; const _FEATURE_ORDER = ['carte_interactive', 'donnees_dvf', 'module_legal', 'geoloc_photo', 'exclusivite_zone']; const _PACK_ORDER = ['starter', 'pro', 'expert']; const _minPackFor = (feature, packs) => { for (const name of _PACK_ORDER) { const pk = packs.find((x) => x.name === name); if (pk && pk[feature]) return pk.label || name; } return null; }; const _fmtLimit = (v) => (v == null ? 'illimité' : _fmt.num(v)); const _QuotaRow = ({ label, used, limit }) => { const pct = limit ? Math.min(100, Math.round(((used || 0) / limit) * 100)) : 0; return (
{label} {used == null ? '—' : _fmt.num(used)} / {_fmtLimit(limit)}
= 100 ? '1' : undefined} style={{width: (limit ? pct : 100) + '%'}} />
); }; const _AbonnementCard = () => { const { user } = _useApp(); const [info, setInfo] = _us(null); // { pack, quota_matches, all_packs } const [nClients, setNClients] = _us(null); const [nAgents, setNAgents] = _us(null); const [showOthers, setShowOthers] = _us(false); _ue(() => { if (!window.SentinelAPI) return; const api = window.SentinelAPI; const safe = (p) => p.catch(() => null); Promise.all([ safe(api.fetchAuth('/api/me/pack')), safe(api.fetchAuth('/api/clients?limit=1000')), safe(api.fetchAuth('/api/cabinet/users')), ]).then(([pk, cl, us]) => { setInfo((pk && pk.data) || {}); setNClients(cl && Array.isArray(cl.data) ? cl.data.length : null); setNAgents(us && Array.isArray(us.data) ? us.data.length : null); }); }, []); const packs = (info && info.all_packs) || []; const current = (info && info.pack) || packs.find((x) => x.name === (user.pack_name || '').toLowerCase()) || null; const q = (info && info.quota_matches) || null; const label = (current && (current.label || current.name)) || user.pack || '—'; const others = packs.filter((x) => !current || x.name !== current.name); return ( <> <_Card title="Abonnement" meta={label} padded style={{marginBottom:16}}> {!info ? (
Chargement…
) : ( <>
Pack {label}
{current && current.prix != null &&
{_fmt.num(current.prix)} € HT / mois
}
Nous contacter
<_QuotaRow label="Matches visibles cette semaine" used={q ? q.used : null} limit={q ? q.limit : (current ? current.max_matches_par_semaine : null)} /> <_QuotaRow label="Clients" used={nClients} limit={current ? current.max_clients : null} /> <_QuotaRow label="Agents" used={nAgents} limit={current ? current.max_agents : null} />
{others.length > 0 && (
{showOthers && (
{others.map((pk) => (
{pk.label || pk.name}
{_fmt.num(pk.prix)} € HT / mois
  • Matches / semaine : {_fmtLimit(pk.max_matches_par_semaine)}
  • Clients : {_fmtLimit(pk.max_clients)}
  • Agents : {_fmtLimit(pk.max_agents)}
  • {_FEATURE_ORDER.filter((f) => pk[f]).map((f) =>
  • {_FEATURE_LABELS[f]}
  • )}
Demander ce pack
))}
)}
)} )} ); }; const _ZoneExclusiveCard = () => { const { user } = _useApp(); const [pack, setPack] = _us(null); _ue(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/me/pack').then((r) => setPack((r && r.data && r.data.pack) || null)).catch(() => {}); }, []); const active = !!(user.features && user.features.exclusivite_zone); const km = pack && pack.exclusivite_zone_km; return ( <_Card title="Zone exclusive" meta={active ? 'active' : 'non incluse'} padded style={{marginBottom:16}}>

L'exclusivité géographique réserve à ton cabinet les alertes de son secteur : aucun autre cabinet Sentinel n'est activé dans le même rayon.

{active ? (
<_Pill tone="success" dot>Active Rayon de {km || 10} km autour du cabinet. Pour ajuster le périmètre, écris à contact@immosentinel.fr.
) : (
<_Pill tone="neutral">Inclus dès Expert Disponible avec le pack Expert (rayon 10 km).
)} ); }; const ParametresPage = () => { const { user } = _useApp(); const isAdmin = !!user.is_cabinet_admin || !!user.is_groupe_admin || !!user.is_superadmin; const [nAgents, setNAgents] = _us(null); // Section demandee par le menu utilisateur (« Mon compte ») ; sinon Cabinet // pour un compte cabinet, Compte pour un agent. const [section, setSection] = _us(() => { let wanted = null; try { wanted = sessionStorage.getItem('sentinel.parametres.section'); sessionStorage.removeItem('sentinel.parametres.section'); } catch (_) {} return wanted || (isAdmin ? 'cabinet' : 'compte'); }); _ue(() => { if (!window.SentinelAPI || !isAdmin) return; window.SentinelAPI.fetchAuth('/api/cabinet/users').then((r) => setNAgents(Array.isArray(r.data) ? r.data.length : null)).catch(() => {}); }, [isAdmin]); const groups = [ { label: 'Personnel', items: [ { id: 'compte', label: 'Compte' }, { id: 'notifications', label: 'Notifications', admin: true }, ]}, { label: 'Cabinet', admin: true, items: [ { id: 'cabinet', label: 'Cabinet' }, { id: 'equipe', label: 'Équipe', count: nAgents }, { id: 'integrations', label: 'Intégrations' }, { id: 'zone', label: 'Zone exclusive' }, ]}, { label: 'Facturation', admin: true, items: [ { id: 'abonnement', label: 'Abonnement', meta: user.pack }, ]}, ].map((g) => ({ ...g, items: g.items.filter((i) => !i.admin || isAdmin) })).filter((g) => (!g.admin || isAdmin) && g.items.length); const allowed = new Set(groups.flatMap((g) => g.items.map((i) => i.id))); const current = allowed.has(section) ? section : 'compte'; const titles = { compte: 'Compte', notifications: 'Notifications', cabinet: 'Cabinet', equipe: 'Équipe', integrations: 'Intégrations', zone: 'Zone exclusive', abonnement: 'Abonnement' }; return (
<_PH title="Paramètres" sub={titles[current]} />
{current === 'compte' && (<> <_AccountCard />
<_MyDataRgpdCard /> )} {current === 'notifications' && <_TelegramCard />} {current === 'cabinet' && (<> <_BrandingCard /> <_DangerZoneCard /> )} {current === 'equipe' && <_AgentsCard />} {current === 'integrations' && (<> <_CollecteCard /> <_GeolocCard /> <_DataCard /> )} {current === 'zone' && <_ZoneExclusiveCard />} {current === 'abonnement' && <_AbonnementCard />}
); }; /* ============================================================ */ /* ADMINISTRATION */ /* ============================================================ */ const AdministrationPage = () => { const [view, _setView] = _us('hub'); if (view === 'audit') return _setView('hub')} />; if (view === 'backups') return _setView('hub')} />; if (view === 'users') return _setView('hub')} />; if (view === 'groups') return _setView('hub')} />; if (view === 'patrimoine') return _setView('hub')} />; return ; }; const AdminHub = ({ onOpen }) => { const { toast } = _useApp(); const [cabinets, setCabinets] = _us(null); const [packs, setPacks] = _us({}); const [tab, setTab] = _us('tous'); const [q, setQ] = _us(''); const [showNew, setShowNew] = _us(false); // Paquet G : tri colonnes + modale detail + total annonces const [sort, setSort] = _us({ col: 'id', dir: 'desc' }); const [detailId, setDetailId] = _us(null); // cabinet_id ouvert dans modale detail const [totalAnnonces, setTotalAnnonces] = _us(null); // Paquet I : modale "resultat creation" pour afficher le code d'invitation // en gros (au lieu d'un toast qu'on rate facilement). const [createdResult, setCreatedResult] = _us(null); const reload = () => { if (!window.SentinelAPI) { setCabinets([]); return; } setCabinets(null); window.SentinelAPI.fetchAuth('/api/superadmin/cabinets') .then((r) => setCabinets(r.data || [])) .catch(() => { setCabinets([]); toast('Erreur chargement cabinets.', 'error'); }); // Audit V2 : router monte sur /api/superadmin/packs (pas /api/packs). window.SentinelAPI.fetchAuth('/api/superadmin/packs/list').then((r) => { const map = {}; (r.data || []).forEach((p) => { map[p.id || p.name] = p; }); setPacks(map); }).catch(() => {}); // Paquet G : total annonces global via /api/collecte/status (accessible super-admin). window.SentinelAPI.fetchAuth('/api/collecte/status') .then((r) => setTotalAnnonces((r && r.data && r.data.total_annonces) || 0)) .catch(() => {}); }; _ue(() => { reload(); }, []); // Audit V2 : backend renvoie {id, nom, slug, code_invitation, actif, date_creation, // n_clients, n_matches, n_users, admin_email, is_empty, is_inactive, last_match_at, pack}. // Pas de `statut` ni `nb_*` : on lit les vrais champs. const list = cabinets || []; const totalActifs = list.filter((c) => c.actif === true).length; const totalSusp = list.filter((c) => c.actif === false).length; const totalUsers = list.reduce((s, c) => s + (c.n_users || 0), 0); const totalClients = list.reduce((s, c) => s + (c.n_clients || 0), 0); const totalMatches = list.reduce((s, c) => s + (c.n_matches || 0), 0); // Paquet G : MRR potentiel = somme des prix des packs des cabinets actifs. // Le pack est `c.pack` (starter/pro/expert). Map `packs` contient {prix, label}. const mrrPotentiel = list.reduce((sum, c) => { if (c.actif !== true) return sum; const p = packs[c.pack || 'starter']; return sum + ((p && p.prix) || 0); }, 0); const filtered = list.filter((c) => { const isActif = c.actif === true; const isVide = (c.n_clients || 0) === 0; if (tab === 'actifs' && !isActif) return false; if (tab === 'susp' && isActif) return false; if (tab === 'vides' && !isVide) return false; // Paquet G : nouvel onglet "Inactifs" — utilise is_inactive du backend (>30j sans match). if (tab === 'inactifs' && c.is_inactive !== true) return false; if (q) { const l = q.toLowerCase(); const nom = (c.nom || '').toLowerCase(); const admin = (c.admin_email || '').toLowerCase(); if (!nom.includes(l) && !admin.includes(l)) return false; } return true; }); // Paquet G : tri colonnes (id / nom / admin_email / date_creation / n_users / // n_clients / n_matches / pack). Click sur en-tete -> cycle asc/desc/none. const sorted = (() => { const arr = filtered.slice(); const { col, dir } = sort; if (!col) return arr; const sign = dir === 'asc' ? 1 : -1; arr.sort((a, b) => { const va = a[col]; const vb = b[col]; if (va == null && vb == null) return 0; if (va == null) return 1; if (vb == null) return -1; if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * sign; return String(va).localeCompare(String(vb)) * sign; }); return arr; })(); const headerSort = (col) => () => setSort((s) => s.col === col ? { col, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { col, dir: 'asc' }); const sortArrow = (col) => sort.col === col ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : ''; const toggleSuspend = async (c) => { const actif = c.actif === true; const action = actif ? 'disable' : 'enable'; const label = actif ? 'Suspendre' : 'Reactiver'; if (!confirm(label + ' le cabinet ' + c.nom + ' ?')) return; try { await window.SentinelAPI.fetchAuth('/api/superadmin/cabinets/' + c.id + '/' + action, { method: 'PATCH' }); toast(c.nom + ' ' + (actif ? 'suspendu' : 'reactive') + '.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; const remove = async (c) => { if (!confirm('Supprimer DEFINITIVEMENT le cabinet ' + c.nom + ' et toutes ses donnees ?')) return; if (!confirm('Confirmer : tous les users, clients, annonces, matches, mandats du cabinet seront perdus.')) return; try { await window.SentinelAPI.fetchAuth('/api/superadmin/cabinets/' + c.id, { method: 'DELETE' }); toast('Cabinet supprime.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; const regenCode = async (c) => { if (!confirm('Regenerer le code d\'invitation de ' + c.nom + ' ?')) return; try { const r = await window.SentinelAPI.fetchAuth('/api/superadmin/cabinets/' + c.id + '/regen-code', { method: 'POST' }); // Audit V2 : backend renvoie {id, new_code}, pas {code}. const newCode = (r.data && (r.data.new_code || r.data.code)) || '?'; toast('Nouveau code : ' + newCode, 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; const setPack = async (c, packId) => { try { // Audit V2 : route cabinet_router est /api/superadmin/packs/cabinets (pas /api/packs). // En realite c'est cote packs_superadmin.router (POST /superadmin/packs/cabinets/{id}). await window.SentinelAPI.fetchAuth('/api/superadmin/packs/cabinets/' + c.id, { method: 'POST', body: { pack: packId } }); toast(c.nom + ' -> ' + packId, 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; const exportCsv = async () => { try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; const r = await fetch('/api/superadmin/cabinets/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 = 'cabinets-' + new Date().toISOString().slice(0, 10) + '.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (e) { toast('Echec export : ' + (e.message || e), 'error'); } }; const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString('fr-FR') : '—'; return (
<_PH title="Administration" sub="Gestion des cabinets clients de la plateforme - reservee aux super-admins." actions={<> <_Pill tone="warning" dot>Super-admin } />
<_I.search /> setQ(e.target.value)} />
{/* Paquet G : ajout MRR potentiel + Annonces collectees (7 KPI au total) */}
<_Kpi label="Cabinets actifs" value={_fmt.num(totalActifs)} /> <_Kpi label="Cabinets suspendus" value={_fmt.num(totalSusp)} /> <_Kpi label="MRR potentiel" value={_fmt.eur(mrrPotentiel)} meta="Packs cumules cabinets actifs" /> <_Kpi label="Utilisateurs total" value={_fmt.num(totalUsers)} /> <_Kpi label="Clients total" value={_fmt.num(totalClients)} /> <_Kpi label="Matches total" value={_fmt.num(totalMatches)} /> <_Kpi label="Annonces collectees" value={totalAnnonces != null ? _fmt.num(totalAnnonces) : '—'} />
<_Tabs ghost value={tab} onChange={setTab} options={[ { value:'tous', label:'Tous · ' + list.length }, { value:'actifs', label:'Actifs · ' + totalActifs }, { value:'susp', label:'Suspendus · ' + totalSusp }, { value:'vides', label:'Vides (0 client) · ' + list.filter((c) => (c.n_clients || 0) === 0).length }, // Paquet G : onglet Inactifs (>30j sans match) via is_inactive backend. { value:'inactifs', label:'Inactifs (>30j) · ' + list.filter((c) => c.is_inactive === true).length }, ]} />
<_Card flush> {cabinets === null ?
Chargement…
: list.length === 0 ?
Aucun cabinet
: {/* Paquet G : en-tete colonnes cliquables -> tri (cycle asc/desc). */} {sorted.map((c) => { const isActif = c.actif === true; const isVide = (c.n_clients || 0) === 0; return ( // Paquet G : clic sur la ligne (hors actions) -> ouvre modale detail. { if (e.target.closest('button,select,a')) return; setDetailId(c.id); }}> ); })}
ID{sortArrow('id')} Nom{sortArrow('nom')} Admin{sortArrow('admin_email')} Code Cree le{sortArrow('date_creation')} Users{sortArrow('n_users')} Clients{sortArrow('n_clients')} Matches{sortArrow('n_matches')} Pack{sortArrow('pack')} Statut
{c.id} {c.nom} {c.admin_email || '—'} {c.code_invitation || '—'} {fmtDate(c.date_creation)} {_fmt.num(c.n_users || 0)} {_fmt.num(c.n_clients || 0)} {_fmt.num(c.n_matches || 0)}
<_Pill tone={isActif ? 'success' : 'danger'} dot>{isActif ? 'Actif' : 'Suspendu'} {isVide && <_Pill tone="warning">Vide} {c.is_inactive === true && !isVide && <_Pill tone="warning">Inactif}
} setShowNew(false)} onCreated={(data) => { // Paquet I : on ne ferme PAS la creation tant que la modale resultat // n'a pas ete fermee. setShowNew(false) puis on ouvre resultat. setShowNew(false); setCreatedResult(data || {}); reload(); }} toast={toast} /> {/* Paquet G : modale detail cabinet (drill-down /api/superadmin/cabinets/{id}/detail). */} setDetailId(null)} toast={toast} /> {/* Paquet I : modale "Cabinet cree" avec code d'invitation en gros + bouton copier. */} setCreatedResult(null)} toast={toast} />
); }; const NewCabinetModal = ({ open, onClose, onCreated, toast }) => { // Paquet I : ajout du select pack a la creation (parite V1 admin.html). const [form, setForm] = _us({ nom: '', slug: '', code_invitation: '', pack: 'starter', admin_email: '', admin_password: '' }); const [saving, setSaving] = _us(false); _ue(() => { if (open) { setForm({ nom: '', slug: '', code_invitation: '', pack: 'starter', admin_email: '', admin_password: '' }); setSaving(false); } }, [open]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const submit = async () => { if (!form.nom.trim()) { toast('Nom du cabinet requis.', 'warning'); return; } const payload = { nom: form.nom.trim(), slug: form.slug.trim() || null, code_invitation: form.code_invitation.trim() || null, pack: form.pack || 'starter', admin_email: form.admin_email.trim() || null, admin_password: form.admin_password || null, }; setSaving(true); try { const r = await window.SentinelAPI.fetchAuth('/api/superadmin/cabinets', { method: 'POST', body: payload }); // Paquet I : remonte le full data au parent (pour afficher la modale resultat). // Backend renvoie {cabinet, admin, code_invitation}. const data = (r && r.data) || {}; onCreated(data); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; if (!open) return null; return ( <_Modal open={open} onClose={onClose} title="Nouveau cabinet" wide footer={<> }>
Cabinet
set('nom', e.target.value)} placeholder="Ex : Cabinet Vieux-Lille" />
set('slug', e.target.value)} placeholder="auto-genere depuis le nom" />
set('code_invitation', e.target.value)} placeholder="auto-genere" />
{/* Paquet I : pack a la creation (parite V1). */}
Modifiable plus tard depuis le tableau cabinets. Default : Starter.
Admin du cabinet (optionnel)
set('admin_email', e.target.value)} />
set('admin_password', e.target.value)} />

Si vides : le cabinet est cree mais sans admin. Le code d'invitation permet a un user de s'inscrire et devenir admin du cabinet.

); }; /* Paquet G : modale detail cabinet — drill-down depuis AdminHub. Fetch GET /api/superadmin/cabinets/{id}/detail au mount, affiche cabinet + users + stats par statut + timeline. */ /* Paquet I : modale "Cabinet cree" — affiche le code d'invitation en gros (vs toast qu'on rate facilement). Bouton "Copier" + lien d'inscription pre-genere. */ const CabinetCreatedModal = ({ result, onClose, toast }) => { if (!result || !result.cabinet) return null; const cab = result.cabinet || {}; const code = result.code_invitation || cab.code_invitation || ''; const admin = result.admin; const inviteUrl = code ? (window.location.origin + '/register.html?code=' + encodeURIComponent(code)) : null; const copy = (text) => { navigator.clipboard.writeText(text) .then(() => toast && toast('Copie.', 'success')) .catch(() => toast && toast('Echec copie.', 'error')); }; return ( <_Modal open={!!result} onClose={onClose} title="Cabinet cree avec succes" wide footer={}>

Le cabinet {cab.nom} a ete cree (ID #{cab.id}, pack {cab.pack || 'starter'}).

{code && (
Code d'invitation
{code}
{inviteUrl && (<>
Lien d'inscription pret a partager
{inviteUrl}
)}
)} {admin && admin.email && (

Un admin a ete cree automatiquement : {admin.email}. Il peut se connecter directement avec son mot de passe.

)} {!admin && (

Aucun admin n'a ete cree. Partage le code d'invitation au futur admin du cabinet pour qu'il s'inscrive et devienne admin.

)}
); }; const CabinetDetailModal = ({ cabinetId, onClose, toast }) => { const [data, setData] = _us(null); _ue(() => { if (!cabinetId) { setData(null); return; } setData(null); if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/superadmin/cabinets/' + cabinetId + '/detail') .then((r) => setData((r && r.data) || null)) .catch(() => { toast && toast('Erreur chargement detail cabinet.', 'error'); onClose(); }); }, [cabinetId]); if (!cabinetId) return null; const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'; const cab = data && data.cabinet; const users = (data && data.users) || []; const stats = (data && data.stats) || {}; const timeline = (data && data.timeline) || []; return ( <_Modal open={!!cabinetId} onClose={onClose} title={cab ? `Cabinet · ${cab.nom}` : 'Cabinet'} wide footer={}> {data === null ? (
Chargement…
) : (<>
Informations
ID
{cab.id}
Slug
{cab.slug || '—'}
Code invitation
{cab.code_invitation || '—'}
Cree le
{fmtDate(cab.date_creation)}
Statut
<_Pill tone={cab.actif ? 'success' : 'danger'} dot>{cab.actif ? 'Actif' : 'Suspendu'}
Stats activite
<_Kpi label="Users" value={_fmt.num(stats.n_users || 0)} /> <_Kpi label="Clients" value={_fmt.num(stats.n_clients || 0)} /> <_Kpi label="Matches total" value={_fmt.num(stats.n_matches || 0)} /> <_Kpi label="Matches parfaits 30j" value={_fmt.num(stats.n_matches_parfaits_mois || 0)} meta="score >= 100" />
{(stats.clients_par_statut && Object.keys(stats.clients_par_statut).length > 0) && (<>
Clients par statut
{Object.entries(stats.clients_par_statut).map(([s, n]) => ( <_Pill key={s} tone="neutral">{s} · {n} ))}
)} {users.length > 0 && (<>
Utilisateurs ({users.length})
{users.map((u) => ( ))}
EmailNomRoleCree le
{u.email} {u.nom || '—'} <_Pill tone={u.is_superadmin ? 'warning' : u.is_cabinet_admin ? 'info' : 'neutral'} dot={u.is_superadmin}>{u.is_superadmin ? 'Superadmin' : u.is_cabinet_admin ? 'Cabinet admin' : 'Agent'} {fmtDate(u.date_creation)}
)} {timeline.length > 0 && (<>
Timeline recente
{timeline.map((e, i) => (
{fmtDate(e.date)} {e.label}
))}
)} )} ); }; /* ----- AUDIT — Paquet B : branche sur GET /api/superadmin/audit ----- */ const AuditView = ({ onBack }) => { const { toast } = _useApp(); const [resp, setResp] = _us(null); // {data, total, stats, actions} const [filters, setFilters] = _us({ action: '', actor_email: '', target_id: '', since_days: '' }); const [applied, setApplied] = _us({ action: '', actor_email: '', target_id: '', since_days: '' }); const reload = () => { if (!window.SentinelAPI) { setResp({ data: [], stats: { n_24h: 0, n_total: 0, by_action: {} }, actions: [] }); return; } setResp(null); const params = new URLSearchParams(); if (applied.action) params.set('action', applied.action); if (applied.actor_email) params.set('actor_email', applied.actor_email); if (applied.target_id) params.set('target_id', String(parseInt(applied.target_id, 10) || 0)); if (applied.since_days) params.set('since_days', applied.since_days); params.set('limit', '100'); const qs = params.toString() ? '?' + params.toString() : ''; window.SentinelAPI.fetchAuth('/api/superadmin/audit' + qs) .then((r) => setResp(r || { data: [] })) .catch(() => { setResp({ data: [], stats: { n_24h: 0, n_total: 0, by_action: {} }, actions: [] }); toast('Erreur chargement audit.', 'error'); }); }; _ue(() => { reload(); }, [applied]); const events = (resp && resp.data) || []; const stats = (resp && resp.stats) || { n_24h: 0, n_total: 0, by_action: {} }; const actions = (resp && resp.actions) || []; const nCreations = stats.by_action ? (stats.by_action['create_cabinet'] || 0) : 0; const nSuppr = stats.by_action ? (stats.by_action['delete_cabinet'] || 0) : 0; const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'; const fmtDetails = (d) => { if (d == null) return '—'; if (typeof d === 'string') return d; try { return Object.entries(d).map(([k, v]) => k + '=' + JSON.stringify(v)).join(' · '); } catch { return String(d); } }; const toneFor = (a) => /create/i.test(a) ? 'success' : /delete|wipe/i.test(a) ? 'danger' : 'warning'; return (
<_PH crumb={← Administration} title="Audit · activité super-admin" sub="Traçabilité des actions sensibles (création, suspension, suppression, regen-code, partage patrimoine)." />
<_Kpi label="Events 24 h" value={_fmt.num(stats.n_24h || 0)} /> <_Kpi label="Events total" value={_fmt.num(stats.n_total || 0)} /> <_Kpi label="Créations cabinet" value={_fmt.num(nCreations)} /> <_Kpi label="Suppressions" value={_fmt.num(nSuppr)} />
<_Card padded style={{marginBottom:16}}>
setFilters({ ...filters, actor_email: e.target.value })} />
setFilters({ ...filters, target_id: e.target.value })} />
<_Card flush> {resp === null ?
Chargement…
: events.length === 0 ?
Aucun event correspondant.
: {events.map((e) => ( ))}
DateActeurActionCibleDétails
{fmtDate(e.date_creation)} {e.actor_email || '—'} <_Pill tone={toneFor(e.action || '')}>{e.action} {e.target_label || (e.target_type ? e.target_type + ' #' + e.target_id : '—')} {fmtDetails(e.details)}
}
); }; /* ----- BACKUPS — Paquet B : branche sur GET/POST /api/superadmin/backups ----- */ const BackupsView = ({ onBack }) => { const { toast } = _useApp(); const [resp, setResp] = _us(null); // {data, stats} const [running, setRunning] = _us(false); const reload = () => { if (!window.SentinelAPI) { setResp({ data: [], stats: { n_backups: 0, last_backup_at: null, last_backup_size_mo: 0, total_size_mo: 0 } }); return; } setResp(null); window.SentinelAPI.fetchAuth('/api/superadmin/backups') .then((r) => setResp(r || { data: [] })) .catch(() => { setResp({ data: [], stats: { n_backups: 0, last_backup_at: null, last_backup_size_mo: 0, total_size_mo: 0 } }); toast('Erreur chargement backups.', 'error'); }); }; _ue(() => { reload(); }, []); const triggerBackup = async () => { if (!confirm('Declencher un backup BDD maintenant ?')) return; setRunning(true); try { const r = await window.SentinelAPI.fetchAuth('/api/superadmin/backups', { method: 'POST' }); const f = (r && r.data) || {}; toast('Backup cree : ' + (f.filename || 'OK') + ' (' + (f.size_mo || 0) + ' Mo)', 'success'); reload(); } catch (e) { toast('Echec backup : ' + (e.message || e), 'error'); } finally { setRunning(false); } }; const download = async (filename) => { try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; const r = await fetch('/api/superadmin/backups/' + encodeURIComponent(filename) + '/download', { 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 = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (e) { toast('Echec telechargement : ' + (e.message || e), 'error'); } }; const backups = (resp && resp.data) || []; const stats = (resp && resp.stats) || { n_backups: 0, last_backup_at: null, last_backup_size_mo: 0, total_size_mo: 0 }; const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'; const sinceDays = (iso) => { if (!iso) return null; const d = new Date(iso); const now = new Date(); return Math.floor((now - d) / (1000*60*60*24)); }; const lastDays = sinceDays(stats.last_backup_at); const lastFresh = lastDays != null && lastDays < 2; return (
<_PH crumb={← Administration} title="Sauvegardes BDD" sub="Snapshots automatiques de la base SQLite. Récupération possible en 1 clic." /> <_Card padded style={{marginBottom:16}}>
<_S state={lastFresh ? 'ready' : (stats.n_backups > 0 ? 'pending' : 'failed')} label="État du système" /> {lastFresh ? 'Backup recent.' : lastDays != null ? 'Dernier backup il y a ' + lastDays + ' j.' : 'Aucun backup pour l\'instant.'}
Dernier backup
{lastDays != null ? (lastDays === 0 ? 'aujourd\'hui' : 'il y a ' + lastDays + ' j') : '—'}
{fmtDate(stats.last_backup_at)}
Fichiers stockés
{_fmt.num(stats.n_backups || 0)}
Rotation auto > 14 jours
Espace disque
{(stats.total_size_mo || 0).toFixed(1).replace('.', ',')} Mo
Backup auto toutes les 6 h
<_Card title="Comment ça marche" padded style={{marginBottom:16}}>
  • Sentinel sauvegarde la BDD automatiquement toutes les 6 h (configurable via BACKUP_INTERVAL_HOURS dans .env).
  • Les fichiers sont compressés en .db.gz (gain ~5× sur du SQL).
  • Rotation automatique : les backups plus vieux que 14 jours sont supprimés (sauf le plus récent, garde-fou).
  • Pour restaurer : téléchargez un fichier ci-dessous, décompressez-le (gunzip), remplacez immosentinel.db, redémarrez le service.
<_Card title="Historique des sauvegardes" meta={(backups.length || 0) + ' fichier(s)'} flush> {resp === null ?
Chargement…
: backups.length === 0 ?
Aucun backup pour l'instant. Clique "Backup maintenant" pour en créer un.
: {backups.map((b) => ( ))}
Date du backupFichierTailleTélécharger
{fmtDate(b.date)} {b.filename} {(b.size_mo || 0).toFixed(2).replace('.', ',')} Mo
}
); }; /* ----- USERS — Paquet B : branche sur GET /api/superadmin/users ----- */ const UsersView = ({ onBack }) => { const { toast } = _useApp(); const [resp, setResp] = _us(null); // {data, stats} const [tab, setTab] = _us('tous'); const [q, setQ] = _us(''); const reload = () => { if (!window.SentinelAPI) { setResp({ data: [], stats: {} }); return; } setResp(null); window.SentinelAPI.fetchAuth('/api/superadmin/users') .then((r) => setResp(r || { data: [] })) .catch(() => { setResp({ data: [], stats: {} }); toast('Erreur chargement users.', 'error'); }); }; _ue(() => { reload(); }, []); const users = (resp && resp.data) || []; const stats = (resp && resp.stats) || {}; const roleTone = { superadmin: 'warning', groupe_admin: 'warning', cabinet_admin: 'warning', agent: 'neutral' }; const roleLabel = { superadmin: 'Superadmin', groupe_admin: 'Groupe admin', cabinet_admin: 'Cabinet admin', agent: 'Agent' }; const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString('fr-FR') : '—'; const sinceText = (iso) => { if (!iso) return null; const d = new Date(iso); const now = new Date(); const days = Math.floor((now - d) / (1000*60*60*24)); if (days === 0) return "Aujourd'hui"; if (days === 1) return 'Hier'; if (days < 30) return days + ' j'; return Math.floor(days/30) + ' mois'; }; // Filtres tabs const filtered = users.filter((u) => { if (tab === 'actifs' && u.activity !== 'active') return false; if (tab === 'dormants' && u.activity !== 'inactive') return false; if (tab === 'jamais' && u.activity !== 'never') return false; if (tab === 'cabinet-admin' && !(u.is_cabinet_admin && !u.is_superadmin)) return false; if (tab === 'groupe-admin' && !u.is_groupe_admin) return false; if (tab === 'superadmin' && !u.is_superadmin) return false; if (tab === 'suspendus' && u.cabinet_actif !== false) return false; if (q) { const l = q.toLowerCase(); if (!(u.email || '').toLowerCase().includes(l) && !(u.nom || '').toLowerCase().includes(l) && !(u.cabinet_nom || '').toLowerCase().includes(l)) return false; } return true; }); const counts = { tous: users.length, actifs: users.filter((u) => u.activity === 'active').length, dormants: users.filter((u) => u.activity === 'inactive').length, jamais: users.filter((u) => u.activity === 'never').length, 'cabinet-admin': users.filter((u) => u.is_cabinet_admin && !u.is_superadmin).length, 'groupe-admin': users.filter((u) => u.is_groupe_admin).length, superadmin: users.filter((u) => u.is_superadmin).length, suspendus: users.filter((u) => u.cabinet_actif === false).length, }; return (
<_PH crumb={← Administration} title="Utilisateurs de la plateforme" sub="Tous les comptes Sentinel, leur activité et leurs performances." />
<_Kpi label="Total comptes" value={_fmt.num(stats.n_users || 0)} meta={(stats.n_actif || 0) + ' actifs · ' + (stats.n_inactif || 0) + ' désactivés'} /> <_Kpi label="Actifs 30 j" value={_fmt.num(stats.n_active_30d || 0)} meta="Se sont connectés" /> <_Kpi label="Dormants" value={_fmt.num(stats.n_inactive || 0)} meta="> 30 j sans connexion" /> <_Kpi label="Jamais loggés" value={_fmt.num(stats.n_never_logged || 0)} meta="Comptes à relancer" />
<_I.search /> setQ(e.target.value)} />
<_Tabs ghost value={tab} onChange={setTab} options={[ { value:'tous', label:'Tous · ' + counts.tous }, { value:'actifs', label:'Actifs 30 j · ' + counts.actifs }, { value:'dormants', label:'Dormants · ' + counts.dormants }, { value:'jamais', label:'Jamais loggés · ' + counts.jamais }, { value:'cabinet-admin', label:'Cabinet admins · ' + counts['cabinet-admin'] }, { value:'groupe-admin', label:'Groupe admins · ' + counts['groupe-admin'] }, { value:'superadmin', label:'Super-admins · ' + counts.superadmin }, { value:'suspendus', label:'Cabinets suspendus · ' + counts.suspendus }, ]} />
<_Card flush> {resp === null ?
Chargement…
: filtered.length === 0 ?
Aucun utilisateur correspondant.
: {filtered.map((u) => ( ))}
EmailNomCabinetRôleInscrit leDernière connexion Clients Matches Ventes Statut
{u.email} {u.nom || '—'} {u.cabinet_nom || '—'} <_Pill tone={roleTone[u.role] || 'neutral'} dot={u.role === 'superadmin'}>{roleLabel[u.role] || u.role} {fmtDate(u.date_creation)} {u.activity === 'never' ? <_Pill tone="warning">Jamais : {sinceText(u.last_login_at)}} {_fmt.num(u.n_clients || 0)} {_fmt.num(u.n_matches || 0)} {_fmt.num(u.n_ventes || 0)} {u.actif ? <_Pill tone="success" dot>Actif : <_Pill tone="danger" dot>Désactivé}
}
); }; /* ----- GROUPS — Paquet B : branche sur GET /api/superadmin/groupes ----- */ const GroupsView = ({ onBack }) => { const { toast } = _useApp(); const [groupes, setGroupes] = _us(null); const [showNew, setShowNew] = _us(false); const [form, setForm] = _us({ nom: '', slug: '' }); const [saving, setSaving] = _us(false); const reload = () => { if (!window.SentinelAPI) { setGroupes([]); return; } setGroupes(null); window.SentinelAPI.fetchAuth('/api/superadmin/groupes') .then((r) => setGroupes(r.data || [])) .catch(() => { setGroupes([]); toast('Erreur chargement groupes.', 'error'); }); }; _ue(() => { reload(); }, []); _ue(() => { if (showNew) setForm({ nom: '', slug: '' }); }, [showNew]); const create = async () => { if (!form.nom.trim()) { toast('Nom du groupe requis.', 'warning'); return; } setSaving(true); try { const body = { nom: form.nom.trim() }; if (form.slug.trim()) body.slug = form.slug.trim(); await window.SentinelAPI.fetchAuth('/api/superadmin/groupes', { method: 'POST', body }); toast('Groupe cree.', 'success'); setShowNew(false); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; const remove = async (g) => { if (g.n_cabinets > 0) { toast('Detache d\'abord les ' + g.n_cabinets + ' cabinet(s) rattaches.', 'warning'); return; } if (!confirm('Supprimer le groupe ' + g.nom + ' ?')) return; try { await window.SentinelAPI.fetchAuth('/api/superadmin/groupes/' + g.id, { method: 'DELETE' }); toast('Groupe supprime.', 'success'); reload(); } catch (e) { toast('Echec : ' + (e.message || e), 'error'); } }; const list = groupes || []; const totalCabinets = list.reduce((s, g) => s + (g.n_cabinets || 0), 0); const totalUsers = list.reduce((s, g) => s + (g.n_users || 0), 0); const totalGroupeAdmins = list.reduce((s, g) => s + (g.n_groupe_admins || 0), 0); const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString('fr-FR') : '—'; return (
<_PH crumb={← Administration} title="Groupes (réseaux d'agences)" sub="Regroupement de plusieurs cabinets sous une entité parente. Un groupe_admin voit tous les cabinets de son groupe." actions={} />
<_Kpi label="Groupes" value={_fmt.num(list.length)} /> <_Kpi label="Cabinets en groupe" value={_fmt.num(totalCabinets)} /> <_Kpi label="Users en groupe" value={_fmt.num(totalUsers)} /> <_Kpi label="Groupe admins" value={_fmt.num(totalGroupeAdmins)} />
<_Card flush> {groupes === null ?
Chargement…
: list.length === 0 ?
Aucun groupe pour l'instant.
: {list.map((g) => ( ))}
GroupeSlugCabinetsUsersGroupe adminsCréé le
{g.nom} {g.actif === false && <_Pill tone="warning" style={{marginLeft:8}}>Inactif} {g.slug || '—'} {_fmt.num(g.n_cabinets || 0)} {_fmt.num(g.n_users || 0)} {_fmt.num(g.n_groupe_admins || 0)} {fmtDate(g.date_creation)}
} <_Modal open={showNew} onClose={()=>setShowNew(false)} title="Nouveau groupe" footer={<> }>
setForm({...form, nom: e.target.value})} placeholder="Ex : Réseau Foncia" />
setForm({...form, slug: e.target.value})} placeholder="auto-généré depuis le nom" />
); }; /* ----- PATRIMOINE LEADS — Paquet B : branche sur GET /api/patrimoine/leads + /stats ----- */ const PatrimoineView = ({ onBack }) => { const { toast } = _useApp(); const [tab, setTab] = _us('actifs'); const [leads, setLeads] = _us(null); const [stats, setStats] = _us({}); const [q, setQ] = _us(''); const [filterCab, setFilterCab] = _us(''); const reloadLeads = (statut) => { if (!window.SentinelAPI) { setLeads([]); return; } setLeads(null); window.SentinelAPI.fetchAuth('/api/patrimoine/leads?statut=' + encodeURIComponent(statut)) .then((r) => setLeads(Array.isArray(r.data) ? r.data : [])) .catch(() => { setLeads([]); toast('Erreur chargement leads.', 'error'); }); }; _ue(() => { reloadLeads(tab); }, [tab]); _ue(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/patrimoine/stats') .then((r) => setStats((r && r.data) || {})) .catch(() => {}); }, []); const list = leads || []; const cabinets = Array.from(new Set(list.map((l) => l.cabinet_nom).filter(Boolean))); const filtered = list.filter((l) => { if (filterCab && l.cabinet_nom !== filterCab) return false; if (q) { const s = q.toLowerCase(); const blob = [l.nom, l.prenom, l.email, l.telephone, l.villes, l.cabinet_nom].filter(Boolean).join(' ').toLowerCase(); if (!blob.includes(s)) return false; } return true; }); const fmtDateTime = (iso) => iso ? new Date(iso).toLocaleString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'; const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString('fr-FR') : '—'; const exportCsv = async () => { try { const token = (window.SentinelAPI && window.SentinelAPI.getToken && window.SentinelAPI.getToken()) || ''; const r = await fetch('/api/patrimoine/leads.csv?statut=' + encodeURIComponent(tab), { 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 = 'leads-patrimoine-' + new Date().toISOString().slice(0,10) + '.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast('Export CSV telecharge.', 'success'); } catch (e) { toast('Echec export : ' + (e.message || e), 'error'); } }; // Repartition par cabinet (calcul cote front depuis la liste actuelle) const repartition = {}; list.forEach((l) => { if (l.cabinet_nom) repartition[l.cabinet_nom] = (repartition[l.cabinet_nom] || 0) + 1; }); return (
<_PH crumb={← Administration} title="Leads Patrimoine" sub="Clients ayant consenti au partage de leurs coordonnées avec un conseiller en gestion de patrimoine partenaire. Réservé aux administrateurs Sentinel." actions={} />
<_Kpi label="Actifs" value={_fmt.num(stats.n_actifs ?? 0)} /> {/* Lot 0 audit : cles reelles de GET /api/patrimoine/stats (n_ce_mois / n_retires_ce_mois / par_cabinet) -> affichait 0. */} <_Kpi label="Nouveaux ce mois" value={_fmt.num(stats.n_ce_mois ?? 0)} /> <_Kpi label="Retirés total" value={_fmt.num(stats.n_retires ?? 0)} /> <_Kpi label="Retirés ce mois" value={_fmt.num(stats.n_retires_ce_mois ?? 0)} /> <_Kpi label="Cabinets participants" value={_fmt.num((stats.par_cabinet && stats.par_cabinet.length) ?? cabinets.length)} />
<_Tabs ghost value={tab} onChange={setTab} options={[ { value:'actifs', label:'Actifs · ' + (stats.n_actifs ?? '—') }, { value:'retires', label:'Retirés · ' + (stats.n_retires ?? '—') }, { value:'tous', label:'Tous · ' + ((stats.n_actifs || 0) + (stats.n_retires || 0)) }, ]} />
{Object.keys(repartition).length > 0 && ( <_Card title="Répartition par cabinet" padded style={{marginBottom:16}}>
{Object.entries(repartition).map(([nom, n]) => (
{nom} ({n})
))}
)} <_Card padded style={{marginBottom:16}}>
setQ(e.target.value)} placeholder="Nom, email, téléphone, ville, cabinet…" />
<_Card flush> {leads === null ?
Chargement…
: filtered.length === 0 ?
Aucun lead patrimoine{tab === 'actifs' ? ' actif' : tab === 'retires' ? ' retire' : ''}.
: <> {filtered.map((l) => ( ))}
ÉtatDateClientEmailTéléphoneBudgetVillesCabinetAgentRetrait
{l.actif ? <_Pill tone="success" dot>Actif : <_Pill tone="neutral" dot>Retiré} {fmtDateTime(l.date_consentement)} {[l.prenom, l.nom].filter(Boolean).join(' ').trim() || '—'} {l.email || '—'} {l.telephone || '—'} {l.budget || '—'} {l.villes || '—'} {l.cabinet_nom || '—'} {l.agent_email || '—'} {l.date_retrait ? {fmtDate(l.date_retrait)} : }
{filtered.length} lead(s)
}
); }; /* ============================================================ */ /* PHOTOLOC DEV */ /* ============================================================ */ /* PHOTOLOC DEV — super-admin : gestion zones + scans */ /* Endpoints : */ /* GET /api/superadmin/photoloc/zones */ /* POST /api/superadmin/photoloc/zones (creer) */ /* GET /api/superadmin/photoloc/zones/{id} */ /* DELETE /api/superadmin/photoloc/zones/{id} */ /* POST /api/superadmin/photoloc/zones/{id}/scan */ /* GET /api/superadmin/photoloc/stats */ /* GET /api/superadmin/photoloc/health */ /* ============================================================ */ /* Fond de carte de la couverture : style vectoriel OpenFreeMap (deja autorise par la CSP). Local car _MAP_STYLES vit dans sentinel-pages.jsx. Les tuiles raster CARTO affichent un filigrane "API KEY REQUIRED" depuis sept. 2026. */ const _COV_MAP_STYLE = 'https://tiles.openfreemap.org/styles/dark'; const _plFmtCost = (usd, eur) => { const u = usd != null ? '$' + Number(usd).toFixed(2) : '—'; const e = eur != null ? ' ≈ ' + Number(eur).toFixed(2) + ' €' : ''; return u + e; }; const _plFmtHours = (h) => { if (h == null) return '—'; if (h < 1) return Math.round(h * 60) + ' min'; if (h < 24) return h.toFixed(1) + ' h'; return (h / 24).toFixed(1) + ' j'; }; const _plDeduceCommune = (s) => { if (!s) return ''; const b = s.split('_')[0]; return b.charAt(0).toUpperCase() + b.slice(1); }; // Bounds MapLibre depuis un FeatureCollection de LineString. function _plGeojsonBounds(geojson) { if (typeof maplibregl === 'undefined' || !geojson || !geojson.features) return null; let b = null; const ext = (c) => { if (!b) b = new maplibregl.LngLatBounds(c, c); else b.extend(c); }; geojson.features.forEach((f) => { const g = f && f.geometry; if (!g) return; if (g.type === 'LineString') g.coordinates.forEach(ext); else if (g.type === 'MultiLineString') g.coordinates.forEach((l) => l.forEach(ext)); else if (g.type === 'Point') ext(g.coordinates); }); return b; } /* 7.2 — Lancer un scan de ville (estimate_city_scan + scan_city) */ const PhotoLocCityScanCard = ({ toast, onReload }) => { const blank = { city_name: '', country: 'France', grid_step_km: '0.3', num_points_per_mini: '200' }; const [form, setForm] = _us(blank); const [est, setEst] = _us(null); const [estimating, setEstimating] = _us(false); const [launching, setLaunching] = _us(false); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const estimate = async () => { const city = form.city_name.trim(); if (!city) { toast('Nom de ville requis.', 'warning'); return; } setEstimating(true); try { const qs = new URLSearchParams({ city_name: city, country: form.country, grid_step_km: form.grid_step_km, num_points_per_mini: form.num_points_per_mini, }).toString(); const r = await window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/estimate_city_scan?' + qs); setEst(r.data || {}); } catch (e) { toast('Estimation echouee : ' + (e.message || e), 'error'); } finally { setEstimating(false); } }; const launch = async () => { if (!est) return; const cost = _plFmtCost(est.estimated_modal_cost_usd, est.estimated_modal_cost_eur); if (!confirm('Lancer le scan de « ' + (est.city_name || est.display_name) + ' » ?\n\n' + _fmt.num(est.total_mini_scans) + ' mini-scans · ' + _plFmtHours(est.estimated_duration_hours) + '\nCout Modal estime : ' + cost)) return; setLaunching(true); try { const r = await window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/scan_city', { method: 'POST', body: { city_name: est.city_name, country: form.country, grid_step_km: parseFloat(form.grid_step_km) || 0.3, num_points_per_mini: parseInt(form.num_points_per_mini, 10) || 200, }, }); toast('Scan ville lance' + (r.data && r.data.job_id ? ' (job ' + r.data.job_id + ')' : ''), 'success'); setEst(null); setForm(blank); // Paquet 7.6 : la zone trackee vient d'etre persistee (kind=city_scan) // -> on recharge la liste pour la faire apparaitre immediatement. if (typeof onReload === 'function') onReload(); } catch (e) { toast('Lancement echoue : ' + (e.message || e), 'error'); } finally { setLaunching(false); } }; return ( <_Card title="Lancer un scan de ville" padded style={{marginBottom:16}}>
set('city_name', e.target.value)} placeholder="Ex : Lille, Marcq-en-Baroeul" />
set('grid_step_km', e.target.value)} />
set('num_points_per_mini', e.target.value)} />
{est && (
{est.display_name || est.city_name}
<_Kpi label="Dimensions" value={(est.lat_span_km != null ? est.lat_span_km.toFixed(1) : '?') + ' × ' + (est.lon_span_km != null ? est.lon_span_km.toFixed(1) : '?') + ' km'} /> <_Kpi label="Mini-scans" value={_fmt.num(est.total_mini_scans)} /> <_Kpi label="Images estimees" value={_fmt.num(est.estimated_total_images)} /> <_Kpi label="Duree estimee" value={_plFmtHours(est.estimated_duration_hours)} /> <_Kpi label="Cout Modal estime" value={_plFmtCost(est.estimated_modal_cost_usd, est.estimated_modal_cost_eur)} />
)} ); }; /* 7.3 — Couverture par ville (carte MapLibre) + comblement */ const PhotoLocCoverageCard = ({ toast, onReload }) => { const [zones, setZones] = _us(null); // liste slugs Modal (null = loading) const [slug, setSlug] = _us(''); const [commune, setCommune] = _us(''); const [coverage, setCoverage] = _us(null); const [loading, setLoading] = _us(false); const [fillEst, setFillEst] = _us(null); const [estimatingFill, setEstimatingFill] = _us(false); const [launchingFill, setLaunchingFill] = _us(false); // Paquet 7.8 : mode caps separe (ROUGES / ORANGES) const [maxUncovered, setMaxUncovered] = _us(30); const [maxPartial, setMaxPartial] = _us(20); const mapEl = _ur(null); const mapRef = _ur(null); // Liste des zones Modal indexees au mount _ue(() => { if (!window.SentinelAPI) return; window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/remote_zones') .then((r) => { const zs = (r.data && r.data.zones) || []; setZones(zs); if (zs.length) { setSlug(zs[0].zone_id); setCommune(_plDeduceCommune(zs[0].zone_id)); } }) .catch(() => setZones([])); }, []); // Init MapLibre + (re)applique la couverture quand elle change _ue(() => { if (!coverage || typeof maplibregl === 'undefined' || !mapEl.current) return; if (!mapRef.current) { mapRef.current = new maplibregl.Map({ container: mapEl.current, style: _COV_MAP_STYLE, center: [3.10, 50.60], zoom: 13, attributionControl: { compact: true }, }); mapRef.current.addControl(new maplibregl.NavigationControl(), 'top-right'); } const map = mapRef.current; const apply = () => { if (map.getLayer('cov-lines')) map.removeLayer('cov-lines'); if (map.getSource('cov')) map.removeSource('cov'); map.addSource('cov', { type: 'geojson', data: coverage }); map.addLayer({ id: 'cov-lines', type: 'line', source: 'cov', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': ['coalesce', ['get', 'stroke'], '#888888'], 'line-width': ['coalesce', ['get', 'stroke-width'], 3], 'line-opacity': ['coalesce', ['get', 'stroke-opacity'], 0.9], }, }); if (!map.__covListeners) { map.on('click', 'cov-lines', (e) => { const p = (e.features && e.features[0] && e.features[0].properties) || {}; new maplibregl.Popup({ offset: 8, closeButton: false }) .setLngLat(e.lngLat) .setHTML('
' + (p.name || '—') + '
' + (p.status || '') + (p.coverage_pct != null ? ' · ' + Math.round(p.coverage_pct) + '%' : '') + '
') .addTo(map); }); map.on('mouseenter', 'cov-lines', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'cov-lines', () => { map.getCanvas().style.cursor = ''; }); map.__covListeners = true; } const b = _plGeojsonBounds(coverage); if (b) map.fitBounds(b, { padding: 30, duration: 600 }); map.resize(); }; if (map.isStyleLoaded()) apply(); else map.once('load', apply); }, [coverage]); // Cleanup map au unmount _ue(() => () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; } }, []); const showCoverage = async () => { if (!slug) return; setLoading(true); setFillEst(null); try { let url = '/api/superadmin/photoloc/remote_zones/' + encodeURIComponent(slug) + '/coverage'; if (commune.trim()) url += '?commune_name=' + encodeURIComponent(commune.trim()); const r = await window.SentinelAPI.fetchAuth(url); setCoverage(r.data || null); } catch (e) { toast('Couverture indisponible : ' + (e.message || e), 'error'); } finally { setLoading(false); } }; const doEstimateFill = async () => { if (!slug) return; if (maxUncovered === 0 && maxPartial === 0) { toast('Renseigne au moins une rue rouge ou orange a combler.', 'warning'); return; } setEstimatingFill(true); try { let url = '/api/superadmin/photoloc/fill_gaps/estimate?zone_slug=' + encodeURIComponent(slug) + '&max_uncovered=' + (maxUncovered || 0) + '&max_partial=' + (maxPartial || 0); if (commune.trim()) url += '&commune_name=' + encodeURIComponent(commune.trim()); const r = await window.SentinelAPI.fetchAuth(url); setFillEst(r.data || {}); } catch (e) { toast('Estimation echouee : ' + (e.message || e), 'error'); } finally { setEstimatingFill(false); } }; const doLaunchFill = async () => { if (!slug || !fillEst) return; const cost = _plFmtCost(fillEst.estimated_cost_usd, fillEst.estimated_cost_eur); const dur = fillEst.estimated_duration_min != null ? Math.round(fillEst.estimated_duration_min) + ' min' : '?'; const detail = (fillEst.uncovered_will_scan || 0) + ' rouges · ' + (fillEst.partial_will_scan || 0) + ' oranges'; if (!confirm('Lancer le comblement de « ' + (commune || slug) + ' » ?\n\n' + detail + ' (' + _fmt.num(fillEst.streets_will_scan) + ' rues au total)' + ' · ~' + dur + '\nCout estime : ' + cost)) return; setLaunchingFill(true); try { const r = await window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/fill_gaps', { method: 'POST', body: { zone_slug: slug, commune_name: commune.trim() || null, max_uncovered: maxUncovered || 0, max_partial: maxPartial || 0, }, }); toast('Comblement lance' + (r.data && r.data.global_job_id ? ' (job ' + r.data.global_job_id + ')' : ''), 'success'); // Paquet 7.6 : la zone trackee vient d'etre persistee (kind=fill_gaps) // -> on recharge la liste pour la faire apparaitre immediatement. if (typeof onReload === 'function') onReload(); } catch (e) { toast('Lancement echoue : ' + (e.message || e), 'error'); } finally { setLaunchingFill(false); } }; const props = (coverage && coverage.properties) || null; const dot = (c) => ({ display:'inline-block', width:10, height:10, borderRadius:'50%', background:c, marginRight:6, verticalAlign:'middle' }); return ( <_Card title="Couverture par ville" padded style={{marginBottom:16}}>
setCommune(e.target.value)} placeholder="Ex : Ronchin" />
{props && (
{props.coverage_pct != null ? props.coverage_pct.toFixed(1) + '%' : '—'} {_fmt.num(props.covered)} couvertes {_fmt.num(props.partial)} partielles {_fmt.num(props.uncovered)} non couvertes {_fmt.num(props.total_streets)} rues · {_fmt.num(props.streets_to_fill)} à combler
)} {coverage && (
)} {coverage && (
setMaxUncovered(parseInt(e.target.value, 10) || 0)} />
setMaxPartial(parseInt(e.target.value, 10) || 0)} />
{fillEst && }
{fillEst && (
<_Kpi label="Rues rouges" value={_fmt.num(fillEst.uncovered_will_scan || 0) + ' / ' + _fmt.num(fillEst.total_uncovered_to_fill || 0)} /> <_Kpi label="Rues oranges" value={_fmt.num(fillEst.partial_will_scan || 0) + ' / ' + _fmt.num(fillEst.total_partial_to_fill || 0)} /> <_Kpi label="Duree estimee" value={fillEst.estimated_duration_min != null ? Math.round(fillEst.estimated_duration_min) + ' min' : '—'} /> <_Kpi label="Cout estime" value={_plFmtCost(fillEst.estimated_cost_usd, fillEst.estimated_cost_eur)} />
)}
)} ); }; const PhotoLocDevPage = () => { const [zones, setZones] = _us(null); // null = loading const [stats, setStats] = _us(null); const [health, setHealth] = _us(null); const [zone, setZone] = _us(null); const [showNew, setShowNew] = _us(false); const { toast } = _useApp(); const reload = () => { if (!window.SentinelAPI) { setZones([]); return; } window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/zones') .then((r) => setZones(r.data || [])) .catch(() => { setZones([]); toast('Erreur chargement zones.', 'error'); }); window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/stats') .then((r) => setStats(r.data || {})).catch(() => setStats({})); window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/health') .then((r) => setHealth(r.data || {})).catch(() => setHealth({ online: false })); }; _ue(() => { reload(); }, []); // Polling pour les zones scanning/training. 60 s (Lot B audit) : chaque // tick relance zones + stats + sante, et le service repond depuis un // container GPU ; a 10 s le L4 restait eveille tout le scan pour du JSON. _ue(() => { if (!zones) return; const hasInProgress = zones.some((z) => z.statut === 'scanning' || z.statut === 'training'); if (!hasInProgress) return; const t = setInterval(reload, 60000); return () => clearInterval(t); }, [zones]); const handleLaunchScan = async (z) => { if (!confirm('Lancer le scan PhotoLoc sur ' + z.nom_zone + ' ?\n\nAction longue (heures), facturee sur Modal.')) return; try { await window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/zones/' + z.id + '/scan', { method: 'POST' }); toast('Scan lance sur ' + z.nom_zone + '.', 'success'); reload(); } catch (e) { toast('Echec scan : ' + (e.message || e), 'error'); } }; // Lot B audit : arret d'un scan en cours (DELETE job cote service). const handleCancelScan = async (z) => { if (!confirm('Annuler le scan en cours sur ' + z.nom_zone + ' ?\n\nLe travail deja indexe est conserve cote service, la zone passe en echec.')) return; try { await window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/zones/' + z.id + '/cancel', { method: 'POST' }); toast('Annulation demandee pour ' + z.nom_zone + '.', 'success'); reload(); } catch (e) { toast('Echec annulation : ' + (e.message || e), 'error'); } }; const handleDelete = async (z) => { if (!confirm('Supprimer la zone ' + z.nom_zone + ' ?\n\nDefinitif (l index FAISS reste sur Modal, manuel a nettoyer).')) return; try { await window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/zones/' + z.id, { method: 'DELETE' }); toast('Zone supprimee.', 'success'); reload(); } catch (e) { toast('Echec suppression : ' + (e.message || e), 'error'); } }; if (zone) return { setZone(null); reload(); }} onScan={handleLaunchScan} onDelete={handleDelete} onCancel={handleCancelScan} />; // Compteurs AUTORITATIFS depuis /stats (le backend y met les totaux LIVE // de l'index PhotoLoc). Fallback sur la somme des zones (cache DB) si /stats // pas encore charge -- evite d'afficher un total fige quand un job a echoue // (ex : comblement ronchin FAILED, DB ~71k vs 236k reels dans l'index). const totalImages = (stats && stats.total_images_collectees != null) ? stats.total_images_collectees : (zones || []).reduce((s, z) => s + (z.nb_images_collectees || 0), 0); const totalBatiments = (stats && stats.total_batiments_indexes != null) ? stats.total_batiments_indexes : (zones || []).reduce((s, z) => s + (z.nb_batiments_indexes || 0), 0); const zonesReady = (zones || []).filter((z) => z.statut === 'ready').length; return (
<_PH title="PhotoLoc Dev" sub="Etat du module de geolocalisation IA. Gestion des zones pre-entrainees." actions={<> <_Pill tone="danger" dot>DEV uniquement } />
<_Kpi label="Zones prêtes" value={_fmt.num(zonesReady)} meta={(zones || []).length + ' zones au total'} /> <_Kpi label="Images collectees" value={_fmt.num(totalImages)} /> <_Kpi label="Batiments indexes" value={_fmt.num(totalBatiments)} /> <_Kpi label="Service PhotoLoc" value={health && health.online ? 'Online' : 'Offline'} meta={health && health.latency_ms ? 'Latence ' + health.latency_ms + ' ms' : '—'} />
<_Card title="Zones" flush style={{marginBottom:16}}> {zones === null ? (
Chargement…
) : zones.length === 0 ? ( <_ES icon="photoloc" title="Aucune zone" desc="Cree une zone PhotoLoc pour commencer le scan." action={} /> ) : (
{zones.map((z) => { // Paquet 7.6 : si progress_pct est fourni par le backend (jobs // city_scan / fill_gaps), on l'utilise tel quel. Sinon fallback // sur le calcul images-collectees/cibles (zones manuelles). const isJob = z.kind === 'city_scan' || z.kind === 'fill_gaps'; const pct = z.progress_pct != null ? Math.round(z.progress_pct) : (z.nb_images_cibles ? Math.round((z.nb_images_collectees || 0) / z.nb_images_cibles * 100) : 0); const kindLabel = z.kind === 'city_scan' ? 'Ville' : z.kind === 'fill_gaps' ? 'Comblement' : null; return (
setZone(z)}>
{z.nom_zone}
{kindLabel && ( {kindLabel} )}
{isJob && z.zone_slug ? ( <>Slug : {z.zone_slug}{z.commune ? ' · ' + z.commune : ''} ) : ( <>Rayon : {z.rayon_km} km · Source : {z.source_images} · GPS : {z.latitude.toFixed(4)}, {z.longitude.toFixed(4)} )}
{(z.statut === 'scanning' || z.statut === 'training') && (
{isJob ? ( <>Progression : {z.progress_pct != null ? Math.round(z.progress_pct) + ' %' : 'En cours…'} ) : ( <>Images : {_fmt.num(z.nb_images_collectees)} / {_fmt.num(z.nb_images_cibles || '?')} )} {z.date_debut && (<> · Demarre {new Date(z.date_debut).toLocaleString('fr-FR')})}
)} {z.statut === 'ready' && (
{_fmt.num(z.nb_batiments_indexes)} batiments indexes {z.duree_secondes && (<> · scan {Math.round(z.duree_secondes / 60)} min)}
)} {z.statut === 'failed' && z.notes && (
Erreur : {z.notes.substring(0, 80)}
)}
<_Pill tone={z.statut === 'ready' ? 'success' : z.statut === 'failed' ? 'danger' : (z.statut === 'scanning' || z.statut === 'training') ? 'warning' : 'neutral'} pulse={z.statut === 'scanning' || z.statut === 'training'} dot> {z.statut}
{(z.statut === 'scanning' || z.statut === 'training') && (z.progress_pct != null || z.nb_images_cibles > 0) && (
)}
{(z.statut === 'pending' || z.statut === 'failed') && ( )} {(z.statut === 'scanning' || z.statut === 'training') && ( )}
); })}
)} setShowNew(false)} onCreated={() => { setShowNew(false); reload(); }} toast={toast} />
); }; /* Modal "Nouvelle zone" branchee */ const PhotoLocNewZoneModal = ({ open, onClose, onCreated, toast }) => { const [form, setForm] = _us({ nom_zone: '', latitude: '50.6371', longitude: '3.0635', rayon_km: '1', source_images: 'mapillary', notes: '', }); const [saving, setSaving] = _us(false); _ue(() => { if (open) { setForm({ nom_zone: '', latitude: '50.6371', longitude: '3.0635', rayon_km: '1', source_images: 'mapillary', notes: '' }); setSaving(false); } }, [open]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const submit = async () => { if (!form.nom_zone.trim()) { toast('Nom de zone requis.', 'warning'); return; } const lat = parseFloat(form.latitude); const lng = parseFloat(form.longitude); const r = parseFloat(form.rayon_km); if (isNaN(lat) || isNaN(lng) || isNaN(r) || r <= 0) { toast('Coordonnees ou rayon invalides.', 'warning'); return; } setSaving(true); try { await window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/zones', { method: 'POST', body: { nom_zone: form.nom_zone.trim(), latitude: lat, longitude: lng, rayon_km: r, source_images: form.source_images, notes: form.notes.trim() || null }, }); toast('Zone creee. Scan en attente.', 'success'); onCreated(); } catch (e) { toast('Echec creation : ' + (e.message || e), 'error'); } finally { setSaving(false); } }; if (!open) return null; return ( <_Modal open={open} onClose={onClose} title="Nouvelle zone PhotoLoc" footer={<> }>
set('nom_zone', e.target.value)} placeholder="Ex : Lille hyper-centre" />
set('latitude', e.target.value)} step="0.0001" />
set('longitude', e.target.value)} step="0.0001" />
set('rayon_km', e.target.value)} step="0.1" min="0.1" />