import { useState } from "react"; import { useApp } from "../context/AppContext.jsx"; import { fmt, fmtDatum, fmtMaand } from "../utils/format.js"; import { crPath, vlakPad } from "../utils/grafiek.js"; import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js"; import { RED, GREEN } from "../constants/index.js"; import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx"; // ── Inline SVG line chart ───────────────────────────────────────────────────── function SvgLineChart({ pts, lines, T, H = 200 }) { const [hovered, setHovered] = useState(null); // { lineKey, ptIdx } if (pts.length < 2) { return (
Minimaal 2 datapunten nodig
); } // pb was 62 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter. const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 34; const iW = W - pl - pr, iH = H - pt2 - pb; // Eén label "sep '26" is ongeveer 46px breed; zoveel passen er naast elkaar. const labelStap = Math.max(1, Math.ceil(pts.length / Math.max(1, Math.floor(iW / 46)))); const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0)); const dataMin = Math.min(...allVals); const dataMax = Math.max(...allVals); const padding = (dataMax - dataMin) * 0.08 || 1; const mn = dataMin - padding; const mx = dataMax + padding; const rng = mx - mn || 1; const xp = (i) => pl + (i / (pts.length - 1)) * iW; const yp = (v) => pt2 + (1 - ((v ?? 0) - mn) / rng) * iH; // Was een vergelijking met een vaste hex (`T.bg === "#0f1117"`). Die kleur is // de achtergrond allang niet meer, dus beide stonden altijd op licht — en met // instelbare thema's valt er sowieso niets meer aan de bg af te lezen. const darkMode = T.darkMode; const gc = darkMode ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)"; return (
{lines.map((l) => ( ))} {[0, 0.25, 0.5, 0.75, 1].map((f) => { const gy = pt2 + (1 - f) * iH; return ( {fmt(mn + f * rng)} ); })} {lines.map((l) => { const validPts = pts.map((p, i) => ({ ...p, _i: i })).filter((p) => p[l.key] !== null); if (validPts.length < 2) return null; const xs = validPts.map((p) => xp(p._i)); const ys = validPts.map((p) => yp(p[l.key])); // Dezelfde Catmull-Rom afronding als de dashboardgrafiek, in plaats // van rechte segmenten tussen de punten. const lijn = crPath(xs, ys); const vlak = vlakPad(lijn, xs[0], xs[xs.length - 1], pt2 + iH); return ( {validPts.map((p, i) => { const x = xs[i], y = ys[i]; const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i; // Twee regels: sinds de x-as alleen nog maanden noemt, is dit // de enige plek in de grafiek waar de exacte dag te lezen is. const tipW = 104, tipH = 34; const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4); const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8; return ( setHovered({ lineKey: l.key, ptIdx: i })} onMouseLeave={() => setHovered(null)} style={{ cursor: "pointer" }} > {isHov && ( {fmtDatum(p.datum)} {fmt(p[l.key])} )} ); })} ); })} {/* X-as: korte maandlabels, rechtop en uitgedund op wat er past. Elk punt een volledige datum geven liep bij een lange reeks over in een onleesbare band. De exacte dag staat in de tooltip op elk punt. */} {pts.map((p, i) => { const laatste = i === pts.length - 1; if (i % labelStap !== 0 && !laatste) return null; return ( {fmtMaand(p.datum)} ); })}
{lines.map((l) => (
{l.label}
))}
); } // ── VoortgangModal ──────────────────────────────────────────────────────────── function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) { const is = { background: T.inputBg, border: `1px solid ${T.inputBorder}`, borderRadius: 8, color: T.text, padding: "8px 10px", fontSize: 13, outline: "none", boxSizing: "border-box" }; const isEdit = !!initialData; const [datum, setDatum] = useState(initialData?.datum || ""); const [ev, setEv] = useState(initialData?.ev != null ? String(initialData.ev) : ""); const [schuld, setSchuld] = useState(initialData?.schuld != null ? String(initialData.schuld) : ""); const [opmerkingen, setOpmerkingen] = useState(initialData?.opmerkingen || ""); const [err, setErr] = useState(""); const save = () => { if (!datum) { setErr("Vul een datum in."); setTimeout(() => setErr(""), 3000); return; } if (!ev && !schuld) { setErr("Vul minimaal Eigen Vermogen of Schuld in."); setTimeout(() => setErr(""), 3000); return; } onSave({ datum, ev: ev ? parseFloat(ev) : null, schuld: schuld ? parseFloat(schuld) : null, opmerkingen, }); onClose(); }; return (
setDatum(e.target.value)} style={{ ...is, width: "100%", marginBottom: 16 }} />
€ setEv(e.target.value)} placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
€ setSchuld(e.target.value)} placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
setOpmerkingen(e.target.value)} placeholder="Optionele notitie..." style={{ ...is, width: "100%" }} /> {err && (
{err}
)}
); } function VLabel({ label, T }) { return (
{label}
); } // ── VoortgangTab (pagina) ───────────────────────────────────────────────────── export default function VoortgangTab() { const [showAdd, setShowAdd] = useState(false); const [editRow, setEditRow] = useState(null); const { totEV, totSchuld, catTotals, schuldBlokken, schuldHistory, evVoortgangHistory, setEvVoortgangHistory, data, setData, schuld, locked, darkMode, T, } = useApp(); const nettoWaarde = totEV - totSchuld; const schuldCatsDash = schuldBlokken.map((b) => ({ naam: b.naam, color: b.color, subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0), })); const combined = [...new Set([ ...evVoortgangHistory.map((r) => r.datum), ...schuldHistory.map((s) => s.datum), ])].sort((a, b) => a.localeCompare(b)).map((d) => { const evRow = evVoortgangHistory.find((r) => r.datum === d); const schRow = schuldHistory.find((s) => s.datum === d); const evVal = evRow ? (evRow.ev || 0) : null; const schVal = schRow ? schRow.schuld : null; return { datum: d, ev: evVal, schuld: schVal, netto: evVal !== null && schVal !== null ? evVal - schVal : null, opmerkingen: evRow?.opmerkingen || "", }; }); const timeline = combined; const voortgangRows = [...combined].reverse(); const addVoortgang = ({ datum, ev: evVal, schuld: schuldVal, opmerkingen }) => { if (evVal !== null) { const newRow = { datum, ev: evVal, opmerkingen: opmerkingen || "" }; const updated = [...evVoortgangHistory.filter((r) => r.datum !== datum), newRow] .sort((a, b) => b.datum.localeCompare(a.datum)); setEvVoortgangHistory(updated); } if (schuldVal !== null) { const updated = [...schuldHistory.filter((s) => s.datum !== datum), { datum, schuld: schuldVal }] .sort((a, b) => b.datum.localeCompare(a.datum)); setData({ ...data, schuld: { ...schuld, schuldHistory: updated } }); } }; const removeVoortgang = (datum) => { setEvVoortgangHistory(evVoortgangHistory.filter((r) => r.datum !== datum)); setData({ ...data, schuld: { ...schuld, schuldHistory: schuldHistory.filter((s) => s.datum !== datum) } }); }; // Kolommen in weergave-volgorde, met hun standaardbreedte. const kolommen = [ { id: "datum", breedte: 120 }, { id: "ev", breedte: 175 }, { id: "schuld", breedte: 160 }, { id: "netto", breedte: 175 }, { id: "opmerkingen", breedte: 340 }, ...(!locked ? [{ id: "acties", breedte: 90 }] : []), ]; const { breedtes, totaal, grip } = useKolomBreedtes("voortgang_kolom_breedtes", kolommen); // overflow/ellipsis horen bij tableLayout "fixed": de inhoud rekt de kolom // niet meer op, dus zonder afkappen loopt lange tekst de buurcel binnen. const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", background: T.popupBg, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` }; const td = { padding: "7px 10px", fontSize: 12, color: T.text, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", borderBottom: `1px solid ${T.border}` }; return (

