677 lines
31 KiB
React
677 lines
31 KiB
React
import { useState } from "react";
|
||
import { useApp } from "../context/AppContext.jsx";
|
||
import { fmt, fmtDatum } from "../utils/format.js";
|
||
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.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));
|
||
|
||
// Kolommen in weergave-volgorde, met hun standaardbreedte. De categorieën
|
||
// zijn dynamisch, dus de lijst wordt per render opgebouwd.
|
||
const kolommen = [
|
||
{ id: "datum", breedte: 100 },
|
||
...cats.map((c) => ({ id: c.id, breedte: 100 })),
|
||
{ id: "nettoWaarde", breedte: 115 },
|
||
{ id: "verschil", breedte: 105 },
|
||
{ id: "opmerkingen", breedte: 150 },
|
||
...(!locked ? [{ id: "acties", breedte: 75 }] : []),
|
||
];
|
||
const { breedtes, totaal, grip } = useKolomBreedtes("ev_kolom_breedtes", kolommen);
|
||
|
||
// overflow/ellipsis horen bij tableLayout "fixed": de inhoud rekt de kolom
|
||
// niet meer op, dus zonder afkappen loopt lange tekst de buurcel binnen.
|
||
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", background: T.popupBg, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
|
||
const td = { padding: "7px 10px", fontSize: 12, color: T.text, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", borderBottom: `1px solid ${T.border}` };
|
||
|
||
return (
|
||
<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: totaal, borderCollapse: "collapse", tableLayout: "fixed" }}>
|
||
<colgroup>
|
||
{kolommen.map((k) => <col key={k.id} style={{ width: breedtes[k.id] }} />)}
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th style={th}>Datum<span {...grip("datum")} /></th>
|
||
{cats.map((c) => (
|
||
<th key={c.id} style={{ ...th, color: c.color }}>
|
||
{c.naam}<span {...grip(c.id)} />
|
||
</th>
|
||
))}
|
||
<th style={th}>Netto Waarde<span {...grip("nettoWaarde")} /></th>
|
||
<th style={th}>Verschil<span {...grip("verschil")} /></th>
|
||
<th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
|
||
{!locked && <th style={th}><span {...grip("acties")} /></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>
|
||
{/* title: bij een smalle kolom is de tekst afgekapt */}
|
||
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{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, naam: c.naam }))} 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 });
|