327 lines
14 KiB
React
327 lines
14 KiB
React
import { useState } from "react";
|
|
import { useApp } from "../context/AppContext.jsx";
|
|
import { fmt, pct, fmtDatum } from "../utils/format.js";
|
|
import { GREEN, RED, GOLD } from "../constants/index.js";
|
|
|
|
const TEAL = "#a855f7";
|
|
import { VCard, VBadge } from "../components/ui/index.jsx";
|
|
|
|
export default function DashboardTab() {
|
|
const { ev, totEV, evRows, cats, T, darkMode, currentUser } = useApp();
|
|
|
|
const hour = new Date().getHours();
|
|
const dagdeel = hour < 12 ? "Goedemorgen" : hour < 18 ? "Goedemiddag" : "Goedenavond";
|
|
const rawNaam = currentUser?.naam?.trim().split(/\s+/)[0] || "";
|
|
const voornaam = rawNaam ? rawNaam.charAt(0).toUpperCase() + rawNaam.slice(1) : "";
|
|
|
|
const catSum = (row) =>
|
|
cats.reduce((a, c) => a + (row[c.id] || 0), 0);
|
|
|
|
const startEV = evRows.length > 0 ? (evRows[evRows.length - 1]?.nettoWaarde || 0) : 0;
|
|
const extraFiat = evRows.length > 1
|
|
? evRows.slice(0, -1).reduce((acc, row) => acc + catSum(row), 0) : 0;
|
|
const totaalFiat = startEV + extraFiat;
|
|
const winst = totEV - totaalFiat;
|
|
const roi = totaalFiat > 0 ? winst / totaalFiat : 0;
|
|
const doel = ev.doel || 1;
|
|
const progressPct = Math.min(totEV / doel, 1);
|
|
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
|
|
const kpis = [
|
|
{ label: "Start eigen vermogen", val: fmt(startEV), accent: TEAL},
|
|
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: TEAL},
|
|
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: TEAL},
|
|
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
|
|
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED },
|
|
{ label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
|
|
];
|
|
|
|
// Minigrafiek data
|
|
const vData = [...evRows].sort((a, b) => a.datum.localeCompare(b.datum));
|
|
|
|
// Cumulatief geïnvesteerde fiat, op dezelfde datums als vData. Dezelfde
|
|
// opbouw als de KPI's hierboven: het oudste punt is de start-EV (daar is de
|
|
// inleg per definitie gelijk aan de waarde), elk volgend punt telt de
|
|
// stortingen van die rij erbij op. Het laatste punt is daarmee gelijk aan
|
|
// "Totaal geïnvesteerde fiat".
|
|
const fiatData = vData.reduce((acc, row, i) => {
|
|
acc.push(i === 0 ? (row.nettoWaarde || 0) : acc[i - 1] + catSum(row));
|
|
return acc;
|
|
}, []);
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 24 }}>
|
|
<div style={{ fontSize: 14, color: T.muted, marginBottom: 6 }}>
|
|
Hi{voornaam ? ` ${voornaam},` : ","}
|
|
</div>
|
|
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 32px", color: T.text, letterSpacing: "-0.5px", lineHeight: 1.15 }}>
|
|
{dagdeel} 👋
|
|
</h1>
|
|
<div style={{ fontSize: 32, fontWeight: 800, color: T.text, letterSpacing: "-0.5px", lineHeight: 1.15 }}>
|
|
The Road naar {fmt(doel)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI grid */}
|
|
<div style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(3, 1fr)",
|
|
gap: 12, marginBottom: 20,
|
|
}}>
|
|
{kpis.map(({ label, val, accent }) => (
|
|
<VCard key={label} style={{
|
|
background: `linear-gradient(to right, ${accent}cc 0%, ${accent}44 50%, transparent 80%) bottom / 100% 3px no-repeat, ${T.card}`,
|
|
border: `1px solid ${T.border}`,
|
|
}}>
|
|
<div style={{
|
|
fontSize: 11, color: T.muted, marginBottom: 6,
|
|
textTransform: "uppercase", letterSpacing: "0.05em",
|
|
}}>{label}</div>
|
|
<div style={{ fontSize: 20, fontWeight: 700, color: accent }}>{val}</div>
|
|
</VCard>
|
|
))}
|
|
</div>
|
|
|
|
{/* Doel progressie */}
|
|
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 16 }}>
|
|
<div style={{
|
|
display: "flex", alignItems: "center",
|
|
justifyContent: "space-between", marginBottom: 12,
|
|
}}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<span style={{ fontSize: 16 }}>🎯</span>
|
|
<span style={{ fontWeight: 600 }}>Doel & Bijbehorende Progressie</span>
|
|
<VBadge label={fmt(doel)} color={TEAL} />
|
|
</div>
|
|
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
|
|
</div>
|
|
<div style={{
|
|
height: 14, background: "rgba(255,255,255,0.07)",
|
|
borderRadius: 99, overflow: "hidden",
|
|
position: "relative", marginBottom: 8,
|
|
}}>
|
|
<div style={{
|
|
position: "absolute", inset: 0,
|
|
width: `${progressPct * 100}%`,
|
|
background: "linear-gradient(90deg, #7c3aed, #a855f7)",
|
|
borderRadius: 99, transition: "width 0.7s ease",
|
|
}} />
|
|
{[0.25, 0.5, 0.75].map((f) => (
|
|
<div key={f} style={{
|
|
position: "absolute", left: `${f * 100}%`,
|
|
top: 0, bottom: 0, width: 1,
|
|
background: "rgba(0,0,0,0.3)",
|
|
}} />
|
|
))}
|
|
</div>
|
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
|
{milestones.map((m) => (
|
|
<span key={m} style={{ fontSize: 11, color: T.muted }}>{fmt(m)}</span>
|
|
))}
|
|
</div>
|
|
</VCard>
|
|
|
|
{/* EV Grafiek */}
|
|
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
gap: 12, flexWrap: "wrap", marginBottom: 16,
|
|
}}>
|
|
<div style={{ fontWeight: 600 }}>Ontwikkeling van Eigen Vermogen</div>
|
|
{vData.length >= 2 && (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 11, color: T.muted }}>
|
|
<Legenda kleur="linear-gradient(90deg, #0eddd4, #8b5cf6)" label="Eigen vermogen" />
|
|
<Legenda
|
|
kleur={`repeating-linear-gradient(90deg, ${GOLD} 0 5px, transparent 5px 9px)`}
|
|
label="Geïnvesteerde fiat"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{vData.length < 2 ? (
|
|
<div style={{ textAlign: "center", padding: "32px 0", color: T.muted }}>
|
|
<div style={{ fontSize: 28, marginBottom: 8 }}>📈</div>
|
|
<div style={{ fontSize: 13 }}>
|
|
Leg minimaal 2 voortgangsmomenten vast in het Eigen Vermogen tabblad om de grafiek te tonen.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<MiniLineChart vData={vData} fiatData={fiatData} darkMode={darkMode} />
|
|
)}
|
|
</VCard>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Legenda-item bij de grafiek ─────────────────────────────────────────────
|
|
function Legenda({ kleur, label }) {
|
|
return (
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
|
<span style={{ width: 16, height: 3, borderRadius: 2, background: kleur }} />
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Smooth Catmull-Rom bezier path ──────────────────────────────────────────
|
|
function crPath(xs, ys, t = 0.4) {
|
|
if (xs.length < 2) return "";
|
|
let d = `M ${xs[0]},${ys[0]}`;
|
|
for (let i = 0; i < xs.length - 1; i++) {
|
|
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]];
|
|
const p1 = [xs[i], ys[i]];
|
|
const p2 = [xs[i+1], ys[i+1]];
|
|
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
|
|
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
|
|
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
|
|
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
|
|
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
|
|
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
|
|
function MiniLineChart({ vData, fiatData, darkMode }) {
|
|
const [hovered, setHovered] = useState(null);
|
|
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 52;
|
|
const vals = vData.map((d) => d.nettoWaarde || 0);
|
|
const fVals = fiatData;
|
|
// Beide reeksen staan in euro's op dezelfde as, dus het bereik moet ze allebei
|
|
// omvatten — anders loopt de inleglijn buiten beeld zodra die boven de EV uitkomt.
|
|
const mn = Math.min(...vals, ...fVals) * 0.95;
|
|
const mx = Math.max(...vals, ...fVals) * 1.05;
|
|
const rng = mx - mn || 1;
|
|
const iW = W - pl - pr, iH = H - pt - pb;
|
|
const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW);
|
|
const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
|
const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
|
const linePath = crPath(xs, ys);
|
|
const areaPath = `${linePath} L ${xs[xs.length-1]},${pt+iH} L ${xs[0]},${pt+iH} Z`;
|
|
// Rechte segmenten, geen crPath: de inleg loopt nooit terug, en de bezier-
|
|
// afronding zou tussen twee stortingen een dip tekenen die er niet is.
|
|
const fiatPath = xs.map((x, i) => `${i ? "L" : "M"} ${x},${fYs[i]}`).join(" ");
|
|
const gc = darkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)";
|
|
const lc = darkMode ? "#6b7280" : "#94a3b8";
|
|
|
|
const handleMouseMove = (e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
|
const dists = xs.map((x) => Math.abs(x - svgX));
|
|
setHovered(dists.indexOf(Math.min(...dists)));
|
|
};
|
|
|
|
const h = hovered;
|
|
const tipW = 150, tipH = 76;
|
|
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0;
|
|
// Ankeren op de hoogste van de twee punten, zodat de tooltip nooit over de
|
|
// andere lijn heen valt.
|
|
const tipAnker = h !== null ? Math.min(ys[h], fYs[h]) : 0;
|
|
const tipY = h !== null ? (tipAnker - tipH - 14 < pt ? Math.max(ys[h], fYs[h]) + 14 : tipAnker - tipH - 14) : 0;
|
|
const resultaat = h !== null ? vals[h] - fVals[h] : 0;
|
|
|
|
return (
|
|
<svg
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
style={{ width: "100%", height: "auto", display: "block", cursor: "crosshair" }}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={() => setHovered(null)}
|
|
>
|
|
<defs>
|
|
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="#0eddd4" />
|
|
<stop offset="100%" stopColor="#8b5cf6" />
|
|
</linearGradient>
|
|
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
|
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
|
</linearGradient>
|
|
<mask id="chartAreaMask">
|
|
<rect x={pl} y={pt} width={iW} height={iH} fill="url(#chartFadeMask)" />
|
|
</mask>
|
|
</defs>
|
|
|
|
{/* Gestippelde gridlijnen */}
|
|
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
|
|
const gy = pt + (1 - f) * iH;
|
|
return (
|
|
<g key={f}>
|
|
<line x1={pl} y1={gy} x2={W - pr} y2={gy}
|
|
stroke={gc} strokeWidth="1" strokeDasharray="4,4" />
|
|
<text x={pl - 6} y={gy + 4} fill={lc} fontSize="9" textAnchor="end">
|
|
{fmt(mn + f * rng)}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Vlakkleur met gradient + fade */}
|
|
<path d={areaPath} fill="url(#chartLineGrad)" mask="url(#chartAreaMask)" opacity="0.4" />
|
|
|
|
{/* Geïnvesteerde fiat — gestippeld, zodat het als referentielijn leest
|
|
en de EV-lijn de hoofdrol houdt */}
|
|
<path d={fiatPath} fill="none" stroke={GOLD} strokeWidth="1.8"
|
|
strokeDasharray="5,4" strokeLinecap="round" strokeLinejoin="round" opacity="0.85" />
|
|
|
|
{/* Lijn */}
|
|
<path d={linePath} fill="none" stroke="url(#chartLineGrad)"
|
|
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
|
|
{/* Verticale hover-lijn */}
|
|
{h !== null && (
|
|
<line x1={xs[h]} y1={pt} x2={xs[h]} y2={pt + iH}
|
|
stroke="rgba(255,255,255,0.25)" strokeWidth="1" strokeDasharray="4,3" />
|
|
)}
|
|
|
|
{/* Hover punten */}
|
|
{h !== null && (
|
|
<>
|
|
<circle cx={xs[h]} cy={fYs[h]} r="4.5"
|
|
fill={GOLD} stroke={darkMode ? "#151c2e" : "#fff"} strokeWidth="2.5" />
|
|
<circle cx={xs[h]} cy={ys[h]} r="5.5"
|
|
fill="#0eddd4" stroke={darkMode ? "#151c2e" : "#fff"} strokeWidth="2.5" />
|
|
</>
|
|
)}
|
|
|
|
{/* Tooltip */}
|
|
{h !== null && (
|
|
<g>
|
|
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
|
|
fill={darkMode ? "#1a2035" : "#fff"}
|
|
stroke="rgba(14,221,212,0.35)" strokeWidth="1" />
|
|
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
|
|
{fmtDatum(vData[h].datum)}
|
|
</text>
|
|
<text x={tipX + 10} y={tipY + 33} fill={lc} fontSize="9">Vermogen</text>
|
|
<text x={tipX + tipW - 10} y={tipY + 33} fill="#0eddd4" fontSize="10"
|
|
fontWeight="700" textAnchor="end">
|
|
{fmt(vals[h])}
|
|
</text>
|
|
<text x={tipX + 10} y={tipY + 48} fill={lc} fontSize="9">Ingelegd</text>
|
|
<text x={tipX + tipW - 10} y={tipY + 48} fill={GOLD} fontSize="10"
|
|
fontWeight="700" textAnchor="end">
|
|
{fmt(fVals[h])}
|
|
</text>
|
|
<line x1={tipX + 10} y1={tipY + 55} x2={tipX + tipW - 10} y2={tipY + 55}
|
|
stroke={gc} strokeWidth="1" />
|
|
<text x={tipX + 10} y={tipY + 68} fill={lc} fontSize="9">Resultaat</text>
|
|
<text x={tipX + tipW - 10} y={tipY + 68}
|
|
fill={resultaat >= 0 ? GREEN : RED} fontSize="10"
|
|
fontWeight="700" textAnchor="end">
|
|
{`${resultaat >= 0 ? "+" : ""}${fmt(resultaat)}`}
|
|
</text>
|
|
</g>
|
|
)}
|
|
|
|
{/* X-as labels */}
|
|
{xs.map((x, i) => (
|
|
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8"
|
|
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}>
|
|
{fmtDatum(vData[i].datum)}
|
|
</text>
|
|
))}
|
|
</svg>
|
|
);
|
|
}
|