Budgetplaner: drei Ansichten und Verteilen über das Zahnrad

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-11 08:22:35 +02:00
parent d6c2c8f06e
commit 20b2406219
12 changed files with 397 additions and 260 deletions

View file

@ -1,5 +1,22 @@
# Änderungen # Ä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) ## Phase 3 – Budgetplaner und Monatsabschluss (11.08.2026)
**Datenmodell** **Datenmodell**

View file

@ -53,18 +53,36 @@ zählten Unterkategorien mehrfach.
### Ansichten ### 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 | | **Alles** (Standard) | Budget, Ist, Saldo |
| **Alle Monate** | Jahressumme und zwölf Budgetspalten – zum Planen des nächsten Jahres oder Nachbessern eines vergangenen | | **Nur Budget** | Budget |
| **Nur IST** | Ist |
Was als abgelaufen gilt, entscheidet `Budgetplaner::abgelaufenBis()`: im laufenden Jahr der Wo die Budgetspalte steht, ist sie **immer editierbar**, im Januar wie im Dezember.
Vormonat, in vergangenen Jahren der Dezember, in künftigen nichts. `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 Die ganze Tabelle steht in **einem** Formular und wird mit einem Knopf gespeichert – so
JavaScript, damit mehrere geänderte Zeilen zusammen ankommen. Der Knopf **↦** in jeder kommen mehrere geänderte Zeilen zusammen an.
Zeile überträgt den Wert des ersten Eingabemonats auf alle Folgemonate
(`Budget::aufFolgemonateUebertragen()`). ### 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 `Budget::kopieren()` legt ein Budget samt aller Monatswerte im neuen Jahr an – der Weg zum
Jahresbudget, ohne 105 Kategorien noch einmal zu tippen. Jahresbudget, ohne 105 Kategorien noch einmal zu tippen.
@ -104,8 +122,9 @@ rückwärts und endet am ersten bereits abgeschlossenen Monat.
## Tests ## Tests
* `tests/11_BudgetTest.php` – Vorzeichen, „nicht geplant“, Ist-Werte samt Splits, * `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, * `tests/12_MonatsabschlussTest.php` – Zeiträume, Prüflauf, Snapshot, Sperre,
Wiedereröffnung, Vorschlag Wiedereröffnung, Vorschlag
* `tests/e2e/specs/planung.spec.js` – Budget anlegen, Wert eintragen und wiederfinden, * `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

View file

@ -451,6 +451,58 @@ table.budgetplaner td.rechts {
font-variant-numeric: tabular-nums; 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. */ /* Aufklappen des Kategoriebaums; ohne JavaScript bleibt alles sichtbar. */
.klappknopf { .klappknopf {
border: none; border: none;
@ -711,6 +763,14 @@ table.splittabelle input.rechts { text-align: right; }
font-size: .85rem; 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 { .knopf.gefahr {
border-color: var(--negativ); border-color: var(--negativ);
color: var(--negativ); color: var(--negativ);

View file

@ -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. // Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
// Über eine Klasse, nicht über element.style: Die Content-Security-Policy // Über eine Klasse, nicht über element.style: Die Content-Security-Policy
// (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles, // (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles,

View file

@ -13,9 +13,9 @@ use Finanzmanager\View;
* Budgetverwaltung (anlegen, kopieren, aktivieren). * Budgetverwaltung (anlegen, kopieren, aktivieren).
* *
* Die Monatswerte werden nicht einzeln gespeichert, sondern die ganze Tabelle * Die Monatswerte werden nicht einzeln gespeichert, sondern die ganze Tabelle
* auf einmal – ein Formular um die Tabelle herum, ein POST. Das kommt ohne * auf einmal – ein Formular um die Tabelle herum, ein POST. Das hält die
* JavaScript aus und hält die Eingabe zusammen, wenn mehrere Zeilen geändert * Eingabe zusammen, wenn mehrere Zeilen geändert wurden, und lässt das
* wurden. * Zahnrad einer Zelle ihren Wert vor dem Speichern über die Zeile verteilen.
*/ */
class BudgetController extends BaseController class BudgetController extends BaseController
{ {
@ -29,16 +29,12 @@ class BudgetController extends BaseController
return; return;
} }
$ansicht = $this->query('ansicht') === 'alle' ? 'alle' : 'verlauf';
$tabelle = Budgetplaner::tabelle($budget);
$this->render('planung/budgetplaner.php', [ $this->render('planung/budgetplaner.php', [
'titel' => 'Budgetplaner', 'titel' => 'Budgetplaner',
'budget' => $budget, 'budget' => $budget,
'budgets' => Budget::alle(), 'budgets' => Budget::alle(),
'tabelle' => $tabelle, 'tabelle' => Budgetplaner::tabelle($budget),
'ansicht' => $ansicht, 'ansicht' => self::ansicht($this->query('ansicht')),
'abgelaufenBis' => Budgetplaner::abgelaufenBis((int)$budget['jahr']),
'laufenderMonat' => Budgetplaner::laufenderMonat((int)$budget['jahr']), 'laufenderMonat' => Budgetplaner::laufenderMonat((int)$budget['jahr']),
'nurGeplante' => $this->query('nur_geplante') === '1', 'nurGeplante' => $this->query('nur_geplante') === '1',
]); ]);
@ -61,7 +57,7 @@ class BudgetController extends BaseController
return; return;
} }
$werte = $_POST['betrag'] ?? []; $werte = $_POST['betrag'] ?? [];
$geaendert = 0; $geaendert = 0;
if (is_array($werte)) { if (is_array($werte)) {
@ -77,16 +73,10 @@ class BudgetController extends BaseController
} }
} }
// „Auf Folgemonate übertragen“ kommt als Knopfwert „kategorie:monat“ und View::redirect(
// greift auf den soeben gespeicherten Stand. $this->zurueckZumPlaner($id),
$meldung = $geaendert > 0 ? 'Budget gespeichert.' : 'Nichts zu speichern.'; $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);
} }
public function verwaltung(): void public function verwaltung(): void
@ -200,6 +190,16 @@ class BudgetController extends BaseController
return trim($eingabe) === '' ? null : KontenController::zahl($eingabe); 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<string,mixed>|null */ /** @return array<string,mixed>|null */
private function gewaehltesBudget(): ?array 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. */ /** Nach dem Speichern zurück in dieselbe Ansicht, nicht auf das aktive Budget. */
private function zurueckZumPlaner(int $id): string private function zurueckZumPlaner(int $id): string
{ {
$pfad = '/planung/budget?budget=' . $id; $pfad = '/planung/budget?budget=' . $id . '&ansicht=' . self::ansicht($this->post('ansicht'));
if ($this->post('ansicht') === 'alle') {
$pfad .= '&ansicht=alle';
}
if ($this->post('nur_geplante') === '1') { if ($this->post('nur_geplante') === '1') {
$pfad .= '&nur_geplante=1'; $pfad .= '&nur_geplante=1';
} }

View file

@ -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 * 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. * neue Jahr aus dem laufenden, statt 105 Kategorien noch einmal zu tippen.

View file

@ -11,7 +11,9 @@ use Finanzmanager\Models\Kategorie;
/** /**
* Stellt die Tabelle des Budgetplaners zusammen: Zeilen sind der Kategoriebaum, * 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 * Vorzeichen durchgehend wie in der Buchhaltung (Ausgaben negativ). Daraus
* folgt eine Definition, die für Einnahmen wie Ausgaben gleich funktioniert: * 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<int, float> $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. */ /** Der Monat, der im laufenden Jahr gerade läuft – sonst 0. */
public static function laufenderMonat(int $jahr, ?string $heute = null): int public static function laufenderMonat(int $jahr, ?string $heute = null): int
{ {

View file

@ -1,6 +1,5 @@
</main> </main>
</div> </div>
</div> </div>
<script src="/static/app.js" defer></script>
</body> </body>
</html> </html>

View file

@ -31,6 +31,8 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool {
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= View::h($titel ?? 'Finanzmanager') ?> – Finanzmanager</title> <title><?= View::h($titel ?? 'Finanzmanager') ?> – Finanzmanager</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<!-- Nur hier eingebunden: Ein zweites Mal im Fuß ließe jeden Handler doppelt
laufen – Rückfragen kämen zweimal, Menüs entstünden zweimal. -->
<script src="/static/app.js" defer></script> <script src="/static/app.js" defer></script>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
</head> </head>

View file

@ -7,19 +7,23 @@ use Finanzmanager\View;
/** @var list<array<string,mixed>> $budgets */ /** @var list<array<string,mixed>> $budgets */
/** @var array{zeilen: list<array<string,mixed>>, gesamt: array<string,mixed>, jahr: int} $tabelle */ /** @var array{zeilen: list<array<string,mixed>>, gesamt: array<string,mixed>, jahr: int} $tabelle */
/** @var string $ansicht */ /** @var string $ansicht */
/** @var int $abgelaufenBis */
/** @var int $laufenderMonat */ /** @var int $laufenderMonat */
/** @var bool $nurGeplante */ /** @var bool $nurGeplante */
/** @var string $csrfToken */ /** @var string $csrfToken */
$jahr = (int)$budget['jahr']; $jahr = (int)$budget['jahr'];
// Welche Monate bekommen eine Eingabespalte? In der Verlaufsansicht sind das // Alle zwölf Monate stehen einzeln da – auch die abgelaufenen. Zusammengefasst
// der laufende Monat und alles danach – abgelaufene Monate fasst eine einzige // wird nichts mehr: Ein vergangener Monat lässt sich sonst weder nachlesen noch
// Spalte zusammen, wie im FinanzManager. „Alle Monate“ zeigt alle zwölf. // nachplanen. Die Ansicht entscheidet nur, welche Wertspalten je Monat kommen.
$alleMonate = $ansicht === 'alle'; $ansichten = ['alles' => 'Alles', 'budget' => 'Nur Budget', 'ist' => 'Nur IST'];
$erster = $alleMonate ? 1 : max(1, $laufenderMonat === 0 ? 1 : $laufenderMonat);
$eingabe = range($erster, 12); $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 { $zelle = static function (float $wert): string {
return View::betrag($wert, false); return View::betrag($wert, false);
@ -58,9 +62,14 @@ include __DIR__ . '/../layout/kopf.php';
<button type="submit" class="knopf klein">Öffnen</button> <button type="submit" class="knopf klein">Öffnen</button>
</form> </form>
<a href="<?= View::h($link(['ansicht' => $alleMonate ? 'verlauf' : 'alle'])) ?>" class="knopf"> <?php foreach ($ansichten as $wert => $beschriftung): ?>
<?= $alleMonate ? 'Ansicht: Verlauf' : 'Ansicht: alle Monate' ?> <a href="<?= View::h($link(['ansicht' => $wert])) ?>"
</a> class="knopf<?= $ansicht === $wert ? ' aktiv' : '' ?>"
<?= $ansicht === $wert ? 'aria-current="page"' : '' ?>>
<?= View::h($beschriftung) ?>
</a>
<?php endforeach; ?>
<a href="<?= View::h($link(['nur_geplante' => $nurGeplante ? null : '1'])) ?>" class="knopf"> <a href="<?= View::h($link(['nur_geplante' => $nurGeplante ? null : '1'])) ?>" class="knopf">
<?= $nurGeplante ? 'Alle Kategorien' : 'Nur geplante Kategorien' ?> <?= $nurGeplante ? 'Alle Kategorien' : 'Nur geplante Kategorien' ?>
</a> </a>
@ -71,11 +80,17 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis"> <p class="hinweis">
Budget <strong><?= View::h($budget['name']) ?></strong> für <?= $jahr ?>. Budget <strong><?= View::h($budget['name']) ?></strong> für <?= $jahr ?>.
Ausgaben werden ohne Vorzeichen eingetragen – die Kategorieart bestimmt es. <?php if ($zeigtSaldo): ?>
Ein leeres Feld heißt „nicht geplant“, eine 0 heißt „mit 0,00 geplant“. <strong>Saldo</strong> ist Ist minus Budget: negativ bedeutet über Plan.
<strong>Saldo</strong> ist Ist minus Budget: negativ bedeutet über Plan. <?php endif; ?>
Übergeordnete Kategorien summieren ihre Unterkategorien und werden deshalb Übergeordnete Kategorien summieren ihre Unterkategorien und werden deshalb
nicht selbst beplant. nicht selbst beplant.
<?php if ($zeigtBudget): ?>
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 <span aria-hidden="true">⚙</span> vor einem Feld überträgt dessen Wert
auf das ganze oder das restliche Jahr.
<?php endif; ?>
</p> </p>
<form method="post" action="/planung/budget/<?= (int)$budget['id'] ?>/positionen"> <form method="post" action="/planung/budget/<?= (int)$budget['id'] ?>/positionen">
@ -85,48 +100,35 @@ include __DIR__ . '/../layout/kopf.php';
<input type="hidden" name="nur_geplante" value="1"> <input type="hidden" name="nur_geplante" value="1">
<?php endif; ?> <?php endif; ?>
<div class="knopfleiste"> <?php if ($zeigtBudget): ?>
<button type="submit" class="knopf primaer">Budget speichern</button> <div class="knopfleiste">
</div> <button type="submit" class="knopf primaer">Budget speichern</button>
</div>
<?php endif; ?>
<div class="breittabelle"> <div class="breittabelle">
<table class="liste budgetplaner"> <table class="liste budgetplaner">
<thead> <thead>
<tr> <tr>
<th rowspan="2" class="spalte-kategorie">Kategorie</th> <th <?= $spalten > 1 ? 'rowspan="2" ' : '' ?>class="spalte-kategorie">Kategorie</th>
<th colspan="3" class="gruppenkopf">Jahr <?= $jahr ?></th> <th colspan="<?= $spalten ?>" class="gruppenkopf">Jahr <?= $jahr ?></th>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?> <?php foreach ($monate as $monat): ?>
<th colspan="3" class="gruppenkopf"> <th colspan="<?= $spalten ?>"
Januar–<?= View::h(Budgetplaner::MONATSKUERZEL[$abgelaufenBis]) ?> class="gruppenkopf<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
</th>
<?php endif; ?>
<?php foreach ($eingabe as $monat): ?>
<th class="gruppenkopf<?= $monat === $laufenderMonat ? ' laufend' : '' ?>"
colspan="<?= !$alleMonate && $monat === $laufenderMonat ? 3 : 1 ?>">
<?= View::h(Budgetplaner::MONATSKUERZEL[$monat]) ?> <?= View::h(Budgetplaner::MONATSKUERZEL[$monat]) ?>
</th> </th>
<?php endforeach; ?> <?php endforeach; ?>
<th rowspan="2" class="rechts">↦</th>
</tr>
<tr>
<th class="rechts">Budget</th>
<th class="rechts">Ist</th>
<th class="rechts">Saldo</th>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
<th class="rechts">Budget</th>
<th class="rechts">Ist</th>
<th class="rechts">Saldo</th>
<?php endif; ?>
<?php foreach ($eingabe as $monat): ?>
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
<th class="rechts laufend">Budget</th>
<th class="rechts laufend">Ist</th>
<th class="rechts laufend">Saldo</th>
<?php else: ?>
<th class="rechts">Budget</th>
<?php endif; ?>
<?php endforeach; ?>
</tr> </tr>
<?php if ($spalten > 1): ?>
<tr>
<?php foreach (array_merge([0], $monate) as $block): ?>
<?php $laufend = $block > 0 && $block === $laufenderMonat ? ' laufend' : ''; ?>
<?php if ($zeigtBudget): ?><th class="rechts<?= $laufend ?>">Budget</th><?php endif; ?>
<?php if ($zeigtIst): ?><th class="rechts<?= $laufend ?>">Ist</th><?php endif; ?>
<?php if ($zeigtSaldo): ?><th class="rechts<?= $laufend ?>">Saldo</th><?php endif; ?>
<?php endforeach; ?>
</tr>
<?php endif; ?>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($tabelle['zeilen'] as $zeile): ?> <?php foreach ($tabelle['zeilen'] as $zeile): ?>
@ -134,9 +136,9 @@ include __DIR__ . '/../layout/kopf.php';
if ($nurGeplante && !$zeile['geplant']) { if ($nurGeplante && !$zeile['geplant']) {
continue; continue;
} }
$id = (int)$zeile['id']; $id = (int)$zeile['id'];
$blatt = !$zeile['hat_kinder']; $blatt = !$zeile['hat_kinder'];
$klassen = $blatt ? '' : ' summenzeile-kategorie'; $klassen = $blatt ? '' : ' summenzeile-kategorie';
?> ?>
<tr class="kategoriezeile<?= $klassen ?>" data-kategorie="<?= $id ?>" <tr class="kategoriezeile<?= $klassen ?>" data-kategorie="<?= $id ?>"
data-eltern="<?= $zeile['eltern_id'] === null ? '' : (int)$zeile['eltern_id'] ?>"> data-eltern="<?= $zeile['eltern_id'] === null ? '' : (int)$zeile['eltern_id'] ?>">
@ -150,93 +152,87 @@ include __DIR__ . '/../layout/kopf.php';
</span> </span>
</td> </td>
<td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td> <?php if ($zeigtBudget): ?>
<td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td> <td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td>
<td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>"> <?php endif; ?>
<?= $zelle($zeile['jahrSaldo']) ?> <?php if ($zeigtIst): ?>
</td> <td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td>
<?php endif; ?>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?> <?php if ($zeigtSaldo): ?>
<?php <td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>">
$budgetBis = Budgetplaner::summe($zeile['budget'], 1, $abgelaufenBis); <?= $zelle($zeile['jahrSaldo']) ?>
$istBis = Budgetplaner::summe($zeile['ist'], 1, $abgelaufenBis);
?>
<td class="rechts nebentext"><?= $zelle($budgetBis) ?></td>
<td class="rechts nebentext"><?= $zelle($istBis) ?></td>
<td class="rechts <?= View::betragKlasse($istBis - $budgetBis) ?>">
<?= $zelle($istBis - $budgetBis) ?>
</td> </td>
<?php endif; ?> <?php endif; ?>
<?php foreach ($eingabe as $monat): ?> <?php foreach ($monate as $monat): ?>
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>"> <?php $laufend = $monat === $laufenderMonat ? ' laufend' : ''; ?>
<?php if ($blatt): ?> <?php if ($zeigtBudget): ?>
<input type="text" inputmode="decimal" class="betragsfeld" <td class="rechts budgetzelle<?= $laufend ?>">
name="betrag[<?= $id ?>][<?= $monat ?>]" <?php if ($blatt): ?>
value="<?= isset($zeile['eigenBudget'][$monat]) <button type="button" class="zahnrad" aria-haspopup="true"
? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>" aria-expanded="false"
aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$monat]) ?>"> aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$monat]) ?> übertragen">⚙</button>
<?php else: ?> <input type="text" inputmode="decimal" class="betragsfeld"
<?= $zelle($zeile['budget'][$monat]) ?> data-monat="<?= $monat ?>"
<?php endif; ?> name="betrag[<?= $id ?>][<?= $monat ?>]"
</td> value="<?= isset($zeile['eigenBudget'][$monat])
<?php if (!$alleMonate && $monat === $laufenderMonat): ?> ? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>"
<td class="rechts laufend"><?= $zelle($zeile['ist'][$monat]) ?></td> aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$monat]) ?>">
<td class="rechts laufend <?= View::betragKlasse($zeile['saldo'][$monat]) ?>"> <?php else: ?>
<?= $zelle($zeile['budget'][$monat]) ?>
<?php endif; ?>
</td>
<?php endif; ?>
<?php if ($zeigtIst): ?>
<td class="rechts<?= $laufend ?>"><?= $zelle($zeile['ist'][$monat]) ?></td>
<?php endif; ?>
<?php if ($zeigtSaldo): ?>
<td class="rechts<?= $laufend ?> <?= View::betragKlasse($zeile['saldo'][$monat]) ?>">
<?= $zelle($zeile['saldo'][$monat]) ?> <?= $zelle($zeile['saldo'][$monat]) ?>
</td> </td>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<td class="rechts">
<?php if ($blatt): ?>
<button type="submit" name="uebertragen" class="knopf klein"
value="<?= $id ?>:<?= $erster ?>"
title="Wert aus <?= View::h(Budgetplaner::MONATSNAMEN[$erster]) ?> auf alle Folgemonate übertragen">↦</button>
<?php endif; ?>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="summenzeile"> <tr class="summenzeile">
<td class="spalte-kategorie">Gesamt</td> <td class="spalte-kategorie">Gesamt</td>
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td> <?php if ($zeigtBudget): ?>
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td> <td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td>
<td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>"> <?php endif; ?>
<?= $zelle($tabelle['gesamt']['jahrSaldo']) ?> <?php if ($zeigtIst): ?>
</td> <td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?> <?php endif; ?>
<?php <?php if ($zeigtSaldo): ?>
$budgetBis = Budgetplaner::summe($tabelle['gesamt']['budget'], 1, $abgelaufenBis); <td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>">
$istBis = Budgetplaner::summe($tabelle['gesamt']['ist'], 1, $abgelaufenBis); <?= $zelle($tabelle['gesamt']['jahrSaldo']) ?>
?>
<td class="rechts"><?= $zelle($budgetBis) ?></td>
<td class="rechts"><?= $zelle($istBis) ?></td>
<td class="rechts <?= View::betragKlasse($istBis - $budgetBis) ?>">
<?= $zelle($istBis - $budgetBis) ?>
</td> </td>
<?php endif; ?> <?php endif; ?>
<?php foreach ($eingabe as $monat): ?> <?php foreach ($monate as $monat): ?>
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>"> <?php $laufend = $monat === $laufenderMonat ? ' laufend' : ''; ?>
<?= $zelle($tabelle['gesamt']['budget'][$monat]) ?> <?php if ($zeigtBudget): ?>
</td> <td class="rechts<?= $laufend ?>"><?= $zelle($tabelle['gesamt']['budget'][$monat]) ?></td>
<?php if (!$alleMonate && $monat === $laufenderMonat): ?> <?php endif; ?>
<td class="rechts laufend"><?= $zelle($tabelle['gesamt']['ist'][$monat]) ?></td> <?php if ($zeigtIst): ?>
<td class="rechts laufend <?= View::betragKlasse($tabelle['gesamt']['saldo'][$monat]) ?>"> <td class="rechts<?= $laufend ?>"><?= $zelle($tabelle['gesamt']['ist'][$monat]) ?></td>
<?php endif; ?>
<?php if ($zeigtSaldo): ?>
<td class="rechts<?= $laufend ?> <?= View::betragKlasse($tabelle['gesamt']['saldo'][$monat]) ?>">
<?= $zelle($tabelle['gesamt']['saldo'][$monat]) ?> <?= $zelle($tabelle['gesamt']['saldo'][$monat]) ?>
</td> </td>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<td></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
<div class="knopfleiste"> <?php if ($zeigtBudget): ?>
<button type="submit" class="knopf primaer">Budget speichern</button> <div class="knopfleiste">
</div> <button type="submit" class="knopf primaer">Budget speichern</button>
</div>
<?php endif; ?>
</form> </form>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -31,9 +31,8 @@ class BudgetTest extends TestCase
$this->istWerteAusBuchungenUndSplits(); $this->istWerteAusBuchungenUndSplits();
$this->tabelleSummiertDenBaum(); $this->tabelleSummiertDenBaum();
$this->saldoZeigtUeberschreitung(); $this->saldoZeigtUeberschreitung();
$this->folgemonateUebertragen();
$this->kopieFuerDasNaechsteJahr(); $this->kopieFuerDasNaechsteJahr();
$this->zeitraeumeDerAnsicht(); $this->ansichtDesPlaners();
} }
private function stammdaten(): void 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 private function kopieFuerDasNaechsteJahr(): void
{ {
$budget = $this->budgetAnlegen('Vorlage'); $budget = $this->budgetAnlegen('Vorlage');
@ -283,23 +266,19 @@ class BudgetTest extends TestCase
$this->assert($doppelt, 'Zwei Budgets mit demselben Namen gehen nicht'); $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(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'); $this->assertEquals(0, Budgetplaner::laufenderMonat(2027, '2026-08-11'), 'Andere Jahre haben keinen laufenden Monat');
$reihe = []; $this->assertEquals('alles', BudgetController::ansicht('alles'), 'Alle drei Wertspalten');
for ($monat = 1; $monat <= 12; $monat++) { $this->assertEquals('budget', BudgetController::ansicht('budget'), 'Nur die Budgetspalte');
$reihe[$monat] = 10.0; $this->assertEquals('ist', BudgetController::ansicht('ist'), 'Nur die Ist-Spalte');
} $this->assertEquals('alles', BudgetController::ansicht(''), 'Ohne Angabe stehen alle Spalten da');
$this->assertEquals(70.0, Budgetplaner::summe($reihe, 1, 7), 'Summe über die abgelaufenen Monate'); $this->assertEquals('alles', BudgetController::ansicht('verlauf'), 'Eine alte URL fällt auf „alles“ zurück');
$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');
} }
} }

View file

@ -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. // und dürfen sich nicht an Resten eines früheren Laufs stoßen.
const MARKE = Date.now().toString().slice(-6) const MARKE = Date.now().toString().slice(-6)
// Ein Jahr weit in der Zukunft: dort sind alle zwölf Monate Folgemonate, die // Ein Jahr weit in der Zukunft: dort gibt es keine Buchungen, die Ist-Werte
// Spaltenaufteilung des Planers bleibt damit über die Jahre stabil. // bleiben leer und die geprüften Summen damit über die Jahre stabil.
const JAHR = new Date().getFullYear() + 5 const JAHR = new Date().getFullYear() + 5
async function anmelden(page) { async function anmelden(page) {
@ -65,7 +65,7 @@ test.describe('Planung', () => {
test('Budget anlegen, Monatswert eintragen und wiederfinden', async ({ page }) => { test('Budget anlegen, Monatswert eintragen und wiederfinden', async ({ page }) => {
const id = await budgetAnlegen(page, `E2E-Budget ${MARKE}`) 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 zeile = ersteBeplanbareZeile(page)
const kategorie = (await zeile.locator('.baumname').innerText()).trim() const kategorie = (await zeile.locator('.baumname').innerText()).trim()
@ -80,24 +80,59 @@ test.describe('Planung', () => {
await expect(wieder).toContainText('250,00') await expect(wieder).toContainText('250,00')
}) })
test('Wert auf die Folgemonate übertragen', async ({ page }) => { test('Die drei Ansichten zeigen Budget, Ist und Saldo je Monat', async ({ page }) => {
const id = await budgetAnlegen(page, `E2E-Folgemonate ${MARKE}`) 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 zeile = ersteBeplanbareZeile(page)
const kategorie = (await zeile.locator('.baumname').innerText()).trim() const kategorie = (await zeile.locator('.baumname').innerText()).trim()
const felder = zeile.locator('input.betragsfeld')
await zeile.locator('input.betragsfeld').first().fill('100,00') // Ganzes Jahr: der Wert aus dem März steht danach auch im Januar.
await zeile.locator('button[name="uebertragen"]').click() await felder.nth(2).fill('100,00')
await expect(page.locator('.meldung-erfolg')).toContainText('Folgemonate') await zeile.locator('.zahnrad').nth(2).click()
await page.click('.zahnradmenue button:has-text("Ganzes Jahr")')
const wieder = page.locator('tr.kategoriezeile', { hasText: kategorie }).first()
const felder = wieder.locator('input.betragsfeld')
await expect(felder.nth(0)).toHaveValue('100,00') await expect(felder.nth(0)).toHaveValue('100,00')
await expect(felder.nth(11)).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 }) => { test('Monat abschließen, Snapshot ansehen und wiedereröffnen', async ({ page }) => {