From 619e427a3087759ad52814c1e39674a6ac7820d8 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 29 Sep 2026 23:34:27 +0200 Subject: [PATCH] Fix #96: Splitbuchungen zeigen Restbetrag, Speichern nur bei Null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigt jetzt den noch offenen Restbetrag zum Buchungswert statt der Summe der Zeilen. „Speichern" bleibt gesperrt, solange Splitzeilen befüllt sind und der Restbetrag nicht Null ist; ohne Splitzeilen (auch mit direkter Kategorie/Gegenkonto) bleibt der Knopf wie bisher an. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 +++ Changes.md | 14 +++++++++++ public/static/betrag.js | 6 +++-- public/static/buchungsmaske.js | 12 ++++++++++ public/static/kontoblatt.js | 7 ++++++ src/Views/buchungen/formular.php | 2 +- src/Views/buchungen/kontoblatt.php | 2 +- tests/e2e/specs/buchungen.spec.js | 37 ++++++++++++++++++++++++------ 8 files changed, 72 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b280d..aae8dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die laufend berechnete Summe der Anteile +- Buchungsmaske und Splitdialog im Kontoblatt: Die Summenzeile der Splitbuchungen zeigt den + noch offenen Restbetrag zum Buchungswert statt der Summe der Zeilen; „Speichern“ bleibt + gesperrt, solange Splitzeilen befüllt sind und der Restbetrag nicht Null ist (#96) ### Fixed diff --git a/Changes.md b/Changes.md index 1528a81..b570754 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,19 @@ # Änderungen +## Splitbuchungen: Summenzeile zeigt den Restbetrag, Speichern nur bei Null (29.09.2026) + +- Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigte + bisher die Summe der erfassten Zeilen, mit „(Rest X)“ als Zusatz. Jetzt steht dort direkt + der noch offene Restbetrag zum Buchungswert (`Buchungswert - Summe der Zeilen`) – die + gemeinsame Rechenfunktion `FmBetrag.splitsumme()` in `betrag.js` liefert ihn beiden Masken. +- „Speichern“ (bzw. „Splitbuchung speichern“) bleibt gesperrt, solange mindestens eine + Splitzeile einen Betrag trägt und der Restbetrag nicht Null ist. Ohne Splitzeilen – egal ob + unbelegt oder mit direkter Kategorie/Gegenkonto zugeordnet – bleibt der Knopf wie bisher an; + das Model prüft die Summe der Splitzeilen ohnehin schon serverseitig. +- Der bisherige E2E-Test, der eine unstimmige Splitsumme über eine serverseitige Fehlermeldung + prüfte, prüft jetzt die neue Sperre im Browser; die serverseitige Prüfung selbst deckt + weiterhin Test 09 (PHP) ab. + ## Regel direkt aus einer Buchung anlegen (29.09.2026) - Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige diff --git a/public/static/betrag.js b/public/static/betrag.js index d6e08e7..dfb7ca3 100644 --- a/public/static/betrag.js +++ b/public/static/betrag.js @@ -31,7 +31,9 @@ } /* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht - * so in die Anzeige, wie beide Masken es brauchen. */ + * so in die Anzeige, wie beide Masken es brauchen. Angezeigt wird der noch + * offene Restbetrag (ziel - summe), nicht die Summe der Zeilen selbst: Nur + * so sieht man auf einen Blick, was noch fehlt. */ function splitsumme(felder, ziel) { let summe = 0; let belegt = false; @@ -50,7 +52,7 @@ belegt: belegt, summe: summe, rest: rest, - text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00' + text: formatiere(rest) }; } diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index ae14dbd..99510cb 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -1,6 +1,10 @@ /* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung * und Splitbuchung gegeneinander sperren. * + * Solange Splitzeilen befüllt sind, aber noch ein Restbetrag zum + * Buchungswert offen ist, bleibt „Speichern“ gesperrt – das Model wiese die + * Buchung sonst ohnehin ab, hier spart es die Rundreise zum Server. + * * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles * bedienbar: Die Splitsumme prüft ohnehin das Model. * @@ -26,6 +30,7 @@ const summenfeld = document.getElementById('splitsumme'); const betragsfeld = document.getElementById('betrag'); + const speichernknopf = maske.querySelector('button[type="submit"]'); function rechneSumme() { if (!summenfeld) { @@ -46,6 +51,13 @@ summenfeld.textContent = stand.text; summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); + + // Gesperrt nur, solange tatsächlich Splitzeilen mit Beträgen stehen und + // deren Summe den Buchungswert verfehlt. Eine direkte Zuordnung (oder + // gar keine Eingabe) deckt den ganzen Betrag stets ab. + if (speichernknopf) { + speichernknopf.disabled = stand.belegt && stand.rest !== 0; + } } const direktfelder = ['zuordnung', 'klasse_id'] diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index fe3e5cf..3a79f72 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -335,6 +335,7 @@ const zeilenkasten = document.getElementById('splitdialogzeilen'); const summenfeld = document.getElementById('splitdialogsumme'); const loeschknopf = document.getElementById('splitloeschen'); + const speichernknopf = dialog.querySelector('button[type="submit"].primaer'); const vorlage = zeilenkasten.innerHTML; let buchungsbetrag = 0; @@ -342,10 +343,16 @@ return Array.from(zeilenkasten.querySelectorAll('.splitbetrag')); } + // Gesperrt nur, solange Beträge stehen, deren Summe den Buchungswert + // verfehlt. Bleiben alle Zeilen leer, hebt „Splitbuchung speichern“ die + // Aufteilung auf – das bleibt erlaubt. function rechneSumme() { const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag); summenfeld.textContent = stand.text; summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); + if (speichernknopf) { + speichernknopf.disabled = stand.belegt && stand.rest !== 0; + } } /* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren. diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 31995f2..53681a0 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -205,7 +205,7 @@ include __DIR__ . '/../layout/kopf.php'; - Summe der Zeilen / Restbetrag + Restbetrag 0,00 diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 630b675..2dc500a 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -507,7 +507,7 @@ include __DIR__ . '/../layout/kopf.php'; - Summe der Zeilen / Restbetrag + Restbetrag 0,00 diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 86e5f3e..464046d 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -286,16 +286,25 @@ test.describe('Buchungen', () => { await expect(dialog).toBeVisible() await expect(dialog).toContainText('-90,00') + const dialogSpeichern = dialog.locator('button[type="submit"].primaer') + await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE }) await dialog.locator('input[name="split[0][betrag]"]').fill('-50') + + // Solange der Restbetrag offen ist, zeigt die Summenzeile ihn an und + // „Splitbuchung speichern“ bleibt gesperrt. + await expect(page.locator('#splitdialogsumme')).toHaveText('-40,00') + await expect(dialogSpeichern).toBeDisabled() + await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` }) await dialog.locator('input[name="split[1][betrag]"]').fill('-40') // Der Dialog rechnet den Restbetrag mit; er muss aufgehen. - await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest') + await expect(page.locator('#splitdialogsumme')).toHaveText('0,00') + await expect(dialogSpeichern).toBeEnabled() // Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen. - await dialog.locator('button[type="submit"].primaer').click() + await dialogSpeichern.click() await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung') await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first()) @@ -494,21 +503,35 @@ test.describe('Buchungen', () => { .not.toHaveAttribute('title') }) - test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => { + test('Splitzeilen, die nicht aufgehen, sperren das Speichern', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) + const speichern = page.locator('.buchungsmaske button[type="submit"]') await page.fill('#datum', '2026-05-03') await page.fill('#betrag', '100') await page.fill('#empfaenger', `Krumm ${MARKE}`) + + // Ohne jede Splitzeile bleibt der Button an – die Buchung ist noch keine + // Splitbuchung, auch wenn die Summenzeile schon den vollen Betrag zeigt. + await expect(page.locator('#splitsumme')).toHaveText('-100,00') + await expect(speichern).toBeEnabled() + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][betrag]"]', '-60') - await expect(page.locator('#splitsumme')).toContainText('Rest') + // Die Summenzeile zeigt den noch offenen Restbetrag, nicht die Summe der + // Zeilen – und „Speichern“ ist gesperrt, solange er nicht Null ist. + await expect(page.locator('#splitsumme')).toHaveText('-40,00') + await expect(speichern).toBeDisabled() - await page.click('.buchungsmaske button[type="submit"]') + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[1][betrag]"]', '-40') - await expect(page.locator('.meldung-fehler')).toBeVisible() - await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen') + await expect(page.locator('#splitsumme')).toHaveText('0,00') + await expect(speichern).toBeEnabled() + + await speichern.click() + await expect(page.locator('.meldung-erfolg')).toBeVisible() }) test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {