diff --git a/Changes.md b/Changes.md index 3308071..1528a81 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,23 @@ # Änderungen +## Regel direkt aus einer Buchung anlegen (29.09.2026) + +- Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige + Verhalten bleibt unverändert, solange er aus ist. +- Ist er beim Speichern an, entsteht aus Empfänger (ersatzweise Buchungstext), Kategorie, + Klasse, Gegenkonto und Splits der Buchung eine neue Regel (`AbgleichRegel::ausBuchung()`). + Ohne Empfänger und Buchungstext gibt es kein sinnvolles Muster – dann bleibt es beim + gewöhnlichen Speichern ohne Regel. +- Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog (`regeldialog.js`, + natives `` wie beim Splitdialog) zum Prüfen und Anpassen – mit denselben Feldern + wie „Neue Regel“ in den Bankregeln (ausgelagert nach `regelfelder.php`). +- „Abbruch“ löscht die gerade erst angelegte Regel wieder (über das bestehende + Lösch-Formular der Regelliste, nur mit Rücksprung zum Kontoblatt statt zur Regelliste); + die Buchung bleibt gespeichert. Die Escape-Taste verhält sich genauso, statt den Dialog + nur zu schließen und eine verwaiste Regel zurückzulassen. +- „Speichern“ übernimmt Änderungen und lässt die Regel in der Liste stehen. +- Neu: Test 51 (PHP) und `tests/e2e/specs/regelausbuchung.spec.js`. + ## Umbuchungen: beide Seiten finden zueinander (29.09.2026) - Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt: diff --git a/docs/banking.md b/docs/banking.md index e4958aa..6ec9b9a 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -474,6 +474,20 @@ Abruf zum Stehen. Eingegeben wird ohne Trennzeichen: `Miete|Nebenkosten`, nicht Der Zähler *Treffer* zeigt, welche Regeln im Alltag überhaupt greifen. +### Regel direkt aus einer Buchung anlegen + +In der Buchungsmaske schaltet der Toggle *Regel anlegen* (standardmäßig aus) eine +Kurzstrecke frei: Beim Speichern entsteht aus Empfänger (ersatzweise Buchungstext), +Kategorie, Klasse, Gegenkonto und einer eventuellen Splitbuchung eine neue Regel +(`AbgleichRegel::ausBuchung()`) – Feld *Empfänger*, Vergleich *enthält*, das Muster ist der +Empfängertext. Fehlen sowohl Empfänger als auch Buchungstext, gibt es kein sinnvolles Muster +und keine Regel entsteht; die Buchung wird trotzdem ganz normal gespeichert. + +Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog mit denselben Feldern wie +*Neue Regel* oben auf dieser Seite. *Abbruch* (auch über Escape) löscht die gerade erst +angelegte Regel wieder – die Buchung bleibt gespeichert, keine ungewollte Regel bleibt +zurück. *Speichern* übernimmt Änderungen und belässt die Regel in der Liste. + ## Protokoll und Diagnose Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das diff --git a/public/static/app.css b/public/static/app.css index 88770c0..6ad60c7 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1320,11 +1320,16 @@ table.splittabelle input.rechts { text-align: right; } .formatname { font-weight: 600; font-size: 1.05rem; } /* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der - Splitdialog; bei kleinen Fenstern scrollt er. */ + Splitdialog; bei kleinen Fenstern scrollt er. Der Regeldialog übernimmt + dieselben Regelfelder wie die Regelliste – aus demselben Grund dieselbe + Bauart. */ .wertpapierdialog { max-height: 88vh; overflow-y: auto; } .wertpapierdialog .formular { max-width: none; } .wertpapierdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +.regeldialog { max-height: 88vh; overflow-y: auto; } +.regeldialog .formular { max-width: none; } + /* Die Kostentabelle: Betrag rechts, Kategorie als stiller Hinweis daneben. */ table.kostentabelle { margin-top: .6rem; } table.kostentabelle td { padding: .25rem .4rem; } diff --git a/public/static/regeldialog.js b/public/static/regeldialog.js new file mode 100644 index 0000000..5e57b92 --- /dev/null +++ b/public/static/regeldialog.js @@ -0,0 +1,27 @@ +/* Regeldialog: öffnet sich von selbst, wenn die Buchungsmaske mit gesetztem + * Toggle „Regel anlegen“ gespeichert hat. + * + * Die Regel steht zu diesem Zeitpunkt schon in der Datenbank – der Dialog + * zeigt sie nur zum Prüfen. Ein Abbruch über die Escape-Taste darf deshalb + * nicht bloß den Dialog schließen, sonst bliebe die Regel ungewollt stehen + * (Fix #94). Er löst stattdessen denselben Knopf aus wie ein Klick auf + * „Abbruch“. + */ + +(function () { + 'use strict'; + + const dialog = document.getElementById('regeldialog'); + const formular = document.getElementById('regelformular'); + const abbrechen = document.getElementById('regelabbrechen'); + if (!dialog || !formular || !abbrechen) { + return; + } + + dialog.showModal(); + + dialog.addEventListener('cancel', function (ereignis) { + ereignis.preventDefault(); + formular.requestSubmit(abbrechen); + }); +})(); diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index ee00bc1..a9eb329 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -941,11 +941,11 @@ class BankingController extends BaseController try { AbgleichRegel::speichern((int)$params['id'], $this->regeldaten()); } catch (\Throwable $e) { - View::redirect('/banking/regeln', $e->getMessage(), 'fehler'); + View::redirect($this->regelZiel(), $e->getMessage(), 'fehler'); return; } - View::redirect('/banking/regeln', 'Regel gespeichert.'); + View::redirect($this->regelZiel(), 'Regel gespeichert.'); } /** @param array $params */ @@ -955,7 +955,24 @@ class BankingController extends BaseController AbgleichRegel::loeschen((int)$params['id']); - View::redirect('/banking/regeln', 'Regel gelöscht.'); + View::redirect($this->regelZiel(), 'Regel gelöscht.'); + } + + /** + * Wohin es nach dem Speichern oder Löschen einer Regel geht: normalerweise + * die Regelliste. Der Regeldialog der Buchungsmaske trägt hier aber das + * Konto ein, von dem er kam – die Regel entstand aus einer Buchung, zu deren + * Kontoblatt soll es danach zurückgehen, ohne dass diese Ansicht selbst + * etwas von der Buchung wissen müsste. + */ + private function regelZiel(): string + { + $kontoId = (int)$this->post('rueckziel_konto_id'); + if ($kontoId > 0 && Konto::find($kontoId) !== null) { + return '/konten/' . $kontoId; + } + + return '/banking/regeln'; } /** diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index d2ce1ff..67ef088 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Buchung; use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Einstellung; @@ -82,6 +83,9 @@ class BuchungenController extends BaseController // Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt – // sonst stünde dort in jeder Zeile derselbe graue Kreis. 'bargeld' => BargeldAbgleich::eingerichtet(), + // Kommt die Seite vom Toggle „Regel anlegen“ in der Buchungsmaske, + // steht hier die frisch erzeugte Regel zum Prüfen im Dialog. + 'neueRegel' => AbgleichRegel::find((int)$this->query('neue_regel')), ]); } @@ -126,7 +130,10 @@ class BuchungenController extends BaseController $abgleich = BargeldAbgleich::pruefe($id); - View::redirect('/konten/' . $kontoId, 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich)); + View::redirect( + $this->kontoblattZiel($kontoId, $id), + 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich) + ); } /** @param array $params */ @@ -175,7 +182,7 @@ class BuchungenController extends BaseController $abgleich = BargeldAbgleich::pruefe($id); View::redirect( - '/konten/' . $buchung['konto_id'], + $this->kontoblattZiel((int)$buchung['konto_id'], $id), 'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich) ); } @@ -557,6 +564,24 @@ class BuchungenController extends BaseController return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query)); } + /** + * Ziel nach dem Speichern einer Buchung: das Kontoblatt – mit angehängter + * neuer Regel, wenn der Toggle „Regel anlegen“ gesetzt war. Ohne Muster + * (kein Empfänger, kein Buchungstext) entsteht keine Regel; das Ziel bleibt + * dann das gewöhnliche. + */ + private function kontoblattZiel(int $kontoId, int $buchungId): string + { + $ziel = '/konten/' . $kontoId; + + if (!isset($_POST['regel_anlegen'])) { + return $ziel; + } + + $regelId = AbgleichRegel::ausBuchung($buchungId); + return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId; + } + /** @param array|null $konto */ private function maske(?array $konto, ?array $buchung): void { diff --git a/src/Models/AbgleichRegel.php b/src/Models/AbgleichRegel.php index cf6ea49..05d7bfa 100644 --- a/src/Models/AbgleichRegel.php +++ b/src/Models/AbgleichRegel.php @@ -100,6 +100,69 @@ class AbgleichRegel Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } + /** + * Baut aus einer bestehenden Buchung eine neue Regel – der Ausgangspunkt für + * die Buchungsmaske, die eine Buchung direkt in eine Regel verwandeln kann. + * Das Muster kommt vom Empfänger, ersatzweise vom Buchungstext; ist beides + * leer, gibt es kein sinnvolles Muster und keine Regel entsteht. + * + * @return int|null Die id der neuen Regel, oder null ohne Muster + */ + public static function ausBuchung(int $buchungId): ?int + { + $buchung = Buchung::find($buchungId); + if ($buchung === null) { + throw new \RuntimeException('Die Buchung gibt es nicht.'); + } + + $empfaenger = trim((string)($buchung['empfaenger'] ?? '')); + $zweck = trim((string)($buchung['buchungstext'] ?? '')); + + if ($empfaenger !== '') { + $feld = 'empfaenger'; + } elseif ($zweck !== '') { + $feld = 'verwendungszweck'; + } else { + return null; + } + + $splitzeilen = array_values(array_filter( + Buchung::splits($buchungId), + static fn(array $zeile): bool => (int)($zeile['kategorie_id'] ?? 0) !== 0 + )); + + // Anteile am Buchungsbetrag statt roher Beträge: So zeigt der Dialog + // gleich sinnvolle Prozentwerte statt der Centbeträge der Buchung – + // splitzeilen() liest den Anteil als Bruchteil von 1. + $gesamt = array_sum(array_map( + static fn(array $zeile): float => abs((float)$zeile['betrag']), + $splitzeilen + )); + + $splits = []; + foreach ($splitzeilen as $zeile) { + $splits[] = [ + 'kategorie_id' => (int)$zeile['kategorie_id'], + 'klasse_id' => $zeile['klasse_id'], + 'anteil' => $gesamt > 0.0 ? round(abs((float)$zeile['betrag']) / $gesamt, 4) : 0, + 'notiz' => (string)($zeile['notiz'] ?? ''), + ]; + } + + return self::anlegen([ + 'name' => $empfaenger !== '' ? $empfaenger : $zweck, + 'feld' => $feld, + 'vergleich' => 'enthaelt', + 'muster' => $empfaenger !== '' ? $empfaenger : $zweck, + 'kategorie_id' => $buchung['kategorie_id'], + 'klasse_id' => $buchung['klasse_id'], + 'gegenkonto_id' => $buchung['gegenkonto_id'], + 'splits' => $splits === [] ? null : $splits, + 'prioritaet' => 100, + 'aktiv' => 1, + ]); + } + /** * Die erste Regel, die auf den Umsatz passt. * diff --git a/src/Views/banking/regelfelder.php b/src/Views/banking/regelfelder.php new file mode 100644 index 0000000..c98dc7e --- /dev/null +++ b/src/Views/banking/regelfelder.php @@ -0,0 +1,112 @@ + $regel Vorbelegung; leeres Array für eine neue Regel + * @var list> $kategorien + * @var list> $klassen + * @var list> $konten + * @var string $idPrefix Vorsatz für die Feld-ids, damit mehrere solcher + * Formulare auf einer Seite nicht kollidieren + */ + +$id = static fn(string $name): string => $idPrefix . $name; + +$betragsfeld = static function (?string $feld) use ($regel): string { + $wert = $regel[$feld] ?? null; + return $wert === null || $wert === '' ? '' : View::h(View::betrag((float)$wert, false)); +}; +?> + + + + + + + + + + + + +

