feat: Phase 2 – Budget-Engine + Direktzugriff (http://IP:Port)
Budget: - Modelle + Migration 0003: budget_template_lines, planned_income_lines, budget_periods, budget_period_lines. - services/budget: Perioden-Grenzen (einstellbarer Starttag, Label nach Endmonat), Standard-Vorlage vs. Monats-Override (Snapshot-on-Customize), Soll/Ist inkl. budgetierter Sparen-Transfers, geplante Einnahmen (Soll/Ist). - routers/budgets: Vorlage, geplante Einnahmen (CRUD), Monats-Soll/Ist, Monats-Override, Zurücksetzen. pytest: Perioden, Soll/Ist, Override/Reset, Einnahmen. - Frontend: Budget-Seite (Monat + Standard-Budget), Fortschrittsbalken grün/rot, Monats-Navigation, editierbare Soll-Beträge; Sparen-Option im Transfer-Formular. Deployment-Fix (Nutzer-Anforderung: http://IP:Port muss funktionieren): - Dienst bindet an APP_BIND_HOST=0.0.0.0 (direkt im LAN erreichbar). - APP_COOKIE_SECURE=false als Standard (sonst kein Login über reines HTTP); für reinen HTTPS-Betrieb umstellbar. Doku + install.sh entsprechend angepasst. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import Dashboard from "./pages/Dashboard";
|
||||
import Accounts from "./pages/Accounts";
|
||||
import Categories from "./pages/Categories";
|
||||
import Transactions from "./pages/Transactions";
|
||||
import Budget from "./pages/Budget";
|
||||
import Users from "./pages/Users";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
@@ -44,6 +45,7 @@ function Sidebar() {
|
||||
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
||||
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||||
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -128,6 +130,7 @@ export default function App() {
|
||||
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
||||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -162,4 +162,17 @@ export const api = {
|
||||
if (on) qs.set("on", on);
|
||||
return request(`/rates/convert?${qs.toString()}`);
|
||||
},
|
||||
|
||||
// Budget
|
||||
budgetCurrent: () => request("/budget/current"),
|
||||
budget: (label) => request(`/budget/${label}`),
|
||||
budgetTemplate: () => request("/budget/template"),
|
||||
setTemplateCategory: (catId, cents) =>
|
||||
request(`/budget/template/category/${catId}?planned_cents=${cents}`, { method: "PUT" }),
|
||||
setMonthCategory: (label, catId, cents) =>
|
||||
request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }),
|
||||
resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }),
|
||||
createIncome: (body) => request("/budget/income", { method: "POST", body }),
|
||||
updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }),
|
||||
deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { centsToInput, parseAmountToCents } from "../format";
|
||||
|
||||
// Kleines, editierbares Betragsfeld (Cent). Speichert bei Verlassen/Enter,
|
||||
// aber nur bei Änderung.
|
||||
export default function EditableAmount({ cents, onSave, placeholder = "0,00" }) {
|
||||
const [val, setVal] = useState(centsToInput(cents));
|
||||
useEffect(() => { setVal(centsToInput(cents)); }, [cents]);
|
||||
|
||||
function commit() {
|
||||
const c = parseAmountToCents(val) ?? 0;
|
||||
if (c !== cents) onSave(c);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="amount-input"
|
||||
inputMode="decimal"
|
||||
value={val}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
e.target.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -261,3 +261,113 @@
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Budget. */
|
||||
.budget-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.budget-month {
|
||||
font-size: 1.05rem;
|
||||
min-width: 170px;
|
||||
text-align: center;
|
||||
}
|
||||
.flip {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.budget-group + .budget-group {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.budget-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 2px solid var(--border-strong);
|
||||
}
|
||||
.budget-cat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.budget-cat:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.budget-cat-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.budget-cat-bar {
|
||||
flex: 1;
|
||||
}
|
||||
.amount-input {
|
||||
width: 96px;
|
||||
text-align: right;
|
||||
margin-top: 0;
|
||||
padding: 5px 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.budget-actual {
|
||||
width: 96px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.budget-slash {
|
||||
margin: 0 -4px;
|
||||
}
|
||||
.budget-bar {
|
||||
height: 7px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.budget-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--ok);
|
||||
border-radius: 4px;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
.budget-bar-fill.over {
|
||||
background: var(--danger);
|
||||
}
|
||||
.budget-total-row.card-total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding-top: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
border-top: 2px solid var(--border-strong);
|
||||
}
|
||||
tr.budget-total-row td {
|
||||
border-top: 2px solid var(--border-strong);
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* Sparen-Option im Transfer-Formular. */
|
||||
.savings-opt {
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.checkline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
.checkline input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
.checkline span {
|
||||
color: var(--text);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
@@ -54,3 +54,14 @@ export function todayIso() {
|
||||
const z = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Monats-Label "2026-09" -> "September 2026". */
|
||||
export function formatMonthLabel(label, locale = "de") {
|
||||
if (!label) return "";
|
||||
const [y, m] = label.split("-").map(Number);
|
||||
const d = new Date(y, (m || 1) - 1, 1);
|
||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
+24
-1
@@ -150,6 +150,29 @@
|
||||
"allTypes": "Alle Typen",
|
||||
"deleteConfirm": "Diese Buchung löschen?",
|
||||
"amountRequired": "Bitte einen Betrag eingeben.",
|
||||
"accountRequired": "Bitte ein Konto wählen."
|
||||
"accountRequired": "Bitte ein Konto wählen.",
|
||||
"budgetAsSavings": "Als Sparen ins Budget zählen",
|
||||
"budgetCategory": "Budget-Kategorie"
|
||||
},
|
||||
"budget": {
|
||||
"title": "Budget",
|
||||
"monthMode": "Monat",
|
||||
"templateMode": "Standard-Budget",
|
||||
"income": "Einnahmen",
|
||||
"expenses": "Ausgaben",
|
||||
"planned": "Geplant",
|
||||
"totalIncome": "Einnahmen gesamt",
|
||||
"totalExpense": "Ausgaben gesamt",
|
||||
"reset": "Auf Standard zurücksetzen",
|
||||
"resetTitle": "Monat zurücksetzen",
|
||||
"resetConfirm": "Diesen Monat auf das Standard-Budget zurücksetzen? Die Anpassungen dieses Monats gehen verloren.",
|
||||
"templateHint": "Das Standard-Budget gilt für jeden Monat, solange du ihn nicht einzeln anpasst.",
|
||||
"addIncome": "Geplante Einnahme",
|
||||
"editIncome": "Einnahme bearbeiten",
|
||||
"noIncome": "Noch keine geplanten Einnahmen.",
|
||||
"deleteIncomeConfirm": "Diese geplante Einnahme löschen?",
|
||||
"incomeLabel": "Bezeichnung",
|
||||
"incomeCategory": "Kategorie",
|
||||
"targetAccount": "Zielkonto"
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -150,6 +150,29 @@
|
||||
"allTypes": "All types",
|
||||
"deleteConfirm": "Delete this transaction?",
|
||||
"amountRequired": "Please enter an amount.",
|
||||
"accountRequired": "Please choose an account."
|
||||
"accountRequired": "Please choose an account.",
|
||||
"budgetAsSavings": "Count as savings in the budget",
|
||||
"budgetCategory": "Budget category"
|
||||
},
|
||||
"budget": {
|
||||
"title": "Budget",
|
||||
"monthMode": "Month",
|
||||
"templateMode": "Standard budget",
|
||||
"income": "Income",
|
||||
"expenses": "Expenses",
|
||||
"planned": "Planned",
|
||||
"totalIncome": "Total income",
|
||||
"totalExpense": "Total expenses",
|
||||
"reset": "Reset to standard",
|
||||
"resetTitle": "Reset month",
|
||||
"resetConfirm": "Reset this month to the standard budget? This month's adjustments will be lost.",
|
||||
"templateHint": "The standard budget applies to every month until you adjust a month individually.",
|
||||
"addIncome": "Planned income",
|
||||
"editIncome": "Edit income",
|
||||
"noIncome": "No planned income yet.",
|
||||
"deleteIncomeConfirm": "Delete this planned income?",
|
||||
"incomeLabel": "Label",
|
||||
"incomeCategory": "Category",
|
||||
"targetAccount": "Target account"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
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 (
|
||||
<div className="budget-bar">
|
||||
<div className={`budget-bar-fill${over ? " over" : ""}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Budget() {
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useSettings();
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
const [mode, setMode] = useState("month"); // "month" | "template"
|
||||
const [view, setView] = useState(null);
|
||||
const [label, setLabel] = useState(null);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [template, setTemplate] = useState({ lines: [], income: [] });
|
||||
const [incomeModal, setIncomeModal] = useState(null);
|
||||
|
||||
const base = view?.base_currency || "EUR";
|
||||
const fmt = (c) => formatMoney(c, base);
|
||||
|
||||
async function loadView(lbl) {
|
||||
try {
|
||||
const v = lbl ? await api.budget(lbl) : await api.budgetCurrent();
|
||||
setView(v);
|
||||
setLabel(v.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(); loadView(); }, []);
|
||||
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]
|
||||
);
|
||||
|
||||
async function saveMonth(catId, cents) {
|
||||
try { setView(await api.setMonthCategory(label, catId, cents)); } 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 resetMonth() {
|
||||
if (!(await confirm({ title: t("budget.resetTitle"), message: t("budget.resetConfirm"), confirmLabel: t("budget.reset") }))) return;
|
||||
try { setView(await api.resetMonth(label)); toast(t("settings.saved")); } 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();
|
||||
loadView(label);
|
||||
} 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(); loadView(label); } catch (err) { toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
if (!view) return <div className="muted">{t("common.loading")}</div>;
|
||||
|
||||
const tot = view.totals;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>{t("budget.title")}</h1>
|
||||
<div className="seg" style={{ width: 260, marginBottom: 0 }}>
|
||||
<button type="button" className={`seg-btn${mode === "month" ? " active" : ""}`} onClick={() => setMode("month")}>
|
||||
{t("budget.monthMode")}
|
||||
</button>
|
||||
<button type="button" className={`seg-btn${mode === "template" ? " active" : ""}`} onClick={() => setMode("template")}>
|
||||
{t("budget.templateMode")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "month" ? (
|
||||
<>
|
||||
<div className="card budget-navbar">
|
||||
<button className="btn ghost" onClick={() => loadView(view.prev)}>
|
||||
<Icon name="chevron-right" size={16} className="flip" />
|
||||
</button>
|
||||
<strong className="budget-month">{formatMonthLabel(view.label, locale)}</strong>
|
||||
<button className="btn ghost" onClick={() => loadView(view.next)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</button>
|
||||
<div className="grow" />
|
||||
{view.customized && (
|
||||
<button className="btn sm" onClick={resetMonth}>
|
||||
<Icon name="refresh-ccw" size={14} /> {t("budget.reset")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view.income.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>{t("budget.income")}</h2>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
{view.income.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td>{i.label}</td>
|
||||
<td style={{ textAlign: "right" }} className="muted">{t("budget.planned")}: {fmt(i.planned_cents)}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<span className={`amount ${i.received_cents >= i.planned_cents ? "pos" : "neg"}`}>{fmt(i.received_cents)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="budget-total-row">
|
||||
<td><strong>{t("budget.totalIncome")}</strong></td>
|
||||
<td style={{ textAlign: "right" }} className="muted">{fmt(tot.planned_income)}</td>
|
||||
<td style={{ textAlign: "right" }}><strong>{fmt(tot.received_income)}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2>{t("budget.expenses")}</h2>
|
||||
{view.groups.map((g) => (
|
||||
<div className="budget-group" key={g.id}>
|
||||
<div className="budget-group-head">
|
||||
<IconChip name={g.icon} color={g.color} size={26} />
|
||||
<span className="grow"><strong>{g.name}</strong></span>
|
||||
<span className={`amount ${g.over ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
|
||||
<span className="muted"> / {fmt(g.planned_cents)}</span>
|
||||
</div>
|
||||
{g.categories.map((c) => (
|
||||
<div className="budget-cat" key={c.category_id}>
|
||||
<div className="budget-cat-name">
|
||||
<IconChip name={c.icon} color={c.color} size={24} />
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
<div className="budget-cat-bar">
|
||||
<ProgressBar actual={c.actual_cents} planned={c.planned_cents} />
|
||||
</div>
|
||||
<span className={`amount budget-actual ${c.over ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
|
||||
<span className="muted budget-slash">/</span>
|
||||
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(c.category_id, cents)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="budget-total-row card-total">
|
||||
<span className="grow"><strong>{t("budget.totalExpense")}</strong></span>
|
||||
<span className={`amount ${tot.actual_expense > tot.planned_expense ? "neg" : ""}`}>{fmt(tot.actual_expense)}</span>
|
||||
<span className="muted"> / {fmt(tot.planned_expense)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="card">
|
||||
<p className="muted small">{t("budget.templateHint")}</p>
|
||||
<h2>{t("budget.expenses")}</h2>
|
||||
{expenseGroups.map((g) => (
|
||||
<div className="budget-group" key={g.id}>
|
||||
<div className="budget-group-head">
|
||||
<IconChip name={g.icon} color={g.color} size={26} />
|
||||
<strong>{g.name}</strong>
|
||||
</div>
|
||||
{g.categories.map((c) => (
|
||||
<div className="budget-cat" key={c.id}>
|
||||
<div className="budget-cat-name grow">
|
||||
<IconChip name={c.icon} color={c.color} size={24} />
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
<EditableAmount cents={tplMap[c.id] || 0} onSave={(cents) => saveTemplate(c.id, cents)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="page-head" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<h2 style={{ margin: 0 }}>{t("budget.income")}</h2>
|
||||
<button className="btn sm primary" onClick={() => setIncomeModal({ label: "", category_id: "", target_account_id: "", planned_cents: 0 })}>
|
||||
<Icon name="plus" size={15} /> {t("budget.addIncome")}
|
||||
</button>
|
||||
</div>
|
||||
{template.income.length === 0 ? (
|
||||
<div className="muted small">{t("budget.noIncome")}</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
{template.income.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td><strong>{i.label}</strong></td>
|
||||
<td style={{ textAlign: "right" }}>{formatMoney(i.planned_cents, base)}</td>
|
||||
<td className="nowrap" style={{ textAlign: "right", width: 80 }}>
|
||||
<button className="btn sm ghost" onClick={() => setIncomeModal({ ...i, category_id: i.category_id || "", target_account_id: i.target_account_id || "" })}>
|
||||
<Icon name="edit" size={14} />
|
||||
</button>
|
||||
<button className="btn sm danger" onClick={() => deleteIncome(i.id)}>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{incomeModal && (
|
||||
<Modal
|
||||
title={incomeModal.id ? t("budget.editIncome") : t("budget.addIncome")}
|
||||
onClose={() => setIncomeModal(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setIncomeModal(null)}>{t("common.cancel")}</button>
|
||||
<button className="btn primary" form="income-form" type="submit">{t("common.save")}</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="income-form" onSubmit={saveIncome}>
|
||||
<label>
|
||||
<span>{t("budget.incomeLabel")}</span>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input autoFocus value={incomeModal.label} onChange={(e) => setIncomeModal({ ...incomeModal, label: e.target.value })} required />
|
||||
</label>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
<span>{t("budget.incomeCategory")}</span>
|
||||
<select value={incomeModal.category_id} onChange={(e) => setIncomeModal({ ...incomeModal, category_id: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{incomeCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("budget.targetAccount")}</span>
|
||||
<select value={incomeModal.target_account_id} onChange={(e) => setIncomeModal({ ...incomeModal, target_account_id: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>{t("budget.planned")} ({base})</span>
|
||||
<input inputMode="decimal" defaultValue={(incomeModal.planned_cents / 100).toFixed(2).replace(".", ",")}
|
||||
onChange={(e) => {
|
||||
const cleaned = e.target.value.replace(",", ".");
|
||||
setIncomeModal((m) => ({ ...m, planned_cents: Math.round((Number.parseFloat(cleaned) || 0) * 100) }));
|
||||
}} />
|
||||
</label>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { centsToInput, parseAmountToCents, todayIso } from "../format";
|
||||
|
||||
const TYPES = ["expense", "income", "transfer"];
|
||||
|
||||
const SAVINGS_TYPES = ["sparkonto", "depot"];
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
type: "expense",
|
||||
@@ -23,6 +25,8 @@ function emptyForm() {
|
||||
to_amount: "",
|
||||
payee: "",
|
||||
description: "",
|
||||
budget_as_expense: true,
|
||||
budget_category_id: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +73,22 @@ export default function Transactions() {
|
||||
const fromAcc = form && accountById[Number(form.account_id)];
|
||||
const toAcc = form && accountById[Number(form.to_account_id)];
|
||||
const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency;
|
||||
const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type);
|
||||
|
||||
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
|
||||
const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]);
|
||||
const sparenCatId = useMemo(() => {
|
||||
for (const g of expenseGroups)
|
||||
for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id);
|
||||
return expenseGroups[0]?.categories?.[0] ? String(expenseGroups[0].categories[0].id) : "";
|
||||
}, [expenseGroups]);
|
||||
|
||||
// Bei Spar-/Depot-Ziel eine Budget-Kategorie vorbelegen.
|
||||
useEffect(() => {
|
||||
if (savingsDest && form && !form.budget_category_id) {
|
||||
setForm((f) => (f && !f.budget_category_id ? { ...f, budget_category_id: sparenCatId } : f));
|
||||
}
|
||||
}, [savingsDest, sparenCatId]);
|
||||
|
||||
// Bei währungsübergreifenden Transfers den Zielbetrag per EZB-Kurs vorschlagen.
|
||||
useEffect(() => {
|
||||
@@ -102,6 +122,8 @@ export default function Transactions() {
|
||||
to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "",
|
||||
payee: tx.payee || "",
|
||||
description: tx.description || "",
|
||||
budget_as_expense: tx.budget_treatment !== "neutral",
|
||||
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,6 +151,11 @@ export default function Transactions() {
|
||||
body.to_account_id = Number(form.to_account_id);
|
||||
const toCents = parseAmountToCents(form.to_amount);
|
||||
if (toCents) body.to_amount_cents = toCents;
|
||||
if (savingsDest) {
|
||||
body.budget_treatment = form.budget_as_expense ? "budgeted" : "neutral";
|
||||
body.budget_category_id =
|
||||
form.budget_as_expense && form.budget_category_id ? Number(form.budget_category_id) : null;
|
||||
}
|
||||
} else {
|
||||
body.category_id = form.category_id ? Number(form.category_id) : null;
|
||||
}
|
||||
@@ -320,6 +347,28 @@ export default function Transactions() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{savingsDest && (
|
||||
<div className="savings-opt">
|
||||
<label className="checkline">
|
||||
<input type="checkbox" checked={form.budget_as_expense}
|
||||
onChange={(e) => setForm({ ...form, budget_as_expense: e.target.checked })} />
|
||||
<span>{t("transactions.budgetAsSavings")}</span>
|
||||
</label>
|
||||
{form.budget_as_expense && (
|
||||
<label>
|
||||
<span>{t("transactions.budgetCategory")}</span>
|
||||
<select value={form.budget_category_id} onChange={(e) => setForm({ ...form, budget_category_id: e.target.value })}>
|
||||
{expenseGroups.map((g) => (
|
||||
<optgroup key={g.id} label={g.name}>
|
||||
{g.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.type !== "transfer" && (
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
|
||||
Reference in New Issue
Block a user