/* ========================================================================
SENTINEL — Shell components + shared utilities
Exposed via Object.assign(window, {...}) for use across .jsx files
======================================================================== */
const { useState, useEffect, useRef, useMemo, useCallback, createContext, useContext } = React;
/* ============================================================ */
/* ICONS — 1.25px stroke, viewBox 24 */
/* ============================================================ */
const Icon = ({ d, fill, children, ...rest }) => (
);
const I = {
dashboard: () => ,
clients: () =>
,
annonces: () => ,
carte: () => ,
outils: () => ,
photoloc: () =>
,
estimation: () => ,
legal: () => ,
rapports: () => ,
parametres: () => ,
admin: () => ,
search: () => ,
plus: () => ,
arrow: () => ,
back: () => ,
chevron: () => ,
download: () => ,
upload: () => ,
external: () => ,
trash: () => ,
edit: () => ,
eye: () => ,
close: () => ,
check: () => ,
filter: () => ,
light: () =>
,
refresh: () => ,
send: () => ,
file: () => ,
clock: () => ,
lock: () => ,
home: () => ,
pin: () =>
,
cmd: () => ,
more: () => ,
phone: () => ,
};
/* ============================================================ */
/* APP CONTEXT — global state */
/* ============================================================ */
const AppCtx = createContext(null);
const useApp = () => useContext(AppCtx);
// Chantier 01 (spec UI v3) : navigation en 4 groupes nommes + groupe
// super-admin. `admin: true` = visible seulement pour les comptes cabinet /
// groupe / super-admin (Parametres contient la config du cabinet).
const NAV_GROUPS = [
{ label: 'Pilotage', items: [
{ id: 'dashboard', label: 'Tableau de bord', glyph: 'dashboard' },
{ id: 'rapports', label: 'Rapports', glyph: 'rapports' },
]},
{ label: 'Activité', items: [
{ id: 'clients', label: 'Clients', glyph: 'clients' },
{ id: 'annonces', label: 'Annonces', glyph: 'annonces' },
{ id: 'carte', label: 'Carte', glyph: 'carte' },
]},
{ label: 'Intelligence', items: [
{ id: 'photoloc', label: 'PhotoLoc', glyph: 'photoloc', badge: '7' },
{ id: 'estimation', label: 'Estimation', glyph: 'estimation' },
{ id: 'outils', label: 'Outils géo', glyph: 'outils' },
]},
{ label: 'Cabinet', items: [
{ id: 'legal', label: 'Juridique', glyph: 'legal' },
{ id: 'parametres', label: 'Paramètres', glyph: 'parametres', admin: true },
]},
];
const NAV_SUPER = [
{ id: 'administration', label: 'Administration', glyph: 'admin', badge: 'SA' },
{ id: 'photolocdev', label: 'PhotoLoc DEV', glyph: 'photoloc', badge: 'SA' },
];
// Compat : listes plates (anciens consommateurs)
const NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items.filter((i) => !i.admin));
const NAV_ADMIN = [{ id: 'parametres', label: 'Paramètres', glyph: 'parametres' }];
// Libelle d'une route (topbar mobile, palette, titres)
const NAV_LABEL = {};
NAV_GROUPS.forEach((g) => g.items.forEach((i) => { NAV_LABEL[i.id] = i.label; }));
NAV_SUPER.forEach((i) => { NAV_LABEL[i.id] = i.label; });
// Options incluses selon le pack (cf. modules/packs.py). Une option dont la
// feature n'est pas dans le pack de l'utilisateur s'affiche verrouillee
// (cadenas) et renvoie vers l'ecran d'upgrade au clic. Doit rester aligne
// avec ROUTE_FEATURE (sentinel-app.jsx) et la table PACKS (packs.py).
const NAV_FEATURE = {
carte: 'carte_interactive',
photoloc: 'geoloc_photo',
estimation: 'donnees_dvf',
legal: 'module_legal',
};
const NAV_FEATURE_MIN_PACK = {
carte_interactive: 'Pro',
donnees_dvf: 'Pro',
module_legal: 'Pro',
geoloc_photo: 'Expert',
};
/* ============================================================ */
/* SIDEBAR */
/* ============================================================ */
const _initiales = (nom, email) => {
const src = (nom && nom !== 'Utilisateur' ? nom : (email || '')).trim();
const parts = src.split(/[\s@._-]+/).filter(Boolean);
const ini = parts.length >= 2 ? parts[0][0] + parts[1][0] : src.slice(0, 2);
return (ini || '??').toUpperCase();
};
/* Menu utilisateur (pied de sidebar) : compte, theme, densite, menu,
aide / mentions, deconnexion. Se ferme au clic exterieur et sur Echap. */
const UserMenu = ({ open, onClose, anchorRef }) => {
const { goto, theme, toggleTheme, themePref, setThemePref, compact, toggleCompact, navCollapsed, toggleNav, user } = useApp();
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onDoc = (e) => {
if (ref.current && !ref.current.contains(e.target) && !(anchorRef.current && anchorRef.current.contains(e.target))) onClose();
};
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
}, [open, onClose, anchorRef]);
if (!open) return null;
const handleLogout = () => {
if (!confirm('Se déconnecter ?')) return;
try { localStorage.removeItem('sentinel.token'); } catch (_) {}
window.location.href = '/login.html';
};
// Theme : 3 choix si l'app expose themePref (chantier 08), sinon 2.
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
{choices.map(([v, l]) => (
))}
Aide
Mentions & CGU
{user.email}
);
};
const Sidebar = () => {
const { route, goto, user, navCollapsed, toggleNav } = useApp();
const [menuOpen, setMenuOpen] = useState(false);
const userBtnRef = useRef(null);
// Sections conditionnelles selon le role reel de l'utilisateur
const showAdminItems = !!user.is_cabinet_admin || !!user.is_groupe_admin || !!user.is_superadmin;
const showSuperGroup = !!user.is_superadmin;
const renderItem = (item) => {
const Glyph = I[item.glyph];
const featKey = NAV_FEATURE[item.id];
const locked = !!featKey && !(user.features && user.features[featKey]);
if (locked) {
const needPack = NAV_FEATURE_MIN_PACK[featKey] || 'superieur';
return (
goto(item.id)}>
{item.label}
);
}
return (
goto(item.id)}>
{item.label}
{item.badge ? {item.badge} : null}
);
};
const groups = NAV_GROUPS.map((g) => ({
label: g.label,
items: g.items.filter((i) => !i.admin || showAdminItems),
})).filter((g) => g.items.length > 0);
if (showSuperGroup) groups.push({ label: 'Super admin', items: NAV_SUPER });
const sub = [user.pack, 'API active'].filter(Boolean).join(' · ');
return (
);
};
/* ============================================================ */
/* PAGE HEADER */
/* ============================================================ */
const PageHeader = ({ crumb, title, sub, actions, className = '' }) => (
{crumb &&
{crumb}
}
{title}
{sub &&
{sub}
}
{actions && {actions}
}
);
/* ============================================================ */
/* CARD / EMPTY / METRICS */
/* ============================================================ */
const Card = ({ title, meta, actions, children, className = '', flush, padded }) => (
{(title || actions) && (
{title && {title}}
{meta && {meta}}
{actions &&
{actions}
}
)}
{children}
);
/* Chantier 06 : etat vide = bordure pointillee, glyphe 36 px, titre Inter
15 px, texte 12.5 px (max 300 px), UN seul CTA primaire. `compact` pour
les zones internes (modale, card de rapport). */
const EmptyState = ({ icon = 'file', title, desc, action, compact }) => {
const Glyph = I[icon] || I.file;
return (
{title}
{desc &&
{desc}}
{action &&
{action}
}
);
};
/* Chantier 06 : squelettes (shimmer 1,4 s). kind = rows | kpi | lines */
const Skel = ({ kind = 'rows', n = 5, pad = true }) => {
const items = Array.from({ length: n });
if (kind === 'kpi') {
return (
{items.map((_, k) => )}
);
}
if (kind === 'lines') {
return (
{items.map((_, k) => )}
);
}
return (
{items.map((_, k) => (
))}
);
};
const MetricRow = ({ label, value, unit }) => (
{label}
{value}{unit && {unit}}
);
/* ============================================================ */
/* KPI CARD */
/* ============================================================ */
const Kpi = ({ label, value, unit, delta, deltaDir, meta, spark }) => (
{label}
{value}{unit && {unit}}
{delta && (
{deltaDir === 'up' ? '↑' : deltaDir === 'down' ? '↓' : '—'} {delta}
)}
{meta && {meta}}
{spark &&
}
);
const Spark = ({ data, color }) => {
const w = 200, h = 28, n = data.length;
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const pts = data.map((v, i) => {
const x = (i / (n - 1)) * w;
const y = h - ((v - min) / range) * h;
return `${x},${y.toFixed(1)}`;
}).join(' L ');
const fillPath = `M 0,${h} L ${pts} L ${w},${h} Z`;
const linePath = `M ${pts}`;
return (
);
};
/* ============================================================ */
/* PILL / STATUS */
/* ============================================================ */
const Pill = ({ tone = 'neutral', pulse, children, dot }) => (
{dot && }
{children}
);
const Status = ({ state, label }) => (
{label || state}
);
/* ============================================================ */
/* TOAST SYSTEM */
/* ============================================================ */
const Toasts = ({ items, onClose }) => (
{items.map(t => (
{t.msg}
))}
);
/* ============================================================ */
/* COMMAND PALETTE */
/* ============================================================ */
const CommandPalette = ({ open, onClose, items, onSelect }) => {
const [q, setQ] = useState('');
const [idx, setIdx] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
if (open) {
setQ(''); setIdx(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
const filtered = useMemo(() => {
if (!q) return items;
const lower = q.toLowerCase();
return items.filter(it =>
it.label.toLowerCase().includes(lower) ||
(it.meta || '').toLowerCase().includes(lower)
);
}, [q, items]);
useEffect(() => { setIdx(0); }, [q]);
const onKey = (e) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(i + 1, filtered.length - 1)); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(i - 1, 0)); return; }
if (e.key === 'Enter') { e.preventDefault(); if (filtered[idx]) { onSelect(filtered[idx]); } return; }
};
if (!open) return null;
return (
e.stopPropagation()}>
setQ(e.target.value)} onKeyDown={onKey} />
{filtered.length === 0 ? (
Aucun résultat
) : filtered.map((it, i) => {
const Glyph = I[it.glyph] || I.file;
return (
setIdx(i)} onClick={() => onSelect(it)}>
{it.label}
{it.meta && {it.meta}
}
{it.kind || '—'}
);
})}
↑↓ naviguer
⏎ ouvrir esc fermer
);
};
/* ============================================================ */
/* MODAL */
/* ============================================================ */
const Modal = ({ open, onClose, title, children, footer, wide }) => {
useEffect(() => {
if (!open) return;
const k = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()} style={wide ? {maxWidth:920} : {}}>
{title}
{children}
{footer &&
{footer}
}
);
};
/* ============================================================ */
/* TABS */
/* ============================================================ */
/* Chantier 02 : deux voix distinctes.
- segment (defaut) : boite 32 px, Inter 12.5 px, pour un choix de vue
(Liste / Kanban, Dark / Blanc...).
- ghost : onglets SOULIGNES (appartiennent au contenu), Inter 13 px, avec
compteur mono 10 px optionnel (`count`). */
const Tabs = ({ value, onChange, options, ghost, className = '' }) => (
ghost ? (
{options.map(o => (
))}
) : (
{options.map(o => (
))}
)
);
/* ============================================================ */
/* MOBILE (chantier 07) — media hook, tab bar, feuilles, FAB */
/* ============================================================ */
const useMedia = (query) => {
const get = () => (window.matchMedia ? window.matchMedia(query).matches : false);
const [m, setM] = useState(get);
useEffect(() => {
if (!window.matchMedia) return;
const mq = window.matchMedia(query);
const on = () => setM(mq.matches);
if (mq.addEventListener) mq.addEventListener('change', on); else mq.addListener(on);
return () => { if (mq.removeEventListener) mq.removeEventListener('change', on); else mq.removeListener(on); };
}, [query]);
return m;
};
const TABBAR_ITEMS = [
{ id: 'dashboard', label: "Aujourd'hui", glyph: 'dashboard' },
{ id: 'clients', label: 'Clients', glyph: 'clients' },
{ id: 'annonces', label: 'Annonces', glyph: 'annonces' },
{ id: 'carte', label: 'Carte', glyph: 'carte' },
];
/* Barre d'onglets fixe 64 px : Aujourd'hui · Clients · Annonces · Carte · Plus */
const TabBar = ({ moreOpen, onMore }) => {
const { route, goto } = useApp();
const inMore = !TABBAR_ITEMS.some((i) => i.id === route);
return (
);
};
/* Feuille « Plus » : le reste des pages + ligne utilisateur (theme, aide, deconnexion) */
const MobileMore = ({ open, onClose }) => {
const { route, goto, user, themePref, setThemePref, theme, toggleTheme } = useApp();
if (!open) return null;
const isAdmin = !!user.is_cabinet_admin || !!user.is_groupe_admin || !!user.is_superadmin;
const items = NAV_GROUPS.flatMap((g) => g.items)
.filter((i) => !TABBAR_ITEMS.some((t) => t.id === i.id))
.filter((i) => !i.admin || isAdmin);
const superItems = user.is_superadmin ? NAV_SUPER : [];
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(); };
const logout = () => {
if (!confirm('Se déconnecter ?')) return;
try { localStorage.removeItem('sentinel.token'); } catch (_) {}
window.location.href = '/login.html';
};
return (
e.stopPropagation()}>
{[...items, ...superItems].map((it) => {
const G = I[it.glyph];
const featKey = NAV_FEATURE[it.id];
const locked = !!featKey && !(user.features && user.features[featKey]);
return (
);
})}
{_initiales(user.nom, user.email)}
{user.nom}
{[user.pack, 'API active'].filter(Boolean).join(' · ')}
{choices.map(([v, l]) => )}
);
};
/* Feuille de detail depuis le bas (au-dessus de la tab bar) */
const MobileSheet = ({ open, onClose, title, aside, rows, actions, children }) => {
useEffect(() => {
if (!open) return;
const k = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()}>
{(title || aside) && (
{title}{aside}
)}
{rows && rows.length > 0 && (
{rows.map((r) => (
{r.k}
{r.v}
))}
)}
{children}
{actions &&
{actions}
}
);
};
const Fab = ({ onClick, label = 'Nouveau client' }) => (
);
/* ============================================================ */
/* EXPORT TO GLOBAL */
/* ============================================================ */
Object.assign(window, {
React, ReactDOM,
useState, useEffect, useRef, useMemo, useCallback, createContext, useContext,
AppCtx, useApp,
NAV_ITEMS, NAV_ADMIN, NAV_SUPER, NAV_GROUPS, NAV_LABEL,
I, Icon,
Sidebar, UserMenu, PageHeader, Card, EmptyState, Skel, MetricRow,
useMedia, TabBar, MobileMore, MobileSheet, Fab, TABBAR_ITEMS,
Kpi, Spark, Pill, Status, Toasts, CommandPalette, Modal, Tabs,
});