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')