+ Groß- und Kleinschreibung spielt keine Rolle. Bei regulärer Ausdruck ohne + Trennzeichen eingeben, also Miete|Nebenkosten statt /Miete/. +

+ + +
+ + +
+

+ Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften + zwischen 40 und 60 Euro. +

+ + + + + + + + + + +

Splitbuchung (statt einer einzelnen Kategorie)

+ + + + + + + diff --git a/src/Views/banking/regeln.php b/src/Views/banking/regeln.php index f8a4129..3c33a53 100644 --- a/src/Views/banking/regeln.php +++ b/src/Views/banking/regeln.php @@ -151,74 +151,8 @@ include __DIR__ . '/../layout/kopf.php';

Neue Regel

- - - - - - - - - - - -

- Groß- und Kleinschreibung spielt keine Rolle. Bei regulärer Ausdruck ohne - Trennzeichen eingeben, also Miete|Nebenkosten statt /Miete/. -

- - -
- - -
-

- Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften - zwischen 40 und 60 Euro. -

- - - - - - - - - - -

Splitbuchung (statt einer einzelnen Kategorie)

- - - - - - - + 1]; $idPrefix = 'neu_'; ?> +
diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index a5c71d2..31995f2 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -144,6 +144,14 @@ include __DIR__ . '/../layout/kopf.php';
+
+ +
+ > $konten */ /** @var array>> $splits */ /** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */ +/** @var array|null $neueRegel Aus der Buchungsmaske erzeugte Regel, zum Prüfen im Dialog */ $kategorieNamen = []; foreach ($kategorien as $k) { @@ -584,4 +585,35 @@ include __DIR__ . '/../layout/kopf.php'; + + + +
+ + +

Regel anlegen

+

+ Aus dieser Buchung wurde diese Regel erzeugt. Vor dem Speichern lässt sie + sich noch anpassen; „Abbruch“ verwirft sie wieder. +

+ + + + +
+ + +
+
+
+ + + + diff --git a/tests/51_RegelAusBuchungTest.php b/tests/51_RegelAusBuchungTest.php new file mode 100644 index 0000000..fc231c0 --- /dev/null +++ b/tests/51_RegelAusBuchungTest.php @@ -0,0 +1,161 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->uebernimmtEmpfaengerKategorieUndKlasse(); + $this->weichtOhneEmpfaengerAufDenBuchungstextAus(); + $this->ohneEmpfaengerUndBuchungstextEntstehtKeineRegel(); + $this->splitbuchungBekommtVerhaeltnisgleicheAnteile(); + $this->unbekannteBuchungWirdAbgewiesen(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Regeltest Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, + ]); + $this->strom = Kategorie::anlegen(['name' => 'Regeltest Strom', 'art' => 'ausgabe']); + $this->versicherung = Kategorie::anlegen(['name' => 'Regeltest Versicherung', 'art' => 'ausgabe']); + $this->klasse = Klasse::anlegen('Regeltest Privat'); + } + + private function uebernimmtEmpfaengerKategorieUndKlasse(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-01', + 'betrag' => -42.50, + 'empfaenger' => 'Stadtwerke Strom', + 'buchungstext' => 'Abschlag Mai', + 'kategorie_id' => $this->strom, + 'klasse_id' => $this->klasse, + ]); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $this->assertNotEmpty($regelId, 'Die Regel wird angelegt'); + + $regel = AbgleichRegel::find((int)$regelId); + $this->assertNotEmpty($regel, 'Sie lässt sich wiederfinden'); + $this->assertEquals('empfaenger', $regel['feld'], 'Geprüft wird der Empfänger'); + $this->assertEquals('Stadtwerke Strom', $regel['muster'], 'Das Muster ist der Empfänger'); + $this->assertEquals('enthaelt', $regel['vergleich'], 'Der Vergleich ist „enthält“'); + $this->assertEquals($this->strom, (int)$regel['kategorie_id'], 'Die Kategorie wird übernommen'); + $this->assertEquals($this->klasse, (int)$regel['klasse_id'], 'Die Klasse wird übernommen'); + $this->assertEquals(1, (int)$regel['aktiv'], 'Die Regel ist gleich aktiv'); + + // Und sie taucht in der Liste der bestehenden Regeln auf – ergänzt, nicht + // ersetzt. + $gefunden = false; + foreach (AbgleichRegel::alle() as $eintrag) { + $gefunden = $gefunden || (int)$eintrag['id'] === (int)$regelId; + } + $this->assert($gefunden, 'Die neue Regel steht in der Liste der bestehenden Regeln'); + + // Und sie trifft tatsächlich auf einen passenden Umsatz. + $this->assert( + AbgleichRegel::passt($regel, ['betrag' => -42.50, 'empfaenger' => 'Stadtwerke Strom AG']), + 'Die Regel trifft einen ähnlichen Umsatz' + ); + } + + private function weichtOhneEmpfaengerAufDenBuchungstextAus(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-02', + 'betrag' => -15.00, + 'buchungstext' => 'Parkgebühr Innenstadt', + ]); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $regel = AbgleichRegel::find((int)$regelId); + + $this->assertEquals('verwendungszweck', $regel['feld'], 'Ohne Empfänger zählt der Buchungstext'); + $this->assertEquals('Parkgebühr Innenstadt', $regel['muster'], 'Das Muster ist der Buchungstext'); + } + + private function ohneEmpfaengerUndBuchungstextEntstehtKeineRegel(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-03', + 'betrag' => -5.00, + ]); + + $this->assertNull(AbgleichRegel::ausBuchung($buchungId), 'Ohne Muster entsteht keine Regel'); + } + + private function splitbuchungBekommtVerhaeltnisgleicheAnteile(): void + { + $buchungId = Buchung::anlegen( + [ + 'konto_id' => $this->giro, + 'datum' => '2026-05-04', + 'betrag' => -80.00, + 'empfaenger' => 'Sammelrechnung Versicherung', + ], + [ + ['kategorie_id' => $this->versicherung, 'betrag' => -50.00, 'notiz' => 'Kfz'], + ['kategorie_id' => $this->versicherung, 'betrag' => -30.00, 'notiz' => 'Haftpflicht'], + ] + ); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $regel = AbgleichRegel::find((int)$regelId); + + $zeilen = AbgleichRegel::splitzeilen($regel); + $this->assertEquals(2, count($zeilen), 'Beide Splitzeilen kommen in der Regel an'); + $this->assertEquals(62.5, $zeilen[0]['prozent'], 'Anteil der ersten Zeile im selben Verhältnis wie die Buchung'); + $this->assertEquals(37.5, $zeilen[1]['prozent'], 'Anteil der zweiten Zeile ebenso'); + + // anwenden() rechnet die Anteile auf jeden Buchungsbetrag um – hier zur + // Probe auf einen doppelt so hohen Umsatz. + $angewendet = AbgleichRegel::anwenden($regel, ['betrag' => -160.00]); + $this->assertEquals(-100.0, $angewendet['splits'][0]['betrag'], 'Erste Zeile bleibt im Verhältnis 62,5 %'); + $this->assertEquals(-60.0, $angewendet['splits'][1]['betrag'], 'Zweite Zeile bleibt im Verhältnis 37,5 %'); + } + + private function unbekannteBuchungWirdAbgewiesen(): void + { + $this->assert( + $this->wirftFehler(fn() => AbgleichRegel::ausBuchung(PHP_INT_MAX)), + 'Eine nicht vorhandene Buchung wird abgewiesen' + ); + } + + /** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */ + private function wirftFehler(callable $aufruf): bool + { + try { + $aufruf(); + } catch (\Throwable) { + return true; + } + + return false; + } +} diff --git a/tests/e2e/specs/regelausbuchung.spec.js b/tests/e2e/specs/regelausbuchung.spec.js new file mode 100644 index 0000000..e5d1a1b --- /dev/null +++ b/tests/e2e/specs/regelausbuchung.spec.js @@ -0,0 +1,138 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +const MARKE = Date.now().toString().slice(-6) +const GIRO = `E2E RegelBuchung ${MARKE}` +const KATEGORIE = `E2E RegelBuchung Kategorie ${MARKE}` + +async function anmelden(page) { + await page.goto('/login') + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +// Fix #94: Aus der Buchungsmaske heraus lässt sich mit dem Toggle +// „Regel anlegen“ direkt eine Regel aus der gerade gespeicherten Buchung +// erzeugen; ein Dialog zeigt sie danach zum Prüfen. +test.describe('Regel aus einer Buchung anlegen', () => { + let giroId = null + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await anmelden(page) + + await page.goto('/konten/neu') + await page.fill('#name', GIRO) + await page.fill('#anfangssaldo', '1.000,00') + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) + giroId = page.url().match(/\/konten\/(\d+)\//)[1] + + await page.goto('/verwaltung/kategorien/neu') + await page.fill('#name', KATEGORIE) + await page.selectOption('#art', 'ausgabe') + await page.click('.formular button[type="submit"]') + + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Toggle ist standardmäßig aus – ohne ihn ändert sich nichts am bisherigen Ablauf', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await expect(page.locator('input[name="regel_anlegen"]')).not.toBeChecked() + + await page.fill('#datum', '2026-05-01') + await page.fill('#betrag', '12,00') + await page.fill('#empfaenger', `Ohne Regel ${MARKE}`) + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + await expect(page.locator('#regeldialog')).toHaveCount(0) + }) + + test('Regel anlegen öffnet den Dialog mit den Buchungsdaten; Abbruch verwirft die Regel wieder', async ({ page }) => { + const empfaenger = `Abbruchregel ${MARKE}` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-02') + await page.fill('#betrag', '20,00') + await page.fill('#empfaenger', empfaenger) + await page.selectOption('#zuordnung', { label: KATEGORIE }) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const dialog = page.locator('#regeldialog') + await expect(dialog).toBeVisible() + await expect(dialog.locator('input[name="name"]')).toHaveValue(empfaenger) + await expect(dialog.locator('input[name="muster"]')).toHaveValue(empfaenger) + await expect(dialog.locator('select[name="kategorie_id"]')).toHaveValue(/^\d+$/) + + await dialog.getByRole('button', { name: 'Abbruch' }).click() + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await expect(page.locator('#regeldialog')).toHaveCount(0) + + // Es darf keine neu angelegte Regel zurückbleiben. Der Name steht im + // Wert eines Eingabefelds, nicht im Text der Zeile – toContainText + // träfe ihn also gar nicht erst. + await page.goto('/banking/regeln') + await expect(page.locator(`input[name="name"][value="${empfaenger}"]`)).toHaveCount(0) + }) + + test('Speichern im Dialog übernimmt Änderungen und behält die Regel', async ({ page }) => { + const empfaenger = `Speicherregel ${MARKE}` + const angepasstesMuster = `Speicherregel ${MARKE} angepasst` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-03') + await page.fill('#betrag', '30,00') + await page.fill('#empfaenger', empfaenger) + await page.selectOption('#zuordnung', { label: KATEGORIE }) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + const dialog = page.locator('#regeldialog') + await expect(dialog).toBeVisible() + + await dialog.locator('input[name="muster"]').fill(angepasstesMuster) + await dialog.getByRole('button', { name: 'Speichern' }).click() + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await expect(page.locator('#regeldialog')).toHaveCount(0) + + // Der Name blieb unverändert; über ihn findet sich die Zeile wieder – + // das Muster steckt wie der Name im Wert eines Eingabefelds. + await page.goto('/banking/regeln') + const zeile = page.locator('tr').filter({ has: page.locator(`input[name="name"][value="${empfaenger}"]`) }) + await expect(zeile).toBeVisible() + await expect(zeile.locator('input[name="muster"]')).toHaveValue(angepasstesMuster) + }) + + test('Escape im Dialog verwirft die Regel wie ein Klick auf Abbruch', async ({ page }) => { + const empfaenger = `Escaperegel ${MARKE}` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-04') + await page.fill('#betrag', '40,00') + await page.fill('#empfaenger', empfaenger) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('#regeldialog')).toBeVisible() + await page.keyboard.press('Escape') + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await page.goto('/banking/regeln') + await expect(page.locator(`input[name="name"][value="${empfaenger}"]`)).toHaveCount(0) + }) +})