diff --git a/Changes.md b/Changes.md index c0e9e40..adcdde7 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,22 @@ # Änderungen +## Budgetplaner: drei Ansichten und Verteilen über das Zahnrad (11.08.2026) + +- Abgelaufene Monate werden **nicht mehr zusammengefasst**. Alle zwölf Monate stehen + einzeln da; die Sammelspalte „Januar–Vormonat“ entfällt samt + `Budgetplaner::abgelaufenBis()` und `Budgetplaner::summe()`. +- Statt *Verlauf* und *alle Monate* gibt es drei Ansichten: **Alles** (Budget, Ist und + Saldo je Monat), **Nur Budget** und **Nur IST** mit je einer Spalte. Alte URLs mit + `ansicht=verlauf|alle` fallen auf *Alles* zurück. +- Die Budgetspalte ist überall dort, wo sie steht, editierbar – auch in vergangenen + Monaten. +- Vor jedem Eingabefeld sitzt ein **⚙**: *Ganzes Jahr* oder *Restliches Jahr* schreibt den + Wert des Feldes in die übrigen Monate der Zeile und überschreibt, was dort stand. + Gearbeitet wird auf den Eingabefeldern, sichtbar vor dem Speichern; der Knopf **↦** und + `Budget::aufFolgemonateUebertragen()` entfallen dafür. +- `app.js` war doppelt eingebunden (Kopf **und** Fuß): Jeder Handler lief zweimal, jede + Rückfrage kam zweimal. Jetzt nur noch im Kopf. + ## Phase 3 – Budgetplaner und Monatsabschluss (11.08.2026) **Datenmodell** diff --git a/docs/planung.md b/docs/planung.md index efb7da3..1f961d4 100644 --- a/docs/planung.md +++ b/docs/planung.md @@ -53,18 +53,36 @@ zählten Unterkategorien mehrfach. ### Ansichten -| Ansicht | Spalten | +Alle zwölf Monate stehen immer einzeln da – auch die abgelaufenen. Zusammengefasst wird +nichts: Ein vergangener Monat ließe sich sonst weder nachlesen noch nachplanen. Die Ansicht +entscheidet nur, welche der drei Wertspalten je Monat erscheinen: + +| Ansicht | Spalten je Monat (und für das Jahr) | |---|---| -| **Verlauf** (Standard) | Jahressumme, abgelaufene Monate zusammengefasst, laufender Monat mit Budget/Ist/Saldo, danach je Folgemonat eine Budgetspalte | -| **Alle Monate** | Jahressumme und zwölf Budgetspalten – zum Planen des nächsten Jahres oder Nachbessern eines vergangenen | +| **Alles** (Standard) | Budget, Ist, Saldo | +| **Nur Budget** | Budget | +| **Nur IST** | Ist | -Was als abgelaufen gilt, entscheidet `Budgetplaner::abgelaufenBis()`: im laufenden Jahr der -Vormonat, in vergangenen Jahren der Dezember, in künftigen nichts. +Wo die Budgetspalte steht, ist sie **immer editierbar**, im Januar wie im Dezember. +`BudgetController::ansicht()` nimmt nur `alles`, `budget` und `ist` an; alles andere fällt +auf `alles` zurück, damit ein altes Lesezeichen nicht ins Leere zeigt. Den laufenden Monat +hebt `Budgetplaner::laufenderMonat()` farblich hervor. -Die ganze Tabelle steht in **einem** Formular und wird mit einem Knopf gespeichert – ohne -JavaScript, damit mehrere geänderte Zeilen zusammen ankommen. Der Knopf **↦** in jeder -Zeile überträgt den Wert des ersten Eingabemonats auf alle Folgemonate -(`Budget::aufFolgemonateUebertragen()`). +Die ganze Tabelle steht in **einem** Formular und wird mit einem Knopf gespeichert – so +kommen mehrere geänderte Zeilen zusammen an. + +### Werte über die Zeile verteilen + +Vor jedem Eingabefeld sitzt ein **⚙**. Es öffnet zwei Einträge: + +* **Ganzes Jahr** – der Wert des Feldes geht in alle zwölf Monate der Zeile +* **Restliches Jahr** – nur in die Monate danach + +Beides überschreibt vorhandene Werte und arbeitet in `app.js` auf den Eingabefeldern, nicht +auf der Datenbank: Das Ergebnis ist vor dem Speichern zu sehen und geht mit demselben Knopf +weg wie jede andere Änderung. Das Menü gibt es nur einmal im Dokument und wandert in die +angeklickte Zelle – ein frei positioniertes bräuchte ein `style`-Attribut, das die +Content-Security-Policy verwirft. `Budget::kopieren()` legt ein Budget samt aller Monatswerte im neuen Jahr an – der Weg zum Jahresbudget, ohne 105 Kategorien noch einmal zu tippen. @@ -104,8 +122,9 @@ rückwärts und endet am ersten bereits abgeschlossenen Monat. ## Tests * `tests/11_BudgetTest.php` – Vorzeichen, „nicht geplant“, Ist-Werte samt Splits, - Baumsummen, Saldo, Folgemonate, Kopie + Baumsummen, Saldo, Kopie, Ansichtswahl * `tests/12_MonatsabschlussTest.php` – Zeiträume, Prüflauf, Snapshot, Sperre, Wiedereröffnung, Vorschlag * `tests/e2e/specs/planung.spec.js` – Budget anlegen, Wert eintragen und wiederfinden, - Übertragen auf Folgemonate, Abschluss mit Snapshot und Wiedereröffnung + Spaltenbild der drei Ansichten, Verteilen über das Zahnrad, Abschluss mit Snapshot und + Wiedereröffnung diff --git a/public/static/app.css b/public/static/app.css index 2aa5f22..99da9e1 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -439,8 +439,9 @@ table.budgetplaner .laufend { background: color-mix(in srgb, var(--akzent) 8%, t table.budgetplaner tr.summenzeile-kategorie td { font-weight: 600; } +/* Breit genug für „123.456,78“ – eine Stelle mehr, als der Haushalt braucht. */ table.budgetplaner input.betragsfeld { - width: 6rem; + width: 7rem; text-align: right; font-family: var(--schrift-zahl); font-variant-numeric: tabular-nums; @@ -451,6 +452,60 @@ table.budgetplaner td.rechts { font-variant-numeric: tabular-nums; } +/* Zahnrad vor jedem Eingabefeld: verteilt den Wert über die Zeile. Das Menü + dazu gibt es nur einmal; app.js hängt es in die angeklickte Zelle, die es + deshalb verankern muss. Positioniert wird über CSS, nicht über ein per + JavaScript gesetztes style-Attribut – das verwürfe die + Content-Security-Policy (style-src 'self'). */ +table.budgetplaner td.budgetzelle { position: relative; } + +.zahnrad { + border: none; + background: none; + color: var(--text-gedaempft); + cursor: pointer; + font-size: .85rem; + line-height: 1; + /* Ein Drittel des früheren Abstands: Der kam aus diesem Padding plus dem + Leerzeichen im Markup, das die Zelle in Ziffernschrift breit setzte. */ + padding: 0 .27rem 0 0; +} + +.zahnrad:hover, +.zahnrad[aria-expanded="true"] { color: var(--akzent); } + +.zahnradmenue { + position: absolute; + top: 100%; + right: .3rem; + z-index: 3; + display: flex; + flex-direction: column; + min-width: 10rem; + padding: .2rem; + border: 1px solid var(--rand-kraeftig); + border-radius: var(--radius); + background: var(--flaeche); + box-shadow: 0 4px 12px rgb(0 0 0 / 18%); +} + +/* Fließtext, nicht Zahlensatz: Die Zelle vererbt sonst ihre Ziffernschrift. */ +.menueeintrag { + border: none; + background: none; + color: var(--text); + cursor: pointer; + font: inherit; + font-family: var(--schrift); + font-size: .88rem; + text-align: left; + padding: .35rem .5rem; + border-radius: var(--radius); + white-space: nowrap; +} + +.menueeintrag:hover { background: var(--flaeche-gedaempft); } + /* Aufklappen des Kategoriebaums; ohne JavaScript bleibt alles sichtbar. */ .klappknopf { border: none; @@ -711,6 +766,14 @@ table.splittabelle input.rechts { text-align: right; } font-size: .85rem; } +/* Ein Knopf, der die gerade gewählte Ansicht markiert – etwa die drei + Spaltenbilder des Budgetplaners. */ +.knopf.aktiv { + background: var(--akzent-schwach); + border-color: var(--akzent); + font-weight: 500; +} + .knopf.gefahr { border-color: var(--negativ); color: var(--negativ); diff --git a/public/static/app.js b/public/static/app.js index f91a2aa..79e4fd8 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -52,6 +52,102 @@ }); }); + // Zahnrad im Budgetplaner: verteilt den eingegebenen Wert über die Zeile, + // wahlweise auf das ganze oder nur auf das restliche Jahr. Gearbeitet wird + // auf den Eingabefeldern, nicht auf der Datenbank – so ist das Ergebnis vor + // dem Speichern zu sehen und geht mit demselben Knopf weg wie alles andere. + // + // Das Menü gibt es nur einmal und wandert in die angeklickte Zelle: Ein + // eigenes Menü je Feld wären zwölf Stück pro Kategoriezeile, und ein frei + // positioniertes bräuchte ein style-Attribut, das die + // Content-Security-Policy verwirft. + const planer = document.querySelector('table.budgetplaner'); + if (planer) { + const menue = document.createElement('div'); + menue.className = 'zahnradmenue'; + + let feld = null; + let zahnrad = null; + + function schliessen() { + if (menue.parentNode) { + menue.parentNode.removeChild(menue); + } + if (zahnrad) { + zahnrad.setAttribute('aria-expanded', 'false'); + } + feld = null; + zahnrad = null; + } + + function uebertragen(abMonat) { + const zeile = feld.closest('tr'); + const wert = feld.value; + + zeile.querySelectorAll('input.betragsfeld').forEach(function (anderes) { + if (Number(anderes.dataset.monat) >= abMonat) { + anderes.value = wert; + } + }); + + schliessen(); + } + + [ + { text: 'Ganzes Jahr', ab: function () { return 1; } }, + { text: 'Restliches Jahr', ab: function (monat) { return monat + 1; } } + ].forEach(function (eintrag) { + const knopf = document.createElement('button'); + knopf.type = 'button'; + knopf.className = 'menueeintrag'; + knopf.textContent = eintrag.text; + + knopf.addEventListener('click', function () { + if (feld) { + uebertragen(eintrag.ab(Number(feld.dataset.monat))); + } + }); + + menue.appendChild(knopf); + }); + + planer.addEventListener('click', function (ereignis) { + const knopf = ereignis.target.closest('.zahnrad'); + if (!knopf) { + return; + } + + const zelle = knopf.closest('td'); + const warOffen = menue.parentNode === zelle; + schliessen(); + if (warOffen) { + return; + } + + feld = zelle.querySelector('input.betragsfeld'); + zahnrad = knopf; + knopf.setAttribute('aria-expanded', 'true'); + zelle.appendChild(menue); + menue.firstChild.focus(); + }); + + // Klick daneben oder Escape schließt das Menü wieder. + document.addEventListener('click', function (ereignis) { + if (!(ereignis.target instanceof Element)) { + return; + } + if (!ereignis.target.closest('.zahnrad, .zahnradmenue')) { + schliessen(); + } + }); + + document.addEventListener('keydown', function (ereignis) { + if (ereignis.key === 'Escape') { + schliessen(); + } + }); + } + // Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen. // Über eine Klasse, nicht über element.style: Die Content-Security-Policy // (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles, diff --git a/src/Controllers/BudgetController.php b/src/Controllers/BudgetController.php index 9ad6aff..ea9b53e 100644 --- a/src/Controllers/BudgetController.php +++ b/src/Controllers/BudgetController.php @@ -13,9 +13,9 @@ use Finanzmanager\View; * Budgetverwaltung (anlegen, kopieren, aktivieren). * * Die Monatswerte werden nicht einzeln gespeichert, sondern die ganze Tabelle - * auf einmal – ein Formular um die Tabelle herum, ein POST. Das kommt ohne - * JavaScript aus und hält die Eingabe zusammen, wenn mehrere Zeilen geändert - * wurden. + * auf einmal – ein Formular um die Tabelle herum, ein POST. Das hält die + * Eingabe zusammen, wenn mehrere Zeilen geändert wurden, und lässt das + * Zahnrad einer Zelle ihren Wert vor dem Speichern über die Zeile verteilen. */ class BudgetController extends BaseController { @@ -29,16 +29,12 @@ class BudgetController extends BaseController return; } - $ansicht = $this->query('ansicht') === 'alle' ? 'alle' : 'verlauf'; - $tabelle = Budgetplaner::tabelle($budget); - $this->render('planung/budgetplaner.php', [ 'titel' => 'Budgetplaner', 'budget' => $budget, 'budgets' => Budget::alle(), - 'tabelle' => $tabelle, - 'ansicht' => $ansicht, - 'abgelaufenBis' => Budgetplaner::abgelaufenBis((int)$budget['jahr']), + 'tabelle' => Budgetplaner::tabelle($budget), + 'ansicht' => self::ansicht($this->query('ansicht')), 'laufenderMonat' => Budgetplaner::laufenderMonat((int)$budget['jahr']), 'nurGeplante' => $this->query('nur_geplante') === '1', ]); @@ -61,7 +57,7 @@ class BudgetController extends BaseController return; } - $werte = $_POST['betrag'] ?? []; + $werte = $_POST['betrag'] ?? []; $geaendert = 0; if (is_array($werte)) { @@ -77,16 +73,10 @@ class BudgetController extends BaseController } } - // „Auf Folgemonate übertragen“ kommt als Knopfwert „kategorie:monat“ und - // greift auf den soeben gespeicherten Stand. - $meldung = $geaendert > 0 ? 'Budget gespeichert.' : 'Nichts zu speichern.'; - $uebertragen = $this->post('uebertragen'); - if ($uebertragen !== '' && preg_match('/^(\d+):(\d{1,2})$/', $uebertragen, $treffer) === 1) { - Budget::aufFolgemonateUebertragen($id, (int)$treffer[1], (int)$treffer[2]); - $meldung = 'Wert auf die Folgemonate übertragen.'; - } - - View::redirect($this->zurueckZumPlaner($id), $meldung); + View::redirect( + $this->zurueckZumPlaner($id), + $geaendert > 0 ? 'Budget gespeichert.' : 'Nichts zu speichern.' + ); } public function verwaltung(): void @@ -200,6 +190,16 @@ class BudgetController extends BaseController return trim($eingabe) === '' ? null : KontenController::zahl($eingabe); } + /** + * Die drei Ansichten des Planers: alle drei Wertspalten je Monat, nur die + * Budgetspalte oder nur die Ist-Spalte. Unbekanntes fällt auf „alles“ + * zurück, damit eine alte Lesezeichen-URL nicht ins Leere zeigt. + */ + public static function ansicht(string $gewaehlt): string + { + return in_array($gewaehlt, ['budget', 'ist'], true) ? $gewaehlt : 'alles'; + } + /** @return array|null */ private function gewaehltesBudget(): ?array { @@ -217,10 +217,7 @@ class BudgetController extends BaseController /** Nach dem Speichern zurück in dieselbe Ansicht, nicht auf das aktive Budget. */ private function zurueckZumPlaner(int $id): string { - $pfad = '/planung/budget?budget=' . $id; - if ($this->post('ansicht') === 'alle') { - $pfad .= '&ansicht=alle'; - } + $pfad = '/planung/budget?budget=' . $id . '&ansicht=' . self::ansicht($this->post('ansicht')); if ($this->post('nur_geplante') === '1') { $pfad .= '&nur_geplante=1'; } diff --git a/src/Models/Budget.php b/src/Models/Budget.php index bb70e14..7374c0c 100644 --- a/src/Models/Budget.php +++ b/src/Models/Budget.php @@ -185,37 +185,6 @@ class Budget ); } - /** - * Trägt den Wert eines Monats in alle folgenden Monate des Jahres ein – - * der „auf Folgemonate übertragen“-Knopf des FinanzManagers. - */ - public static function aufFolgemonateUebertragen(int $budgetId, int $kategorieId, int $abMonat): void - { - $betrag = Database::fetchValue( - 'SELECT betrag FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?', - [$budgetId, $kategorieId, $abMonat] - ); - - Database::transaktion(static function () use ($budgetId, $kategorieId, $abMonat, $betrag): void { - for ($monat = $abMonat + 1; $monat <= 12; $monat++) { - if ($betrag === null) { - Database::execute( - 'DELETE FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?', - [$budgetId, $kategorieId, $monat] - ); - continue; - } - - Database::execute( - 'INSERT INTO budget_positionen (budget_id, kategorie_id, monat, betrag) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE betrag = VALUES(betrag)', - [$budgetId, $kategorieId, $monat, (float)$betrag] - ); - } - }); - } - /** * Legt eine Kopie samt aller Positionen an – so entsteht das Budget für das * neue Jahr aus dem laufenden, statt 105 Kategorien noch einmal zu tippen. diff --git a/src/Services/Budgetplaner.php b/src/Services/Budgetplaner.php index 05972e6..ef1020b 100644 --- a/src/Services/Budgetplaner.php +++ b/src/Services/Budgetplaner.php @@ -11,7 +11,9 @@ use Finanzmanager\Models\Kategorie; /** * Stellt die Tabelle des Budgetplaners zusammen: Zeilen sind der Kategoriebaum, - * Spalten die zwölf Monate mit Budget, Ist und Saldo. + * Spalten die zwölf Monate mit Budget, Ist und Saldo. Zusammengefasst wird + * nichts – auch abgelaufene Monate stehen einzeln da und bleiben beplanbar; + * welche der drei Wertspalten je Monat erscheinen, entscheidet die Ansicht. * * Vorzeichen durchgehend wie in der Buchhaltung (Ausgaben negativ). Daraus * folgt eine Definition, die für Einnahmen wie Ausgaben gleich funktioniert: @@ -151,40 +153,6 @@ class Budgetplaner ]; } - /** - * Summiert einen Monatsbereich einer Zeile – für die zusammengefassten - * Spalten „Januar bis Vormonat“ und den Jahresüberblick. - * - * @param array $reihe - */ - public static function summe(array $reihe, int $vonMonat, int $bisMonat): float - { - $summe = 0.0; - for ($monat = max(1, $vonMonat); $monat <= min(12, $bisMonat); $monat++) { - $summe += $reihe[$monat] ?? 0.0; - } - return $summe; - } - - /** - * Bis zu welchem Monat das Jahr abgelaufen ist: im laufenden Jahr der - * Vormonat, in vergangenen Jahren der Dezember, in künftigen gar nichts. - */ - public static function abgelaufenBis(int $jahr, ?string $heute = null): int - { - $heute = $heute ?? date('Y-m-d'); - $jetztJahr = (int)substr($heute, 0, 4); - $jetztMonat = (int)substr($heute, 5, 2); - - if ($jahr < $jetztJahr) { - return 12; - } - if ($jahr > $jetztJahr) { - return 0; - } - return $jetztMonat - 1; - } - /** Der Monat, der im laufenden Jahr gerade läuft – sonst 0. */ public static function laufenderMonat(int $jahr, ?string $heute = null): int { diff --git a/src/Views/layout/fuss.php b/src/Views/layout/fuss.php index 4ef449c..f13ca99 100644 --- a/src/Views/layout/fuss.php +++ b/src/Views/layout/fuss.php @@ -1,6 +1,5 @@ - diff --git a/src/Views/layout/kopf.php b/src/Views/layout/kopf.php index 16932b9..e3bfed6 100644 --- a/src/Views/layout/kopf.php +++ b/src/Views/layout/kopf.php @@ -31,6 +31,8 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool { <?= View::h($titel ?? 'Finanzmanager') ?> – Finanzmanager + diff --git a/src/Views/planung/budgetplaner.php b/src/Views/planung/budgetplaner.php index 4e5e600..9135fc0 100644 --- a/src/Views/planung/budgetplaner.php +++ b/src/Views/planung/budgetplaner.php @@ -7,19 +7,23 @@ use Finanzmanager\View; /** @var list> $budgets */ /** @var array{zeilen: list>, gesamt: array, jahr: int} $tabelle */ /** @var string $ansicht */ -/** @var int $abgelaufenBis */ /** @var int $laufenderMonat */ /** @var bool $nurGeplante */ /** @var string $csrfToken */ $jahr = (int)$budget['jahr']; -// Welche Monate bekommen eine Eingabespalte? In der Verlaufsansicht sind das -// der laufende Monat und alles danach – abgelaufene Monate fasst eine einzige -// Spalte zusammen, wie im FinanzManager. „Alle Monate“ zeigt alle zwölf. -$alleMonate = $ansicht === 'alle'; -$erster = $alleMonate ? 1 : max(1, $laufenderMonat === 0 ? 1 : $laufenderMonat); -$eingabe = range($erster, 12); +// Alle zwölf Monate stehen einzeln da – auch die abgelaufenen. Zusammengefasst +// wird nichts mehr: Ein vergangener Monat lässt sich sonst weder nachlesen noch +// nachplanen. Die Ansicht entscheidet nur, welche Wertspalten je Monat kommen. +$ansichten = ['alles' => 'Alles', 'budget' => 'Nur Budget', 'ist' => 'Nur IST']; + +$zeigtBudget = $ansicht !== 'ist'; +$zeigtIst = $ansicht !== 'budget'; +$zeigtSaldo = $ansicht === 'alles'; +$spalten = ($zeigtBudget ? 1 : 0) + ($zeigtIst ? 1 : 0) + ($zeigtSaldo ? 1 : 0); + +$monate = range(1, 12); $zelle = static function (float $wert): string { return View::betrag($wert, false); @@ -58,9 +62,14 @@ include __DIR__ . '/../layout/kopf.php'; - - - + $beschriftung): ?> + > + + + + @@ -71,11 +80,17 @@ include __DIR__ . '/../layout/kopf.php';

