import { useMemo, useState, useRef, useEffect } from 'react'; /* ── Constantes ─────────────────────────────────────────────── */ const GOLD = '#4fa8e8'; // bleu ciel — accord avec le thème navy du site const BG = '#070c15'; const GRID = 'rgba(255,255,255,0.055)'; const LABEL = '#4a5568'; const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; /* ── Helpers ────────────────────────────────────────────────── */ function fmtK(v) { if (v === 0) return '0 €'; const abs = Math.abs(v); if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €'; return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €'; } function fmtAxisDate(dateStr, range) { const d = new Date(dateStr + 'T00:00:00'); const day = String(d.getDate()).padStart(2, '0'); const mon = MOIS_COURT[d.getMonth()]; const yr = d.getFullYear(); if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`; if (range === '1M') return `${day} ${mon}`; if (range === '3M') return `${day} ${mon}`; return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`; } function fmtValueDisplay(v) { return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €'; } function fmtTodayFull() { return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }); } /* ── Composant ──────────────────────────────────────────────── */ export default function SoldeChart({ rows }) { const [range, setRange] = useState('TOUT'); const [hover, setHover] = useState(null); // { x, y, value, date } const svgRef = useRef(null); const wrapRef = useRef(null); /* ── 1. Cumul complet (toutes les données) ── */ const allPoints = useMemo(() => { if (!rows?.length) return []; const byDate = {}; for (const r of rows) { const d = r.date_operation.slice(0, 10); byDate[d] = (byDate[d] || 0) + (r.type === 'depot' ? r.montant : -r.montant); } let cum = 0; return Object.keys(byDate).sort().map(date => { cum += byDate[date]; return { date, value: cum }; }); }, [rows]); const todayStr = new Date().toISOString().slice(0, 10); const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0; /* ── 2. Filtrage par plage ── */ const points = useMemo(() => { if (!allPoints.length) return []; if (range === 'TOUT') { const pts = [...allPoints]; if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); return pts; } const now = new Date(); let fromDate = new Date(now); switch (range) { case '1J': fromDate.setDate(now.getDate() - 1); break; case '7J': fromDate.setDate(now.getDate() - 7); break; case '1M': fromDate.setMonth(now.getMonth() - 1); break; case '3M': fromDate.setMonth(now.getMonth() - 3); break; case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break; case '1A': fromDate.setFullYear(now.getFullYear() - 1); break; } const fromStr = fromDate.toISOString().slice(0, 10); const before = allPoints.filter(p => p.date < fromStr); const startV = before.length ? before[before.length - 1].value : 0; const after = allPoints.filter(p => p.date >= fromStr); const pts = [{ date: fromStr, value: startV }, ...after]; if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); return pts; }, [allPoints, range, todayStr]); /* ── 3. SVG dimensions ── */ const W = 900, H = 260; const PAD = { top: 16, right: 16, bottom: 32, left: 70 }; const plotW = W - PAD.left - PAD.right; const plotH = H - PAD.top - PAD.bottom; /* ── 4. Échelles ── */ const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => { if (points.length < 2) return {}; const vals = points.map(p => p.value); const dataMin = Math.min(...vals); const dataMax = Math.max(...vals); // Inclure 0 pour ancrer l'axe ; ajouter 10 % de padding const lo = Math.min(0, dataMin); const hi = Math.max(0, dataMax); const pad = (hi - lo) * 0.1 || 10; const scaleLo = lo - (lo < 0 ? pad : 0); const scaleHi = hi + (hi > 0 ? pad : pad); const valRange = scaleHi - scaleLo || 1; const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime()); const minDt = ts[0]; const maxDt = ts[ts.length - 1]; const dtRange = maxDt - minDt || 1; const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW; const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH; // Y ticks : 5 niveaux couvrant la plage réelle const step = (scaleHi - scaleLo) / 4; const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) })); // X ticks : max 8 const nX = Math.min(8, points.length); const xTicks = Array.from({ length: nX }, (_, i) => { const idx = Math.round((i / (nX - 1)) * (points.length - 1)); return points[idx]; }); return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) }; }, [points, plotW, plotH, PAD]); /* ── 5. Chemins SVG ── */ const { linePath, areaPath } = useMemo(() => { if (!xScale || points.length < 2) return { linePath: '', areaPath: '' }; let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`; for (let i = 1; i < points.length; i++) { line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`; } // Refermer l'aire sur la ligne zéro (et non le bas du chart) const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1); const area = `${line} V ${zeroY} H ${PAD.left} Z`; return { linePath: line, areaPath: area }; }, [points, xScale, yScale, yZero, PAD, plotH]); /* ── 6. Hover ── */ const handleMouseMove = (e) => { if (!svgRef.current || !xScale || points.length < 2) return; const rect = svgRef.current.getBoundingClientRect(); const svgX = ((e.clientX - rect.left) / rect.width) * W; const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate); // Find nearest point let nearest = points[0], minDiff = Infinity; for (const p of points) { const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t); if (diff < minDiff) { minDiff = diff; nearest = p; } } setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date, }); }; /* ── Tooltip flottant : DOIT être avant tout return conditionnel (Rules of Hooks) ── */ const tooltipStyle = useMemo(() => { if (!hover) return null; const xPct = (hover.x / W) * 100; const yPct = (hover.y / H) * 100; const anchorRight = xPct > 65; return { position: 'absolute', top: `calc(${yPct}% - 64px)`, left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`, right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto', transform: 'none', pointerEvents: 'none', }; }, [hover]); if (!allPoints.length) return null; /* ── Date affichée dans l'en-tête (hover ou aujourd'hui) ── */ const displayDate = hover ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }) : fmtTodayFull(); const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue); const tooltipDate = hover ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) : null; return (
{/* ── En-tête ── */}
{displayDate}
{displayValue}
{RANGES.map(r => ( ))}
{/* ── SVG ── */} {xScale && ( setHover(null)} > {/* Grille horizontale */} {yTicks.map(({ v, y }) => ( {fmtK(v)} ))} {/* Ligne zéro */} {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( )} {/* Remplissage dégradé */} {/* Ligne principale */} {/* Labels axe X */} {xTicks.map((p, i) => { const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; return ( {fmtAxisDate(p.date, range)} ); })} {/* Ligne verticale + point hover */} {hover && ( )} )} {/* ── Tooltip flottant ── */} {hover && tooltipStyle && (
{tooltipDate} {fmtValueDisplay(hover.value)}
)}
); }