feat: Zu welchem Budget-Monat eine Buchung gehoert

Bisher entschied allein das Datum. Bei einem Budget-Monat, der nicht am
Ersten beginnt, faellt das auf: Die Zahlung geht am 24. ab und gehoert
doch zum naechsten Monat, die Rechnung wird verspaetet beglichen und
gehoert zum letzten.

Neu traegt jede Buchung einen Budget-Monat. Leer heisst weiterhin "aus dem
Datum" - der Normalfall, und er bleibt es. Wird einer von Hand gesetzt,
zaehlt die Buchung nur noch dort und in ihrem eigenen Monat NICHT mehr,
sonst stuende sie zweimal da.

Das Aendern liegt bewusst hinter einem Klick und hinter Pfeilen statt
hinter einer Auswahlliste: Es ist der Ausnahmefall, nicht der Normalfall,
und soll sich auch so anfuehlen. Waehlt man genau den Monat, der ohnehin
gilt, bleibt die Buchung automatisch - verschiebt sich das Datum spaeter,
wandert sie mit. In der Liste zeigt eine Marke den Monat, aber nur, wenn
jemand eingegriffen hat.

Der Server nimmt drei Monate in jede Richtung an. Ein Vertipper wie
2019-03 wird abgelehnt: Er liesse die Buchung aus jeder Auswertung
verschwinden, ohne dass irgendwo etwas fehlt.

