Merge pull request 'Budgetplaner: drei Ansichten und Verteilen über das Zahnrad' (#6) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/6
This commit is contained in:
commit
d07b85c1f7
12 changed files with 404 additions and 261 deletions
17
Changes.md
17
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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string,mixed>|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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<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. */
|
||||
public static function laufenderMonat(int $jahr, ?string $heute = null): int
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= View::h($titel ?? 'Finanzmanager') ?> – Finanzmanager</title>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -7,19 +7,23 @@ use Finanzmanager\View;
|
|||
/** @var list<array<string,mixed>> $budgets */
|
||||
/** @var array{zeilen: list<array<string,mixed>>, gesamt: array<string,mixed>, 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';
|
|||
<button type="submit" class="knopf klein">Öffnen</button>
|
||||
</form>
|
||||
|
||||
<a href="<?= View::h($link(['ansicht' => $alleMonate ? 'verlauf' : 'alle'])) ?>" class="knopf">
|
||||
<?= $alleMonate ? 'Ansicht: Verlauf' : 'Ansicht: alle Monate' ?>
|
||||
</a>
|
||||
<?php foreach ($ansichten as $wert => $beschriftung): ?>
|
||||
<a href="<?= View::h($link(['ansicht' => $wert])) ?>"
|
||||
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">
|
||||
<?= $nurGeplante ? 'Alle Kategorien' : 'Nur geplante Kategorien' ?>
|
||||
</a>
|
||||
|
|
@ -71,11 +80,17 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<p class="hinweis">
|
||||
Budget <strong><?= View::h($budget['name']) ?></strong> für <?= $jahr ?>.
|
||||
Ausgaben werden ohne Vorzeichen eingetragen – die Kategorieart bestimmt es.
|
||||
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.
|
||||
<?php if ($zeigtSaldo): ?>
|
||||
<strong>Saldo</strong> ist Ist minus Budget: negativ bedeutet über Plan.
|
||||
<?php endif; ?>
|
||||
Übergeordnete Kategorien summieren ihre Unterkategorien und werden deshalb
|
||||
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>
|
||||
|
||||
<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">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||
</div>
|
||||
<?php if ($zeigtBudget): ?>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="breittabelle">
|
||||
<table class="liste budgetplaner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="spalte-kategorie">Kategorie</th>
|
||||
<th colspan="3" class="gruppenkopf">Jahr <?= $jahr ?></th>
|
||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
||||
<th colspan="3" class="gruppenkopf">
|
||||
Januar–<?= View::h(Budgetplaner::MONATSKUERZEL[$abgelaufenBis]) ?>
|
||||
</th>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($eingabe as $monat): ?>
|
||||
<th class="gruppenkopf<?= $monat === $laufenderMonat ? ' laufend' : '' ?>"
|
||||
colspan="<?= !$alleMonate && $monat === $laufenderMonat ? 3 : 1 ?>">
|
||||
<th <?= $spalten > 1 ? 'rowspan="2" ' : '' ?>class="spalte-kategorie">Kategorie</th>
|
||||
<th colspan="<?= $spalten ?>" class="gruppenkopf">Jahr <?= $jahr ?></th>
|
||||
<?php foreach ($monate as $monat): ?>
|
||||
<th colspan="<?= $spalten ?>"
|
||||
class="gruppenkopf<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
|
||||
<?= View::h(Budgetplaner::MONATSKUERZEL[$monat]) ?>
|
||||
</th>
|
||||
<?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>
|
||||
<?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>
|
||||
<tbody>
|
||||
<?php foreach ($tabelle['zeilen'] as $zeile): ?>
|
||||
|
|
@ -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';
|
||||
?>
|
||||
<tr class="kategoriezeile<?= $klassen ?>" data-kategorie="<?= $id ?>"
|
||||
data-eltern="<?= $zeile['eltern_id'] === null ? '' : (int)$zeile['eltern_id'] ?>">
|
||||
|
|
@ -150,93 +152,90 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</span>
|
||||
</td>
|
||||
|
||||
<td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td>
|
||||
<td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>">
|
||||
<?= $zelle($zeile['jahrSaldo']) ?>
|
||||
</td>
|
||||
|
||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
||||
<?php
|
||||
$budgetBis = Budgetplaner::summe($zeile['budget'], 1, $abgelaufenBis);
|
||||
$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) ?>
|
||||
<?php if ($zeigtBudget): ?>
|
||||
<td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td>
|
||||
<?php endif; ?>
|
||||
<?php if ($zeigtIst): ?>
|
||||
<td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td>
|
||||
<?php endif; ?>
|
||||
<?php if ($zeigtSaldo): ?>
|
||||
<td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>">
|
||||
<?= $zelle($zeile['jahrSaldo']) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($eingabe as $monat): ?>
|
||||
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
|
||||
<?php if ($blatt): ?>
|
||||
<input type="text" inputmode="decimal" class="betragsfeld"
|
||||
name="betrag[<?= $id ?>][<?= $monat ?>]"
|
||||
value="<?= isset($zeile['eigenBudget'][$monat])
|
||||
? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>"
|
||||
aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$monat]) ?>">
|
||||
<?php else: ?>
|
||||
<?= $zelle($zeile['budget'][$monat]) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
|
||||
<td class="rechts laufend"><?= $zelle($zeile['ist'][$monat]) ?></td>
|
||||
<td class="rechts laufend <?= View::betragKlasse($zeile['saldo'][$monat]) ?>">
|
||||
<?php foreach ($monate as $monat): ?>
|
||||
<?php $laufend = $monat === $laufenderMonat ? ' laufend' : ''; ?>
|
||||
<?php if ($zeigtBudget): ?>
|
||||
<td class="rechts budgetzelle<?= $laufend ?>">
|
||||
<?php if ($blatt): ?>
|
||||
<?php /* Zahnrad und Feld ohne Zeilenumbruch dazwischen: Der
|
||||
Umbruch wäre ein Leerzeichen, und in der Ziffernschrift
|
||||
der Zelle ist das breiter als der Abstand selbst. */ ?>
|
||||
<button type="button" class="zahnrad" aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$monat]) ?> übertragen">⚙</button><input
|
||||
type="text" inputmode="decimal" class="betragsfeld"
|
||||
data-monat="<?= $monat ?>"
|
||||
name="betrag[<?= $id ?>][<?= $monat ?>]"
|
||||
value="<?= isset($zeile['eigenBudget'][$monat])
|
||||
? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>"
|
||||
aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$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]) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<?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>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summenzeile">
|
||||
<td class="spalte-kategorie">Gesamt</td>
|
||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td>
|
||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>">
|
||||
<?= $zelle($tabelle['gesamt']['jahrSaldo']) ?>
|
||||
</td>
|
||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
||||
<?php
|
||||
$budgetBis = Budgetplaner::summe($tabelle['gesamt']['budget'], 1, $abgelaufenBis);
|
||||
$istBis = Budgetplaner::summe($tabelle['gesamt']['ist'], 1, $abgelaufenBis);
|
||||
?>
|
||||
<td class="rechts"><?= $zelle($budgetBis) ?></td>
|
||||
<td class="rechts"><?= $zelle($istBis) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($istBis - $budgetBis) ?>">
|
||||
<?= $zelle($istBis - $budgetBis) ?>
|
||||
<?php if ($zeigtBudget): ?>
|
||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td>
|
||||
<?php endif; ?>
|
||||
<?php if ($zeigtIst): ?>
|
||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td>
|
||||
<?php endif; ?>
|
||||
<?php if ($zeigtSaldo): ?>
|
||||
<td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>">
|
||||
<?= $zelle($tabelle['gesamt']['jahrSaldo']) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($eingabe as $monat): ?>
|
||||
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
|
||||
<?= $zelle($tabelle['gesamt']['budget'][$monat]) ?>
|
||||
</td>
|
||||
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
|
||||
<td class="rechts laufend"><?= $zelle($tabelle['gesamt']['ist'][$monat]) ?></td>
|
||||
<td class="rechts laufend <?= View::betragKlasse($tabelle['gesamt']['saldo'][$monat]) ?>">
|
||||
<?php foreach ($monate as $monat): ?>
|
||||
<?php $laufend = $monat === $laufenderMonat ? ' laufend' : ''; ?>
|
||||
<?php if ($zeigtBudget): ?>
|
||||
<td class="rechts<?= $laufend ?>"><?= $zelle($tabelle['gesamt']['budget'][$monat]) ?></td>
|
||||
<?php endif; ?>
|
||||
<?php if ($zeigtIst): ?>
|
||||
<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]) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||
</div>
|
||||
<?php if ($zeigtBudget): ?>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue