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:
@@ -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