Geprueft im Browser gegen eine Wegwerf-Datenbank mit Stichtag 25: Die
Karte summiert 25.08.-24.09., eine verschobene Buchung verlaesst diesen
Zeitraum und kehrt beim Zuruecknehmen zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 14:57:16 +02:00
co-authored by Claude Opus 5
parent bd79b83339
commit 0b19e07ce0
15 changed files with 413 additions and 35 deletions
+96
View File
@@ -0,0 +1,96 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import { formatMonthLabel } from "../format";
/** Auf welchen Tag der Monat gekappt fällt – einen 31. Februar gibt es nicht.
* Dieselbe Kappung wie `_clamp_day` im Server, sonst fiele der 28. Februar bei
* Starttag 31 in einen Monat, der ihn gar nicht enthält. */
function gekappt(jahr, monat, tag) {
return Math.min(tag, new Date(jahr, monat, 0).getDate());
}
/** Der Budget-Monat, in den ein Datum von selbst fällt – die Entsprechung zu
* `budget.period_of` im Server. Hier gerechnet und nicht erfragt, damit die
* Anzeige mitläuft, während man im Formular noch am Datum dreht. */
export function budgetMonat(iso, startTag) {
const [j, m, d] = String(iso).split("-").map(Number);
if (!j || !m) return "";
const zwei = (n) => String(n).padStart(2, "0");
if (startTag <= 1 || d < gekappt(j, m, startTag)) return `${j}-${zwei(m)}`;
return m === 12 ? `${j + 1}-01` : `${j}-${zwei(m + 1)}`;
}
export function verschoben(label, monate) {
const [j, m] = label.split("-").map(Number);
const d = new Date(j, m - 1 + monate, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
// Drei Monate in jede Richtung – so weit wie der Server es annimmt, und weit
// genug für den echten Fall (eine Zahlung kurz vor dem Stichtag, eine
// verspätet beglichene Rechnung).
const REICHWEITE = 3;
/**
* Zu welchem Budget-Monat eine Buchung zählt.
*
* Normalerweise entscheidet das Datum, und dann ist hier nichts zu tun – das
* Feld sagt nur, was gilt. Das Ändern liegt bewusst hinter einem Klick und
* hinter Pfeilen statt hinter einer Auswahlliste: Es ist der Ausnahmefall,
* nicht der Normalfall, und soll sich auch so anfühlen.
*/
export default function BudgetZeitraum({ wert, datum, startTag, onChange }) {
const { t, i18n } = useTranslation();
const [offen, setOffen] = useState(false);
const eigen = budgetMonat(datum, startTag);
if (!eigen) return null;
const gilt = wert || eigen;
const abstand = Math.round(
(Number(gilt.split("-")[0]) - Number(eigen.split("-")[0])) * 12
+ Number(gilt.split("-")[1]) - Number(eigen.split("-")[1]));
function setze(schritt) {
const neu = verschoben(gilt, schritt);
onChange(neu === eigen ? null : neu);
}
return (
<div className="budgetzeitraum">
<span className="line-label">{t("transactions.budgetPeriod")}</span>
<div className="budgetzeitraum-kopf">
<strong>{formatMonthLabel(gilt, i18n.language)}</strong>
{wert
? <span className="badge">{t("transactions.budgetPeriodManual")}</span>
: <span className="muted small">{t("transactions.budgetPeriodAuto")}</span>}
<span className="grow" />
{!offen && (
<button type="button" className="link-btn" onClick={() => setOffen(true)}>
{t("transactions.budgetPeriodChange")}
</button>
)}
</div>
{offen && (
<div className="budgetzeitraum-wahl">
<p className="muted small">{t("transactions.budgetPeriodHint")}</p>
<div className="budgetzeitraum-schritte">
<button type="button" className="btn sm" disabled={abstand <= -REICHWEITE}
aria-label={t("transactions.budgetPeriodBack")} onClick={() => setze(-1)}>
<Icon name="chevron-left" size={16} />
</button>
<span className="budgetzeitraum-monat">{formatMonthLabel(gilt, i18n.language)}</span>
<button type="button" className="btn sm" disabled={abstand >= REICHWEITE}
aria-label={t("transactions.budgetPeriodForward")} onClick={() => setze(1)}>
<Icon name="chevron-right" size={16} />
</button>
{wert && (
<button type="button" className="link-btn" onClick={() => onChange(null)}>
{t("transactions.budgetPeriodReset")}
</button>
)}
</div>
</div>
)}
</div>
);
}
+7
View File
@@ -72,6 +72,13 @@ const PATHS = {
<path d="M7 10l5 5 5-5M12 15V3" />
</>
),
calendar: (
<>
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</>
),
"chevron-left": <path d="m15 18-6-6 6-6" />,
"chevron-right": <path d="m9 18 6-6-6-6" />,
"chevron-down": <path d="m6 9 6 6 6-6" />,
"chevron-up": <path d="m18 15-6-6-6 6" />,
+36
View File
@@ -1487,3 +1487,39 @@ tr.row-dup {
.dash-zeitraum {
margin-bottom: var(--sp-2);
}
/* ---------- Budgetzeitraum einer Buchung ----------
Normalerweise entscheidet das Datum, und dann steht hier nur, was gilt. Das
Ändern liegt hinter einem Klick und hinter Pfeilen statt hinter einer
Auswahlliste: Es ist der Ausnahmefall und soll sich auch so anfühlen. */
.budgetzeitraum {
margin-bottom: var(--sp-4);
}
.budgetzeitraum-kopf {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 32px;
}
.budgetzeitraum-wahl {
margin-top: var(--sp-2);
padding: var(--sp-3);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-sm);
}
.budgetzeitraum-wahl p {
margin: 0 0 var(--sp-3);
}
.budgetzeitraum-schritte {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
/* Feste Breite: Sonst springen die Pfeile, sobald „Mai" auf „September" folgt. */
.budgetzeitraum-monat {
min-width: 9.5em;
text-align: center;
font-weight: 580;
color: var(--text);
}
+9
View File
@@ -67,6 +67,15 @@ export function todayIso() {
}
/** Monats-Label "2026-09" -> "September 2026". */
/** Kurzform für enge Stellen – "Okt. 2026" statt "Oktober 2026". */
export function formatMonthShort(label, locale = "de") {
if (!label) return "";
const [y, m] = label.split("-").map(Number);
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
month: "short", year: "numeric",
}).format(new Date(y, (m || 1) - 1, 1));
}
export function formatMonthLabel(label, locale = "de") {
if (!label) return "";
const [y, m] = label.split("-").map(Number);
+9 -1
View File
@@ -276,7 +276,15 @@
"accountRequired": "Bitte ein Konto wählen.",
"budgetAsSavings": "Als Sparen ins Budget zählen",
"budgetCategory": "Budget-Kategorie",
"subject": "Buchung"
"subject": "Buchung",
"budgetPeriod": "Budgetzeitraum",
"budgetPeriodAuto": "aus dem Datum",
"budgetPeriodManual": "von Hand gesetzt",
"budgetPeriodReset": "wieder aus dem Datum",
"budgetPeriodBack": "Ein Monat zurück",
"budgetPeriodForward": "Ein Monat vor",
"budgetPeriodHint": "Nur für Ausnahmefälle: eine Zahlung, die sichtbar in den einen Monat fällt und doch zum anderen gehört. Sie zählt dann nur noch dort – in ihrem eigenen Monat nicht mehr.",
"budgetPeriodChange": "ändern"
},
"budget": {
"title": "Budget",
+9 -1
View File
@@ -276,7 +276,15 @@
"accountRequired": "Please choose an account.",
"budgetAsSavings": "Count as savings in the budget",
"budgetCategory": "Budget category",
"subject": "Subject"
"subject": "Subject",
"budgetPeriod": "Budget period",
"budgetPeriodAuto": "from the date",
"budgetPeriodManual": "set by hand",
"budgetPeriodReset": "back to the date",
"budgetPeriodBack": "One month back",
"budgetPeriodForward": "One month forward",
"budgetPeriodHint": "For exceptions only: a payment that visibly falls in one month but belongs to another. It then counts there alone – no longer in its own month.",
"budgetPeriodChange": "change"
},
"budget": {
"title": "Budget",
+27 -3
View File
@@ -8,12 +8,13 @@ import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
import Modal from "../components/Modal";
import BudgetZeitraum from "../components/BudgetZeitraum";
import AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect";
import UebertragDialog from "../components/UebertragDialog";
import DataTable from "../components/DataTable";
import { gruppenFuer } from "../kategorien";
import { centsToInput, formatMoney, parseAmountToCents, todayIso } from "../format";
import { centsToInput, formatMonthLabel, formatMonthShort, formatMoney, parseAmountToCents, todayIso } from "../format";
const TYPES = ["expense", "income", "transfer"];
@@ -32,12 +33,14 @@ function emptyForm() {
description: "",
budget_as_expense: true,
budget_category_id: "",
// Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es.
budget_period: null,
};
}
export default function Transactions() {
const { t } = useTranslation();
const { baseCurrency, formatDate } = useSettings();
const { t, i18n } = useTranslation();
const { baseCurrency, formatDate, budget_start_day: startTag } = useSettings();
const confirm = useConfirm();
const toast = useToast();
@@ -203,6 +206,9 @@ export default function Transactions() {
description: tx.description || "",
budget_as_expense: tx.budget_treatment !== "neutral",
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
// Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst:
// aus dem Datum – und wandert mit, wenn man das Datum noch aendert.
budget_period: tx.budget_period_manuell ? tx.budget_period : null,
});
}
@@ -225,6 +231,7 @@ export default function Transactions() {
amount_cents: cents,
payee: form.payee.trim() || null,
description: form.description.trim() || null,
budget_period: form.budget_period || null,
};
if (form.type === "transfer") {
body.to_account_id = Number(form.to_account_id);
@@ -332,6 +339,16 @@ export default function Transactions() {
<span className="badge herkunft">{t(`todo.from.${tx.herkunft}`, {
defaultValue: tx.herkunft,
})}</span>
{/* Nur der Ausnahmefall wird gezeigt: Steht hier nichts, gilt
das Datum – und das steht ohnehin in seiner eigenen
Spalte. */}
{tx.budget_period_manuell && (
<span className="badge accent herkunft"
title={`${t("transactions.budgetPeriod")}: ${formatMonthLabel(tx.budget_period, i18n.language)} (${t("transactions.budgetPeriodManual")})`}>
<Icon name="calendar" size={11} />
{formatMonthShort(tx.budget_period, i18n.language)}
</span>
)}
</div>
{l.sub && <div className="named-sub ellipsis">{l.sub}</div>}
{/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme.
@@ -593,6 +610,13 @@ export default function Transactions() {
)}
</div>
<BudgetZeitraum
wert={form.budget_period}
datum={form.date}
startTag={Number(startTag) || 1}
onChange={(label) => setForm({ ...form, budget_period: label })}
/>
{savingsDest && (
<div className="savings-opt">
<label className="checkline">