diff --git a/Changes.md b/Changes.md index 3cd7fee..c181e87 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,13 @@ # Ä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`). 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/RegelmaessigeBuchung.php b/src/Models/RegelmaessigeBuchung.php index e7c0bee..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 = ?, diff --git a/src/Views/termine/index.php b/src/Views/termine/index.php index 6387750..6cf3735 100644 --- a/src/Views/termine/index.php +++ b/src/Views/termine/index.php @@ -87,78 +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)) ?>
+
+
|
- + | - + | - + | @@ -242,3 +291,5 @@ include __DIR__ . '/../layout/kopf.php'; + + diff --git a/tests/52_RegelmaessigeLoeschenTest.php b/tests/52_RegelmaessigeLoeschenTest.php index 7c2f8bb..1ef05f3 100644 --- a/tests/52_RegelmaessigeLoeschenTest.php +++ b/tests/52_RegelmaessigeLoeschenTest.php @@ -29,6 +29,7 @@ class RegelmaessigeLoeschenTest extends TestCase $this->beendeteReiheWirdAktiv(); $this->manuelleBuchungBleibtUnberuehrt(); $this->buchungstextWirdUebernommen(); + $this->naechsterTerminLaesstSichSetzen(); } private function reihe(string $name, string $beginn, ?string $ende = null): int @@ -109,4 +110,19 @@ class RegelmaessigeLoeschenTest extends TestCase $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'); + } } diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index 68dc959..ff3b5b8 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -312,6 +312,30 @@ test.describe('Module der Phasen 4 bis 7', () => { 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 }) => { await page.goto('/termine/kalender') await expect(page.locator('h1')).toContainText('Finanzkalender')