Merge pull request 'Fix n.n.: Regelmäßige Buchungen fachlich überarbeitet' (#105) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/105
This commit is contained in:
fds2610 2026-10-01 09:33:52 +02:00
commit 073df9e72f
12 changed files with 463 additions and 39 deletions

View file

@ -1,5 +1,21 @@
# Änderungen # Änderungen
## Regelmäßige Buchungen: Klick zum Bearbeiten (01.10.2026)
- In „Alle Reihen“ erscheinen alle Felder zunächst als Text und werden erst beim Anklicken zum Eingabefeld (`reihenliste.js`). Ja/Nein-Felder schalten per Klick um, „Speichern“ erscheint erst nach einer Änderung. Konto und Klasse stehen untereinander, die Spalte Name/Buchungstext nimmt den freien Platz.
## Klasse und nächster Termin in „Alle Reihen“ (01.10.2026)
- Unter der Kontoauswahl gibt es eine Klassenauswahl, der „Nächste Termin“ ist ein Datumsfeld und lässt sich von Hand verschieben (`RegelmaessigeBuchung::speichern()`, ungültige Angaben werden ignoriert).
## Buchungstext bei regelmäßigen Buchungen (01.10.2026)
- Das Feld „Buchungstext“ fehlte im Formular für neue regelmäßige Buchungen und war in der Liste nur ein verstecktes Feld. Jetzt beim Anlegen vorhanden und in der Liste unter dem Namen bearbeitbar (`termine/index.php`).
## Fälligkeit folgt der Buchung (01.10.2026)
- Eine per Fälligkeitslauf oder „Buchen“ erzeugte Buchung merkt sich jetzt ihre regelmäßige Buchung und den Soll-Termin (`buchungen.regelmaessige_id`, `faelligkeit`, Migration 023). Wird sie gelöscht, rückt die Fälligkeit auf diesen Termin zurück, eine ausgelaufene Reihe wird wieder aktiv, `zuletzt_am` folgt der jüngsten verbleibenden Buchung (`RegelmaessigeBuchung::terminWiederOeffnen()`). Bereits vorhandene Buchungen sind nicht verknüpft; bei ihnen bleibt es beim bisherigen Verhalten.
## Darkmode-Farben nach Mockup (01.10.2026) ## Darkmode-Farben nach Mockup (01.10.2026)
- Dunkle Palette bläulicher (Navy) statt grau-blau, Seitenleiste, Flächen, Ränder und Akzent angepasst. Der aktive Menüeintrag ist wie im hellen Modus blau hinterlegt (`--navi-aktiv`), der Balken links bleibt weiß (neue Variable `--navi-balken`). Nur Farben, nur im Darkmode (`app.css`). - Dunkle Palette bläulicher (Navy) statt grau-blau, Seitenleiste, Flächen, Ränder und Akzent angepasst. Der aktive Menüeintrag ist wie im hellen Modus blau hinterlegt (`--navi-aktiv`), der Balken links bleibt weiß (neue Variable `--navi-balken`). Nur Farben, nur im Darkmode (`app.css`).

View file

@ -0,0 +1,14 @@
-- Verknüpfung einer Buchung mit der regelmäßigen Buchung, aus der sie entstand.
--
-- Bisher war „naechste_faelligkeit“ ein reiner Zähler: Wurde eine per
-- Fälligkeitslauf erzeugte Buchung gelöscht, blieb der Termin als erledigt
-- stehen. „faelligkeit“ hält den Soll-Termin fest (nicht das Buchungsdatum, das
-- abweichen kann), damit Buchung::loeschen() die Fälligkeit zurücksetzen kann.
ALTER TABLE buchungen ADD COLUMN IF NOT EXISTS
regelmaessige_id INT UNSIGNED NULL AFTER quelle;
ALTER TABLE buchungen ADD COLUMN IF NOT EXISTS
faelligkeit DATE NULL AFTER regelmaessige_id;
ALTER TABLE buchungen ADD INDEX IF NOT EXISTS idx_buchung_regelmaessig (regelmaessige_id);
ALTER TABLE buchungen ADD CONSTRAINT fk_buchung_regelmaessig FOREIGN KEY IF NOT EXISTS (regelmaessige_id)
REFERENCES regelmaessige_buchungen (id) ON DELETE SET NULL;

View file

@ -869,6 +869,14 @@ table.liste input[type="number"] {
table.liste input.schmal { width: 5rem; } table.liste input.schmal { width: 5rem; }
/* Regelmäßige Buchungen: Name und Buchungstext nehmen den freien Platz, alle
anderen Spalten bleiben so schmal wie ihr Inhalt. Konto und Klasse stehen
fest untereinander. */
table.liste.reihen th:first-child { width: 100%; }
table.liste.reihen td:first-child input[type="text"] { min-width: 12rem; }
table.liste.reihen td select { display: block; max-width: 14rem; }
table.liste.reihen td select + select { margin-top: .25rem; }
/* ── Budgetplaner ──────────────────────────────────────────────────────────── /* ── Budgetplaner ────────────────────────────────────────────────────────────
Zwölf Monatsspalten passen auf keinen Bildschirm: Die Tabelle scrollt in Zwölf Monatsspalten passen auf keinen Bildschirm: Die Tabelle scrollt in
@ -2391,3 +2399,11 @@ input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="u
input[type="checkbox"], input[type="checkbox"],
input[type="checkbox"]::before { transition: none; } input[type="checkbox"]::before { transition: none; }
} }
/* Regelmäßige Buchungen: Text, der sich per Klick in ein Eingabefeld verwandelt. */
table.liste.reihen .feld + .feld { margin-top: .25rem; }
table.liste.reihen .anzeige { cursor: text; border-radius: 4px; padding: .1rem .2rem; margin: 0 -.2rem; }
table.liste.reihen .anzeige:hover,
table.liste.reihen .anzeige:focus-visible { background: var(--flaeche-gedaempft); outline: none; }
table.liste.reihen .anzeige.leer { color: var(--text-gedaempft); font-style: italic; }
table.liste.reihen [hidden] { display: none; }

View file

@ -0,0 +1,112 @@
/* Regelmäßige Buchungen: Felder erscheinen als Text und werden erst beim
* Anklicken zum Eingabefeld. Die Eingabefelder bleiben im Formular (`hidden`
* verhindert das Absenden nicht), Speichern erscheint erst nach einer Änderung.
*/
(function () {
'use strict';
const tabelle = document.querySelector('table.reihen');
if (!tabelle) {
return;
}
function datum(wert) {
const t = wert.split('-');
return t.length === 3 ? t[2] + '.' + t[1] + '.' + t[0] : '–';
}
function text(feld, steuer) {
if (steuer.type === 'checkbox') {
return steuer.checked ? 'Ja' : 'Nein';
}
if (feld.hasAttribute('data-datum')) {
return steuer.value ? datum(steuer.value) : '–';
}
if (steuer.tagName === 'SELECT') {
return steuer.value ? steuer.selectedOptions[0].textContent.trim() : '';
}
return steuer.value;
}
function zeige(feld) {
const anzeige = feld.querySelector('.anzeige');
const steuer = feld.querySelector('input, select');
const wert = text(feld, steuer);
// Betrag behält seine Formatierung vom Server, bis er geändert wird.
if (steuer.name === 'betrag') {
const zahl = parseFloat(steuer.value.replace(/\./g, '').replace(',', '.'));
if (!isNaN(zahl)) {
anzeige.textContent = zahl.toLocaleString('de-DE', {
minimumFractionDigits: 2, maximumFractionDigits: 2
}) + ' €';
anzeige.classList.toggle('negativ', zahl < 0);
}
} else {
anzeige.textContent = wert;
if (steuer.name === 'name') {
const fett = document.createElement('strong');
fett.textContent = wert;
anzeige.replaceChildren(fett);
}
}
anzeige.classList.toggle('leer', wert === '' && feld.dataset.leer !== undefined);
if (wert === '' && feld.dataset.leer !== undefined) {
anzeige.textContent = feld.dataset.leer;
}
steuer.hidden = true;
anzeige.hidden = false;
}
tabelle.querySelectorAll('.feld').forEach(function (feld) {
const anzeige = feld.querySelector('.anzeige');
const steuer = feld.querySelector('input, select');
if (!anzeige || !steuer) {
return;
}
if (anzeige.textContent.trim() === '' && feld.dataset.leer !== undefined) {
anzeige.textContent = feld.dataset.leer;
anzeige.classList.add('leer');
}
anzeige.tabIndex = 0;
anzeige.title = 'Zum Bearbeiten anklicken';
function bearbeiten() {
if (steuer.type === 'checkbox') {
steuer.checked = !steuer.checked;
steuer.dispatchEvent(new Event('change', { bubbles: true }));
zeige(feld);
return;
}
anzeige.hidden = true;
steuer.hidden = false;
steuer.focus();
}
anzeige.addEventListener('click', bearbeiten);
anzeige.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
bearbeiten();
}
});
steuer.addEventListener('blur', function () { zeige(feld); });
steuer.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
steuer.blur();
}
});
});
function geaendert(e) {
const zeile = e.target.closest('tr');
const knopf = zeile ? zeile.querySelector('[data-speichern]') : null;
if (knopf) {
knopf.hidden = false;
}
}
tabelle.addEventListener('input', geaendert);
tabelle.addEventListener('change', geaendert);
})();

