/* ========================================================================
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) &&
<_I.download /> CSV }>
{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) &&
<_I.download /> CSV }>
{loading ? <_Skel kind="lines" n={4} /> :
Client Matches
{data.topClients.length === 0 ? (
Aucun client actif sur la periode
) : data.topClients.map((c) => {
const max = data.topClients[0].nb_matches || 1;
return (
{c.nom}
{c.nb_matches}
);
})}
}
<_Card title="Top 5 villes (annonces)" flush
actions={(data && data.topVilles && data.topVilles.length > 0) &&
<_I.download /> CSV }>
{loading ? <_Skel kind="lines" n={4} /> :
Ville Annonces
{data.topVilles.length === 0 ? (
Aucune ville sur la periode
) : data.topVilles.map((v) => (
{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." /> :
<>
Agent Clients
Nouveaux
Matches
Ventes
{/* Paquet I : nouvelle col Pipeline (parite V1) — breakdown par statut. */}
Pipeline
{data.teamPerf.users.map((a) => {
const p = a.pipeline || {};
return (
{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} /> :
Source Clients %
{(!data.clientSources || data.clientSources.length === 0) ? (
Aucune source renseignee
) : (() => {
const tot = data.clientSources.reduce((s, x) => s + (x.n || 0), 0) || 1;
return data.clientSources.map((s) => (
{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 (
Thème de l'interface
{choices.map(([v, l]) => (
pick(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>
Annuler
{saving ? 'Enregistrement…' : 'Enregistrer'}
);
};
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.
URL du logo (PNG / SVG carre, fond transparent ideal)
setLogoUrl(e.target.value)} placeholder="https://exemple.fr/logo.png" />
save('')} disabled={saving}>Retirer
save(logoUrl)} disabled={saving}>{saving ? 'Enregistrement…' : 'Enregistrer'}
);
};
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.
Token Telegram (du bot dedie)
setToken(e.target.value)} placeholder="123456:ABC-DEF..." />
Tester l'envoi
{saving ? 'Enregistrement…' : 'Enregistrer'}
);
};
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={ setShowNew(true)}><_I.plus /> Nouvel agent } 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
:
Email Nom Role Actif
{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 (
{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 && (<>
promote(u)}>{u.is_cabinet_admin ? '→ Agent' : '→ Cabinet'}
remove(u)}><_I.trash />
>)}
);
})}
}
<_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={<>
Annuler
{saving ? 'Creation…' : 'Creer'}
>}>
setForm({ ...form, is_cabinet_admin: e.target.checked })} />
{form.is_cabinet_admin && }
Compte cabinet (peut lancer la collecte, voir tous les clients)
);
};
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.
<_I.refresh /> {running ? 'Collecte en cours…' : 'Lancer une collecte maintenant'}
);
};
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.
{running ? 'En cours…' : 'Relancer la geoloc des echouees'}
);
};
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.
<_I.download /> {exporting ? 'Export…' : 'Exporter toutes les donnees (JSON)'}
);
};
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.
<_I.trash /> {deleting ? 'Suppression…' : 'Tout supprimer'}
);
};
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
<_I.download /> {exporting ? 'Export…' : 'Exporter mes donnees'}
);
};
/* 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} />
{_FEATURE_ORDER.map((f) => {
const incl = !!(current && current[f]);
const minPack = incl ? null : _minPackFor(f, packs);
return (
{_FEATURE_LABELS[f]}{f === 'exclusivite_zone' && incl && current.exclusivite_zone_km ? ` · ${current.exclusivite_zone_km} km` : ''}
{incl ? 'Inclus' : (minPack ? `Inclus dès ${minPack}` : 'Sur devis')}
);
})}
{others.length > 0 && (
setShowOthers((o) => !o)} aria-expanded={showOthers}>
{showOthers ? 'Masquer les autres packs' : 'Changer de pack'}
{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]} />
{groups.map((g) => (
{g.label}
{g.items.map((it) => (
setSection(it.id)} aria-current={current === it.id ? 'page' : undefined}>
{it.label}
{it.count != null && {it.count} }
{it.meta && · {it.meta} }
))}
))}
{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
setShowNew(true)}><_I.plus /> Nouveau cabinet
>}
/>
{/* 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). */}
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
{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); }}>
{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)}
setPack(c, e.target.value)}>
Starter
Pro
Expert
<_Pill tone={isActif ? 'success' : 'danger'} dot>{isActif ? 'Actif' : 'Suspendu'}
{isVide && <_Pill tone="warning">Vide}
{c.is_inactive === true && !isVide && <_Pill tone="warning">Inactif}
regenCode(c)}><_I.refresh />
toggleSuspend(c)}>{isActif ? 'Suspendre' : 'Activer'}
remove(c)}><_I.trash />
);
})}
}
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={<>
Annuler
{saving ? 'Creation…' : 'Creer le cabinet'}
>}>
Admin du cabinet (optionnel)
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={Fermer }>
Le cabinet {cab.nom} a ete cree (ID #{cab.id}, pack {cab.pack || 'starter'}).
{code && (
Code d'invitation
{code}
copy(code)}>Copier
{inviteUrl && (<>
Lien d'inscription pret a partager
{inviteUrl}
copy(inviteUrl)}>Copier
>)}
)}
{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={Fermer }>
{data === null ? (
Chargement…
) : (<>
Informations
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})
Email Nom Role Cree le
{users.map((u) => (
{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}}>
{ const empty = { action: '', actor_email: '', target_id: '', since_days: '' }; setFilters(empty); setApplied(empty); }}>Reset
setApplied({ ...filters })}>Filtrer
<_Card flush>
{resp === null ?
Chargement…
: events.length === 0 ?
Aucun event correspondant.
:
Date Acteur Action Cible Détails
{events.map((e) => (
{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.'}
{running ? 'Backup en cours…' : 'Backup maintenant'}
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.
:
Date du backup Fichier Taille Télécharger
{backups.map((b) => (
{fmtDate(b.date)}
{b.filename}
{(b.size_mo || 0).toFixed(2).replace('.', ',')} Mo
download(b.filename)}><_I.download /> Télécharger
))}
}
);
};
/* ----- 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" />
<_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.
:
Email Nom Cabinet Rôle Inscrit le Dernière connexion
Clients
Matches
Ventes
Statut
{filtered.map((u) => (
{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={
setShowNew(true)}><_I.plus /> Nouveau groupe }
/>
<_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.
:
Groupe Slug Cabinets Users Groupe admins Créé le
{list.map((g) => (
{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)}
remove(g)} title={g.n_cabinets > 0 ? 'Detache d\'abord les cabinets' : 'Supprimer'}><_I.trash />
))}
}
<_Modal open={showNew} onClose={()=>setShowNew(false)} title="Nouveau groupe"
footer={<>
setShowNew(false)} disabled={saving}>Annuler
{saving ? 'Création…' : 'Créer le groupe'}
>}>
Nom du groupe *
setForm({...form, nom: e.target.value})} placeholder="Ex : Réseau Foncia" />
Slug (optionnel)
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={
<_I.download /> Export CSV }
/>
<_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}}>
<_Card flush>
{leads === null ?
Chargement…
: filtered.length === 0 ?
Aucun lead patrimoine{tab === 'actifs' ? ' actif' : tab === 'retires' ? ' retire' : ''}.
: <>
État Date Client Email Téléphone Budget Villes Cabinet Agent Retrait
{filtered.map((l) => (
{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}}>
{estimating ? 'Estimation…' : 'Estimer'}
{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)} />
{launching ? 'Lancement…' : 'Lancer le scan'}
)}
);
};
/* 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}}>
{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 && (
{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
setShowNew(true)}><_I.plus /> Nouvelle zone
>}
/>
<_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={
setShowNew(true)}><_I.plus /> Nouvelle zone } />
) : (
{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) && (
)}
{ e.stopPropagation(); setZone(z); }}>Voir details
{(z.statut === 'pending' || z.statut === 'failed') && (
{ e.stopPropagation(); handleLaunchScan(z); }}>
{z.statut === 'failed' ? 'Relancer' : 'Lancer le scan'}
)}
{(z.statut === 'scanning' || z.statut === 'training') && (
{ e.stopPropagation(); handleCancelScan(z); }}>Annuler le scan
)}
{ e.stopPropagation(); handleDelete(z); }}>Supprimer
);
})}
)}
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={<>
Annuler
{saving ? 'Creation…' : 'Creer'}
>}>
Nom de la zone *
set('nom_zone', e.target.value)} placeholder="Ex : Lille hyper-centre" />
Notes (optionnel)
);
};
const PhotoLocZoneDetail = ({ zone: initialZone, onBack, onScan, onDelete, onCancel }) => {
const [zone, setZone] = _us(initialZone);
// Re-fetch detail pour avoir les dernieres infos
_ue(() => {
if (!window.SentinelAPI) return;
window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/zones/' + initialZone.id)
.then((r) => { if (r.data) setZone(r.data); })
.catch(() => {});
}, [initialZone.id]);
// Polling toutes les 60 s si en cours (Lot B audit : chaque lecture de
// zone en cours interroge /status cote service = container GPU).
_ue(() => {
if (!zone) return;
if (zone.statut !== 'scanning' && zone.statut !== 'training') return;
const t = setInterval(() => {
window.SentinelAPI.fetchAuth('/api/superadmin/photoloc/zones/' + zone.id)
.then((r) => { if (r.data) setZone(r.data); }).catch(() => {});
}, 60000);
return () => clearInterval(t);
}, [zone && zone.statut]);
const fmtDate = (d) => d ? new Date(d).toLocaleString('fr-FR') : '—';
const fmtDur = (s) => {
if (!s) return '—';
const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60);
if (h > 0) return h + 'h' + String(m).padStart(2, '0');
return m + ' min';
};
return (
<_PH
crumb={
← PhotoLoc Dev }
title={zone.nom_zone}
sub={`${zone.rayon_km} km · ${zone.source_images} · ${zone.statut}`}
actions={<>
<_S state={zone.statut === 'ready' ? 'ready' : zone.statut === 'failed' ? 'failed' : 'pending'} label={zone.statut} />
{(zone.statut === 'pending' || zone.statut === 'failed') && (
onScan(zone)}>
<_I.refresh /> {zone.statut === 'failed' ? 'Relancer le scan' : 'Lancer le scan'}
)}
{(zone.statut === 'scanning' || zone.statut === 'training') && onCancel && (
onCancel(zone)}>Annuler le scan
)}
{ onDelete(zone); onBack(); }}><_I.trash /> Supprimer
>}
/>
<_Card title="Timeline" padded>
{[
{ label:'Zone creee', meta: fmtDate(zone.created_at), state: 'ready' },
{ label:'Collecte d\'images', meta: zone.nb_images_collectees ? _fmt.num(zone.nb_images_collectees) + ' images' : 'pas demarre', state: zone.nb_images_collectees > 0 ? (zone.statut === 'scanning' ? 'running' : 'ready') : 'pending' },
{ label:'Segmentation + features', meta: zone.statut === 'training' ? 'En cours…' : (zone.statut === 'ready' ? 'OK' : '—'), state: zone.statut === 'training' ? 'running' : zone.statut === 'ready' || zone.statut === 'failed' ? 'ready' : 'pending' },
{ label:'Index FAISS pret', meta: zone.nb_batiments_indexes ? _fmt.num(zone.nb_batiments_indexes) + ' batiments indexes' : '—', state: zone.statut === 'ready' ? 'ready' : zone.statut === 'failed' ? 'failed' : 'pending' },
].map((s, i) => (
))}
<_Card title="Metriques">
<_MR label="Statut" value={zone.statut} />
{/* Paquet 8 (P4) : les jobs fill_gaps (comblement rue) n'ont pas
de point GPS unique -> coords placeholder 0,0. On affiche "—"
plutot que "0.0000, 0.0000" qui n'a aucun sens. */}
<_MR label="Latitude" value={(zone.kind === 'fill_gaps' || (!zone.latitude && !zone.longitude)) ? '—' : zone.latitude.toFixed(4)} />
<_MR label="Longitude" value={(zone.kind === 'fill_gaps' || (!zone.latitude && !zone.longitude)) ? '—' : zone.longitude.toFixed(4)} />
<_MR label="Rayon" value={zone.rayon_km} unit=" km" />
<_MR label="Source" value={zone.source_images} />
<_MR label="Images collectees" value={_fmt.num(zone.nb_images_collectees)} />
<_MR label="Images cibles" value={zone.nb_images_cibles != null ? _fmt.num(zone.nb_images_cibles) : '—'} />
<_MR label="Batiments indexes" value={_fmt.num(zone.nb_batiments_indexes)} />
<_MR label="Taille index" value={zone.taille_index_mb != null ? zone.taille_index_mb.toFixed(1) + ' MB' : '—'} />
<_MR label="Precision test" value={zone.precision_test != null ? zone.precision_test.toFixed(1) + ' %' : '—'} />
<_MR label="Demarre" value={fmtDate(zone.date_debut)} />
<_MR label="Termine" value={fmtDate(zone.date_fin)} />
<_MR label="Duree" value={fmtDur(zone.duree_secondes)} />
<_MR label="Job ID" value={zone.job_id || '—'} />
{zone.notes && (
<_Card title="Notes" padded style={{marginBottom:16}}>
{zone.notes}
)}
);
};
/* ============================================================ */
/* 404 */
/* ============================================================ */
const NotFoundPage = () => (
404
Page introuvable
Cette page n'existe pas, ou votre rôle n'a pas accès à cet écran.
← Retour au dashboard
);
Object.assign(window, {
RapportsPage, ParametresPage, AdministrationPage, PhotoLocDevPage, NotFoundPage,
Donut, ChartLine,
AdminHub, AuditView, BackupsView, UsersView, GroupsView, PatrimoineView, NewCabinetModal,
});