Initiële commit: VibeFinance 3-container opzet

Basislijn van de bestaande applicatie (Vite/React frontend, Express
backend, PostgreSQL) zoals die in de index stond, plus dev-, release-
en documentatiescripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordy Hoogeveen
2026-09-04 09:19:14 +02:00
co-authored by Claude Opus 5
commit e45d5b73fb
54 changed files with 11434 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, pct, fmtDatum } from "../utils/format.js";
import { GREEN, RED } 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));
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 &amp; 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={{ fontWeight: 600, marginBottom: 16 }}>Ontwikkeling van Eigen Vermogen</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} darkMode={darkMode} />
)}
</VCard>
</div>
);
}
// ── 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, 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 mn = Math.min(...vals) * 0.95;
const mx = Math.max(...vals) * 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 linePath = crPath(xs, ys);
const areaPath = `${linePath} L ${xs[xs.length-1]},${pt+iH} L ${xs[0]},${pt+iH} Z`;
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 = 114, tipH = 46;
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0;
const tipY = h !== null ? (ys[h] - tipH - 14 < pt ? ys[h] + 14 : ys[h] - tipH - 14) : 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" />
{/* 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 punt */}
{h !== null && (
<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 + tipW / 2} y={tipY + 34} fill="#0eddd4" fontSize="11"
fontWeight="700" textAnchor="middle">
{fmt(vals[h])}
</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>
);
}
+653
View File
@@ -0,0 +1,653 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, GREEN, RED, CAT_COLORS } from "../constants/index.js";
import {
VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter,
EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState,
} from "../components/ui/index.jsx";
// ── AanpassenPopup (EV categorie) ─────────────────────────────────────────────
function AanpassenPopup({ cat, onClose }) {
const { data, setData, ev, cats, darkMode, T } = useApp();
const [items, setItems] = useState(cat.items.map((i) => ({ ...i })));
const [catNaam, setCatNaam] = useState(cat.naam);
const [catColor, setCatColor] = useState(cat.color);
const [catIcon, setCatIcon] = useState(cat.icon || "");
const [showEmoji, setShowEmoji] = useState(false);
const [nieuwNaam, setNieuwNaam] = useState("");
const [nieuwVal, setNieuwVal] = useState("");
const [err, setErr] = useState("");
const [confirmDel, setConfirmDel] = useState(false);
const is = inputStyle(T);
const updateItem = (id, field, val) =>
setItems((p) => p.map((i) => i.id !== id ? i : { ...i, [field]: val }));
const removeItemLocal = (id) => {
if (items.length <= 1) return;
setItems((p) => p.filter((i) => i.id !== id));
};
const addNieuw = () => {
if (!nieuwNaam.trim()) { setErr("Naam is verplicht."); return; }
setItems((p) => [...p, { id: `i${Date.now()}`, naam: nieuwNaam.trim(), waarde: parseFloat(nieuwVal) || 0 }]);
setNieuwNaam(""); setNieuwVal(""); setErr("");
};
const handleSave = () => {
const updated = {
...data,
eigenVermogen: {
...ev,
categories: cats.map((c) =>
c.id !== cat.id ? c : {
...c,
naam: catNaam.trim() || c.naam,
color: catColor,
icon: catIcon,
items: items.map((i) => ({ ...i, waarde: parseFloat(i.waarde) || 0 })),
}
),
},
};
setData(updated); onClose();
};
const handleDelete = () => {
setData({ ...data, eigenVermogen: { ...ev, categories: cats.filter((c) => c.id !== cat.id) } });
onClose();
};
return (
<Overlay onClose={onClose}>
<ModalBox>
<ModalHeader
icon="✏️" iconBg={cat.color}
title="Aanpassen" subtitle={cat.naam} subtitleColor={cat.color}
onClose={onClose}
headerBg={darkMode ? `${cat.color}11` : `${cat.color}09`}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "65vh" }}>
{/* Naam + emoji */}
<FieldLabel label="Categorie naam" T={T} />
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button onClick={() => setShowEmoji((p) => !p)} style={{
width: 38, height: 38, borderRadius: 8,
background: `${cat.color}22`,
border: `2px solid ${showEmoji ? cat.color : cat.color + "44"}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, flexShrink: 0, cursor: "pointer",
}}>
<AppIcon value={catIcon} fallback="🏷" size={20} />
</button>
<input value={catNaam} onChange={(e) => setCatNaam(e.target.value)}
style={{ ...is, flex: 1, borderLeft: `3px solid ${cat.color}` }}
placeholder="Naam categorie" />
</div>
{showEmoji && (
<EmojiPicker
selected={catIcon} T={T} accentColor={cat.color}
onSelect={(e) => { setCatIcon(e); setShowEmoji(false); }}
onClear={() => { setCatIcon(""); setShowEmoji(false); }}
/>
)}
</div>
{/* Kleur */}
<FieldLabel label="Kleur" T={T} />
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
{CAT_COLORS.map((c) => (
<button key={c} onClick={() => setCatColor(c)} style={{
width: 28, height: 28, borderRadius: "50%", background: c,
border: `3px solid ${catColor === c ? "#fff" : "transparent"}`,
cursor: "pointer", outline: catColor === c ? `2px solid ${c}` : "none", outlineOffset: 1,
}} />
))}
</div>
{/* Bestaande items */}
<FieldLabel label="Items bewerken" T={T} />
{items.map((item) => (
<div key={item.id} style={{
display: "flex", alignItems: "center", gap: 8, marginBottom: 10,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${cat.color}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={item.naam} onChange={(e) => updateItem(item.id, "naam", e.target.value)}
style={{ ...is, flex: 1 }} placeholder="Naam" />
<EuroInput value={item.waarde} onChange={(v) => updateItem(item.id, "waarde", v)} is={is} T={T} />
{items.length > 1 && (
<button onClick={() => removeItemLocal(item.id)} style={deleteSmBtn}>×</button>
)}
</div>
))}
{/* Nieuw item */}
<div style={{ marginTop: 18, paddingTop: 16, borderTop: `1px solid ${T.border}` }}>
<FieldLabel label="Nieuw item toevoegen" T={T} />
<div style={{
display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
</div>
{err && <ErrMsg msg={err} />}
</div>
</div>
<ModalFooter T={T}>
{confirmDel ? (
<ConfirmDeleteDialog
naam={cat.naam} T={T}
onCancel={() => setConfirmDel(false)}
onConfirm={handleDelete}
/>
) : (
<>
<button onClick={() => setConfirmDel(true)} style={trashBtn}>🗑 Verwijderen</button>
<button onClick={onClose} style={cancelBtn(T)}>Annuleren</button>
<button onClick={handleSave} style={{
flex: 2, padding: "10px",
background: `linear-gradient(135deg, ${cat.color}, ${cat.color}cc)`,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, cursor: "pointer", fontSize: 13,
}}>Opslaan</button>
</>
)}
</ModalFooter>
</ModalBox>
</Overlay>
);
}
// ── CatKaart ──────────────────────────────────────────────────────────────────
function CatKaart({ cat }) {
const { locked, T } = useApp();
const [showAanpassen, setShowAanpassen] = useState(false);
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${cat.color}` }}>
{showAanpassen && <AanpassenPopup cat={cat} onClose={() => setShowAanpassen(false)} />}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: cat.color }}>
{cat.icon && <span style={{ marginRight: 6 }}><AppIcon value={cat.icon} size={16} /></span>}
{cat.naam}
</span>
{!locked && (
<button onClick={() => setShowAanpassen(true)} style={{
padding: "4px 10px", background: `${cat.color}22`,
border: `1px solid ${cat.color}44`, borderRadius: 7,
color: cat.color, cursor: "pointer", fontSize: 11, fontWeight: 700,
}}>Aanpassen</button>
)}
</div>
{cat.items.map((item) => (
<div key={item.id} style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<span style={{ fontSize: 13, color: T.muted, flex: 1, minWidth: 0, wordBreak: "break-word" }}>
{item.naam}
</span>
<span style={{ fontSize: 14, fontWeight: 700, color: T.text, whiteSpace: "nowrap" }}>
{fmt(item.waarde)}
</span>
</div>
))}
<div style={{
marginTop: 10, paddingTop: 10,
borderTop: `1px solid ${T.border}`,
display: "flex", justifyContent: "flex-end",
}}>
<span style={{ fontSize: 14, fontWeight: 700, color: cat.color }}>
{fmt(cat.items.reduce((a, i) => a + (i.waarde || 0), 0))}
</span>
</div>
</VCard>
);
}
// ── EVVoortgangTabel ──────────────────────────────────────────────────────────
function EVVoortgangTabel() {
const { evRows, setEvRows, cats, locked, darkMode, T } = useApp();
const empty = () => ({
datum: "", nettoWaarde: "", opmerkingen: "",
...cats.reduce((a, c) => ({ ...a, [c.id]: "" }), {}),
});
const [showModal, setShowModal] = useState(false);
const [input, setInput] = useState(empty);
const [editDatum, setEditDatum] = useState(null);
const [err, setErr] = useState("");
const is = inputStyle(T);
const openModal = () => { setInput(empty()); setEditDatum(null); setErr(""); setShowModal(true); };
const openEdit = (row) => {
setInput({ ...row, opmerkingen: row.opmerkingen || "" });
setEditDatum(row.datum); setErr(""); setShowModal(true);
};
const closeModal = () => { setShowModal(false); setErr(""); setEditDatum(null); };
const addRow = () => {
if (!input.datum) { setErr("Datum is verplicht."); return; }
const parsedCats = cats.reduce((a, c) => ({ ...a, [c.id]: parseFloat(input[c.id]) || 0 }), {});
const hasValue = cats.some((c) => parsedCats[c.id] > 0);
if (!hasValue) { setErr("Vul minimaal één bedrag in."); return; }
const row = {
...input,
...parsedCats,
nettoWaarde: parseFloat(input.nettoWaarde) || 0,
};
const updated = [...evRows.filter((r) => r.datum !== row.datum), row]
.sort((a, b) => b.datum.localeCompare(a.datum));
setEvRows(updated); closeModal();
};
const removeRow = (datum) => setEvRows(evRows.filter((r) => r.datum !== datum));
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` };
return (
<div>
{/* Modal */}
{showModal && (
<Overlay onClose={closeModal}>
<ModalBox maxWidth={560}>
<ModalHeader
icon="📊" iconBg={PURPLE}
title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"}
subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted}
onClose={closeModal}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
<FieldLabel label="Datum *" T={T} />
<input type="date" value={input.datum}
onChange={(e) => setInput((s) => ({ ...s, datum: e.target.value }))}
style={{ ...is, width: "100%", marginBottom: 16 }} />
<FieldLabel label="Bedragen per categorie" T={T} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 16 }}>
{cats.map((c) => (
<div key={c.id} style={{
display: "flex", alignItems: "center", gap: 12,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${c.color}`,
borderRadius: 10, padding: "8px 12px",
}}>
<span style={{ fontSize: 12, color: c.color, fontWeight: 700, minWidth: 90, flexShrink: 0 }}>{c.naam}</span>
<EuroInput
value={input[c.id]}
onChange={(v) => setInput((s) => ({ ...s, [c.id]: v }))}
is={is} T={T}
/>
</div>
))}
</div>
<FieldLabel label="Netto Waarde *" T={T} />
<EuroInput
value={input.nettoWaarde}
onChange={(v) => setInput((s) => ({ ...s, nettoWaarde: v }))}
is={is} T={T}
/>
<div style={{ marginBottom: 16 }} />
<FieldLabel label="Opmerkingen" T={T} />
<input type="text" value={input.opmerkingen}
onChange={(e) => setInput((s) => ({ ...s, opmerkingen: e.target.value }))}
placeholder="Optionele notitie..." style={{ ...is, width: "100%" }} />
{err && <ErrMsg msg={err} />}
</div>
<ModalFooter T={T}>
<button onClick={closeModal} style={cancelBtn(T)}>Annuleren</button>
<button onClick={addRow} style={primaryBtn}>{editDatum ? "Opslaan" : "Toevoegen"}</button>
</ModalFooter>
</ModalBox>
</Overlay>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div>
{!locked && (
<button onClick={openModal} style={primaryBtn}>+ Toevoegen</button>
)}
</div>
{evRows.length > 0 ? (
<div style={{ overflowX: "auto" }}>
<div style={{ maxHeight: 340, overflowY: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 600 }}>
<thead>
<tr>
<th style={th}>Datum</th>
{cats.map((c) => <th key={c.id} style={{ ...th, color: c.color }}>{c.naam}</th>)}
<th style={th}>Netto Waarde</th>
<th style={th}>Verschil</th>
<th style={th}>Opmerkingen</th>
{!locked && <th style={th}></th>}
</tr>
</thead>
<tbody>
{evRows.map((row, i) => {
const vorigeNW = i < evRows.length - 1 ? (evRows[i + 1].nettoWaarde || 0) : null;
const verschil = vorigeNW !== null ? (row.nettoWaarde || 0) - vorigeNW : null;
const vColor = verschil === null ? T.muted : verschil > 0 ? GREEN : verschil < 0 ? RED : T.muted;
const vLabel = verschil === null ? "—" : `${verschil >= 0 ? "+" : ""}${fmt(verschil)}`;
return (
<tr key={i}>
<td style={td}>{fmtDatum(row.datum)}</td>
{cats.map((c) => <td key={c.id} style={{ ...td, color: c.color }}>{fmt(row[c.id] || 0)}</td>)}
<td style={{ ...td, fontWeight: 700, color: PURPLE_LIGHT }}>{fmt(row.nettoWaarde || 0)}</td>
<td style={{ ...td, fontWeight: 700, color: vColor }}>{vLabel}</td>
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td>
{!locked && (
<td style={td}>
<div style={{ display: "flex", gap: 6 }}>
<button onClick={() => openEdit(row)} style={editBtn}>✏️</button>
<button onClick={() => removeRow(row.datum)} style={deleteSmBtn}>×</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : (
<EmptyState
icon="📊" text="Nog geen datapunten"
hint="Klik hier om je eerste datapunt toe te voegen"
locked={locked} onClick={openModal} T={T}
/>
)}
</div>
);
}
// ── DoelProgressie ────────────────────────────────────────────────────────────
function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState("");
const doel = ev.doel || 1;
const progressPct = Math.min(totVerdeling / doel, 1);
const openEdit = () => { setVal(String(doel)); setEditing(true); };
const save = () => {
const n = parseFloat(val);
if (n > 0) setData({ ...data, eigenVermogen: { ...ev, doel: n } });
setEditing(false);
};
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 14 }}>🎯</span>
<span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontWeight: 700, fontSize: 15, color: PURPLE_LIGHT }}>{fmt(totVerdeling)}</span>
{editing ? (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: T.muted, fontSize: 13 }}>/</span>
<input
autoFocus type="number" value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }}
style={{
width: 130, padding: "4px 8px", borderRadius: 8, fontSize: 13,
background: T.inputBg, border: `1px solid ${PURPLE}`, color: T.text, outline: "none",
}}
/>
<button onClick={save} style={{ padding: "4px 10px", background: PURPLE, border: "none", borderRadius: 7, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 700 }}>OK</button>
</div>
) : (
<>
<span style={{ color: T.muted, fontSize: 13 }}>/ {fmt(doel)}</span>
{!locked && (
<button onClick={openEdit} style={{ background: "transparent", border: "none", cursor: "pointer", color: T.muted, fontSize: 14, padding: 2 }}>✏️</button>
)}
</>
)}
<span style={{ color: PURPLE_LIGHT, fontWeight: 700, fontSize: 13 }}>{Math.round(progressPct * 100)}%</span>
</div>
</div>
<div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", width: `${progressPct * 100}%`,
background: `linear-gradient(90deg,${PURPLE},#a855f7)`,
borderRadius: 99, transition: "width 0.7s ease",
}} />
</div>
</VCard>
);
}
// ── EigenVermogenTab (pagina) ─────────────────────────────────────────────────
export default function EigenVermogenTab() {
const { cats, catTotals, ev, data, setData, locked, darkMode, T, evRows } = useApp();
const [showAddCat, setShowAddCat] = useState(false);
const [newNaam, setNewNaam] = useState("");
const [newColor, setNewColor] = useState(CAT_COLORS[0]);
const totVerdeling = catTotals.reduce((a, c) => a + c.tot, 0);
const sumCat = (id) => evRows.reduce((a, r) => a + (r[id] || 0), 0);
const totGeinv = cats.reduce((a, c) => a + sumCat(c.id), 0);
const iStyle2 = inputStyle(T);
const addCat = () => {
if (!newNaam.trim()) return;
setData({
...data,
eigenVermogen: {
...ev,
categories: [...cats, {
id: `c${Date.now()}`, naam: newNaam, color: newColor,
items: [{ id: `i${Date.now()}`, naam: "Item 1", waarde: 0 }],
}],
},
});
setNewNaam(""); setShowAddCat(false);
};
const summaryBlocks = [
{
title: "Verdeling Eigen Vermogen", accent: GREEN,
rows: catTotals.map((c) => ({ label: c.naam, value: fmt(c.tot), color: c.color })),
total: fmt(totVerdeling), totalColor: GREEN,
},
{
title: "Geïnvesteerde Fiat", accent: PURPLE_LIGHT,
rows: cats.map((c) => ({ label: c.naam, value: evRows.length > 0 ? fmt(sumCat(c.id)) : "—", color: c.color })),
total: evRows.length > 0 ? fmt(totGeinv) : "—", totalColor: PURPLE_LIGHT,
},
{
title: "Rendement", accent: "#f59e0b",
rows: cats.map((c) => {
const ev2 = catTotals.find((ct) => ct.id === c.id)?.tot ?? 0;
const g = sumCat(c.id);
const r = g > 0 ? (ev2 - g) / g : null;
return { label: c.naam, value: r === null ? "—" : `${(r * 100).toFixed(1)}%`, color: r === null ? T.muted : r >= 0 ? GREEN : RED };
}),
total: (() => {
const et = totVerdeling;
const gt = totGeinv;
return gt > 0 ? `${(((et - gt) / gt) * 100).toFixed(1)}%` : "—";
})(),
totalColor: (() => {
return totGeinv > 0 ? (totVerdeling >= totGeinv ? GREEN : RED) : T.muted;
})(),
},
];
return (
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, margin: "0 0 20px 0" }}>Eigen Vermogen</h1>
{/* Doel progressie */}
<DoelProgressie ev={ev} data={data} setData={setData} totVerdeling={totVerdeling} locked={locked} T={T} />
{/* Summary blokken */}
<div style={{ display: "flex", gap: 16, marginBottom: 20, flexWrap: "wrap" }}>
{summaryBlocks.map((block) => (
<div key={block.title} style={{
background: T.card, border: `1px solid ${T.border}`,
borderTop: `3px solid ${block.accent}`, borderRadius: 12,
padding: "16px 18px", flex: 1, minWidth: 200,
}}>
<div style={{
fontWeight: 700, fontSize: 12, letterSpacing: "0.1em",
textTransform: "uppercase", color: block.accent, marginBottom: 12,
}}>{block.title}</div>
{block.rows.map(({ label, value, color }) => (
<div key={label} style={{
display: "flex", justifyContent: "space-between",
padding: "6px 0", borderBottom: `1px solid ${T.border}`,
}}>
<span style={{ fontSize: 13, color: T.muted }}>{label}</span>
<span style={{ fontSize: 13, fontWeight: 600, color }}>{value}</span>
</div>
))}
<div style={{
display: "flex", justifyContent: "space-between",
alignItems: "center", marginTop: 10, paddingTop: 8,
}}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal</span>
<span style={{ fontSize: 15, fontWeight: 800, color: block.totalColor }}>{block.total}</span>
</div>
</div>
))}
{totVerdeling > 0 && (
<div style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, padding: "20px",
flex: 1, minWidth: 200,
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<DonutChart segments={catTotals.map((c) => ({ val: c.tot, color: c.color }))} total={totVerdeling} size={200} label="Verdeling" centerVal={fmt(totVerdeling)} />
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
{catTotals.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 5 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: c.color }} />
<span style={{ fontSize: 11, color: T.subtext }}>{c.naam}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Nieuwe categorie */}
{!locked && (
<div style={{ marginBottom: 16 }}>
<button onClick={() => setShowAddCat(true)} style={{
padding: "10px 20px", background: `${PURPLE}22`,
border: `1px solid ${PURPLE}44`, borderRadius: 10,
color: PURPLE_LIGHT, cursor: "pointer", fontWeight: 700, fontSize: 13,
}}>+ Nieuwe categorie</button>
</div>
)}
{showAddCat && (
<Overlay onClose={() => setShowAddCat(false)}>
<ModalBox maxWidth={400}>
<ModalHeader icon="➕" iconBg={PURPLE} title="Nieuwe categorie" subtitle="Voeg een EV-categorie toe" subtitleColor={PURPLE_LIGHT} onClose={() => setShowAddCat(false)} headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"} T={T} />
<div style={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
<input autoFocus placeholder="Bijv. Aandelen" value={newNaam}
onChange={(e) => setNewNaam(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addCat()}
style={{ ...iStyle2, marginBottom: 16, width: "100%", boxSizing: "border-box" }} />
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 8 }}>Kleur</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{CAT_COLORS.map((c) => (
<button key={c} onClick={() => setNewColor(c)} style={{
width: 28, height: 28, borderRadius: "50%", background: c,
border: `3px solid ${newColor === c ? "#fff" : "transparent"}`,
cursor: "pointer", outline: newColor === c ? `2px solid ${c}` : "none", outlineOffset: 1,
}} />
))}
</div>
</div>
<ModalFooter T={T}>
<button onClick={() => setShowAddCat(false)} style={{ flex: 1, padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>Annuleren</button>
<button onClick={addCat} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
</ModalFooter>
</ModalBox>
</Overlay>
)}
{/* Categorie kaarten */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: 16, marginBottom: 20,
}}>
{cats.map((cat) => <CatKaart key={cat.id} cat={cat} />)}
</div>
{/* Voortgang tabel */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}` }}>
<EVVoortgangTabel />
</VCard>
</div>
);
}
// ── Gedeelde kleine helpers ────────────────────────────────────────────────────
function inputStyle(T) {
return {
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px",
fontSize: 13, outline: "none", boxSizing: "border-box",
};
}
function FieldLabel({ label, T }) {
return (
<div style={{
fontSize: 11, color: T.muted, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8,
}}>{label}</div>
);
}
function EuroInput({ value, onChange, is, T }) {
return (
<div style={{ position: "relative", width: 110, flexShrink: 0 }}>
<span style={{
position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)",
fontSize: 12, color: T.muted, pointerEvents: "none",
}}>€</span>
<input type="number" value={value} onChange={(e) => onChange(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
);
}
function ErrMsg({ msg }) {
return (
<div style={{
color: RED, fontSize: 12, marginTop: 8,
padding: "6px 10px", background: "rgba(239,68,68,0.1)",
borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)",
}}>{msg}</div>
);
}
const primaryBtn = { padding: "10px 20px", background: PURPLE, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 };
const editBtn = { padding: "2px 10px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 600 };
const deleteSmBtn = { padding: "2px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 12 };
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
+452
View File
@@ -0,0 +1,452 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, pct } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, EmojiPicker, AppIcon, ConfirmDeleteDialog } from "../components/ui/index.jsx";
// ── SchuldAanpassenPopup ──────────────────────────────────────────────────────
function SchuldAanpassenPopup({ blok, onClose }) {
const { data, setData, schuld, schuldBlokken, darkMode, T } = useApp();
const [items, setItems] = useState(blok.items.map((i) => ({ ...i })));
const [blokNaam, setBlokNaam] = useState(blok.naam);
const [blokIcon, setBlokIcon] = useState(blok.icon || "");
const [showEmoji, setShowEmoji] = useState(false);
const [nieuwNaam, setNieuwNaam] = useState("");
const [nieuwVal, setNieuwVal] = useState("");
const [nieuwErr, setNieuwErr] = useState("");
const [confirmDel, setConfirmDel] = useState(false);
const is = inputStyle(T);
const updateItem = (id, field, val) =>
setItems((p) => p.map((i) => i.id !== id ? i : { ...i, [field]: val }));
const removeItemLocal = (id) => {
if (items.length <= 1) return;
setItems((p) => p.filter((i) => i.id !== id));
};
const addNieuw = () => {
if (!nieuwNaam.trim()) { setNieuwErr("Naam is verplicht."); setTimeout(() => setNieuwErr(""), 3000); return; }
setItems((p) => [...p, { id: `si${Date.now()}`, naam: nieuwNaam.trim(), waarde: parseFloat(nieuwVal) || 0 }]);
setNieuwNaam(""); setNieuwVal(""); setNieuwErr("");
};
const handleSave = () => {
setData({
...data,
schuld: {
...schuld,
blokken: schuldBlokken.map((b) =>
b.id !== blok.id ? b : {
...b, naam: blokNaam.trim() || b.naam, icon: blokIcon,
items: items.map((i) => ({ ...i, waarde: parseFloat(i.waarde) || 0 })),
}
),
},
});
onClose();
};
const handleDelete = () => {
setData({ ...data, schuld: { ...schuld, blokken: schuldBlokken.filter((b) => b.id !== blok.id) } });
onClose();
};
return (
<Overlay onClose={onClose}>
<ModalBox>
<ModalHeader
icon="✏️" iconBg={blok.color}
title="Aanpassen" subtitle={blok.naam} subtitleColor={blok.color}
onClose={onClose}
headerBg={darkMode ? `${blok.color}11` : `${blok.color}09`}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "65vh" }}>
{/* Naam + emoji */}
<FieldLabel label="Categorie naam" T={T} />
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button onClick={() => setShowEmoji((p) => !p)} style={{
width: 38, height: 38, borderRadius: 8,
background: `${blok.color}22`,
border: `2px solid ${showEmoji ? blok.color : blok.color + "44"}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, flexShrink: 0, cursor: "pointer",
}}><AppIcon value={blokIcon} fallback="🏷" size={20} /></button>
<input value={blokNaam} onChange={(e) => setBlokNaam(e.target.value)}
style={{ ...is, flex: 1, borderLeft: `3px solid ${blok.color}` }}
placeholder="Naam categorie" />
</div>
{showEmoji && (
<EmojiPicker
selected={blokIcon} T={T} accentColor={blok.color}
onSelect={(e) => { setBlokIcon(e); setShowEmoji(false); }}
onClear={() => { setBlokIcon(""); setShowEmoji(false); }}
/>
)}
</div>
{/* Items */}
<FieldLabel label="Items bewerken" T={T} />
{items.map((item) => (
<div key={item.id} style={{
display: "flex", alignItems: "center", gap: 8, marginBottom: 10,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${blok.color}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={item.naam} onChange={(e) => updateItem(item.id, "naam", e.target.value)}
style={{ ...is, flex: 1 }} placeholder="Naam" />
<EuroInput value={item.waarde} onChange={(v) => updateItem(item.id, "waarde", v)} is={is} T={T} />
{items.length > 1 && (
<button onClick={() => removeItemLocal(item.id)} style={deleteSmBtn}>×</button>
)}
</div>
))}
{/* Nieuw item */}
<div style={{ marginTop: 18, paddingTop: 16, borderTop: `1px solid ${T.border}` }}>
<FieldLabel label="Nieuw item toevoegen" T={T} />
<div style={{
display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
</div>
{nieuwErr && <ErrMsg msg={nieuwErr} />}
</div>
</div>
{/* Footer */}
<div style={{ padding: "14px 22px", borderTop: `1px solid ${T.border}` }}>
{confirmDel ? (
<ConfirmDeleteDialog
naam={blok.naam} T={T}
onCancel={() => setConfirmDel(false)}
onConfirm={handleDelete}
/>
) : (
<div style={{ display: "flex", gap: 10 }}>
<button onClick={() => setConfirmDel(true)} style={trashBtn}>🗑 Verwijderen</button>
<button onClick={onClose} style={cancelBtn(T)}>Annuleren</button>
<button onClick={handleSave} style={{
flex: 2, padding: "10px",
background: `linear-gradient(135deg, ${blok.color}, ${blok.color}cc)`,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, cursor: "pointer", fontSize: 13,
}}>Opslaan</button>
</div>
)}
</div>
</ModalBox>
</Overlay>
);
}
// ── SchuldKaart ───────────────────────────────────────────────────────────────
function SchuldKaart({ blok }) {
const { locked, T } = useApp();
const [showAanpassen, setShowAanpassen] = useState(false);
const subtot = blok.items.reduce((a, i) => a + (i.waarde || 0), 0);
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${blok.color}` }}>
{showAanpassen && <SchuldAanpassenPopup blok={blok} onClose={() => setShowAanpassen(false)} />}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: blok.color }}>
{blok.icon && <span style={{ marginRight: 6 }}><AppIcon value={blok.icon} size={16} /></span>}
{blok.naam}
</span>
{!locked && (
<button onClick={() => setShowAanpassen(true)} style={{
padding: "4px 10px", background: `${blok.color}22`,
border: `1px solid ${blok.color}44`, borderRadius: 7,
color: blok.color, cursor: "pointer", fontSize: 11, fontWeight: 700,
}}>Aanpassen</button>
)}
</div>
{blok.items.map((item) => (
<div key={item.id} style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<span style={{ fontSize: 13, color: T.muted, flex: 1, minWidth: 0, wordBreak: "break-word" }}>
{item.naam}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: blok.color, whiteSpace: "nowrap" }}>
{fmt(item.waarde)}
</span>
</div>
))}
<div style={{
marginTop: 8, paddingTop: 8,
borderTop: `1px solid ${T.border}`,
display: "flex", justifyContent: "flex-end",
}}>
<span style={{ fontSize: 14, fontWeight: 700, color: blok.color }}>{fmt(subtot)}</span>
</div>
</VCard>
);
}
// ── AflossingProgressie ───────────────────────────────────────────────────────
function AflossingProgressie() {
const { data, setData, schuld, totSchuld, locked, darkMode, T } = useApp();
const init = schuld.initieleSchuld || 0;
const betaald = Math.min(Math.max(init - totSchuld, 0), init);
const aflossingPct = init > 0 ? betaald / init : 0;
const [editOpen, setEditOpen] = useState(false);
const [editVal, setEditVal] = useState(schuld.initieleSchuld || "");
const saveInitiele = () => {
setData({ ...data, schuld: { ...schuld, initieleSchuld: parseFloat(editVal) || 0 } });
setEditOpen(false);
};
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginTop: 4 }}>
{editOpen && (
<Overlay onClose={() => setEditOpen(false)}>
<ModalBox maxWidth={380}>
<ModalHeader
icon="✏️" iconBg={RED}
title="Initiële schuld aanpassen"
subtitle="Startbedrag voor aflossing progressie"
subtitleColor={RED}
onClose={() => setEditOpen(false)}
headerBg={darkMode ? `${RED}11` : `${RED}09`}
T={T}
/>
<div style={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, display: "block", marginBottom: 6, textTransform: "uppercase" }}>Bedrag (€)</div>
<div style={{ position: "relative" }}>
<span style={{ position: "absolute", left: 12, top: "50%", transform: "translateY(-50%)", fontSize: 13, color: T.muted }}>€</span>
<input type="number" value={editVal} onChange={(e) => setEditVal(e.target.value)} autoFocus
style={{ background: T.inputBg, border: `1px solid ${T.inputBorder}`, borderRadius: 10, color: T.text, padding: "10px 12px 10px 28px", fontSize: 14, outline: "none", width: "100%", boxSizing: "border-box" }} />
</div>
</div>
<div style={{ display: "flex", gap: 10, padding: "14px 22px", borderTop: `1px solid ${T.border}` }}>
<button onClick={() => setEditOpen(false)} style={cancelBtn(T)}>Annuleren</button>
<button onClick={saveInitiele} style={{
flex: 2, padding: "10px",
background: `linear-gradient(135deg, ${RED}, #f97316)`,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, cursor: "pointer", fontSize: 13,
}}>Opslaan</button>
</div>
</ModalBox>
</Overlay>
)}
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<span style={{ fontWeight: 600 }}>Aflossing progressie</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: T.muted }}>Initiële schuld:</span>
<span style={{ fontSize: 13, fontWeight: 700, color: RED }}>{fmt(schuld.initieleSchuld)}</span>
{!locked && (
<button onClick={() => { setEditVal(schuld.initieleSchuld || ""); setEditOpen(true); }} style={{
padding: "4px 10px", background: `${RED}22`, border: `1px solid ${RED}44`,
borderRadius: 7, color: RED, cursor: "pointer", fontSize: 11, fontWeight: 700,
}}>Aanpassen</button>
)}
</div>
</div>
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%",
width: `${Math.min(aflossingPct * 100, 100)}%`,
background: GREEN,
borderRadius: 99,
transition: "width 0.5s ease",
marginLeft: 0,
}} />
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, fontSize: 11, color: T.muted }}>
<span>€0</span>
<span style={{ color: GREEN }}>{pct(aflossingPct)} afgelost</span>
<span>{fmt(init)}</span>
</div>
</VCard>
);
}
// ── NieuweCategoriepopup ──────────────────────────────────────────────────────
function NieuweCategoriepopup({ onSave, onClose, T, darkMode }) {
const is = { background: T.inputBg, border: `1px solid ${T.inputBorder}`, borderRadius: 8, color: T.text, padding: "10px 12px", fontSize: 13, outline: "none", boxSizing: "border-box", width: "100%" };
const COLORS = ["#ef4444","#f97316","#eab308","#a855f7","#06b6d4","#10b981","#3b82f6","#ec4899","#6b7280"];
const [naam, setNaam] = useState("");
const [color, setColor] = useState(COLORS[0]);
const save = () => {
if (!naam.trim()) return;
onSave({ naam: naam.trim(), color });
onClose();
};
return (
<Overlay onClose={onClose}>
<ModalBox maxWidth={400}>
<ModalHeader icon="➕" iconBg={RED} title="Nieuwe categorie" subtitle="Voeg een schuldcategorie toe" subtitleColor={RED} onClose={onClose} headerBg={darkMode ? `${RED}11` : `${RED}09`} T={T} />
<div style={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
<input autoFocus value={naam} onChange={(e) => setNaam(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
placeholder="Bijv. Hypotheek" style={{ ...is, marginBottom: 16 }} />
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 8 }}>Kleur</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{COLORS.map((c) => (
<button key={c} onClick={() => setColor(c)} style={{
width: 28, height: 28, borderRadius: "50%", background: c, border: `3px solid ${color === c ? "#fff" : "transparent"}`,
cursor: "pointer", outline: color === c ? `2px solid ${c}` : "none", outlineOffset: 1,
}} />
))}
</div>
</div>
<div style={{ display: "flex", gap: 10, padding: "14px 22px", borderTop: `1px solid ${T.border}` }}>
<button onClick={onClose} style={{ flex: 1, padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>Annuleren</button>
<button onClick={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${RED}, #f97316)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
</div>
</ModalBox>
</Overlay>
);
}
// ── SchuldTab (pagina) ────────────────────────────────────────────────────────
export default function SchuldTab() {
const { data, setData, schuld, schuldBlokken, locked, darkMode, T } = useApp();
const [showNieuweCategorie, setShowNieuweCategorie] = useState(false);
const schuldCats = schuldBlokken.map((b) => ({
...b, subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0),
}));
const donutTotal = schuldCats.reduce((a, b) => a + b.subtot, 0);
const addSchuldBlok = ({ naam, color }) => {
setData({
...data,
schuld: {
...schuld,
blokken: [...schuldBlokken, {
id: `s${Date.now()}`, naam, color,
items: [{ id: `si${Date.now()}`, naam: "Item 1", waarde: 0 }],
}],
},
});
};
return (
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, margin: "0 0 20px 0" }}>Schuld</h1>
<AflossingProgressie />
<div style={{ marginBottom: 20 }} />
{schuldCats.length > 0 && (
<div style={{ display: "flex", gap: 16, marginBottom: 20, flexWrap: "wrap" }}>
{/* Verdeling lijst */}
<div style={{
background: T.card, border: `1px solid ${T.border}`,
borderTop: `3px solid ${RED}`, borderRadius: 12,
padding: "16px 18px", flex: 1, minWidth: 200,
}}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.1em", textTransform: "uppercase", color: RED, marginBottom: 12 }}>
Verdeling Schuld
</div>
{schuldCats.map((b) => (
<div key={b.id} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: `1px solid ${T.border}` }}>
<span style={{ fontSize: 13, color: T.muted }}>{b.naam}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: b.color }}>{fmt(b.subtot)}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, paddingTop: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal</span>
<span style={{ fontSize: 15, fontWeight: 800, color: RED }}>{fmt(donutTotal)}</span>
</div>
</div>
{/* Donut */}
<div style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, padding: "20px",
flex: 1, minWidth: 200,
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<DonutChart
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color }))}
total={donutTotal} size={200} label="Totaal schuld" centerVal={fmt(donutTotal)}
/>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
{schuldCats.map((b) => (
<div key={b.id} style={{ display: "flex", alignItems: "center", gap: 5 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: b.color }} />
<span style={{ fontSize: 11, color: T.subtext }}>{b.naam}</span>
</div>
))}
</div>
</div>
</div>
)}
{!locked && (
<button onClick={() => setShowNieuweCategorie(true)} style={{
padding: "10px 20px", background: `${RED}22`,
border: `1px solid ${RED}44`, borderRadius: 10,
color: RED, cursor: "pointer", fontWeight: 700,
fontSize: 13, marginBottom: 16, display: "inline-block",
}}>+ Nieuwe categorie</button>
)}
{showNieuweCategorie && (
<NieuweCategoriepopup
onSave={addSchuldBlok}
onClose={() => setShowNieuweCategorie(false)}
T={T}
darkMode={darkMode}
/>
)}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))",
gap: 16, marginBottom: 20,
}}>
{schuldCats.map((blok) => <SchuldKaart key={blok.id} blok={blok} />)}
</div>
</div>
);
}
// ── kleine helpers ────────────────────────────────────────────────────────────
function inputStyle(T) {
return {
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px",
fontSize: 13, outline: "none", boxSizing: "border-box",
};
}
function FieldLabel({ label, T }) {
return (
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>
{label}
</div>
);
}
function EuroInput({ value, onChange, is, T }) {
return (
<div style={{ position: "relative", width: 110, flexShrink: 0 }}>
<span style={{ position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)", fontSize: 12, color: T.muted, pointerEvents: "none" }}>€</span>
<input type="number" value={value} onChange={(e) => onChange(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
);
}
function ErrMsg({ msg }) {
return <div style={{ color: RED, fontSize: 12, marginTop: 6, padding: "6px 10px", background: "rgba(239,68,68,0.1)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)" }}>{msg}</div>;
}
const deleteSmBtn = { padding: "4px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 13, flexShrink: 0 };
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
+438
View File
@@ -0,0 +1,438 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, 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 (
<div style={{ color: T.muted, fontSize: 12, textAlign: "center", padding: "24px 0" }}>
Minimaal 2 datapunten nodig
</div>
);
}
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 62;
const iW = W - pl - pr, iH = H - pt2 - pb;
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;
const gc = T.bg === "#0f1117" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
const darkMode = T.bg === "#0f1117";
return (
<div style={{ overflowX: "auto" }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", minWidth: 340, height: "auto", display: "block" }}>
<defs>
{lines.map((l) => (
<linearGradient key={l.key} id={`g_${l.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={l.color} stopOpacity="0.25" />
<stop offset="100%" stopColor={l.color} stopOpacity="0.02" />
</linearGradient>
))}
</defs>
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const gy = pt2 + (1 - f) * iH;
return (
<g key={f}>
<line x1={pl} y1={gy} x2={W - pr} y2={gy} stroke={gc} strokeWidth="1" />
<text x={pl - 4} y={gy + 4} fill={T.muted} fontSize="8" textAnchor="end">
{fmt(mn + f * rng)}
</text>
</g>
);
})}
{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]));
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
return (
<g key={l.key}>
<polygon points={ar} fill={`url(#g_${l.key})`} />
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" />
{validPts.map((p, i) => {
const x = xs[i], y = ys[i];
const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i;
const tipW = 90, tipH = 22;
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 (
<g key={i}
onMouseEnter={() => setHovered({ lineKey: l.key, ptIdx: i })}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
>
<circle cx={x} cy={y} r="14" fill="transparent" />
<circle cx={x} cy={y} r={isHov ? 6 : 4}
fill={l.color} stroke={T.card} strokeWidth="2" />
{isHov && (
<g>
<rect x={tipX} y={tipY} width={tipW} height={22}
rx="5" fill={darkMode ? "#1e2130" : "#fff"}
stroke={l.color} strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 15} fill={l.color} fontSize="8"
fontWeight="700" textAnchor="middle">
{fmt(p[l.key])}
</text>
</g>
)}
</g>
);
})}
</g>
);
})}
{pts.map((p, i) => (
<text key={i}
x={xp(i)} y={H - pb + 16}
fill={T.muted} fontSize="8"
textAnchor="end"
transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}>
{fmtDatum(p.datum)}
</text>
))}
</svg>
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
{lines.map((l) => (
<div key={l.key} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 18, height: 3, background: l.color, borderRadius: 2 }} />
<div style={{ width: 8, height: 8, borderRadius: "50%", background: l.color }} />
<span style={{ fontSize: 12, color: T.muted }}>{l.label}</span>
</div>
))}
</div>
</div>
);
}
// ── 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 (
<Overlay onClose={onClose}>
<ModalBox maxWidth={480}>
<ModalHeader
icon="📊" iconBg={PURPLE}
title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"}
subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted}
onClose={onClose}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
<VLabel label="Datum *" T={T} />
<input type="date" value={datum}
onChange={(e) => setDatum(e.target.value)}
style={{ ...is, width: "100%", marginBottom: 16 }} />
<VLabel label="Eigen Vermogen (€)" T={T} />
<div style={{ position: "relative", marginBottom: 16 }}>
<span style={{ position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)", fontSize: 12, color: T.muted, pointerEvents: "none" }}>€</span>
<input type="number" value={ev} onChange={(e) => setEv(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
<VLabel label="Schuld (€)" T={T} />
<div style={{ position: "relative", marginBottom: 16 }}>
<span style={{ position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)", fontSize: 12, color: T.muted, pointerEvents: "none" }}>€</span>
<input type="number" value={schuld} onChange={(e) => setSchuld(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
<VLabel label="Opmerkingen" T={T} />
<input type="text" value={opmerkingen}
onChange={(e) => setOpmerkingen(e.target.value)}
placeholder="Optionele notitie..."
style={{ ...is, width: "100%" }} />
{err && (
<div style={{ color: RED, fontSize: 12, marginTop: 12, padding: "6px 10px", background: "rgba(239,68,68,0.1)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)" }}>
{err}
</div>
)}
</div>
<ModalFooter T={T}>
<button onClick={onClose} style={{ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>
Annuleren
</button>
<button onClick={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, ${PURPLE}cc)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>
{isEdit ? "Opslaan" : "Toevoegen"}
</button>
</ModalFooter>
</ModalBox>
</Overlay>
);
}
function VLabel({ label, T }) {
return (
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>
{label}
</div>
);
}
// ── 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) } });
};
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` };
return (
<div>
<div style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 20, fontWeight: 800, margin: 0 }}>Voortgang Schuld & EV</h1>
</div>
{/* Hero netto */}
<VCard style={{
background: `linear-gradient(135deg, ${PURPLE}22, rgba(16,185,129,0.08))`,
border: `1px solid ${T.border}`, borderLeft: `4px solid ${PURPLE}`,
marginBottom: 16,
}}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
<div>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 6 }}>Mijn Nettowaarde</div>
<div style={{ fontSize: 32, fontWeight: 800, color: nettoWaarde >= 0 ? GREEN : RED }}>{fmt(nettoWaarde)}</div>
</div>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4, textTransform: "uppercase" }}>Eigen Vermogen</div>
<div style={{ fontSize: 20, fontWeight: 700, color: GREEN }}>{fmt(totEV)}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4, textTransform: "uppercase" }}>Totale Schuld</div>
<div style={{ fontSize: 20, fontWeight: 700, color: RED }}>{fmt(totSchuld)}</div>
</div>
</div>
</div>
</VCard>
{/* KPI kaarten */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(260px,1fr))", gap: 14, marginBottom: 20 }}>
{/* Schuld verdeling */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${RED}` }}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: RED, marginBottom: 12 }}>Verdeling Schuld</div>
{schuldCatsDash.map((b) => (
<div key={b.naam} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: `1px solid ${T.border}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: b.color }} />
<span style={{ fontSize: 13, color: T.muted }}>{b.naam}</span>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: b.color }}>{fmt(b.subtot)}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 10, paddingTop: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal schuld</span>
<span style={{ fontSize: 15, fontWeight: 800, color: RED }}>{fmt(totSchuld)}</span>
</div>
</VCard>
{/* EV verdeling */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${GREEN}` }}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: GREEN, marginBottom: 12 }}>Verdeling Eigen Vermogen</div>
{catTotals.map((c) => (
<div key={c.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: `1px solid ${T.border}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: c.color }} />
<span style={{ fontSize: 13, color: T.muted }}>{c.naam}</span>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: c.color }}>{fmt(c.tot)}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 10, paddingTop: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal EV</span>
<span style={{ fontSize: 15, fontWeight: 800, color: GREEN }}>{fmt(totEV)}</span>
</div>
</VCard>
{/* Rendement */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid #f59e0b` }}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: "#f59e0b", marginBottom: 12 }}>Rendement Eigen Vermogen</div>
<div style={{ padding: "10px 0", borderBottom: `1px solid ${T.border}` }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Bedrag</div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
</div>
<div style={{ padding: "10px 0" }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Percentage</div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
</div>
</VCard>
{/* Donut */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderRadius: 16, display: "flex", flexDirection: "column", alignItems: "center" }}>
<DonutChart segments={[{ val: totEV, color: GREEN }, { val: totSchuld, color: RED }]} total={totEV + totSchuld || 1} size={200} label="Netto waarde" centerVal={fmt(totEV - totSchuld)} />
<div style={{ display: "flex", gap: 20, marginTop: 12, justifyContent: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 10, height: 10, borderRadius: "50%", background: GREEN }} />
<span style={{ fontSize: 12, color: T.subtext }}>Eigen Vermogen</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 10, height: 10, borderRadius: "50%", background: RED }} />
<span style={{ fontSize: 12, color: T.subtext }}>Schuld</span>
</div>
</div>
</VCard>
</div>
{/* Grafieken */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 14 }}>
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 12 }}>Ontwikkeling Schuld en EV</div>
<SvgLineChart pts={timeline} lines={[
{ key: "ev", color: GREEN, label: "Eigen Vermogen" },
{ key: "schuld", color: RED, label: "Schuld" },
]} T={T} H={200} />
</VCard>
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 12 }}>Ontwikkeling Mijn Nettowaarde</div>
<SvgLineChart pts={timeline} lines={[
{ key: "netto", color: GREEN, label: "Netto Vermogen" },
]} T={T} H={200} />
</VCard>
{/* Voortgang tabel */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 14 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div>
{!locked && (
<button onClick={() => setShowAdd(true)} style={{
padding: "6px 14px", background: PURPLE, border: `1px solid ${PURPLE}`,
borderRadius: 8, color: "#fff", fontWeight: 700, fontSize: 12, cursor: "pointer",
}}>+ Toevoegen</button>
)}
</div>
{showAdd && !locked && (
<VoortgangModal onSave={addVoortgang} onClose={() => setShowAdd(false)} T={T} darkMode={darkMode} />
)}
{editRow && !locked && (
<VoortgangModal onSave={addVoortgang} onClose={() => setEditRow(null)} T={T} darkMode={darkMode} initialData={editRow} />
)}
{voortgangRows.length > 0 ? (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 560 }}>
<thead>
<tr>
<th style={th}>Datum</th>
<th style={{ ...th, color: GREEN }}>Eigen Vermogen</th>
<th style={{ ...th, color: RED }}>Schuld</th>
<th style={{ ...th, color: PURPLE_LIGHT }}>Nettowaarde</th>
<th style={th}>Opmerkingen</th>
{!locked && <th style={th}></th>}
</tr>
</thead>
<tbody>
{voortgangRows.map((row, i) => (
<tr key={i}>
<td style={td}>{fmtDatum(row.datum)}</td>
<td style={{ ...td, color: GREEN, fontWeight: 600 }}>{row.ev !== null ? fmt(row.ev) : "—"}</td>
<td style={{ ...td, color: RED, fontWeight: 600 }}>{row.schuld !== null ? fmt(row.schuld) : "—"}</td>
<td style={{ ...td, fontWeight: 700, color: row.netto !== null ? (row.netto >= 0 ? GREEN : RED) : T.muted }}>{row.netto !== null ? fmt(row.netto) : "—"}</td>
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td>
{!locked && (
<td style={{ ...td, whiteSpace: "nowrap" }}>
<button onClick={() => setEditRow(row)} style={{ padding: "2px 8px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, marginRight: 4 }}>✏️</button>
<button onClick={() => removeVoortgang(row.datum)} style={{ padding: "2px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 12 }}>×</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ color: T.muted, fontSize: 13, textAlign: "center", padding: "24px 0" }}>
Nog geen gecombineerde datapunten. Voeg schuld- en EV-momenten toe.
</div>
)}
</VCard>
</div>
);
}