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:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user