View file

@ -200,6 +200,7 @@ class TermineController extends BaseController
'faktor' => $this->post('faktor', '1'), 'faktor' => $this->post('faktor', '1'),
'beginn' => $this->post('beginn'), 'beginn' => $this->post('beginn'),
'ende' => $this->post('ende'), 'ende' => $this->post('ende'),
'naechste_faelligkeit' => $this->post('naechste_faelligkeit'),
'automatisch' => isset($_POST['automatisch']) ? 1 : 0, 'automatisch' => isset($_POST['automatisch']) ? 1 : 0,
'vorlauf_tage' => $this->post('vorlauf_tage', '7'), 'vorlauf_tage' => $this->post('vorlauf_tage', '7'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0, 'aktiv' => isset($_POST['aktiv']) ? 1 : 0,

View file

@ -322,7 +322,18 @@ class Buchung
// übersprungen. // übersprungen.
Bankumsatz::loeseVonBuchung($id); Bankumsatz::loeseVonBuchung($id);
Database::transaktion(static function () use ($id, $buchung): void {
Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
// Entstand die Buchung aus einer regelmäßigen Buchung, ist deren
// Termin wieder offen.
if ($buchung !== null && !empty($buchung['regelmaessige_id']) && !empty($buchung['faelligkeit'])) {
RegelmaessigeBuchung::terminWiederOeffnen(
(int)$buchung['regelmaessige_id'],
(string)$buchung['faelligkeit']
);
}
});
} }
/** /**

View file

@ -125,6 +125,14 @@ class RegelmaessigeBuchung
$naechste = $f['beginn']; $naechste = $f['beginn'];
} }
// Ein ausdrücklich gesetzter Termin gilt – so lässt sich die Fälligkeit
// von Hand verschieben.
$gewuenscht = (string)($daten['naechste_faelligkeit'] ?? '');
$zeit = \DateTimeImmutable::createFromFormat('!Y-m-d', $gewuenscht);
if ($zeit !== false && $zeit->format('Y-m-d') === $gewuenscht) {
$naechste = $gewuenscht;
}
Database::execute( Database::execute(
'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?, 'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?, buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?,
@ -183,12 +191,51 @@ class RegelmaessigeBuchung
$splits $splits
); );
// Merken, aus welchem Termin die Buchung entstand – beim Löschen
// wird die Fälligkeit darüber wieder geöffnet.
Database::execute(
'UPDATE buchungen SET regelmaessige_id = ?, faelligkeit = ? WHERE id = ? AND benutzer_id = ?',
[$id, $reihe['naechste_faelligkeit'], $buchungId, Mandant::id()]
);
self::rueckeVor($id, (string)$reihe['naechste_faelligkeit'], $datum); self::rueckeVor($id, (string)$reihe['naechste_faelligkeit'], $datum);
return $buchungId; return $buchungId;
}); });
} }
/**
* Öffnet einen Termin wieder, dessen Buchung gelöscht wurde: Die Fälligkeit
* rückt auf diesen Termin zurück (falls er vor der aktuellen liegt), eine
* ausgelaufene Reihe wird wieder aktiv, solange der Termin im Zeitraum liegt.
* `zuletzt_am` folgt der jüngsten verbleibenden Buchung der Reihe.
* Aufzurufen, wenn die Buchung schon gelöscht ist.
*/
public static function terminWiederOeffnen(int $id, string $termin): void
{
$reihe = self::find($id);
if ($reihe === null || $termin === '') {
return;
}
$aktuell = (string)($reihe['naechste_faelligkeit'] ?? '');
$neu = ($aktuell === '' || $termin < $aktuell) ? $termin : $aktuell;
$ende = (string)($reihe['ende'] ?? '');
$aktiv = ($ende === '' || $neu <= $ende) ? 1 : (int)$reihe['aktiv'];
$zuletzt = Database::fetchValue(
'SELECT MAX(datum) FROM buchungen WHERE regelmaessige_id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
Database::execute(
'UPDATE regelmaessige_buchungen
SET naechste_faelligkeit = ?, zuletzt_am = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[$neu, $zuletzt !== null && $zuletzt !== false ? $zuletzt : null, $aktiv, $id, Mandant::id()]
);
}
/** /**
* Überspringt einen Termin, ohne zu buchen – etwa wenn die Miete einmal * Überspringt einen Termin, ohne zu buchen – etwa wenn die Miete einmal
* ausgesetzt wurde. * ausgesetzt wurde.

View file

@ -184,8 +184,8 @@ include __DIR__ . '/../layout/kopf.php';
ausgewählte Buchungen, die denselben Vorgang doppelt ausgewählte Buchungen, die denselben Vorgang doppelt
zeigen (eine Umbuchung und ihre einzeln erfasste zeigen (eine Umbuchung und ihre einzeln erfasste
Dublette), führt es zusammen. */ ?> Dublette), führt es zusammen. */ ?>
<button type="submit" class="knopf gefahr">Auswahl löschen</button> <button type="submit" id="auswahl-loeschen" class="knopf gefahr">Auswahl löschen</button>
<button type="submit" formaction="<?= View::h($verknuepfenZiel) ?>" class="knopf" <button type="submit" id="auswahl-verknuepfen" formaction="<?= View::h($verknuepfenZiel) ?>" class="knopf"
data-bestaetigen="Beide ausgewählten Buchungen zu einer Umbuchung verknüpfen? Die einzeln erfasste Buchung wird dabei gelöscht."> data-bestaetigen="Beide ausgewählten Buchungen zu einer Umbuchung verknüpfen? Die einzeln erfasste Buchung wird dabei gelöscht.">
Verknüpfen Verknüpfen
</button> </button>

