/* ======================================================================== 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 }) => ( {Array.isArray(d) ? d.map((p, i) => ) : (d ? : null)} {children} ); 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 ( ); } if (kind === 'lines') { return ( ); } return ( ); }; 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]) => )}
Aide Mentions & CGU
); }; /* 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, });