/* ======================================================================== SENTINEL — Power features 19 upgrades : notifications inbox, cheatsheet, tour, skeletons, page transitions, focus mode, konami egg, sound toggle, live activity, AI suggestions, heatmap, saved searches, bulk actions, inline edit, client detail, annonce detail full, kanban drag & drop, comparison ======================================================================== */ const { useState: _useS, useEffect: _useE, useRef: _useR, useMemo: _useM, useCallback: _useC } = React; const _W = window; /* ============================================================ */ /* NOTIFICATIONS BELL — inbox dropdown */ /* ============================================================ */ const NOTIF_SEEN_KEY = 'sentinel.notifLastSeen'; const NotifBell = () => { const [open, setOpen] = _useS(false); const [items, setItems] = _useS([]); const [lastSeen, setLastSeen] = _useS(() => { try { return localStorage.getItem(NOTIF_SEEN_KEY) || ''; } catch (e) { return ''; } }); const ref = _useR(null); _useE(() => { let alive = true; const api = _W.SentinelAPI; if (!api) return; api.fetchAuth('/api/dashboard/notifications') .then((r) => { if (alive) setItems((r && r.data) || []); }) .catch(() => {}); return () => { alive = false; }; }, []); _useE(() => { const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, []); const isUnread = (n) => !!n.date && (!lastSeen || n.date > lastSeen); const unread = items.filter(isUnread).length; const markAllRead = () => { const now = new Date().toISOString(); try { localStorage.setItem(NOTIF_SEEN_KEY, now); } catch (e) {} setLastSeen(now); }; return (
{open && (
Notifications
{items.length === 0 ? (
Aucune notification récente.
) : items.map(n => (
{n.title}
{n.meta}
{n.time}
))}
)}
); }; /* ============================================================ */ /* COMMAND CHEATSHEET — ? key */ /* ============================================================ */ const Cheatsheet = ({ open, onClose }) => { _useE(() => { 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; const groups = [ { title:'Navigation', items:[ ['G puis D', 'Tableau de bord'], ['G puis C', 'Clients'], ['G puis A', 'Annonces'], ['G puis M', 'Carte'], ['G puis L', 'Juridique'], ['G puis R', 'Rapports'], ]}, { title:'Actions', items:[ ['⌘ K', 'Recherche / palette'], ['N', 'Nouveau client'], ['/', 'Filtrer la table'], ['E', 'Éditer la sélection'], ['⌫', 'Supprimer la sélection'], ]}, { title:'Affichage', items:[ ['?', 'Afficher cette aide'], ['[ ]', 'Replier / déplier le menu'], ['F', 'Mode focus (masquer la nav)'], ['⌘ /', 'Basculer mode clair / sombre'], ['⌘ Shift S', 'Console debug'], ]}, ]; return (
e.stopPropagation()} style={{maxWidth:720}}>
Raccourcis clavier
{groups.map(g => (
{g.title}
{g.items.map(([k, v]) => (
{v} {k.split(' ').map((part, i) => {part})}
))}
))}
); }; /* ============================================================ */ /* TOUR — first-time onboarding */ /* ============================================================ */ const TOUR_STEPS = [ { sel:'.nav__brand', title:'Bienvenue dans Sentinel', desc:'Votre navigation est rangée en quatre groupes : Pilotage, Activité, Intelligence, Cabinet.' }, { sel:'.top__search', title:'⌘K — recherche universelle', desc:'Tapez ⌘K (ou Ctrl+K) à tout moment pour ouvrir la palette de commandes. Pages, clients, annonces, actions — tout est trouvable.' }, { sel:'.nav__item.is-active', title:'Le Tableau de bord est votre QG', desc:'Matches du jour, alertes clients, KPIs et activité récente. Tout ce qui compte ce matin est ici.' }, { sel:'.notif', title:'Notifications en temps réel', desc:'Paul vous prévient sur Telegram pour les matches qualifiés, mais une copie reste toujours dans cette cloche.' }, ]; const Tour = ({ open, onClose }) => { const [i, setI] = _useS(0); const [box, setBox] = _useS(null); _useE(() => { if (!open) return; const step = TOUR_STEPS[i]; if (!step) return; const el = document.querySelector(step.sel); if (!el) { setBox(null); return; } const r = el.getBoundingClientRect(); setBox({ top: r.top - 8, left: r.left - 8, width: r.width + 16, height: r.height + 16 }); }, [open, i]); if (!open) return null; const step = TOUR_STEPS[i]; const last = i === TOUR_STEPS.length - 1; const tipStyle = box ? { top: box.top + box.height + 16, left: Math.min(box.left, window.innerWidth - 360) } : { top:'50%', left:'50%', transform:'translate(-50%, -50%)' }; return ( <>
{box && (
)}
{i + 1} / {TOUR_STEPS.length}
{step.title}
{step.desc}
{i > 0 && }
); }; /* ============================================================ */ /* LIVE ACTIVITY TICKER */ /* ============================================================ */ const LIVE_LINES = [ '🟢 Paul vient de scanner SeLoger Lille 3e · 142 annonces', '🟡 Géocodage en cours · 8 annonces sans coordonnées', '🟢 Match 100 % détecté · Marie Dupont · T4 Vieux-Lille', '🔵 Sophie M. (Paris-Marais) a ouvert son dashboard', '🟢 Bot Telegram Paul · 3 SMS envoyés en 60 s', '🟡 DVF synchronisation · 1 247 transactions importées', '🟢 Nouvelle annonce indexée · Tourcoing · 580 k€', ]; const LiveTicker = () => (
LIVE
{LIVE_LINES.concat(LIVE_LINES).map((line, i) => ( {line} ))}
); /* ============================================================ */ /* AI SUGGESTIONS — proactive card on dashboard */ /* ============================================================ */ const AI_ICONS = { relance: '💡', match: '📈', diag: '⏰' }; const AISuggestions = ({ onAction }) => { const [items, setItems] = _useS(null); // null = chargement _useE(() => { let alive = true; const api = _W.SentinelAPI; if (!api) { setItems([]); return; } api.fetchAuth('/api/dashboard/suggestions') .then((r) => { if (alive) setItems((r && r.data) || []); }) .catch(() => { if (alive) setItems([]); }); return () => { alive = false; }; }, []); const list = Array.isArray(items) ? items : []; const countLabel = items === null ? 'analyse…' : `${list.length} proposition${list.length > 1 ? 's' : ''}`; return (
Suggestions Paul IA · {countLabel}
{/* Le backend plafonne a 2 suggestions par type : « Tout voir » ouvre la page Clients, ou vivent relances et matches. */}
{/* etats vides : display block, sans quoi la grille .ai-row (36px 1fr auto) ecrase le texte dans la colonne de 36px */} {items === null ? (
Analyse en cours…
) : list.length === 0 ? (
Aucune suggestion pour le moment.
) : list.map(s => (
{AI_ICONS[s.kind] || '•'} {s.title}
))}
); }; /* ============================================================ */ /* HEATMAP — activity calendar 8 weeks */ /* ============================================================ */ const Heatmap = () => { const weeks = 8; const days = 7; const [cells, setCells] = _useS(null); // null = chargement _useE(() => { let alive = true; const api = _W.SentinelAPI; if (!api) { setCells([]); return; } api.fetchAuth(`/api/dashboard/match-density?weeks=${weeks}`) .then((r) => { if (alive) setCells((r && r.data && r.data.cells) || []); }) .catch(() => { if (alive) setCells([]); }); return () => { alive = false; }; }, []); const { data, dates } = _useM(() => { const d = Array.from({length: weeks * days}, () => 0); const dt = Array.from({length: weeks * days}, () => null); if (Array.isArray(cells)) { for (let i = 0; i < cells.length && i < d.length; i++) { d[i] = cells[i].count || 0; dt[i] = cells[i].date || null; } } return { data: d, dates: dt }; }, [cells]); const intensity = (v) => { if (v === 0) return 'var(--color-surface-3)'; if (v <= 2) return 'color-mix(in oklch, var(--accent) 25%, var(--color-surface-3))'; if (v <= 4) return 'color-mix(in oklch, var(--accent) 55%, var(--color-surface-3))'; return 'var(--accent)'; }; return (
Activité · 8 dernières semaines {data.reduce((s,v) => s + v, 0)} matches
{Array.from({length: weeks}, (_, w) => (
{Array.from({length: days}, (_, d) => ( ))}
))}
Moins {[0, 2, 4, 6].map(v => ( ))} Plus
); }; /* ============================================================ */ /* SKELETON */ /* ============================================================ */ const Skeleton = ({ w = '100%', h = 14, r = 3, mb }) => ( ); /* ============================================================ */ /* KONAMI + FOCUS MODE */ /* ============================================================ */ const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']; const useKonami = (onUnlock) => { _useE(() => { let idx = 0; const onKey = (e) => { if (e.key === KONAMI[idx]) { idx++; if (idx === KONAMI.length) { onUnlock(); idx = 0; } } else idx = 0; }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onUnlock]); }; const KonamiEgg = ({ active }) => { if (!active) return null; return ; }; /* ============================================================ */ /* SOUND TOGGLE */ /* ============================================================ */ const useSounds = (enabled) => { const ctxRef = _useR(null); const play = _useC((freq, dur = 0.06, vol = 0.06) => { if (!enabled) return; try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!ctxRef.current) ctxRef.current = new Ctx(); const ctx = ctxRef.current; const o = ctx.createOscillator(); const g = ctx.createGain(); o.frequency.value = freq; o.type = 'sine'; g.gain.value = vol; o.connect(g); g.connect(ctx.destination); o.start(); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur); o.stop(ctx.currentTime + dur); } catch (_) {} }, [enabled]); return play; }; /* ============================================================ */ /* CLIENT DETAIL — full page */ /* ============================================================ */ const ClientDetailView = ({ client, onBack }) => { const PageHeader = _W.PageHeader; const Card = _W.Card; const Pill = _W.Pill; const I = _W.I; const score = 92; return (
← Clients} title={client.name} sub={`${client.critères} · ${client.budget} · ${client.villes}`} actions={<> Actif · 47 min } />
Ajouter une note} flush>
  1. Match 100 % détectéil y a 47 min
    T4 Vieux-Lille · 420 k€ · 92 m² · balcon
  2. Email reçuhier · 18:42
    « Toujours intéressée si vous trouvez un T4 sous 200 k€ »
  3. Budget mis à jour13/05 · 09:14
    140 000 € → 200 000 € (par l'agent)
  4. Visite réalisée06/05 · 11:30
    Studio Wazemmes · pas concluant
  5. Inscription Sentinel05/05/2026
    Premier contact via le formulaire site
{score} / 100 · chaud
  • 3 matches précédents+18
  • 1 visite réalisée+22
  • Budget récemment relevé+12
  • Email cliqué < 24 h+8
  • 15 j sans contact−14
<_W.MetricRow label="Type" value="Appartement" /> <_W.MetricRow label="Pièces" value="4" unit=" +" /> <_W.MetricRow label="Budget" value="140 000 → 200 000" unit=" €" /> <_W.MetricRow label="Villes" value="Ronchin · Lille" /> <_W.MetricRow label="Options" value="Balcon · Ascenseur" />
ScoreAnnoncePrixStatutReçu
<_W.ScoreBar score={100} /> T4 Vieux-Lille · 92 m² 420 000 € Envoyé · ouvert à l'instant
<_W.ScoreBar score={84} /> T4 Ronchin · 88 m² 189 000 € Envoyé · sans réponse il y a 3 j
<_W.ScoreBar score={71} /> T3 Lille Centre · 65 m² 175 000 € Pas envoyé · sous seuil il y a 5 j
); }; /* ============================================================ */ /* EXPORTS */ /* ============================================================ */ Object.assign(window, { NotifBell, Cheatsheet, Tour, LiveTicker, AISuggestions, Heatmap, Skeleton, useKonami, KonamiEgg, useSounds, ClientDetailView, });