View file

@ -87,44 +87,86 @@ include __DIR__ . '/../layout/kopf.php';
data-bestaetigen="Reihe „<?= View::h($reihe['name']) ?>“ löschen? Gebuchte Zahlungen bleiben erhalten."></form> data-bestaetigen="Reihe „<?= View::h($reihe['name']) ?>“ löschen? Gebuchte Zahlungen bleiben erhalten."></form>
<?php endforeach; ?> <?php endforeach; ?>
<table class="liste"> <table class="liste reihen">
<thead> <thead>
<tr> <tr>
<th>Name</th><th>Konto</th><th class="rechts">Betrag</th><th>Rhythmus</th> <th>Name und Buchungstext</th><th>Konto und Klasse</th><th class="rechts">Betrag</th><th>Rhythmus</th>
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Aktiv</th><th class="rechts">Aktion</th> <th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Aktiv</th><th class="rechts">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($reihen as $reihe): ?> <?php foreach ($reihen as $reihe): ?>
<?php $f = 'reihe-' . (int)$reihe['id']; ?> <?php $f = 'reihe-' . (int)$reihe['id']; ?>
<?php
$konto = '';
foreach ($konten as $k) {
if ((int)$k['id'] === (int)$reihe['konto_id']) {
$konto = (string)$k['name'];
}
}
$klasseName = '';
foreach ($klassen as $k) {
if ((int)$k['id'] === (int)($reihe['klasse_id'] ?? 0)) {
$klasseName = (string)$k['name'];
}
}
?>
<tr<?= (int)$reihe['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>> <tr<?= (int)$reihe['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td> <td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name" value="<?= View::h($reihe['name']) ?>" required>
<input type="hidden" form="<?= $f ?>" name="empfaenger" value="<?= View::h($reihe['empfaenger']) ?>"> <input type="hidden" form="<?= $f ?>" name="empfaenger" value="<?= View::h($reihe['empfaenger']) ?>">
<input type="hidden" form="<?= $f ?>" name="buchungstext" value="<?= View::h($reihe['buchungstext'] ?? '') ?>">
<input type="hidden" form="<?= $f ?>" name="kategorie_id" value="<?= View::h((string)$reihe['kategorie_id']) ?>"> <input type="hidden" form="<?= $f ?>" name="kategorie_id" value="<?= View::h((string)$reihe['kategorie_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="klasse_id" value="<?= View::h((string)$reihe['klasse_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="gegenkonto_id" value="<?= View::h((string)$reihe['gegenkonto_id']) ?>"> <input type="hidden" form="<?= $f ?>" name="gegenkonto_id" value="<?= View::h((string)$reihe['gegenkonto_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="vorlauf_tage" value="<?= (int)$reihe['vorlauf_tage'] ?>"> <input type="hidden" form="<?= $f ?>" name="vorlauf_tage" value="<?= (int)$reihe['vorlauf_tage'] ?>">
<input type="hidden" form="<?= $f ?>" name="faktor" value="<?= (int)$reihe['faktor'] ?>"> <input type="hidden" form="<?= $f ?>" name="faktor" value="<?= (int)$reihe['faktor'] ?>">
<input type="hidden" form="<?= $f ?>" name="beginn" value="<?= View::h((string)$reihe['beginn']) ?>">
<div class="feld" data-leer="–">
<span class="anzeige"><strong><?= View::h($reihe['name']) ?></strong></span>
<input type="text" form="<?= $f ?>" name="name" required hidden
value="<?= View::h($reihe['name']) ?>">
</div>
<div class="feld" data-leer="Buchungstext">
<span class="anzeige"><?= View::h($reihe['buchungstext'] ?? '') ?></span>
<input type="text" form="<?= $f ?>" name="buchungstext" placeholder="Buchungstext" hidden
value="<?= View::h($reihe['buchungstext'] ?? '') ?>">
</div>
</td> </td>
<td> <td>
<select form="<?= $f ?>" name="konto_id"> <div class="feld">
<?php foreach ($konten as $konto): ?> <span class="anzeige"><?= View::h($konto) ?></span>
<option value="<?= (int)$konto['id'] ?>" <select form="<?= $f ?>" name="konto_id" hidden>
<?= (int)$reihe['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>> <?php foreach ($konten as $k): ?>
<?= View::h($konto['name']) ?> <option value="<?= (int)$k['id'] ?>"
<?= (int)$reihe['konto_id'] === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
<div class="feld" data-leer="keine Klasse">
<span class="anzeige"><?= View::h($klasseName) ?></span>
<select form="<?= $f ?>" name="klasse_id" hidden>
<option value="">– keine Klasse –</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($reihe['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</td> </td>
<td class="rechts"> <td class="rechts">
<input type="text" form="<?= $f ?>" name="betrag" class="schmal" inputmode="decimal" <div class="feld">
<span class="anzeige <?= View::betragKlasse($reihe['betrag']) ?>"><?= View::betrag($reihe['betrag']) ?></span>
<input type="text" form="<?= $f ?>" name="betrag" class="schmal" inputmode="decimal" hidden
value="<?= View::h(number_format((float)$reihe['betrag'], 2, ',', '')) ?>"> value="<?= View::h(number_format((float)$reihe['betrag'], 2, ',', '')) ?>">
</div>
</td> </td>
<td> <td>
<select form="<?= $f ?>" name="intervall"> <div class="feld">
<span class="anzeige"><?= View::h($intervalle[$reihe['intervall']] ?? (string)$reihe['intervall']) ?></span>
<select form="<?= $f ?>" name="intervall" hidden>
<?php foreach ($intervalle as $schluessel => $name): ?> <?php foreach ($intervalle as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>" <option value="<?= View::h($schluessel) ?>"
<?= $reihe['intervall'] === $schluessel ? 'selected' : '' ?>> <?= $reihe['intervall'] === $schluessel ? 'selected' : '' ?>>
@ -132,32 +174,40 @@ include __DIR__ . '/../layout/kopf.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
</td> </td>
<td> <td>
<?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?> <div class="feld" data-datum>
<input type="hidden" form="<?= $f ?>" name="beginn" <span class="anzeige"><?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?></span>
value="<?= View::h((string)$reihe['beginn']) ?>"> <input type="date" form="<?= $f ?>" name="naechste_faelligkeit" hidden
value="<?= View::h((string)($reihe['naechste_faelligkeit'] ?? '')) ?>">
</div>
</td> </td>
<td> <td>
<input type="date" form="<?= $f ?>" name="ende" <div class="feld" data-datum>
<span class="anzeige"><?= View::h(View::datum($reihe['ende'] ?? null)) ?></span>
<input type="date" form="<?= $f ?>" name="ende" hidden
value="<?= View::h((string)($reihe['ende'] ?? '')) ?>"> value="<?= View::h((string)($reihe['ende'] ?? '')) ?>">
</div>
</td> </td>
<td> <td>
<label class="ankreuzfeld"> <div class="feld" data-schalter>
<input type="checkbox" form="<?= $f ?>" name="automatisch" value="1" <span class="anzeige"><?= (int)$reihe['automatisch'] === 1 ? 'Ja' : 'Nein' ?></span>
<input type="checkbox" form="<?= $f ?>" name="automatisch" value="1" hidden
<?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>> <?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>>
</label> </div>
</td> </td>
<td> <td>
<label class="ankreuzfeld"> <div class="feld" data-schalter>
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1" <span class="anzeige"><?= (int)$reihe['aktiv'] === 1 ? 'Ja' : 'Nein' ?></span>
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1" hidden
<?= (int)$reihe['aktiv'] === 1 ? 'checked' : '' ?>> <?= (int)$reihe['aktiv'] === 1 ? 'checked' : '' ?>>
</label> </div>
</td> </td>
<td class="rechts nowrap"> <td class="rechts nowrap">
<input type="hidden" form="reihe-loeschen-<?= (int)$reihe['id'] ?>" <input type="hidden" form="reihe-loeschen-<?= (int)$reihe['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>"> name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button> <button type="submit" form="<?= $f ?>" class="knopf klein" data-speichern hidden>Speichern</button>
<button type="submit" form="reihe-loeschen-<?= (int)$reihe['id'] ?>" <button type="submit" form="reihe-loeschen-<?= (int)$reihe['id'] ?>"
class="knopf klein gefahr">Löschen</button> class="knopf klein gefahr">Löschen</button>
</td> </td>
@ -188,6 +238,9 @@ include __DIR__ . '/../layout/kopf.php';
<label for="neu_empfaenger">Empfänger</label> <label for="neu_empfaenger">Empfänger</label>
<input type="text" id="neu_empfaenger" name="empfaenger"> <input type="text" id="neu_empfaenger" name="empfaenger">
<label for="neu_buchungstext">Buchungstext</label>
<input type="text" id="neu_buchungstext" name="buchungstext">
<label for="neu_kategorie">Kategorie</label> <label for="neu_kategorie">Kategorie</label>
<select id="neu_kategorie" name="kategorie_id"> <select id="neu_kategorie" name="kategorie_id">
<option value="">– keine –</option> <option value="">– keine –</option>
@ -238,3 +291,5 @@ include __DIR__ . '/../layout/kopf.php';
</form> </form>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>
<script src="/static/reihenliste.js" defer></script>

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\RegelmaessigeBuchung;
/**
* Wird eine Buchung gelöscht, die aus einer regelmäßigen Buchung entstand,
* ist deren Termin wieder offen: die Fälligkeit rückt zurück.
*/
class RegelmaessigeLoeschenTest extends TestCase
{
protected string $testName = 'Test 52: Fälligkeit nach Löschen der Buchung';
private int $giro = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->giro = Konto::anlegen([
'name' => 'Fälligkeitsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->loeschenOeffnetTerminWieder();
$this->aeltereVonZweienGeloescht();
$this->beendeteReiheWirdAktiv();
$this->manuelleBuchungBleibtUnberuehrt();
$this->buchungstextWirdUebernommen();
$this->naechsterTerminLaesstSichSetzen();
}
private function reihe(string $name, string $beginn, ?string $ende = null): int
{
return RegelmaessigeBuchung::anlegen([
'name' => $name, 'konto_id' => $this->giro, 'betrag' => -10,
'intervall' => 'monatlich', 'beginn' => $beginn, 'ende' => $ende, 'aktiv' => 1,
]);
}
private function faelligkeit(int $id): string
{
return (string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit'];
}
private function loeschenOeffnetTerminWieder(): void
{
$id = $this->reihe('Löschtest', '2026-06-01');
$buchung = RegelmaessigeBuchung::buchen($id);
$this->assertEquals($id, (int)Buchung::find($buchung)['regelmaessige_id'], 'Die Buchung kennt ihre Reihe');
$this->assertEquals('2026-06-01', (string)Buchung::find($buchung)['faelligkeit'], 'Und ihren Termin');
$this->assert($this->faelligkeit($id) > '2026-06-01', 'Nach dem Buchen ist der Termin fortgeschrieben');
Buchung::loeschen($buchung);
$this->assertEquals('2026-06-01', $this->faelligkeit($id), 'Nach dem Löschen steht der Termin wieder an');
$this->assertNull(RegelmaessigeBuchung::find($id)['zuletzt_am'], 'Ohne verbleibende Buchung kein letztes Datum');
}
private function aeltereVonZweienGeloescht(): void
{
$id = $this->reihe('Zwei Termine', '2026-03-01');
$erste = RegelmaessigeBuchung::buchen($id);
$zweite = RegelmaessigeBuchung::buchen($id);
Buchung::loeschen($erste);
$this->assertEquals('2026-03-01', $this->faelligkeit($id), 'Es gilt der älteste offene Termin');
$this->assertEquals(
(string)Buchung::find($zweite)['datum'],
(string)RegelmaessigeBuchung::find($id)['zuletzt_am'],
'zuletzt_am folgt der verbleibenden Buchung'
);
}
private function beendeteReiheWirdAktiv(): void
{
$id = $this->reihe('Befristet', '2026-07-01', '2026-07-31');
$buchung = RegelmaessigeBuchung::buchen($id);
$this->assertEquals(0, (int)RegelmaessigeBuchung::find($id)['aktiv'], 'Die Reihe ist ausgelaufen');
Buchung::loeschen($buchung);
$this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['aktiv'], 'Das Löschen öffnet sie wieder');
$this->assertEquals('2026-07-01', $this->faelligkeit($id), 'Mit dem gelöschten Termin');
}
private function manuelleBuchungBleibtUnberuehrt(): void
{
$id = $this->reihe('Unbeteiligt', '2026-09-01');
$vorher = $this->faelligkeit($id);
$manuell = Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => -5]);
Buchung::loeschen($manuell);
$this->assertEquals($vorher, $this->faelligkeit($id), 'Eine freie Buchung ändert keine Fälligkeit');
}
private function buchungstextWirdUebernommen(): void
{
$id = RegelmaessigeBuchung::anlegen([
'name' => 'Mit Text', 'konto_id' => $this->giro, 'betrag' => -10, 'buchungstext' => 'Miete Oktober',
'intervall' => 'monatlich', 'beginn' => '2026-10-01', 'aktiv' => 1,
]);
$this->assertEquals('Miete Oktober', (string)RegelmaessigeBuchung::find($id)['buchungstext'], 'Der Buchungstext wird gespeichert');
$buchung = RegelmaessigeBuchung::buchen($id);
$this->assertEquals('Miete Oktober', (string)Buchung::find($buchung)['buchungstext'], 'Und in die Buchung übernommen');
}
private function naechsterTerminLaesstSichSetzen(): void
{
$id = $this->reihe('Verschiebbar', '2026-10-01');
$daten = RegelmaessigeBuchung::find($id);
RegelmaessigeBuchung::speichern($id, $daten);
$this->assertEquals('2026-10-01', $this->faelligkeit($id), 'Speichern ohne Änderung lässt den Termin stehen');
RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => '2026-12-15'] + $daten);
$this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ein gesetzter Termin gilt');
RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => 'Unsinn'] + $daten);
$this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ungültige Angaben werden ignoriert');
}
}

View file

@ -655,7 +655,7 @@ test.describe('Buchungen', () => {
await page.goto(`/konten/${sparId}`) await page.goto(`/konten/${sparId}`)
// Ohne Auswahl bleibt der Knopf gesperrt. // Ohne Auswahl bleibt der Knopf gesperrt.
const knopf = page.locator('#buchungsauswahl button') const knopf = page.locator('#auswahl-loeschen')
await expect(knopf).toBeDisabled() await expect(knopf).toBeDisabled()
// Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen. // Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.

View file

@ -312,6 +312,30 @@ test.describe('Module der Phasen 4 bis 7', () => {
await expect(page.locator('.meldung')).toContainText('gelöscht') await expect(page.locator('.meldung')).toContainText('gelöscht')
}) })
test('Regelmäßige Buchungen werden per Klick bearbeitbar', async ({ page }) => {
await page.goto('/termine')
await page.fill('#name', 'E2E-Reihe')
await page.fill('#neu_betrag', '-12,50')
await page.fill('#neu_buchungstext', 'E2E-Text')
await page.click('form[action="/termine/neu"] button[type="submit"]')
const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })
await expect(zeile.locator('input[name="betrag"]')).toBeHidden()
await expect(zeile.locator('[data-speichern]')).toBeHidden()
await zeile.locator('.feld:has(input[name="betrag"]) .anzeige').click()
await expect(zeile.locator('input[name="betrag"]')).toBeVisible()
await zeile.locator('input[name="betrag"]').fill('-13,00')
await expect(zeile.locator('[data-speichern]')).toBeVisible()
await zeile.locator('[data-speichern]').click()
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toContainText('13,00')
page.once('dialog', (dialog) => dialog.accept())
await page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }).locator('button:has-text("Löschen")').click()
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toHaveCount(0)
})
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => { test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
await page.goto('/termine/kalender') await page.goto('/termine/kalender')
await expect(page.locator('h1')).toContainText('Finanzkalender') await expect(page.locator('h1')).toContainText('Finanzkalender')