Fix #77: Splitbuchung und direkte Zuordnung sperren sich gegenseitig

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-28 22:40:13 +02:00
parent 6f29e4644f
commit dba60c64e4
6 changed files with 145 additions and 3 deletions

View file

@ -1,5 +1,16 @@
# Änderungen # Ä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) ## Kontoblatt: Klassen der Splitzeilen in der Klassenspalte (28.09.2026)
Die Klassenspalte des Kontoblatts zeigte bei einer Splitbuchung nur die Klasse der Buchung Die Klassenspalte des Kontoblatts zeigte bei einer Splitbuchung nur die Klasse der Buchung

View file

@ -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 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. 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 Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit
`aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich `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 – was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung –

View file

@ -1248,6 +1248,11 @@ table.splittabelle input.rechts { text-align: right; }
.buchungsmaske [hidden] { display: none; } .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 <dialog> und nicht als eigene Seite: /* Der Splitdialog des Kontoblatts. Als <dialog> und nicht als eigene Seite:
Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */ Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */
.splitdialog { .splitdialog {
@ -1425,6 +1430,7 @@ table.liste th.bargeldspalte {
halbe Zeile zu breit sind. */ halbe Zeile zu breit sind. */
.formular.zweispaltig > input[type="hidden"], .formular.zweispaltig > input[type="hidden"],
.formular.zweispaltig > h3, .formular.zweispaltig > h3,
.formular.zweispaltig > .splitkopf,
.formular.zweispaltig > p, .formular.zweispaltig > p,
.formular.zweispaltig > table, .formular.zweispaltig > table,
.formular.zweispaltig > .knopfleiste, .formular.zweispaltig > .knopfleiste,

View file

@ -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 * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
* bedienbar: Die Splitsumme prüft ohnehin das Model. * 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 * Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts
* mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung. * mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung.
*/ */
@ -40,13 +48,57 @@
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); 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); art.addEventListener('change', rechneSumme);
maske.addEventListener('input', function (ereignis) { maske.addEventListener('input', function (ereignis) {
if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') { if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') {
rechneSumme(); rechneSumme();
} }
sperrenAktualisieren();
}); });
maske.addEventListener('change', sperrenAktualisieren);
if (leerenknopf) {
leerenknopf.addEventListener('click', splitsLeeren);
}
sperrenAktualisieren();
rechneSumme(); rechneSumme();
})(); })();

View file

@ -144,8 +144,18 @@ include __DIR__ . '/../layout/kopf.php';
</label> </label>
</div> </div>
<h3><span title="Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag entsprechen.">Splitbuchung</span> <?php /* Direkte Zuordnung und Splitzeilen schließen sich aus: buchungsmaske.js
sperrt jeweils die andere Seite, sobald eine davon belegt ist. Der
Knopf rechts leert alle Splitzeilen und gibt Kategorie und Klasse
wieder frei. */ ?>
<div class="splitkopf">
<h3><span title="Sobald hier etwas eingetragen ist, gilt die Zuordnung aus den Zeilen; Kategorie / Gegenkonto und Klasse oben sind dann gesperrt. Umgekehrt sperrt eine Kategorie oder Klasse oben die Splitzeilen. Die Summe muss dem Buchungsbetrag entsprechen.">Splitbuchung</span>
<span class="optional">(optional)</span></h3> <span class="optional">(optional)</span></h3>
<button type="button" class="knopf klein gefahr" id="splitsleeren" hidden
title="Leert alle Splitzeilen und gibt Kategorie / Gegenkonto und Klasse wieder frei.">
Splitbuchungen löschen
</button>
</div>
<table class="liste splittabelle"> <table class="liste splittabelle">
<thead> <thead>

View file

@ -372,6 +372,61 @@ test.describe('Buchungen', () => {
expect(titel).toContain(`${KATEGORIE}: -97,98`) 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 }) => { test('Klassen der Splitzeilen stehen in der Klassenspalte', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`) await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-03') await page.fill('#datum', '2026-05-03')