/* ======================================================================== SENTINEL — Power features part 2 Remaining 7 upgrades: Annonce full-page, Kanban DnD, bulk actions, inline edit, compare 2 annonces, saved searches chips, chart tooltips ======================================================================== */ const { useState: _S2, useEffect: _E2, useRef: _R2, useMemo: _M2, useCallback: _C2 } = React; const _W2 = window; /* ============================================================ */ /* ANNONCE DETAIL — FULL PAGE */ /* ============================================================ */ const AnnonceDetailFull = ({ annonce, onBack }) => { const { toast } = _W2.useApp(); const { PageHeader, Card, Pill, I, MetricRow, ScoreBar } = _W2; const a = annonce; return (
← Annonces} title={a.title} sub={`${a.ville} · publié le 13/05/2026 · source ${a.source}`} actions={<> Score max {a.score || 0} % } />
{/* LEFT — photo + map + similars */}
{Array.from({length:6}).map((_,i) => )} {Array.from({length:6}).map((_,i) => )} {Array.from({length:6}).map((_,i) => )} PHOTO 1 / 12 · CRÉDIT SELOGER
Prix
{a.prix?.toLocaleString('fr-FR')} €
{Math.round(a.prix/a.surf).toLocaleString('fr-FR')} €/m²
Surface
{a.surf} m²
Pièces
{a.pieces}
Type
{a.type}
!
45,7689° N · 4,8900° E · Approx
{[ { t:'T4 Vieux-Lille · 88 m²', p:410000, em:4659, d:-2 }, { t:'T4 République · 92 m²', p:445000, em:4837, d:+2 }, { t:'T4 Wazemmes · 95 m²', p:399000, em:4200, d:-8 }, { t:'T4 Lille Centre · 90 m²',p:435000, em:4833, d:+2 }, ].map((s,i) => ( ))}
BienPrix€/m²Écart
{s.t} {s.p.toLocaleString('fr-FR')} € {s.em.toLocaleString('fr-FR')} 0 ? 'success' : 'danger'}>{s.d > 0 ? '+' : ''}{s.d} %
{/* RIGHT */}
ScoreClient
Marie Dupont
4P · ≤ 200 k€
); }; /* ============================================================ */ /* KANBAN DRAG & DROP */ /* ============================================================ */ const KanbanDnD = ({ clients: initial, onMove }) => { const { Pill } = _W2; const { toast } = _W2.useApp(); const [clients, setClients] = _S2(initial); const [draggingId, setDraggingId] = _S2(null); const [hoverCol, setHoverCol] = _S2(null); // Le parent recharge/filtre la liste : on se resynchronise sur la prop. _E2(() => { setClients(initial); }, [initial]); const cols = [ { id:'prospect', label:'Prospect' }, { id:'actif', label:'Actif' }, { id:'chaud', label:'Chaud' }, { id:'signe', label:'Signé' }, { id:'perdu', label:'Perdu' }, ]; /* ── Glisser-deposer en Pointer Events : souris ET tactile ────────── (l'API HTML5 drag&drop ne fonctionne pas au doigt) - Souris : le glisser demarre apres 6px de mouvement. - Tactile : APPUI LONG (~220ms sans bouger) souleve la carte ; un mouvement avant ca reste un scroll normal de la page. - Un fantome de la carte suit le pointeur, la colonne sous le doigt s'illumine (elementFromPoint), et la page auto-scrolle pres des bords (sur mobile les colonnes sont empilees verticalement). Session mutable dans un ref : rien a re-rendre pendant le geste. */ const sess = _R2(null); const _colAt = (x, y) => { const el = document.elementFromPoint(x, y); const col = el && el.closest ? el.closest('.kanban__col') : null; return col ? col.dataset.state : null; }; const _moveGhost = (g, x, y) => { g.style.left = (x - 30) + 'px'; g.style.top = (y - 24) + 'px'; }; const _blockScroll = (e) => { if (sess.current && sess.current.active) e.preventDefault(); }; const _activate = (s, x, y) => { s.active = true; const r = s.el.getBoundingClientRect(); const g = s.el.cloneNode(true); g.className = 'kanban-card kanban-ghost'; g.style.width = r.width + 'px'; document.body.appendChild(g); _moveGhost(g, x, y); s.ghost = g; setDraggingId(s.id); setHoverCol(_colAt(x, y)); // Bloque le scroll natif pendant le drag (listener non-passif requis) document.addEventListener('touchmove', _blockScroll, { passive: false }); // Auto-scroll du conteneur quand le pointeur approche des bords const main = document.querySelector('.app__main'); const step = () => { const cur = sess.current; if (!cur || !cur.active) return; if (main && cur.last) { const rc = main.getBoundingClientRect(); if (cur.last.y < rc.top + 80) main.scrollTop -= 12; else if (cur.last.y > rc.bottom - 80) main.scrollTop += 12; } cur.raf = requestAnimationFrame(step); }; s.raf = requestAnimationFrame(step); }; const _cleanup = () => { const s = sess.current; if (!s) return; if (s.timer) clearTimeout(s.timer); if (s.raf) cancelAnimationFrame(s.raf); if (s.ghost) s.ghost.remove(); document.removeEventListener('touchmove', _blockScroll); sess.current = null; setDraggingId(null); setHoverCol(null); }; // Filet : jamais de fantome orphelin si le composant se demonte en plein geste _E2(() => _cleanup, []); const onCardDown = (e, c) => { if (e.pointerType === 'mouse' && e.button !== 0) return; const s = { id: c.id, el: e.currentTarget, x0: e.clientX, y0: e.clientY, active: false, last: { x: e.clientX, y: e.clientY } }; sess.current = s; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} if (e.pointerType !== 'mouse') { s.timer = setTimeout(() => { if (sess.current === s && !s.active) _activate(s, s.last.x, s.last.y); }, 220); } }; const onCardMove = (e) => { const s = sess.current; if (!s) return; s.last = { x: e.clientX, y: e.clientY }; if (!s.active) { const dist = Math.hypot(e.clientX - s.x0, e.clientY - s.y0); if (e.pointerType === 'mouse') { if (dist > 6) _activate(s, e.clientX, e.clientY); } else if (dist > 12 && s.timer) { // Le doigt bouge avant l'appui long : c'est un scroll, on abandonne. clearTimeout(s.timer); s.timer = null; sess.current = null; } if (!sess.current || !sess.current.active) return; } _moveGhost(s.ghost, e.clientX, e.clientY); setHoverCol(_colAt(e.clientX, e.clientY)); }; const onCardUp = (e) => { const s = sess.current; if (!s) return; if (s.active) { const col = _colAt(e.clientX, e.clientY); const c = clients.find(x => x.id === s.id); if (col && c && c.state !== col) { setClients(clients.map(x => x.id === s.id ? { ...x, state: col } : x)); if (onMove) onMove(s.id, col); // persistance (PATCH) geree par le parent else toast(`${c.name} déplacé vers ${col}.`, 'success'); } } _cleanup(); }; const onCardCancel = () => _cleanup(); return (
{cols.map(col => { const items = clients.filter(c => c.state === col.id); return (
{col.label} {items.length}
{items.length === 0 ? (
{hoverCol === col.id ? '✓ Déposer ici' : '—'}
) : items.map(c => (
onCardDown(e, c)} onPointerMove={onCardMove} onPointerUp={onCardUp} onPointerCancel={onCardCancel} onContextMenu={(e)=>{ if (draggingId) e.preventDefault(); }} data-dragging={draggingId === c.id || undefined}> {c.name} {c.villes} {c.budget}
{c.critères} 14 ? 'warning' : 'neutral'}>{c.lastContact}j
))}
); })}
); }; /* ============================================================ */ /* BULK ACTIONS BAR (Annonces) */ /* ============================================================ */ const BulkBar = ({ count, onClear, onAction }) => { if (count === 0) return null; return (
{count} sélectionné{count > 1 ? 's' : ''}
); }; /* ============================================================ */ /* INLINE EDITABLE CELL */ /* ============================================================ */ const InlineEdit = ({ value, onSave, format = (v) => v }) => { const [editing, setEditing] = _S2(false); const [val, setVal] = _S2(value); const ref = _R2(null); _E2(() => { if (editing) ref.current?.focus(); }, [editing]); const commit = () => { setEditing(false); if (val !== value) onSave(val); }; if (editing) { return ( setVal(e.target.value)} onBlur={commit} onKeyDown={(e)=>{ if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setVal(value); setEditing(false); } }} /> ); } return ( setEditing(true)} onClick={()=>{ /* tactile : pas de double-clic au doigt */ if (window.matchMedia('(pointer: coarse)').matches) setEditing(true); }} title="Double-clique pour éditer"> {format(val)} ); }; /* ============================================================ */ /* COMPARE 2 ANNONCES — modal split view */ /* ============================================================ */ const CompareModal = ({ open, items, onClose }) => { if (!open || items.length < 2) return null; const [a, b] = items.slice(0, 2); const diff = (av, bv, suffix = '') => { const aa = Number(av) || 0; const bb = Number(bv) || 0; if (aa === 0 || bb === 0) return null; const pct = ((bb - aa) / aa * 100).toFixed(1); return aa ? 'var(--color-success)' : 'var(--color-danger)', fontFamily:'var(--font-mono)', fontSize:10, marginLeft:6}}>{bb > aa ? '+' : ''}{pct} %{suffix}; }; return (
e.stopPropagation()} style={{maxWidth:920}}>
Comparer 2 annonces
A
{a.title}
Prix
{a.prix?.toLocaleString('fr-FR')} €
Surface
{a.surf} m²
€/m²
{Math.round(a.prix/a.surf).toLocaleString('fr-FR')}
Pièces
{a.pieces}
Ville
{a.ville}
Score max
{a.score || '—'} %
B
{b.title}
Prix
{b.prix?.toLocaleString('fr-FR')} €{diff(a.prix, b.prix)}
Surface
{b.surf} m²{diff(a.surf, b.surf)}
€/m²
{Math.round(b.prix/b.surf).toLocaleString('fr-FR')}{diff(a.prix/a.surf, b.prix/b.surf)}
Pièces
{b.pieces}
Ville
{b.ville}
Score max
{b.score || '—'} %
); }; /* ============================================================ */ /* SAVED SEARCHES — chips */ /* ============================================================ */ const SavedSearches = ({ items, active, onSelect, onAdd, onRemove }) => (
Recherches
{items.map(s => ( ))}
); /* ============================================================ */ /* CHART TOOLTIP — replacement ChartArea */ /* ============================================================ */ const ChartAreaInteractive = ({ data, labels }) => { const [hover, setHover] = _S2(null); const w = 600, h = 200; const pad = { t: 12, r: 16, b: 24, l: 32 }; const innerW = w - pad.l - pad.r; const innerH = h - pad.t - pad.b; const max = Math.max(...data, 6); const yTicks = [0, 2, 4, 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 onMove = (e) => { const rect = e.currentTarget.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width * w; const idx = Math.round(((x - pad.l) / innerW) * (data.length - 1)); if (idx >= 0 && idx < data.length) setHover(idx); }; return (
setHover(null)}> {yTicks.map((t, i) => { const y = pad.t + innerH - (t / max) * innerH; return ; })} {yTicks.map((t, i) => { const y = pad.t + innerH - (t / max) * innerH; return {t}; })} {pts.map((p, i) => ( ))} {hover != null && ( )} {labels.map((l, i) => { const x = pad.l + (i / (labels.length - 1)) * innerW; return {l}; })} {hover != null && (
{labels[hover]}
{data[hover]} annonces
)}
); }; Object.assign(window, { AnnonceDetailFull, KanbanDnD, BulkBar, InlineEdit, CompareModal, SavedSearches, ChartAreaInteractive, });