298 lines
12 KiB
React
298 lines
12 KiB
React
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 (
|
|
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
|
|
{/* ── En-tête ── */}
|
|
<div className="solde-chart-header">
|
|
<div className="solde-chart-info">
|
|
<div className="solde-chart-date">{displayDate}</div>
|
|
<div className="solde-chart-value">{displayValue}</div>
|
|
</div>
|
|
<div className="solde-chart-controls">
|
|
<div className="solde-chart-ranges">
|
|
{RANGES.map(r => (
|
|
<button key={r}
|
|
className={`solde-range-btn${range === r ? ' active' : ''}`}
|
|
onClick={() => { setRange(r); setHover(null); }}>
|
|
{r}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── SVG ── */}
|
|
{xScale && (
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={() => setHover(null)}
|
|
>
|
|
<defs>
|
|
<linearGradient id="sg-fill" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={GOLD} stopOpacity="0.22" />
|
|
<stop offset="70%" stopColor={GOLD} stopOpacity="0.06" />
|
|
<stop offset="100%" stopColor={GOLD} stopOpacity="0" />
|
|
</linearGradient>
|
|
<filter id="sg-glow">
|
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* Grille horizontale */}
|
|
{yTicks.map(({ v, y }) => (
|
|
<g key={v}>
|
|
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
|
|
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
|
|
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
|
|
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
|
|
{fmtK(v)}
|
|
</text>
|
|
</g>
|
|
))}
|
|
|
|
{/* Ligne zéro */}
|
|
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
|
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
|
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
|
)}
|
|
|
|
{/* Remplissage dégradé */}
|
|
<path d={areaPath} fill="url(#sg-fill)" />
|
|
|
|
{/* Ligne principale */}
|
|
<path d={linePath} fill="none" stroke={GOLD} strokeWidth="1"
|
|
filter="url(#sg-glow)" strokeLinejoin="round" />
|
|
|
|
{/* Labels axe X */}
|
|
{xTicks.map((p, i) => {
|
|
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
|
|
return (
|
|
<text key={i} x={xScale(p.date)} y={H - 8}
|
|
textAnchor={anchor} fill={LABEL} fontSize="10"
|
|
fontFamily="system-ui,sans-serif">
|
|
{fmtAxisDate(p.date, range)}
|
|
</text>
|
|
);
|
|
})}
|
|
|
|
{/* Ligne verticale + point hover */}
|
|
{hover && (
|
|
<g>
|
|
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
|
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
|
<circle cx={hover.x} cy={hover.y} r="4.5"
|
|
fill={GOLD} stroke={BG} strokeWidth="2" />
|
|
</g>
|
|
)}
|
|
</svg>
|
|
)}
|
|
|
|
{/* ── Tooltip flottant ── */}
|
|
{hover && tooltipStyle && (
|
|
<div style={tooltipStyle}>
|
|
<div className="sg-tooltip">
|
|
<span className="sg-tooltip-date">{tooltipDate}</span>
|
|
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|