import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useSettings } from "../settings"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; import Modal from "../components/Modal"; import EditableAmount from "../components/EditableAmount"; import { formatMoney, formatMonthLabel } from "../format"; function ProgressBar({ actual, planned }) { const pct = planned > 0 ? Math.min(actual / planned, 1) * 100 : actual > 0 ? 100 : 0; const over = actual > planned; return (
); } // "YYYY-MM" um n Monate verschieben (rein clientseitig, für die Nebeneinander-Ansicht). function shiftLabel(label, n) { const [y, m] = label.split("-").map(Number); const d = new Date(y, m - 1 + n, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } export default function Budget() { const { t } = useTranslation(); const { locale } = useSettings(); const confirm = useConfirm(); const toast = useToast(); const [mode, setMode] = useState("month"); // "month" | "template" const [views, setViews] = useState(null); // Array von Monatsansichten (1..3) const [anchor, setAnchor] = useState(null); // Label des ersten (linken) Monats const [count, setCount] = useState(1); const [groups, setGroups] = useState([]); const [accounts, setAccounts] = useState([]); const [template, setTemplate] = useState({ lines: [], income: [] }); const [incomeModal, setIncomeModal] = useState(null); const base = views?.[0]?.base_currency || "EUR"; const fmt = (c) => formatMoney(c, base); async function loadViews(anchorLbl, n) { try { const first = anchorLbl ? await api.budget(anchorLbl) : await api.budgetCurrent(); const rest = []; for (let k = 1; k < n; k++) rest.push(await api.budget(shiftLabel(first.label, k))); setViews([first, ...rest]); setAnchor(first.label); } catch (err) { toast(err.message, "error"); } } async function loadTemplate() { try { setTemplate(await api.budgetTemplate()); } catch (err) { toast(err.message, "error"); } } async function loadStamm() { try { const [g, a] = await Promise.all([api.listCategories(), api.listAccounts()]); setGroups(g); setAccounts(a); } catch (err) { toast(err.message, "error"); } } useEffect(() => { loadStamm(); loadViews(null, 1); }, []); useEffect(() => { if (mode === "template") loadTemplate(); }, [mode]); const tplMap = useMemo( () => Object.fromEntries(template.lines.map((l) => [l.category_id, l.planned_cents])), [template] ); const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]); const incomeCategories = useMemo( () => groups.filter((g) => g.kind === "income").flatMap((g) => g.categories), [groups] ); function replaceView(v) { setViews((vs) => vs.map((x) => (x.label === v.label ? v : x))); } function changeCount(n) { setCount(n); loadViews(anchor, n); } async function saveMonth(lbl, catId, cents) { try { replaceView(await api.setMonthCategory(lbl, catId, cents)); } catch (err) { toast(err.message, "error"); } } async function resetMonth(lbl) { if (!(await confirm({ title: t("budget.resetTitle"), message: t("budget.resetConfirm"), confirmLabel: t("budget.reset") }))) return; try { replaceView(await api.resetMonth(lbl)); toast(t("settings.saved")); } catch (err) { toast(err.message, "error"); } } async function copyMonthToTemplate(lbl) { if (!(await confirm({ title: t("budget.setAsTemplate"), message: t("budget.setAsTemplateConfirm"), confirmLabel: t("budget.setAsTemplate") }))) return; try { await api.budgetTemplateFromMonth(lbl); toast(t("settings.saved")); if (mode === "template") loadTemplate(); } catch (err) { toast(err.message, "error"); } } async function copyTemplateToMonth(lbl) { if (!(await confirm({ title: t("budget.copyFromTemplate"), message: t("budget.copyFromTemplateConfirm"), confirmLabel: t("budget.copyFromTemplate"), danger: true }))) return; try { replaceView(await api.budgetMonthFromTemplate(lbl)); toast(t("settings.saved")); } catch (err) { toast(err.message, "error"); } } async function saveTemplate(catId, cents) { try { await api.setTemplateCategory(catId, cents); loadTemplate(); } catch (err) { toast(err.message, "error"); } } async function saveIncome(e) { e.preventDefault(); const f = incomeModal; const body = { label: f.label.trim(), category_id: f.category_id ? Number(f.category_id) : null, target_account_id: f.target_account_id ? Number(f.target_account_id) : null, planned_cents: f.planned_cents, }; try { if (f.id) await api.updateIncome(f.id, body); else await api.createIncome(body); setIncomeModal(null); loadTemplate(); loadViews(anchor, count); } catch (err) { toast(err.message, "error"); } } async function deleteIncome(id) { if (!(await confirm({ title: t("common.delete"), message: t("budget.deleteIncomeConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; try { await api.deleteIncome(id); loadTemplate(); loadViews(anchor, count); } catch (err) { toast(err.message, "error"); } } function MonthColumn({ view: v }) { const totv = v.totals; return (
{formatMonthLabel(v.label, locale)} {v.customized && ( )}
{v.income.length > 0 && (
{t("budget.income")} = totv.planned_income ? "pos" : "neg"}`}>{fmt(totv.received_income)} / {fmt(totv.planned_income)}
)} {v.groups.map((g) => (
{g.name} {fmt(g.actual_cents)} / {fmt(g.planned_cents)}
{g.categories.map((c) => (
{c.name} {fmt(c.actual_cents)} saveMonth(v.label, c.category_id, cents)} />
))}
))}
{t("budget.totalExpense")} totv.planned_expense ? "neg" : ""}`}>{fmt(totv.actual_expense)} / {fmt(totv.planned_expense)}
); } if (!views) return
{t("common.loading")}
; const first = views[0]?.label; const last = views[views.length - 1]?.label; const rangeTitle = views.length === 1 ? formatMonthLabel(first, locale) : `${formatMonthLabel(first, locale)} – ${formatMonthLabel(last, locale)}`; return (

{t("budget.title")}

{mode === "month" ? ( <>
{rangeTitle}
{t("budget.monthsShown")}
{[1, 2, 3].map((n) => ( ))}
{views.map((v) => )}
) : ( <>

{t("budget.templateHint")}

{t("budget.expenses")}

{expenseGroups.map((g) => (
{g.name}
{g.categories.map((c) => (
{c.name}
saveTemplate(c.id, cents)} />
))}
))}

{t("budget.income")}

{template.income.length === 0 ? (
{t("budget.noIncome")}
) : ( {template.income.map((i) => ( ))}
{i.label} {formatMoney(i.planned_cents, base)}
)}
)} {incomeModal && ( setIncomeModal(null)} footer={ <> } >
)}
); }