From dba60c64e44bbbb60069315775eba119f2e102f6 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 28 Sep 2026 22:40:13 +0200 Subject: [PATCH] Fix #77: Splitbuchung und direkte Zuordnung sperren sich gegenseitig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In der Buchungsmaske sperrt eine Eingabe in den Splitzeilen sofort Kategorie / Gegenkonto und Klasse, und umgekehrt sperrt eine Kategorie oder Klasse alle Splitzeilen. Der neue Knopf „Splitbuchungen löschen“ rechts über der Splittabelle leert alle Zeilen und gibt die direkten Felder wieder frei. Co-Authored-By: Claude Opus 5.5 --- Changes.md | 11 +++++++ docs/buchungen.md | 8 +++++ public/static/app.css | 6 ++++ public/static/buchungsmaske.js | 54 +++++++++++++++++++++++++++++- src/Views/buchungen/formular.php | 14 ++++++-- tests/e2e/specs/buchungen.spec.js | 55 +++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 3 deletions(-) diff --git a/Changes.md b/Changes.md index 97538f8..a9f83a0 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,16 @@ # Änderungen +## Buchungsmaske: Splitbuchung und direkte Zuordnung sperren sich gegenseitig (28.09.2026) + +Splitzeilen und die Felder *Kategorie / Gegenkonto* und *Klasse* ließen sich in der +Buchungsmaske gleichzeitig ausfüllen (#77). + +- Sobald in einer Splitzeile etwas eingetragen ist, sind *Kategorie / Gegenkonto* und + *Klasse* sofort gesperrt; eine Kategorie oder Klasse sperrt umgekehrt alle Splitzeilen. +- Neuer Knopf *Splitbuchungen löschen* rechts über der Splittabelle: leert alle + Splitzeilen und gibt Kategorie und Klasse wieder frei. +- E2E-Prüfung in `buchungen.spec.js`. + ## Kontoblatt: Klassen der Splitzeilen in der Klassenspalte (28.09.2026) Die Klassenspalte des Kontoblatts zeigte bei einer Splitbuchung nur die Klasse der Buchung diff --git a/docs/buchungen.md b/docs/buchungen.md index fe2680a..76fb69e 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -185,6 +185,14 @@ deutsche Zahlenformat steht damit einmal im Browser und einmal in PHP, nicht dre Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag dieser Zeile. Auf das eigene Konto darf sie nicht zeigen – die Buchung zählte dort doppelt. +In der **Buchungsmaske** sperren sich beide Wege gegenseitig (`public/static/buchungsmaske.js`): +Sobald in einer Splitzeile irgendetwas steht, sind *Kategorie / Gegenkonto* und *Klasse* +gesperrt; steht dort umgekehrt eine Kategorie, ein Gegenkonto oder eine Klasse, sind alle +Splitzeilen gesperrt. Gesperrte Felder schickt der Browser nicht mit. Stehen beim Öffnen +beide Seiten voll, gilt wie im Model die Splitbuchung. Der Knopf *Splitbuchungen löschen* +rechts über der Splittabelle erscheint, sobald eine Splitzeile belegt ist; er leert alle +Zeilen und gibt die direkten Felder wieder frei. Gespeichert wird erst mit dem Formular. + Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit `aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung – diff --git a/public/static/app.css b/public/static/app.css index 8b26b8c..67f78e1 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1248,6 +1248,11 @@ table.splittabelle input.rechts { text-align: right; } .buchungsmaske [hidden] { display: none; } +/* Überschrift der Splittabelle mit dem Knopf „Splitbuchungen löschen“ rechts. */ +.buchungsmaske .splitkopf { display: flex; align-items: center; gap: .6rem; } +.buchungsmaske .splitkopf h3 { margin: 0; } +.buchungsmaske .splitkopf .knopf { margin-left: auto; } + /* Der Splitdialog des Kontoblatts. Als und nicht als eigene Seite: Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */ .splitdialog { @@ -1425,6 +1430,7 @@ table.liste th.bargeldspalte { halbe Zeile zu breit sind. */ .formular.zweispaltig > input[type="hidden"], .formular.zweispaltig > h3, +.formular.zweispaltig > .splitkopf, .formular.zweispaltig > p, .formular.zweispaltig > table, .formular.zweispaltig > .knopfleiste, diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index f06c32d..ae14dbd 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -1,8 +1,16 @@ -/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen. +/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung + * und Splitbuchung gegeneinander sperren. * * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles * bedienbar: Die Splitsumme prüft ohnehin das Model. * + * Eine Buchung ist entweder direkt zugeordnet (Kategorie / Gegenkonto, Klasse) + * oder eine Splitbuchung. Sobald in einer Splitzeile etwas steht, sind die + * beiden Felder oben gesperrt; steht oben eine Kategorie oder Klasse, sind es + * die Splitzeilen. Gesperrte Felder schickt der Browser nicht mit – was dort + * noch stehen mag, geht also nicht in die Buchung ein. Der Knopf + * „Splitbuchungen löschen“ leert alle Splitzeilen und gibt oben wieder frei. + * * Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts * mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung. */ @@ -40,13 +48,57 @@ summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); } + const direktfelder = ['zuordnung', 'klasse_id'] + .map(function (id) { return document.getElementById(id); }) + .filter(Boolean); + const splitkasten = document.getElementById('splitzeilen'); + const leerenknopf = document.getElementById('splitsleeren'); + + function splitfelder() { + return splitkasten ? Array.from(splitkasten.querySelectorAll('input, select')) : []; + } + + function belegt(felder) { + return felder.some(function (feld) { return feld.value.trim() !== ''; }); + } + + function sperrenAktualisieren() { + const splits = splitfelder(); + const splitBelegt = belegt(splits); + // Stehen beim Öffnen beide Seiten voll (ältere Daten), gilt wie im + // Model die Splitbuchung. + const direktBelegt = !splitBelegt && belegt(direktfelder); + + direktfelder.forEach(function (feld) { feld.disabled = splitBelegt; }); + splits.forEach(function (feld) { feld.disabled = direktBelegt; }); + + if (leerenknopf) { + leerenknopf.hidden = !splitBelegt; + } + } + + function splitsLeeren() { + splitfelder().forEach(function (feld) { + feld.value = ''; + }); + sperrenAktualisieren(); + rechneSumme(); + } + art.addEventListener('change', rechneSumme); maske.addEventListener('input', function (ereignis) { if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') { rechneSumme(); } + sperrenAktualisieren(); }); + maske.addEventListener('change', sperrenAktualisieren); + if (leerenknopf) { + leerenknopf.addEventListener('click', splitsLeeren); + } + + sperrenAktualisieren(); rechneSumme(); })(); diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 5db3270..a5c71d2 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -144,8 +144,18 @@ include __DIR__ . '/../layout/kopf.php'; -

