diff --git a/Changes.md b/Changes.md index d9935aa..c181e87 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,21 @@ # Ä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) - 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`). diff --git a/migrations/023_buchung_regelmaessig.sql b/migrations/023_buchung_regelmaessig.sql new file mode 100644 index 0000000..7b82f84 --- /dev/null +++ b/migrations/023_buchung_regelmaessig.sql @@ -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; diff --git a/public/static/app.css b/public/static/app.css index f165dc7..5093ecc 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -869,6 +869,14 @@ table.liste input[type="number"] { 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 ──────────────────────────────────────────────────────────── 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"]::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; } diff --git a/public/static/reihenliste.js b/public/static/reihenliste.js new file mode 100644 index 0000000..87c55ca --- /dev/null +++ b/public/static/reihenliste.js @@ -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); +})(); diff --git a/src/Controllers/TermineController.php b/src/Controllers/TermineController.php index 97a4763..5db5ef6 100644 --- a/src/Controllers/TermineController.php +++ b/src/Controllers/TermineController.php @@ -200,6 +200,7 @@ class TermineController extends BaseController 'faktor' => $this->post('faktor', '1'), 'beginn' => $this->post('beginn'), 'ende' => $this->post('ende'), + 'naechste_faelligkeit' => $this->post('naechste_faelligkeit'), 'automatisch' => isset($_POST['automatisch']) ? 1 : 0, 'vorlauf_tage' => $this->post('vorlauf_tage', '7'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index e898f0d..a6c2882 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -322,7 +322,18 @@ class Buchung // übersprungen. Bankumsatz::loeseVonBuchung($id); - Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); + Database::transaktion(static function () use ($id, $buchung): void { + 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'] + ); + } + }); } /** diff --git a/src/Models/RegelmaessigeBuchung.php b/src/Models/RegelmaessigeBuchung.php index ddf1c2f..a8b22cd 100644 --- a/src/Models/RegelmaessigeBuchung.php +++ b/src/Models/RegelmaessigeBuchung.php @@ -125,6 +125,14 @@ class RegelmaessigeBuchung $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( 'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?, buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?, @@ -183,12 +191,51 @@ class RegelmaessigeBuchung $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); 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 * ausgesetzt wurde. diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 2dc500a..888b5f1 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -184,8 +184,8 @@ include __DIR__ . '/../layout/kopf.php'; ausgewählte Buchungen, die denselben Vorgang doppelt zeigen (eine Umbuchung und ihre einzeln erfasste Dublette), führt es zusammen. */ ?> - - + diff --git a/src/Views/termine/index.php b/src/Views/termine/index.php index 1201560..6cf3735 100644 --- a/src/Views/termine/index.php +++ b/src/Views/termine/index.php @@ -87,77 +87,127 @@ include __DIR__ . '/../layout/kopf.php'; data-bestaetigen="Reihe „= View::h($reihe['name']) ?>“ löschen? Gebuchte Zahlungen bleiben erhalten."> -
| Name | Konto | Betrag | Rhythmus | +Name und Buchungstext | Konto und Klasse | Betrag | Rhythmus | Nächster Termin | Ende | Auto | Aktiv | Aktion |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
-
-
-
+
+
+ = View::h($reihe['name']) ?>
+
+
+
+ = View::h($reihe['buchungstext'] ?? '') ?>
+
+
|
-
+
+ = View::h($konto) ?>
+
+
+
+ = View::h($klasseName) ?>
+
+
|
-
+
+ = View::betrag($reihe['betrag']) ?>
+
+
|
-
+
+ = View::h($intervalle[$reihe['intervall']] ?? (string)$reihe['intervall']) ?>
+
+
|
- = View::h(View::datum($reihe['naechste_faelligkeit'])) ?>
-
+
+ = View::h(View::datum($reihe['naechste_faelligkeit'])) ?>
+
+
|
-
+
+ = View::h(View::datum($reihe['ende'] ?? null)) ?>
+
+
|
- + | - + | - + | @@ -188,6 +238,9 @@ include __DIR__ . '/../layout/kopf.php'; + + +