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