Splitbuchung - (optional)

+ +
+

Splitbuchung + (optional)

+ +
diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index b5da638..86e5f3e 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -372,6 +372,61 @@ test.describe('Buchungen', () => { expect(titel).toContain(`${KATEGORIE}: -97,98`) }) + test('Splitzeilen und direkte Zuordnung sperren sich gegenseitig', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + const kategorie = page.locator('#zuordnung') + const klasse = page.locator('#klasse_id') + const splitZuordnung = page.locator('select[name="split[0][zuordnung]"]') + const splitBetrag = page.locator('input[name="split[1][betrag]"]') + const leeren = page.locator('#splitsleeren') + + // Leere Maske: alles offen, nichts zu löschen. + await expect(kategorie).toBeEnabled() + await expect(splitBetrag).toBeEnabled() + await expect(leeren).toBeHidden() + + // Eine Eingabe in den Splitzeilen sperrt Kategorie und Klasse sofort. + await page.fill('input[name="split[0][notiz]"]', 'Teil') + await expect(kategorie).toBeDisabled() + await expect(klasse).toBeDisabled() + await expect(leeren).toBeVisible() + + // Der Knopf leert alle Zeilen und gibt oben wieder frei. + await splitZuordnung.selectOption({ label: KATEGORIE }) + await splitBetrag.fill('-10') + await leeren.click() + await expect(page.locator('input[name="split[0][notiz]"]')).toHaveValue('') + await expect(splitZuordnung).toHaveValue('') + await expect(splitBetrag).toHaveValue('') + await expect(kategorie).toBeEnabled() + await expect(klasse).toBeEnabled() + await expect(leeren).toBeHidden() + + // Umgekehrt: Eine Kategorie oben sperrt die Splitzeilen … + await kategorie.selectOption({ label: KATEGORIE }) + await expect(splitZuordnung).toBeDisabled() + await expect(splitBetrag).toBeDisabled() + await kategorie.selectOption('') + await expect(splitBetrag).toBeEnabled() + + // … und eine Klasse ebenso. + await klasse.selectOption({ label: KLASSE }) + await expect(splitZuordnung).toBeDisabled() + await klasse.selectOption('') + await expect(splitZuordnung).toBeEnabled() + }) + + test('Bestehende Splitbuchung öffnet mit gesperrter Kategorie', async ({ page }) => { + const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` }) + await page.goto(`/konten/${giroId}`) + const bearbeiten = await zeile.locator('a[href^="/buchungen/"]').first().getAttribute('href') + await page.goto(bearbeiten) + + await expect(page.locator('#zuordnung')).toBeDisabled() + await expect(page.locator('#klasse_id')).toBeDisabled() + await expect(page.locator('#splitsleeren')).toBeVisible() + }) + test('Klassen der Splitzeilen stehen in der Klassenspalte', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-05-03')