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 {
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, '