/* ========================================================================
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 */}
Prix
{a.prix?.toLocaleString('fr-FR')} €
{Math.round(a.prix/a.surf).toLocaleString('fr-FR')} €/m²
!
45,7689° N · 4,8900° E · Approx
| Bien | Prix | €/m² | Écart |
{[
{ 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) => (
| {s.t} |
{s.p.toLocaleString('fr-FR')} € |
{s.em.toLocaleString('fr-FR')} |
0 ? 'success' : 'danger'}>{s.d > 0 ? '+' : ''}{s.d} % |
))}
{/* RIGHT */}
| Score | Client | |
|
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 (
{hover != null && (
{labels[hover]}
{data[hover]} annonces
)}
);
};
Object.assign(window, {
AnnonceDetailFull, KanbanDnD, BulkBar, InlineEdit,
CompareModal, SavedSearches, ChartAreaInteractive,
});