From 20b24062196605b3c91ca4a83af41b3ef1e29bbf Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 08:22:35 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Budgetplaner:=20drei=20Ansichten=20und=20Ve?= =?UTF-8?q?rteilen=20=C3=BCber=20das=20Zahnrad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abgelaufene Monate werden nicht mehr zusammengefasst – alle zwölf Monate stehen einzeln da und bleiben beplanbar. Statt Verlauf und alle Monate gibt es Alles, Nur Budget und Nur IST; die Budgetspalte ist überall dort editierbar, wo sie steht. Das Zahnrad vor jedem Feld verteilt dessen Wert auf das ganze oder das restliche Jahr. Es arbeitet auf den Eingabefeldern statt auf der Datenbank: Das Ergebnis ist vor dem Speichern zu sehen und geht mit demselben Knopf weg wie jede andere Änderung. Nebenbei: app.js war doppelt eingebunden, jeder Handler lief zweimal. Co-Authored-By: Claude Opus 5 --- Changes.md | 17 +++ docs/planung.md | 41 +++-- public/static/app.css | 60 ++++++++ public/static/app.js | 96 ++++++++++++ src/Controllers/BudgetController.php | 45 +++--- src/Models/Budget.php | 31 ---- src/Services/Budgetplaner.php | 38 +---- src/Views/layout/fuss.php | 1 - src/Views/layout/kopf.php | 2 + src/Views/planung/budgetplaner.php | 220 +++++++++++++-------------- tests/11_BudgetTest.php | 43 ++---- tests/e2e/specs/planung.spec.js | 63 ++++++-- 12 files changed, 397 insertions(+), 260 deletions(-) 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..84ca0f7 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -451,6 +451,58 @@ 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; + padding: 0 .25rem 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 +763,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..7d7951e 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,87 @@ 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 }) => { From c4e79e7a9afcd4d844c3ebc101f4c3c94fb7b5d8 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 08:32:01 +0200 Subject: [PATCH 2/3] Budgetplaner: Abstand zwischen Zahnrad und Eingabefeld verkleinert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Abstand kam zum größeren Teil aus dem Zeilenumbruch zwischen Knopf und Feld: Als Leerzeichen in der Ziffernschrift der Zelle war er breiter als das Padding selbst. Beides zusammen jetzt auf ein Drittel. Co-Authored-By: Claude Opus 5 --- public/static/app.css | 4 +++- src/Views/planung/budgetplaner.php | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/public/static/app.css b/public/static/app.css index 84ca0f7..6ae4af9 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -465,7 +465,9 @@ table.budgetplaner td.budgetzelle { position: relative; } cursor: pointer; font-size: .85rem; line-height: 1; - padding: 0 .25rem 0 0; + /* 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, diff --git a/src/Views/planung/budgetplaner.php b/src/Views/planung/budgetplaner.php index 7d7951e..9135fc0 100644 --- a/src/Views/planung/budgetplaner.php +++ b/src/Views/planung/budgetplaner.php @@ -169,10 +169,13 @@ include __DIR__ . '/../layout/kopf.php'; + - ⚙