Budget für . - Ausgaben werden ohne Vorzeichen eingetragen – die Kategorieart bestimmt es. - Ein leeres Feld heißt „nicht geplant“, eine 0 heißt „mit 0,00 geplant“. - Saldo ist Ist minus Budget: negativ bedeutet über Plan. + + Saldo ist Ist minus Budget: negativ bedeutet über Plan. + Übergeordnete Kategorien summieren ihre Unterkategorien und werden deshalb nicht selbst beplant. + + Ausgaben werden ohne Vorzeichen eingetragen – die Kategorieart bestimmt es. + Ein leeres Feld heißt „nicht geplant“, eine 0 heißt „mit 0,00 geplant“. + Das vor einem Feld überträgt dessen Wert + auf das ganze oder das restliche Jahr. +

@@ -85,48 +100,35 @@ include __DIR__ . '/../layout/kopf.php'; -
- -
+ +
+ +
+
- - - 0): ?> - - - - + + + - - - - - - - 0): ?> - - - - - - - - - - - - - + 1): ?> + + + 0 && $block === $laufenderMonat ? ' laufend' : ''; ?> + + + + + + @@ -134,9 +136,9 @@ include __DIR__ . '/../layout/kopf.php'; if ($nurGeplante && !$zeile['geplant']) { continue; } - $id = (int)$zeile['id']; - $blatt = !$zeile['hat_kinder']; - $klassen = $blatt ? '' : ' summenzeile-kategorie'; + $id = (int)$zeile['id']; + $blatt = !$zeile['hat_kinder']; + $klassen = $blatt ? '' : ' summenzeile-kategorie'; ?> @@ -150,93 +152,90 @@ include __DIR__ . '/../layout/kopf.php'; - - - - - 0): ?> - - - - + + + + + + - - - - - + + + + + + - - - - - - 0): ?> - - - - + + + + + + - - - - - + + + + + + -
KategorieJahr - Januar– - + 1 ? 'rowspan="2" ' : '' ?>class="spalte-kategorie">KategorieJahr ↦
BudgetIstSaldoBudgetIstSaldoBudgetIstSaldoBudget
BudgetIstSaldo
- - - + + + - - - - - - + + + + + + + + + + + - - - -
Gesamt - - - + + + - - + + + +
-
- -
+ +
+ +
+
diff --git a/tests/11_BudgetTest.php b/tests/11_BudgetTest.php index f073f81..725dedf 100644 --- a/tests/11_BudgetTest.php +++ b/tests/11_BudgetTest.php @@ -31,9 +31,8 @@ class BudgetTest extends TestCase $this->istWerteAusBuchungenUndSplits(); $this->tabelleSummiertDenBaum(); $this->saldoZeigtUeberschreitung(); - $this->folgemonateUebertragen(); $this->kopieFuerDasNaechsteJahr(); - $this->zeitraeumeDerAnsicht(); + $this->ansichtDesPlaners(); } private function stammdaten(): void @@ -235,22 +234,6 @@ class BudgetTest extends TestCase ); } - private function folgemonateUebertragen(): void - { - $budget = $this->budgetAnlegen(); - Budget::positionSetzen($budget, $this->lebensmittel, 3, 300.0); - Budget::positionSetzen($budget, $this->lebensmittel, 12, 999.0); - - Budget::aufFolgemonateUebertragen($budget, $this->lebensmittel, 3); - - $positionen = Budget::positionen($budget)[$this->lebensmittel]; - $this->assert(!isset($positionen[2]), 'Der Vormonat bleibt unangetastet'); - $this->assertEquals(-300.0, $positionen[3], 'Der Ausgangsmonat bleibt stehen'); - $this->assertEquals(-300.0, $positionen[4], 'April übernimmt den Wert'); - $this->assertEquals(-300.0, $positionen[12], 'Auch ein vorhandener Dezemberwert wird überschrieben'); - $this->assertEquals(10, count($positionen), 'März bis Dezember sind gefüllt'); - } - private function kopieFuerDasNaechsteJahr(): void { $budget = $this->budgetAnlegen('Vorlage'); @@ -283,23 +266,19 @@ class BudgetTest extends TestCase $this->assert($doppelt, 'Zwei Budgets mit demselben Namen gehen nicht'); } - /** Welche Monate gelten als abgelaufen, welcher läuft gerade? */ - private function zeitraeumeDerAnsicht(): void + /** + * Der laufende Monat wird im Planer hervorgehoben; die Ansicht entscheidet, + * welche Wertspalten je Monat erscheinen. + */ + private function ansichtDesPlaners(): void { - $this->assertEquals(7, Budgetplaner::abgelaufenBis(2026, '2026-08-11'), 'Im August sind sieben Monate durch'); - $this->assertEquals(0, Budgetplaner::abgelaufenBis(2026, '2026-01-15'), 'Im Januar ist noch nichts abgelaufen'); - $this->assertEquals(12, Budgetplaner::abgelaufenBis(2025, '2026-08-11'), 'Ein vergangenes Jahr ist ganz durch'); - $this->assertEquals(0, Budgetplaner::abgelaufenBis(2027, '2026-08-11'), 'Ein künftiges Jahr ist offen'); - $this->assertEquals(8, Budgetplaner::laufenderMonat(2026, '2026-08-11'), 'Der laufende Monat ist der August'); $this->assertEquals(0, Budgetplaner::laufenderMonat(2027, '2026-08-11'), 'Andere Jahre haben keinen laufenden Monat'); - $reihe = []; - for ($monat = 1; $monat <= 12; $monat++) { - $reihe[$monat] = 10.0; - } - $this->assertEquals(70.0, Budgetplaner::summe($reihe, 1, 7), 'Summe über die abgelaufenen Monate'); - $this->assertEquals(120.0, Budgetplaner::summe($reihe, 1, 99), 'Über den Dezember hinaus wird nicht gezählt'); - $this->assertEquals(0.0, Budgetplaner::summe($reihe, 5, 4), 'Ein leerer Bereich ergibt null'); + $this->assertEquals('alles', BudgetController::ansicht('alles'), 'Alle drei Wertspalten'); + $this->assertEquals('budget', BudgetController::ansicht('budget'), 'Nur die Budgetspalte'); + $this->assertEquals('ist', BudgetController::ansicht('ist'), 'Nur die Ist-Spalte'); + $this->assertEquals('alles', BudgetController::ansicht(''), 'Ohne Angabe stehen alle Spalten da'); + $this->assertEquals('alles', BudgetController::ansicht('verlauf'), 'Eine alte URL fällt auf „alles“ zurück'); } } diff --git a/tests/e2e/specs/planung.spec.js b/tests/e2e/specs/planung.spec.js index f77a7d0..c23c7b3 100644 --- a/tests/e2e/specs/planung.spec.js +++ b/tests/e2e/specs/planung.spec.js @@ -8,8 +8,8 @@ const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' // und dürfen sich nicht an Resten eines früheren Laufs stoßen. const MARKE = Date.now().toString().slice(-6) -// Ein Jahr weit in der Zukunft: dort sind alle zwölf Monate Folgemonate, die -// Spaltenaufteilung des Planers bleibt damit über die Jahre stabil. +// Ein Jahr weit in der Zukunft: dort gibt es keine Buchungen, die Ist-Werte +// bleiben leer und die geprüften Summen damit über die Jahre stabil. const JAHR = new Date().getFullYear() + 5 async function anmelden(page) { @@ -65,7 +65,7 @@ test.describe('Planung', () => { test('Budget anlegen, Monatswert eintragen und wiederfinden', async ({ page }) => { const id = await budgetAnlegen(page, `E2E-Budget ${MARKE}`) - await page.goto(`/planung/budget?budget=${id}&ansicht=alle`) + await page.goto(`/planung/budget?budget=${id}`) const zeile = ersteBeplanbareZeile(page) const kategorie = (await zeile.locator('.baumname').innerText()).trim() @@ -80,24 +80,59 @@ test.describe('Planung', () => { await expect(wieder).toContainText('250,00') }) - test('Wert auf die Folgemonate übertragen', async ({ page }) => { - const id = await budgetAnlegen(page, `E2E-Folgemonate ${MARKE}`) + test('Die drei Ansichten zeigen Budget, Ist und Saldo je Monat', async ({ page }) => { + const id = await budgetAnlegen(page, `E2E-Ansichten ${MARKE}`) - await page.goto(`/planung/budget?budget=${id}&ansicht=alle`) + // Alles: zwölf Monate à drei Wertspalten, dazu die Jahresspalten. + await page.goto(`/planung/budget?budget=${id}`) + const zeile = ersteBeplanbareZeile(page) + await expect(zeile.locator('td')).toHaveCount(1 + 3 + 12 * 3) + await expect(zeile.locator('input.betragsfeld')).toHaveCount(12) + // Kein Monat ist zusammengefasst: Januar steht als eigene Spalte da. + await expect(page.locator('th.gruppenkopf', { hasText: 'Jan' })).toHaveCount(1) + + await page.click('a:has-text("Nur Budget")') + await expect(ersteBeplanbareZeile(page).locator('td')).toHaveCount(1 + 1 + 12) + await expect(ersteBeplanbareZeile(page).locator('input.betragsfeld')).toHaveCount(12) + + // Nur IST ist eine reine Leseansicht – ohne Eingabefelder und ohne Speichern. + await page.click('a:has-text("Nur IST")') + await expect(ersteBeplanbareZeile(page).locator('td')).toHaveCount(1 + 1 + 12) + await expect(page.locator('input.betragsfeld')).toHaveCount(0) + await expect(page.locator('button:has-text("Budget speichern")')).toHaveCount(0) + }) + + test('Zahnrad überträgt den Wert auf das ganze und das restliche Jahr', async ({ page }) => { + const id = await budgetAnlegen(page, `E2E-Zahnrad ${MARKE}`) + + await page.goto(`/planung/budget?budget=${id}&ansicht=budget`) const zeile = ersteBeplanbareZeile(page) const kategorie = (await zeile.locator('.baumname').innerText()).trim() + const felder = zeile.locator('input.betragsfeld') - await zeile.locator('input.betragsfeld').first().fill('100,00') - await zeile.locator('button[name="uebertragen"]').click() - await expect(page.locator('.meldung-erfolg')).toContainText('Folgemonate') - - const wieder = page.locator('tr.kategoriezeile', { hasText: kategorie }).first() - const felder = wieder.locator('input.betragsfeld') + // Ganzes Jahr: der Wert aus dem März steht danach auch im Januar. + await felder.nth(2).fill('100,00') + await zeile.locator('.zahnrad').nth(2).click() + await page.click('.zahnradmenue button:has-text("Ganzes Jahr")') await expect(felder.nth(0)).toHaveValue('100,00') await expect(felder.nth(11)).toHaveValue('100,00') - // Zwölf Monate à 100,00 stehen als Jahressumme in der Zeile. - await expect(wieder).toContainText('1.200,00') + + // Restliches Jahr: die Monate davor bleiben stehen. + await felder.nth(5).fill('40,00') + await zeile.locator('.zahnrad').nth(5).click() + await page.click('.zahnradmenue button:has-text("Restliches Jahr")') + await expect(felder.nth(4)).toHaveValue('100,00') + await expect(felder.nth(6)).toHaveValue('40,00') + await expect(felder.nth(11)).toHaveValue('40,00') + + // Erst das Speichern schreibt die verteilten Werte weg. + await page.click('button:has-text("Budget speichern")') + await expect(page.locator('.meldung-erfolg')).toContainText('Budget gespeichert') + + const wieder = page.locator('tr.kategoriezeile', { hasText: kategorie }).first() + // Fünf Monate à 100,00 und sieben à 40,00 ergeben 780,00 im Jahr. + await expect(wieder).toContainText('780,00') }) test('Monat abschließen, Snapshot ansehen und wiedereröffnen', async ({ page }) => {