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 }) => {