Voortgang Schuld & EV

{/* Hero netto */}
Mijn Nettowaarde
= 0 ? GREEN : RED }}>{fmt(nettoWaarde)}
Eigen Vermogen
{fmt(totEV)}
Totale Schuld
{fmt(totSchuld)}
{/* KPI kaarten */}
{/* Schuld verdeling */}
Verdeling Schuld
{schuldCatsDash.map((b) => (
{b.naam}
{fmt(b.subtot)}
))}
Totaal schuld {fmt(totSchuld)}
{/* EV verdeling */}
Verdeling Eigen Vermogen
{catTotals.map((c) => (
{c.naam}
{fmt(c.tot)}
))}
Totaal EV {fmt(totEV)}
{/* Rendement */}
Rendement Eigen Vermogen
Bedrag
—
Percentage
—
{/* Donut */}
Eigen Vermogen
Schuld
{/* Grafieken */}
Ontwikkeling Schuld en EV
Ontwikkeling Mijn Nettowaarde
{/* Voortgang tabel */}
Voortgang overzicht
{!locked && ( )}
{showAdd && !locked && ( setShowAdd(false)} T={T} darkMode={darkMode} /> )} {editRow && !locked && ( setEditRow(null)} T={T} darkMode={darkMode} initialData={editRow} /> )} {voortgangRows.length > 0 ? (
{kolommen.map((k) => )} {!locked && } {voortgangRows.map((row, i) => ( {/* title: bij een smalle kolom is de tekst afgekapt */} {!locked && ( )} ))}
Datum Eigen Vermogen Schuld Nettowaarde Opmerkingen
{fmtDatum(row.datum)} {row.ev !== null ? fmt(row.ev) : "—"} {row.schuld !== null ? fmt(row.schuld) : "—"} = 0 ? GREEN : RED) : T.muted }}>{row.netto !== null ? fmt(row.netto) : "—"}{row.opmerkingen || "—"}
) : (
Nog geen gecombineerde datapunten. Voeg schuld- en EV-momenten toe.
)}
); }