From 1fbc3edc5ec6783571a7066ff71825f9905f0483 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 13 Aug 2026 09:46:32 +0200 Subject: [PATCH] =?UTF-8?q?Termine=20und=20Vertr=C3=A4ge=20unter=20der=20V?= =?UTF-8?q?erwaltung,=20Kacheln=20vereinheitlicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Seitenleiste war lang geworden. Termine und Verträge sind kein täglicher Einstieg und stehen deshalb nicht mehr im Hauptmenü, sondern als letzte Kacheln in der Verwaltung. - Menüeinträge können weitere Pfade mitführen (`auch`), unter denen sie als aktiv gelten – die Verwaltung leuchtet damit auch unter /termine und /vertraege, wo die Seitenleiste sonst ohne Markierung bliebe. - Neue Kacheln Termine (aktive Reihen, offene Erinnerungen) und Verträge (laufende Verträge, Fristen der nächsten 90 Tage). - Alle Kacheln der Verwaltung tragen jetzt dieselben kleinen Knöpfe; vorher standen zwei Größen nebeneinander im selben Raster. - Das Raster richtet seine Knopfleisten am Kachelfuß aus, statt sie der jeweiligen Textlänge folgen zu lassen. - .knopfleiste bricht um, statt über den rechten Kachelrand zu ragen. Test: tests/24_NavigationTest.php Co-Authored-By: Claude Opus 5 --- Changes.md | 42 +++++ public/static/app.css | 20 +++ src/Controllers/VerwaltungController.php | 7 + src/Views/layout/kopf.php | 21 ++- src/Views/verwaltung/index.php | 43 ++++- tests/24_NavigationTest.php | 208 +++++++++++++++++++++++ 6 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 tests/24_NavigationTest.php diff --git a/Changes.md b/Changes.md index 07bbf6b..fab17ec 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,47 @@ # Änderungen +## Termine und Verträge wandern unter die Verwaltung (13.08.2026) + +Die Seitenleiste war lang geworden. *Termine* und *Verträge* sind kein täglicher Einstieg – +die Reihen laufen von selbst, Verträge sind Stammdaten – und stehen deshalb nicht mehr im +Hauptmenü, sondern als letzte Kacheln in der Verwaltung. + +**Navigation** (`Views/layout/kopf.php`) +- Die Menüpunkte *Termine* und *Verträge* entfallen. +- Ein Menüeintrag kann jetzt weitere Pfade mitführen (`auch`), unter denen er als aktiv + gilt. *Verwaltung* leuchtet damit auch auf `/termine`, `/termine/kalender`, + `/termine/erinnerungen` und unterhalb von `/vertraege` – sonst stünde die Seitenleiste + dort ohne Markierung. + +**Verwaltung** (`Views/verwaltung/index.php`, `VerwaltungController`) +- Neue Kachel *Termine* mit der Zahl der aktiven Reihen, den offenen Erinnerungen und + Wegen zu regelmäßigen Buchungen, Finanzkalender und Erinnerungen. +- Neue letzte Kachel *Verträge* mit der Zahl der laufenden Verträge, den in den nächsten + 90 Tagen ablaufenden Kündigungsfristen und Wegen zu Verträgen, Adressen, Sparzielen und + Krediten. +- Beide Kacheln führen mehrere Wege und nutzen deshalb kleine Knöpfe und einen knappen + Beitext. +- Alle übrigen Kacheln ziehen nach: *Kategorien*, *Klassen*, *Konten*, *QIF-Import* und + *Online-Banking* tragen jetzt dieselben kleinen Knöpfe. Vorher standen zwei Größen + nebeneinander im selben Raster. + +**Knopfleisten stehen am Kachelfuß** (`static/app.css`) +- Das Raster der Verwaltung heißt `kachelraster fussbuendig`: die Kacheln werden zu + Flexspalten, der freie Platz sammelt sich über der Knopfleiste. Die Knöpfe stehen damit + in jeder Kachel einer Zeile auf gleicher Höhe unten, statt der jeweiligen Textlänge zu + folgen. Der Abstand zum Text steckt im Padding – `margin-top: auto` schluckt den + Außenabstand und ließe die Knöpfe in der höchsten Kachel sonst am Text kleben. + +**Knopfleisten brechen um** (`static/app.css`) +- `.knopfleiste` bekommt `flex-wrap: wrap`. Bisher ragten Leisten mit mehreren Knöpfen bei + schmalen Kacheln über den rechten Rand hinaus, statt in die nächste Zeile zu rutschen. + Das gilt für alle Knopfleisten, nicht nur die der Verwaltung. + +Alle Adressen unter `/termine` und `/vertraege` bleiben unverändert erreichbar, ebenso die +Reiter innerhalb der Seiten und die Verlinkungen aus Cockpit und Buchungsvorlagen. + +**Test:** `tests/24_NavigationTest.php` + ## QIF-Import: nur ausgewählte Konten übernehmen (12.08.2026) Der QIF-Import kannte bisher nur den Zeitraum als Filter – ein Export ließ sich nur ganz diff --git a/public/static/app.css b/public/static/app.css index 11ada55..c43a674 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -278,6 +278,23 @@ form.inline { display: inline; margin: 0; } .kachel.einzeln { max-width: 640px; } +/* Kacheln mit unterschiedlich langem Text – etwa in der Verwaltung – lassen ihre + Knopfleisten sonst auf verschiedenen Höhen enden. Die Kachel wird zur + Flexspalte, der freie Platz sammelt sich über der Knopfleiste, und die Knöpfe + stehen in jeder Kachel derselben Zeile auf gleicher Höhe am unteren Rand. */ +.kachelraster.fussbuendig > .kachel { + display: flex; + flex-direction: column; +} + +/* Der Abstand nach oben wandert ins Padding: `margin-top: auto` schluckt den + Außenabstand und ließe die Knöpfe in der höchsten Kachel – die keinen freien + Platz mehr hat – direkt am Text kleben. */ +.kachelraster.fussbuendig > .kachel > .knopfleiste { + margin-top: auto; + padding-top: 1.25rem; +} + .kachel header { margin-bottom: .6rem; } .kachel header.mitschalter { @@ -1159,8 +1176,11 @@ table.splittabelle input.rechts { text-align: right; } .optional { font-weight: 400; color: var(--text-gedaempft); } +/* Umbruch statt Überlauf: Knopfleisten mit mehreren Wegen – etwa in den Kacheln + der Verwaltung – ragten sonst über den rechten Kachelrand hinaus. */ .knopfleiste { display: flex; + flex-wrap: wrap; gap: .6rem; margin-top: 1.25rem; } diff --git a/src/Controllers/VerwaltungController.php b/src/Controllers/VerwaltungController.php index e344a4e..9e1d77a 100644 --- a/src/Controllers/VerwaltungController.php +++ b/src/Controllers/VerwaltungController.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\Erinnerung; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Klasse; use Finanzmanager\Models\Konto; use Finanzmanager\Models\Kontengruppe; +use Finanzmanager\Models\RegelmaessigeBuchung; +use Finanzmanager\Models\Vertrag; use Finanzmanager\View; /** @@ -30,6 +33,10 @@ class VerwaltungController extends BaseController 'klassen' => Klasse::anzahl(), 'konten' => Konto::anzahl(), 'kontengruppen' => count(Kontengruppe::alle()), + 'reihen' => count(RegelmaessigeBuchung::alle(true)), + 'erinnerungen' => Erinnerung::anzahlOffen(), + 'vertraege' => count(Vertrag::alle(true)), + 'fristen' => count(Vertrag::fristenLaufenAb(90)), ], ]); } diff --git a/src/Views/layout/kopf.php b/src/Views/layout/kopf.php index 2b19549..992b439 100644 --- a/src/Views/layout/kopf.php +++ b/src/Views/layout/kopf.php @@ -14,17 +14,26 @@ $navigation = [ ['pfad' => '/zahlungsverkehr', 'name' => 'Zahlungsverkehr', 'icon' => '⇄'], ['pfad' => '/auswertungen', 'name' => 'Auswertungen', 'icon' => '◱'], ['pfad' => '/planung', 'name' => 'Planung', 'icon' => '◷'], - ['pfad' => '/termine', 'name' => 'Termine', 'icon' => '⏱'], - ['pfad' => '/vertraege', 'name' => 'Verträge', 'icon' => '§'], ['pfad' => '/wertpapiere', 'name' => 'Wertpapiere', 'icon' => '↗'], - ['pfad' => '/verwaltung', 'name' => 'Verwaltung', 'icon' => '⚙'], + // Termine und Verträge haben keinen eigenen Menüpunkt mehr, sind aber weiter + // erreichbar – deshalb leuchtet für sie die Verwaltung, unter der sie hängen. + ['pfad' => '/verwaltung', 'name' => 'Verwaltung', 'icon' => '⚙', 'auch' => ['/termine', '/vertraege']], ]; -$istAktiv = static function (string $pfad) use ($aktuellerPfad): bool { +/** @param list $auch */ +$istAktiv = static function (string $pfad, array $auch = []) use ($aktuellerPfad): bool { + if (!is_string($aktuellerPfad)) { + return false; + } if ($pfad === '/') { return $aktuellerPfad === '/' || $aktuellerPfad === ''; } - return is_string($aktuellerPfad) && str_starts_with($aktuellerPfad, $pfad); + foreach ([$pfad, ...$auch] as $kandidat) { + if (str_starts_with($aktuellerPfad, $kandidat)) { + return true; + } + } + return false; }; ?> @@ -48,7 +57,7 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool {
  • + class=""> diff --git a/src/Views/verwaltung/index.php b/src/Views/verwaltung/index.php index 6199fa3..8501ad2 100644 --- a/src/Views/verwaltung/index.php +++ b/src/Views/verwaltung/index.php @@ -7,7 +7,7 @@ use Finanzmanager\View; include __DIR__ . '/../layout/kopf.php'; ?> -
    +

    Kategorien

    @@ -16,7 +16,7 @@ include __DIR__ . '/../layout/kopf.php'; Hierarchisch, mit Klassen als zweiter Auswertungsdimension.

    @@ -27,7 +27,7 @@ include __DIR__ . '/../layout/kopf.php'; Zweite Dimension neben der Kategorie – etwa Ehemann, Ehefrau, Haus 1.

    @@ -38,8 +38,8 @@ include __DIR__ . '/../layout/kopf.php'; In Kontengruppen gegliedert.

    @@ -50,7 +50,7 @@ include __DIR__ . '/../layout/kopf.php'; übernehmen – erst im Trockenlauf prüfen, dann übernehmen.

    @@ -61,8 +61,35 @@ include __DIR__ . '/../layout/kopf.php'; Buchungen und Regeln für die automatische Kategorisierung.

    + + +
    +

    Termine

    +

    +

    + Aktive Reihen – offene Erinnerungen: . +

    + +
    + +
    +

    Verträge

    +

    +

    + Laufende Verträge – Fristen in den nächsten 90 Tagen: . +

    +
    diff --git a/tests/24_NavigationTest.php b/tests/24_NavigationTest.php new file mode 100644 index 0000000..36e709b --- /dev/null +++ b/tests/24_NavigationTest.php @@ -0,0 +1,208 @@ + 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0, + 'konten' => 0, 'kontengruppen' => 0, 'reihen' => 3, 'erinnerungen' => 2, + 'vertraege' => 7, 'fristen' => 1, + ]; + + public function run(): void + { + $this->menueOhneTermineUndVertraege(); + $this->verwaltungLeuchtetFuerBeide(); + $this->verwaltungsseiteFuehrtZuDenTerminen(); + $this->verwaltungsseiteFuehrtZuDenVertraegen(); + $this->alleKnoepfeGleichUndFussbuendig(); + } + + /** + * Alle Kacheln der Verwaltung tragen dieselben kleinen Knöpfe wie die von + * Termine, und die Leisten stehen unabhängig von der Textlänge auf gleicher + * Höhe am unteren Kachelrand. + */ + private function alleKnoepfeGleichUndFussbuendig(): void + { + $kacheln = $this->kacheln(); + + // Das abschließende \b hält die umgebende .knopfleiste heraus. + preg_match_all('#class="knopf\b([^"]*)"#', $kacheln, $treffer); + $this->assertEquals( + substr_count($kacheln, '
    '), + substr_count($kacheln, '
    '), + 'Jede Kachel führt eine Knopfleiste' + ); + $this->assertGreaterThan(0, count($treffer[1]), 'Die Kacheln führen Knöpfe'); + foreach ($treffer[1] as $zusatz) { + $this->assert( + str_contains($zusatz, ' klein'), + 'Knopf "knopf' . $zusatz . '" ist klein wie die unter Termine' + ); + } + + $this->assert( + (bool)preg_match('#
    #', $kacheln), + 'Das Kachelraster richtet seine Knopfleisten am Fuß aus' + ); + + $stil = $this->stilblatt(); + $this->assert( + (bool)preg_match('#\.kachelraster\.fussbuendig > \.kachel\s*\{[^}]*flex-direction:\s*column#', $stil), + 'Die Kacheln des Rasters sind Flexspalten' + ); + $this->assert( + (bool)preg_match( + '#\.kachelraster\.fussbuendig > \.kachel > \.knopfleiste\s*\{[^}]*margin-top:\s*auto[^}]*padding-top:#', + $stil + ), + 'Die Knopfleiste sinkt nach unten und behält ihren Abstand zum Text' + ); + } + + private function menueOhneTermineUndVertraege(): void + { + $menue = $this->seitenleiste($this->rendere('layout/kopf.php', ['aktuellerPfad' => '/'])); + + $this->assert(!str_contains($menue, 'href="/termine"'), 'Kein Menüpunkt Termine mehr'); + $this->assert(!str_contains($menue, '>Termine'), 'Auch die Beschriftung Termine ist verschwunden'); + $this->assert(!str_contains($menue, 'href="/vertraege"'), 'Kein Menüpunkt Verträge mehr'); + $this->assert(!str_contains($menue, '>Verträge'), 'Auch die Beschriftung Verträge ist verschwunden'); + $this->assert(str_contains($menue, 'href="/verwaltung"'), 'Verwaltung steht weiter im Menü'); + $this->assert(str_contains($menue, 'href="/planung"'), 'Die übrigen Menüpunkte bleiben unberührt'); + $this->assert(str_contains($menue, 'href="/wertpapiere"'), 'Wertpapiere stehen weiter im Menü'); + } + + private function verwaltungLeuchtetFuerBeide(): void + { + // Ohne eigenen Menüpunkt bliebe die Seitenleiste auf diesen Seiten + // sonst ganz ohne Markierung. + $pfade = [ + '/termine', '/termine/kalender', '/termine/erinnerungen', + '/vertraege', '/vertraege/adressen', '/vertraege/sparziele', '/vertraege/kredite', + ]; + foreach ($pfade as $pfad) { + $menue = $this->seitenleiste($this->rendere('layout/kopf.php', ['aktuellerPfad' => $pfad])); + $this->assert( + (bool)preg_match('#href="/verwaltung"\s+class="aktiv"#', $menue), + 'Verwaltung ist markiert auf ' . $pfad + ); + } + + $cockpit = $this->seitenleiste($this->rendere('layout/kopf.php', ['aktuellerPfad' => '/'])); + $this->assert( + (bool)preg_match('#href="/"\s+class="aktiv"#', $cockpit), + 'Cockpit ist auf der Startseite markiert' + ); + $this->assert( + !preg_match('#href="/verwaltung"\s+class="aktiv"#', $cockpit), + 'Verwaltung leuchtet nicht überall mit' + ); + } + + private function verwaltungsseiteFuehrtZuDenTerminen(): void + { + $kacheln = $this->kacheln(); + + $this->assert(str_contains($kacheln, 'href="/termine"'), 'Regelmäßige Buchungen sind verlinkt'); + $this->assert(str_contains($kacheln, 'href="/termine/kalender"'), 'Finanzkalender ist verlinkt'); + $this->assert(str_contains($kacheln, 'href="/termine/erinnerungen"'), 'Erinnerungen sind verlinkt'); + $this->assert(str_contains($kacheln, '

    Termine

    '), 'Die Kachel heißt Termine'); + $this->assert(str_contains($kacheln, '>3<'), 'Die Kennzahl zeigt die Zahl der Reihen'); + $this->assert(str_contains($kacheln, 'offene Erinnerungen: 2'), 'Offene Erinnerungen werden genannt'); + + foreach (['/termine', '/termine/kalender', '/termine/erinnerungen'] as $ziel) { + $this->assert( + (bool)preg_match('#href="' . preg_quote($ziel, '#') . '" class="knopf klein#', $kacheln), + 'Der Knopf zu ' . $ziel . ' ist klein' + ); + } + + // Umbruch statt Überlauf am rechten Kachelrand. + $this->assert( + (bool)preg_match('#\.knopfleiste\s*\{[^}]*flex-wrap:\s*wrap#', $this->stilblatt()), + 'Die Knopfleiste bricht um' + ); + + $this->assert( + strrpos($kacheln, '

    Termine

    ') > (int)strrpos($kacheln, '

    Online-Banking

    '), + 'Termine stehen hinter dem Online-Banking' + ); + } + + private function verwaltungsseiteFuehrtZuDenVertraegen(): void + { + $kacheln = $this->kacheln(); + + $this->assert(str_contains($kacheln, 'href="/vertraege"'), 'Verträge sind verlinkt'); + $this->assert(str_contains($kacheln, 'href="/vertraege/adressen"'), 'Adressen sind verlinkt'); + $this->assert(str_contains($kacheln, 'href="/vertraege/sparziele"'), 'Sparziele sind verlinkt'); + $this->assert(str_contains($kacheln, 'href="/vertraege/kredite"'), 'Kredite sind verlinkt'); + $this->assert(str_contains($kacheln, '

    Verträge

    '), 'Die Kachel heißt Verträge'); + $this->assert(str_contains($kacheln, '>7<'), 'Die Kennzahl zeigt die Zahl der aktiven Verträge'); + $this->assert(str_contains($kacheln, 'nächsten 90 Tagen: 1'), 'Ablaufende Fristen werden genannt'); + + // Vier Wege in einer Kachel: nur als kleine Knöpfe, und die Leiste bricht um. + foreach (['/vertraege', '/vertraege/adressen', '/vertraege/sparziele', '/vertraege/kredite'] as $ziel) { + $this->assert( + (bool)preg_match('#href="' . preg_quote($ziel, '#') . '" class="knopf klein#', $kacheln), + 'Der Knopf zu ' . $ziel . ' ist klein' + ); + } + + // Letzter Punkt: keine weitere Kachel hinter der von Verträge. + $letzte = (int)strrpos($kacheln, '

    Verträge

    '); + foreach (['Kategorien', 'Klassen', 'Konten', 'QIF-Import', 'Online-Banking', 'Termine'] as $vorher) { + $this->assert( + $letzte > (int)strrpos($kacheln, '

    ' . $vorher . '

    '), + 'Verträge stehen hinter der Kachel ' . $vorher + ); + } + } + + private function stilblatt(): string + { + return (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css'); + } + + /** Nur der Inhalt der Verwaltungsseite – die Seitenleiste soll nicht mitzählen. */ + private function kacheln(): string + { + $seite = $this->rendere('verwaltung/index.php', [ + 'aktuellerPfad' => '/verwaltung', + 'zahlen' => self::ZAHLEN, + ]); + + return substr($seite, (int)strpos($seite, ' $daten */ + private function rendere(string $view, array $daten): string + { + $daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x']; + + extract($daten, EXTR_SKIP); + + ob_start(); + include dirname(__DIR__) . '/src/Views/' . $view; + return (string)ob_get_clean(); + } + + /** Nur die Seitenleiste – Verlinkungen im Inhalt sollen nicht mitzählen. */ + private function seitenleiste(string $html): string + { + $anfang = (int)strpos($html, ''); + + return substr($html, $anfang, $ende - $anfang); + } +}