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
|
# Ä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**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
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 {
|
table.budgetplaner input.betragsfeld {
|
||||||
width: 6rem;
|
width: 7rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-family: var(--schrift-zahl);
|
font-family: var(--schrift-zahl);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
@ -451,6 +452,60 @@ 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;
|
||||||
|
/* 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. */
|
/* Aufklappen des Kategoriebaums; ohne JavaScript bleibt alles sichtbar. */
|
||||||
.klappknopf {
|
.klappknopf {
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -711,6 +766,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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/app.js" defer></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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])) ?>"
|
||||||
|
class="knopf<?= $ansicht === $wert ? ' aktiv' : '' ?>"
|
||||||
|
<?= $ansicht === $wert ? 'aria-current="page"' : '' ?>>
|
||||||
|
<?= View::h($beschriftung) ?>
|
||||||
</a>
|
</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; ?>
|
||||||
|
|
||||||
|
<?php if ($zeigtBudget): ?>
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||||
</div>
|
</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>
|
||||||
|
<?php if ($spalten > 1): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="rechts">Budget</th>
|
<?php foreach (array_merge([0], $monate) as $block): ?>
|
||||||
<th class="rechts">Ist</th>
|
<?php $laufend = $block > 0 && $block === $laufenderMonat ? ' laufend' : ''; ?>
|
||||||
<th class="rechts">Saldo</th>
|
<?php if ($zeigtBudget): ?><th class="rechts<?= $laufend ?>">Budget</th><?php endif; ?>
|
||||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
<?php if ($zeigtIst): ?><th class="rechts<?= $laufend ?>">Ist</th><?php endif; ?>
|
||||||
<th class="rechts">Budget</th>
|
<?php if ($zeigtSaldo): ?><th class="rechts<?= $laufend ?>">Saldo</th><?php endif; ?>
|
||||||
<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; ?>
|
<?php endforeach; ?>
|
||||||
</tr>
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($tabelle['zeilen'] as $zeile): ?>
|
<?php foreach ($tabelle['zeilen'] as $zeile): ?>
|
||||||
|
|
@ -150,28 +152,31 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<?php if ($zeigtBudget): ?>
|
||||||
<td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td>
|
<td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($zeigtIst): ?>
|
||||||
<td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td>
|
<td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($zeigtSaldo): ?>
|
||||||
<td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>">
|
<td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>">
|
||||||
<?= $zelle($zeile['jahrSaldo']) ?>
|
<?= $zelle($zeile['jahrSaldo']) ?>
|
||||||
</td>
|
</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) ?>
|
|
||||||
</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 ($zeigtBudget): ?>
|
||||||
|
<td class="rechts budgetzelle<?= $laufend ?>">
|
||||||
<?php if ($blatt): ?>
|
<?php if ($blatt): ?>
|
||||||
<input type="text" inputmode="decimal" class="betragsfeld"
|
<?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 ?>]"
|
name="betrag[<?= $id ?>][<?= $monat ?>]"
|
||||||
value="<?= isset($zeile['eigenBudget'][$monat])
|
value="<?= isset($zeile['eigenBudget'][$monat])
|
||||||
? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>"
|
? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>"
|
||||||
|
|
@ -180,63 +185,57 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?= $zelle($zeile['budget'][$monat]) ?>
|
<?= $zelle($zeile['budget'][$monat]) ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
|
<?php endif; ?>
|
||||||
<td class="rechts laufend"><?= $zelle($zeile['ist'][$monat]) ?></td>
|
<?php if ($zeigtIst): ?>
|
||||||
<td class="rechts laufend <?= View::betragKlasse($zeile['saldo'][$monat]) ?>">
|
<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>
|
||||||
|
<?php if ($zeigtBudget): ?>
|
||||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td>
|
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($zeigtIst): ?>
|
||||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td>
|
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($zeigtSaldo): ?>
|
||||||
<td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>">
|
<td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>">
|
||||||
<?= $zelle($tabelle['gesamt']['jahrSaldo']) ?>
|
<?= $zelle($tabelle['gesamt']['jahrSaldo']) ?>
|
||||||
</td>
|
</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) ?>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
<?php if ($zeigtBudget): ?>
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue