Files
VibeFinance/frontend/src/pages/SchuldTab.jsx
T
2026-04-16 10:22:13 +02:00

456 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`,
borderTop: `3px solid ${RED}`, borderRadius: 12,
padding: "16px 18px", flex: 1, minWidth: 200,
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.05em", color: T.text, marginBottom: 12, alignSelf: "flex-start" }}>
Verdeling Schuld
</div>
<DonutChart
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color }))}
total={donutTotal} size={160}
/>
<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 });