Änderung Menü

This commit is contained in:
2026-04-08 17:40:23 +02:00
parent 8f608fcf6b
commit 629a1f9e51
11 changed files with 1918 additions and 230 deletions
+312
View File
@@ -0,0 +1,312 @@
# Mobile-Konzept 3: Buchungsseite fuer datenlastige Sammelerfassung
## Zielbild
Dieses dritte Mobile-Konzept priorisiert nicht die klassische Audit-Ansicht,
sondern den operativen Erfassungsmodus fuer viele Buchungen in kurzer Zeit.
Die Buchungsseite wird auf Mobilgeraeten als Arbeitsoberflaeche fuer
Sammelerfassung, schnelle Filterung und kompaktes Nacharbeiten gedacht.
Ausgangspunkt:
- Die Legacy-Seite `stricheintragen.php` war auf hohe Eingabedichte optimiert.
- Die aktuelle SaaS-Seite `ledger/index.blade.php` ist vor allem lesend und
audit-orientiert.
- Fuer mobile SaaS-Workflows braucht die Buchungsseite einen eigenen
Erfassungsmodus statt einer nur umgebrochenen Tabelle.
## Priorisierung
### P0: Muss im ersten mobilen Konzeptionsschnitt sitzen
- Sammelerfassung als primaerer Einstieg
- Filter direkt ueber der Erfassung statt in einem separaten Report-Kontext
- Sticky Header mit wenigen, stabilen Aktionen
- Eingabedichte ueber Listenzeilen statt ueber Karten
- Einhand-Bedienung fuer Auswahl, Mengenanpassung und Speichern
- Kontrolliertes Scrollverhalten ohne horizontales Tabellen-Scrolling
### P1: Sollte im naechsten Ausbauschritt folgen
- Batch-Aktionen fuer markierte Mitglieder
- Zuletzt verwendete Filter und letzte Auswahl merken
- Segmentierte Sichten fuer `Erfassen`, `Pruefen`, `Historie`
- Undo-Snackbar fuer lokale Ruecknahme vor finalem Commit
### P2: Spaeter sinnvoll
- Barcode- oder QR-gestuetzte Mitgliedsauswahl
- Offline-Zwischenspeicher fuer kurze Netzabbrueche
- Vorlagen fuer wiederkehrende Buchungssaetze
## Kernprinzip
Mobile bekommt keine verkleinerte Ledger-Tabelle. Mobile bekommt zwei klar
getrennte Modi auf derselben Seite:
1. `Erfassen`
2. `Journal`
`Erfassen` ist der Default auf Smartphones. `Journal` bleibt wichtig, aber
sekundaer und ueber ein Segment oder Bottom-Sheet erreichbar.
## Informationsarchitektur der Buchungsseite
### 1. Header
Der Header bleibt sticky und reduziert sich beim Scrollen auf eine kompakte
Arbeitsleiste.
Inhalt im initialen Zustand:
- Seitentitel `Buchungen`
- Tenant- oder Kontextlabel
- globale Suche
- primaere Aktion `Speichern`
- Menue-Trigger `Mehr`
Inhalt im kompakten Scroll-Zustand:
- nur Titel oder aktiver Modus
- Such-Trigger
- Counter fuer offene Aenderungen, zum Beispiel `12 offen`
- `Speichern`
Bewusste Entscheidung:
- keine KPI-Karten im mobilen Header
- keine langen Audit-Erklaerungen oberhalb der Erfassung
- keine zweite Navigationszeile unter dem Header
### 2. Modusumschalter direkt unter dem Header
Segmented Control mit:
- `Erfassen`
- `Journal`
Optional spaeter:
- `Pruefen`
Der Umschalter bleibt waehrend kurzer Scrollwege sticky unter dem Header, damit
der Wechsel zwischen Arbeits- und Kontrollsicht ohne Ruecksprung nach oben
funktioniert.
### 3. Filterleiste
Die Filterleiste sitzt direkt unter dem Modusumschalter und ist horizontal
scrollbar, aber aus klaren Chips aufgebaut.
Pflichtfilter:
- Bereich: `Alle`, `Vorderseite`, `Rueckseite`, `Favoriten`
- Buchungstyp: `Striche`, `Einzahlung`, `Korrektur`
- Status: `Nur geaendert`, `Nur offen`, `Mit Aktivitaet`
- Suche nach Mitglied
Verhalten:
- ein aktiver Filter ist immer visuell sichtbar
- Filter oeffnen keine Vollseiten, sondern ein Bottom-Sheet
- zuletzt genutzte Filter bleiben fuer die Session erhalten
## Sammelerfassung als Hauptinteraktion
### Listenmodell statt Tabelle
Jede Mitgliederzeile wird zu einer kompakten Erfassungszeile mit fester
Interaktionslogik:
- Name
- optional Sekundaerinfo wie Team oder letzter Stand
- Mengensteuerung fuer Striche
- Shortcut fuer `+1`, `+2`, `+5`
- optional numerisches Direktfeld
Empfohlener Zeilenaufbau:
- links: Identitaet
- mitte: Schnellaktionen
- rechts: aktueller Entwurf oder Betrag
Die Zeile darf auf Mobile maximal zwei Hoehen haben:
- Standardzustand fuer schnelles Durchscrollen
- expandierter Zustand fuer seltene Details
### Eingabedichte
Die Seite muss 6 bis 8 bearbeitbare Zeilen gleichzeitig auf typischen
Smartphones zeigen koennen. Das spricht gegen hohe Cards, ausufernde Labels und
sekundaere Erklaertexte in jeder Zeile.
Deshalb:
- Zeilenhoehe kompakt halten
- Stepper und Zahlenwert auf Daumenreichweite unten rechts ausrichten
- Sekundaerdetails erst auf Expand oder Long-Press zeigen
- Inline-Validierung nur bei Konflikten anzeigen
### Batch-Logik
Die Sammelerfassung braucht lokale Entwurfsdaten vor dem finalen Speichern.
Empfohlenes Verhalten:
- jede Aenderung landet sofort in einem lokalen Batch
- der Header zeigt die Anzahl offener Aenderungen
- `Speichern` commitet alle geaenderten Zeilen gemeinsam
- `Verwerfen` liegt im `Mehr`-Menue
Das reduziert Server-Roundtrips und verhindert Unterbrechungen im
Erfassungsfluss.
## Scrollverhalten
### Vertikales Scrollen
Die Seite hat genau einen primaeren Scroll-Container: die Inhaltsflaeche unter
dem Header. Keine verschachtelten Scrollbereiche fuer Tabelle, Sidebar oder
Filterpanel.
Regeln:
- Header sticky
- Modusumschalter sticky
- Filterleiste sticky, aber visuell leichter als der Header
- Erfassungszeilen scrollen normal im Hauptflow
### Horizontales Scrollen
Horizontaler Tabellen-Scroll wird fuer Mobile bewusst vermieden. Er ist fuer
Massenerfassung zu langsam, fehleranfaellig und verdeckt Bedienelemente.
Nur die Filterchips duerfen horizontal scrollen.
### Scroll-Feedback
Beim Herunterscrollen:
- Header komprimiert
- sekundare Meta-Infos verschwinden
- `Speichern` bleibt immer erreichbar
Beim Zurueckscrollen nach oben:
- voller Seitentitel kommt zurueck
- kontextuelle Hilfe darf wieder sichtbar werden
## Mobile Bedienbarkeit
### Einhand-Nutzung
Die haeufigsten Aktionen muessen im unteren rechten Bereich erreichbar sein:
- Menge erhoehen
- Menge reduzieren
- Zeile markieren
- Speichern
Suche und seltene Menuepunkte duerfen oben liegen. Die eigentliche
Massenerfassung nicht.
### Touch-Ziele
- Schnellaktionen mindestens 44 px hoch
- Zeilen interaktiv ueber die ganze Breite
- kein kleiner Linktext fuer Kernaktionen
### Tastaturverhalten
Wenn ein numerisches Feld direkt bearbeitet wird:
- numerische Tastatur oeffnen
- Fokus springt nach Speichern des Werts auf die naechste sinnvolle Zeile
- der Header bleibt sichtbar, aber kompakter, um vertikalen Platz zu sparen
## Header- und Menueverhalten
### Header
Der mobile Header ist arbeitsorientiert, nicht report-orientiert.
Pflichtinhalte:
- Seitentitel oder Modus
- Such-Trigger
- offener Batch-Counter
- `Speichern`
- `Mehr`
Nicht in den Header:
- Metriken wie Saldo, Anzahl Einzahlungen, Anzahl Korrekturen
- lange Badge-Reihen
- Vollnavigation mit vielen Primaerlinks
### Menue
Das Menue verlagert selten genutzte oder sekundare Aktionen in ein Bottom-Sheet
oder Slide-over.
Menueeintraege mobil:
- `Journal oeffnen`
- `Filter zuruecksetzen`
- `Entwurf verwerfen`
- `Nur geaenderte zeigen`
- `Korrekturregeln`
- `Export` nur falls fachlich noetig
Die globale App-Navigation sollte mobil nicht als volle Top-Navigation laufen,
sondern als kompaktes App-Menue oder Bottom-Navigation mit maximal 4 bis 5
Punkten. Fuer diese Seite wichtig:
- `Dashboard`
- `Mitglieder`
- `Buchungen`
- `Einzahlungen`
- `Mehr`
`Buchungen` bleibt als fester Hauptpunkt sichtbar.
## Konkrete Empfehlung fuer die Buchungsseite
### Empfohlene Reihenfolge auf Mobile
1. Sticky Header mit `Buchungen`, Suche, offenem Batch-Counter, `Speichern`
2. Segment `Erfassen | Journal`
3. Sticky Filterchips
4. Kompakte Erfassungsliste als Default
5. Optionaler Sammel-Footer fuer Batch-Aktionen bei markierten Zeilen
### Was aus der aktuellen Seite nach unten oder raus sollte
- Hero-Bereich
- KPI-Karten
- lange Korrekturregel-Panels oberhalb der eigentlichen Buchungsliste
- breite Ledger-Tabelle als primaere Mobile-Darstellung
Diese Inhalte gehoeren auf Mobile in:
- `Journal`
- `Mehr`
- separate Detail- oder Hilfesheets
## Warum dieses Konzept priorisiert werden sollte
Dieses Konzept passt am besten zu datenlastigen SaaS-Workflows, weil es nicht
versucht, Desktop-Audit und Mobile-Erfassung in einer einzigen Darstellungsform
zu vereinen. Es trennt operative Geschwindigkeit von auditierbarer Historie,
haelt die Eingabedichte hoch und macht die Buchungsseite auf dem Smartphone zu
einem echten Arbeitswerkzeug.
## Umsetzungsleitplanke fuer Design und Frontend
- Mobile first fuer `Erfassen`, nicht fuer `Journal`
- keine horizontale Datentabelle als Kernmuster
- lokale Batch-Verwaltung vor Persistierung
- sticky, kompakter Header mit dauerhaftem Save-Pfad
- Filter als Chips plus Bottom-Sheet
- Audit- und Regeltexte nur kontextuell, nicht dauerhaft im Sichtfeld
@@ -2,5 +2,5 @@
return <<<'SQL'
ALTER TABLE survey_questions
ADD COLUMN options_json LONGTEXT NULL AFTER question_type;
MODIFY COLUMN options_json LONGTEXT NULL;
SQL;
@@ -0,0 +1,6 @@
<?php
return <<<'SQL'
ALTER TABLE users
ADD COLUMN theme_mode VARCHAR(10) NULL AFTER password_hash;
SQL;
+127 -7
View File
@@ -409,11 +409,12 @@ function app_tenant_navigation_items(?array $auth, array $license = []): array
$items = [
['key' => 'dashboard', 'label' => 'Übersicht', 'href' => '/dashboard/'],
['key' => 'content', 'label' => 'Hinweise', 'href' => '/content/'],
['key' => 'support', 'label' => 'Support', 'href' => '/support/'],
['key' => 'surveys', 'label' => 'Umfragen', 'href' => '/surveys/'],
];
if ($canManage) {
$items[] = ['key' => 'members', 'label' => 'Mitglieder', 'href' => '/members/'];
}
if ($canFinance) {
$items[] = ['key' => 'ledger', 'label' => 'Buchungen', 'href' => '/ledger/'];
$items[] = ['key' => 'payments', 'label' => 'Zahlungen', 'href' => '/payments/'];
@@ -421,7 +422,7 @@ function app_tenant_navigation_items(?array $auth, array $license = []): array
}
if ($canManage) {
$items[] = ['key' => 'members', 'label' => 'Mitglieder', 'href' => '/members/'];
$items[] = ['key' => 'content', 'label' => 'Hinweise & FAQ', 'href' => '/content/'];
$items[] = ['key' => 'reports', 'label' => 'Reporting', 'href' => '/reports/'];
$items[] = ['key' => 'roles', 'label' => 'Rollen', 'href' => '/tenants/roles/'];
@@ -434,14 +435,96 @@ function app_tenant_navigation_items(?array $auth, array $license = []): array
}
}
if (app_can_manage_support($auth)) {
$items[] = ['key' => 'support', 'label' => 'Hilfe', 'href' => '/support/'];
}
if (app_can_manage_surveys($auth)) {
$items[] = ['key' => 'surveys', 'label' => 'Umfragen', 'href' => '/surveys/'];
}
return $items;
}
/**
* @return array<int, array{label:string,items:array<int, array{key:string,label:string,href:string}>}>
*/
function app_tenant_navigation_groups(array $items): array
{
$groupOrder = ['data', 'content', 'management'];
$groupLabels = [
'data' => 'Daten',
'content' => 'Hilfe & Inhalte',
'management' => 'Verwaltung',
];
$groupMap = [
'imports' => 'data',
'exports' => 'data',
'reports' => 'data',
'content' => 'content',
'support' => 'content',
'surveys' => 'content',
'roles' => 'management',
'settings' => 'management',
];
$grouped = [];
foreach ($items as $item) {
$key = (string) ($item['key'] ?? '');
$groupKey = $groupMap[$key] ?? null;
if ($groupKey === null) {
continue;
}
if (!isset($grouped[$groupKey])) {
$grouped[$groupKey] = [
'label' => $groupLabels[$groupKey] ?? ucfirst($groupKey),
'items' => [],
];
}
$grouped[$groupKey]['items'][] = $item;
}
$result = [];
foreach ($groupOrder as $groupKey) {
if (!empty($grouped[$groupKey]['items'])) {
$result[] = $grouped[$groupKey];
}
}
return $result;
}
function app_users_support_theme_mode(PDO $pdo): bool
{
static $cache = null;
if (is_bool($cache)) {
return $cache;
}
$cache = app_table_has_column($pdo, 'users', 'theme_mode');
return $cache;
}
function app_normalize_theme_mode(?string $mode): string
{
$mode = strtolower(trim((string) $mode));
return in_array($mode, ['light', 'dark'], true) ? $mode : 'dark';
}
function app_platform_admin_by_email(PDO $pdo, string $email): ?array
{
$themeSelect = app_users_support_theme_mode($pdo)
? ', theme_mode'
: ", 'dark' AS theme_mode";
return app_query_one(
$pdo,
'SELECT id, email, password_hash, display_name, is_platform_admin, created_at, updated_at FROM users WHERE LOWER(email) = LOWER(:email) AND is_platform_admin = 1 LIMIT 1',
'SELECT id, email, password_hash, display_name, is_platform_admin' . $themeSelect . ', created_at, updated_at FROM users WHERE LOWER(email) = LOWER(:email) AND is_platform_admin = 1 LIMIT 1',
['email' => $email]
);
}
@@ -679,6 +762,7 @@ function app_enter_tenant_as_platform_admin(PDO $pdo, array $admin, string $tena
'user_id' => $admin['user_id'],
'email' => $admin['email'],
'display_name' => $admin['display_name'],
'theme_mode' => app_normalize_theme_mode((string) ($admin['theme_mode'] ?? 'dark')),
'is_platform_admin' => true,
'tenant_id' => $tenant['id'],
'tenant_key' => $tenant['tenant_key'],
@@ -1197,6 +1281,37 @@ function app_handle_profile_action(PDO $pdo, array $auth): void
app_redirect('/dashboard/');
}
function app_handle_theme_action(PDO $pdo, array $auth): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || (string) ($_POST['action'] ?? '') !== 'save-theme-mode') {
return;
}
$userId = (string) ($auth['user_id'] ?? '');
if ($userId === '') {
app_flash('Das Theme konnte nicht gespeichert werden.', 'error');
app_redirect(app_request_path());
}
$themeMode = app_normalize_theme_mode((string) ($_POST['theme_mode'] ?? 'dark'));
if (app_users_support_theme_mode($pdo)) {
app_execute(
$pdo,
'UPDATE users SET theme_mode = :theme_mode, updated_at = :updated_at WHERE id = :id',
[
'theme_mode' => $themeMode,
'updated_at' => date('Y-m-d H:i:s'),
'id' => $userId,
]
);
}
app_set_auth_user(array_merge($auth, ['theme_mode' => $themeMode]));
app_flash('Dein Anzeigemodus wurde gespeichert.', 'success');
app_redirect(app_request_path());
}
function app_handle_content_action(PDO $pdo, array $auth): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
@@ -1954,11 +2069,15 @@ function app_send_print_list_pdf(PDO $pdo, string $tenantId, string $listId): vo
function app_memberships_by_email(PDO $pdo, string $email): array
{
$themeSelect = app_users_support_theme_mode($pdo)
? 'u.theme_mode,'
: "'dark' AS theme_mode,";
$sql = <<<'SQL'
SELECT
u.id AS user_id,
u.email AS user_email,
u.password_hash,
__THEME_SELECT__
u.display_name,
u.is_platform_admin,
t.id AS tenant_id,
@@ -1980,13 +2099,13 @@ LEFT JOIN roles r ON r.id = tur.role_id
LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id AND tip.is_enabled = 1
WHERE LOWER(u.email) = LOWER(:email) OR LOWER(COALESCE(m.email, '')) = LOWER(:email)
GROUP BY
u.id, u.email, u.password_hash, u.display_name, u.is_platform_admin,
u.id, u.email, u.password_hash, theme_mode, u.display_name, u.is_platform_admin,
t.id, t.tenant_key, t.name, t.status,
tu.id, tu.status, m.id, m.email
ORDER BY t.name ASC
SQL;
return app_query_all($pdo, $sql, ['email' => $email]);
return app_query_all($pdo, str_replace('__THEME_SELECT__', $themeSelect, $sql), ['email' => $email]);
}
function app_verify_password(?string $hash, string $password): bool
@@ -2102,6 +2221,7 @@ function app_finalize_login(array $membership): void
'user_id' => $membership['user_id'],
'email' => $membership['user_email'],
'display_name' => $membership['display_name'],
'theme_mode' => app_normalize_theme_mode((string) ($membership['theme_mode'] ?? 'dark')),
'is_platform_admin' => (int) ($membership['is_platform_admin'] ?? 0) === 1,
'tenant_id' => $membership['tenant_id'],
'tenant_key' => $membership['tenant_key'],
+435 -60
View File
@@ -140,6 +140,7 @@ if ($auth !== null && $pdo instanceof PDO) {
try {
$tenantLicense = app_tenant_license($pdo, (string) $auth['tenant_id']);
$tenantSettings = app_tenant_settings($pdo, (string) $auth['tenant_id']);
app_handle_theme_action($pdo, $auth);
$hasTenantSettingsFeature = !empty($tenantLicense['features']['tenant_settings']);
$hasPdfExportFeature = !empty($tenantLicense['features']['pdf_export']);
$hasPaperStrikeEntryFeature = !empty($tenantLicense['features']['paper_strike_entry']);
@@ -307,6 +308,32 @@ $guestNavItems = [
['key' => 'tenants', 'href' => '/admin/login/', 'label' => 'Admin'],
];
$primaryNavItems = $auth === null ? $guestNavItems : $tenantNavItems;
$navGroups = $auth === null ? [] : app_tenant_navigation_groups($primaryNavItems);
$headerNavItems = $primaryNavItems;
if ($auth !== null) {
$headerNavItems = [];
$primaryKeys = ['dashboard', 'members', 'ledger', 'payments'];
foreach ($primaryNavItems as $item) {
if (in_array((string) ($item['key'] ?? ''), $primaryKeys, true)) {
$headerNavItems[] = $item;
}
}
}
$themeMode = app_normalize_theme_mode((string) ($auth['theme_mode'] ?? 'dark'));
$ledgerViewMode = in_array((string) ($_GET['mode'] ?? 'capture'), ['capture', 'journal'], true)
? (string) ($_GET['mode'] ?? 'capture')
: 'capture';
$mobileBottomNavItems = [];
if ($auth !== null) {
foreach (['dashboard', 'members', 'ledger'] as $mobileKey) {
foreach ($primaryNavItems as $item) {
if ((string) ($item['key'] ?? '') === $mobileKey) {
$mobileBottomNavItems[] = $item;
break;
}
}
}
}
$currentNavLabel = 'Start';
foreach ($primaryNavItems as $item) {
if ($page === (string) ($item['key'] ?? '')) {
@@ -354,33 +381,79 @@ $marketing = app_marketing_messages();
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Die Kaffeeliste</title>
<style>
:root{<?= $themeCss ?>--line:rgba(143,158,191,.16);--line-strong:rgba(174,189,223,.26);--shadow:0 24px 64px rgba(1,6,18,.36);--shadow-soft:0 16px 34px rgba(1,6,18,.2);--radius:14px;--surface-0:#05070d;--surface-1:rgba(8,12,22,.84);--surface-2:rgba(13,18,31,.92);--surface-3:rgba(17,24,40,.96);--surface-soft:rgba(255,255,255,.04);--text-strong:#f4f7ff;--text-soft:rgba(219,228,248,.74);--text-faint:rgba(190,202,226,.52)}
:root{<?= $themeCss ?>--radius:14px;--brand-glow:rgba(var(--brand-rgb),.22);--accent-glow:rgba(var(--accent-rgb),.16)}
:root,[data-theme="dark"]{--line:rgba(143,158,191,.16);--line-strong:rgba(174,189,223,.26);--shadow:0 24px 64px rgba(1,6,18,.30);--shadow-soft:0 16px 34px rgba(1,6,18,.16);--surface-0:#0f1720;--surface-1:rgba(18,26,36,.84);--surface-2:rgba(23,32,45,.92);--surface-3:rgba(29,40,55,.96);--surface-soft:rgba(255,255,255,.05);--text-strong:#f4f7ff;--text-soft:rgba(219,228,248,.74);--text-faint:rgba(190,202,226,.56)}
[data-theme="light"]{--line:rgba(61,84,126,.14);--line-strong:rgba(61,84,126,.2);--shadow:0 20px 48px rgba(20,36,68,.12);--shadow-soft:0 12px 24px rgba(20,36,68,.08);--surface-0:#eef2f6;--surface-1:rgba(255,253,248,.88);--surface-2:rgba(255,255,255,.94);--surface-3:#ffffff;--surface-soft:rgba(var(--brand-rgb),.08);--text-strong:#17212f;--text-soft:rgba(37,49,72,.76);--text-faint:rgba(58,72,98,.58)}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;font-family:"Aptos","Segoe UI",sans-serif;color:var(--text-strong);background:radial-gradient(circle at top center,rgba(88,122,255,.2),transparent 26%),radial-gradient(circle at 18% 18%,rgba(var(--brand-rgb),.16),transparent 22%),linear-gradient(180deg,#05070d 0%,#0a0f1a 48%,#0d1320 100%)}
body{margin:0;min-height:100vh;font-family:"Aptos","Segoe UI",sans-serif;color:var(--text-strong);background:radial-gradient(circle at top center,var(--brand-glow),transparent 26%),radial-gradient(circle at 18% 18%,rgba(var(--brand-rgb),.16),transparent 22%),linear-gradient(180deg,var(--surface-0) 0%,color-mix(in srgb,var(--surface-0) 82%, black 18%) 100%)}
a{color:inherit;text-decoration:none}
h1,h2,h3{font-family:"Inter Tight","Aptos","Segoe UI",sans-serif;letter-spacing:-.04em}
.button,button{display:inline-flex;align-items:center;justify-content:center;padding:.72rem 1rem;border-radius:10px;border:1px solid transparent;background:linear-gradient(135deg,#587aff 0%,#243a91 100%);color:#fff;font:inherit;font-weight:700;cursor:pointer;box-shadow:0 12px 24px rgba(36,58,145,.22)}
.button,button{display:inline-flex;align-items:center;justify-content:center;padding:.72rem 1rem;border-radius:10px;border:1px solid transparent;background:linear-gradient(135deg,var(--brand) 0%,var(--brand-strong) 100%);color:#fff;font:inherit;font-weight:700;cursor:pointer;box-shadow:0 12px 24px rgba(var(--brand-rgb),.22)}
.button.secondary,.button--ghost{background:rgba(255,255,255,.03);color:var(--text-strong);border-color:rgba(255,255,255,.12)}
.page-shell{width:100%;margin:0 0 34px;display:grid;gap:16px}
.site-header{position:sticky;top:0;z-index:30;width:100vw;margin-left:calc(50% - 50vw);margin-bottom:24px}
.site-header__inner{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:74px;width:100%;margin:0;padding:0 24px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:rgba(8,10,18,.82);box-shadow:var(--shadow);backdrop-filter:blur(18px)}
.site-header__inner{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:74px;width:100%;margin:0;padding:0 24px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:color-mix(in srgb,var(--surface-1) 88%, rgba(var(--brand-rgb),.12) 12%);box-shadow:var(--shadow);backdrop-filter:blur(18px)}
.site-brand{display:flex;align-items:center;gap:12px;min-width:0}
.site-brand__mark{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;background:linear-gradient(135deg,#587aff 0%,#243a91 100%);color:#fff;font-weight:800;box-shadow:none}
.site-brand__mark{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;background:linear-gradient(135deg,var(--brand) 0%,var(--brand-strong) 100%);color:#fff;font-weight:800;box-shadow:none}
.site-brand__title{margin:0;font-size:1rem;color:var(--text-strong);letter-spacing:-.03em}
.site-brand__subtitle{margin:2px 0 0;color:var(--text-soft);font-size:.88rem}
.site-nav,.site-actions,.actions,.context{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.site-nav__link{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:0 .34rem;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-soft);font-size:.96rem;font-weight:600}
.site-nav__link:hover{text-decoration:none;color:var(--text-strong);background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.08)}
.site-nav__link.active{background:rgba(255,255,255,.08);color:var(--text-strong);border-color:rgba(255,255,255,.12)}
.site-mobile{display:none;position:relative}
.site-mobile[open]{z-index:20}
.site-toggle{display:inline-flex;align-items:center;gap:10px;cursor:pointer;list-style:none;min-height:38px;padding:.45rem .72rem;border-radius:10px;border:1px solid var(--line-strong);background:rgba(255,255,255,.04);color:var(--text-strong);font-weight:600}
.site-toggle::-webkit-details-marker{display:none}
.site-toggle::before{content:"";display:block;width:18px;height:2px;border-radius:999px;background:currentColor;box-shadow:0 -6px 0 currentColor,0 6px 0 currentColor}
.site-panel{position:absolute;right:0;top:calc(100% + 12px);width:min(320px,calc(100vw - 32px));padding:14px;border-radius:16px;border:1px solid var(--line);background:var(--surface-3);box-shadow:var(--shadow)}
.site-stack{display:grid;gap:10px}
.site-stack .site-nav__link{justify-content:flex-start;background:rgba(255,255,255,.04);border-color:rgba(255,255,255,.08);color:var(--text-strong)}
.site-footer-actions{display:flex;justify-content:flex-end;margin-top:12px}
.site-nav__link:hover{text-decoration:none;color:var(--text-strong);background:rgba(var(--brand-rgb),.09);border-color:rgba(var(--brand-rgb),.18)}
.site-nav__link.active{background:rgba(var(--brand-rgb),.16);color:var(--text-strong);border-color:rgba(var(--brand-rgb),.28)}
.site-more{position:relative}
.site-more[open]{z-index:25}
.site-more__toggle{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:0 .6rem;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);color:var(--text-soft);font-size:.96rem;font-weight:600;cursor:pointer;list-style:none}
.site-more__toggle::-webkit-details-marker{display:none}
.site-more__panel{position:absolute;right:0;top:calc(100% + 10px);min-width:240px;padding:12px;border-radius:14px;border:1px solid var(--line);background:var(--surface-3);box-shadow:var(--shadow);display:grid;gap:8px}
.site-more__group{display:grid;gap:8px;padding:8px;border-radius:12px;background:var(--surface-soft)}
.site-more__label{font-size:.76rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--text-faint)}
.site-mobile{display:none}
.site-mobile__toggle{display:inline-flex;align-items:center;gap:10px;cursor:pointer;min-height:38px;padding:.45rem .72rem;border-radius:10px;border:1px solid var(--line-strong);background:rgba(255,255,255,.04);color:var(--text-strong);font-weight:600}
.site-mobile__toggle::before{content:"";display:block;width:18px;height:2px;border-radius:999px;background:currentColor;box-shadow:0 -6px 0 currentColor,0 6px 0 currentColor}
.mobile-drawer{position:fixed;inset:0;display:none;z-index:55}
.mobile-drawer.is-open{display:block}
.mobile-drawer__scrim{position:absolute;inset:0;background:rgba(4,10,18,.58);backdrop-filter:blur(6px)}
.mobile-drawer__panel{position:absolute;right:0;top:0;bottom:0;width:min(92vw,380px);padding:18px 16px calc(28px + env(safe-area-inset-bottom));background:var(--surface-3);border-left:1px solid var(--line);box-shadow:var(--shadow);display:grid;grid-template-rows:auto 1fr auto;gap:18px;overflow:auto}
.mobile-drawer__header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.mobile-drawer__title{margin:0;font-size:1.08rem}
.mobile-drawer__subtitle{margin:4px 0 0;color:var(--text-soft);font-size:.9rem}
.mobile-drawer__close{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.04);color:var(--text-strong);font-size:1.15rem;font-weight:700;cursor:pointer}
.mobile-drawer__section{display:grid;gap:10px}
.mobile-drawer__label{font-size:.76rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--text-faint)}
.mobile-drawer__links{display:grid;gap:8px}
.mobile-drawer__link{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:.9rem 1rem;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.04);color:var(--text-strong);font-weight:700}
.mobile-drawer__link.active{background:rgba(var(--brand-rgb),.16);border-color:rgba(var(--brand-rgb),.28)}
.mobile-drawer__footer{display:grid;gap:12px;padding-top:8px;border-top:1px solid var(--line)}
.mobile-drawer__meta{display:grid;gap:4px}
.mobile-bottom-nav{display:none}
.mobile-bottom-nav__link,.mobile-bottom-nav__toggle{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:56px;padding:0 8px;border:0;background:transparent;color:var(--text-soft);font:inherit;font-weight:700}
.mobile-bottom-nav__link.active,.mobile-bottom-nav__toggle.active{color:var(--text-strong)}
.mobile-bottom-nav__icon{font-size:1rem;line-height:1}
.mobile-bottom-nav__label{font-size:.74rem}
.desktop-ledger-shell{display:grid;gap:18px}
.mobile-ledger-shell{display:none}
.mobile-screen-head{display:grid;gap:12px}
.mobile-screen-head__top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.mobile-screen-head__title{margin:0;font-size:1.55rem;line-height:1.02}
.mobile-screen-head__copy{margin:0;color:var(--text-soft);font-size:.96rem}
.mobile-chip-row{display:flex;gap:8px;overflow:auto;padding-bottom:2px}
.mobile-chip-row .button{white-space:nowrap;min-height:38px;padding:.55rem .8rem}
.mobile-segmented{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.mobile-segmented .button{width:100%}
.mobile-capture-card,.mobile-journal-card{display:grid;gap:14px}
.mobile-form-grid{display:grid;gap:12px}
.mobile-entry-list{display:grid;gap:10px}
.mobile-entry-row{display:grid;grid-template-columns:minmax(0,1fr) 82px;gap:12px;align-items:center;padding:14px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.04)}
.mobile-entry-row strong{display:block;font-size:1rem}
.mobile-entry-row span{display:block;color:var(--text-soft);font-size:.84rem;line-height:1.45}
.mobile-entry-row input{padding:11px 10px;text-align:center}
.mobile-sticky-submit{position:sticky;bottom:84px;z-index:8;display:flex;gap:10px;align-items:center;justify-content:space-between;padding:12px 14px;border:1px solid var(--line);border-radius:16px;background:color-mix(in srgb,var(--surface-3) 90%, rgba(var(--brand-rgb),.12) 10%);box-shadow:var(--shadow-soft)}
.mobile-sticky-submit .button{flex:0 0 auto}
.mobile-journal-list{display:grid;gap:12px}
.mobile-journal-item{display:grid;gap:8px;padding:14px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.04)}
.mobile-journal-item__head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.mobile-journal-item__meta{display:grid;gap:4px;color:var(--text-soft);font-size:.86rem}
.hero,.card,.alert{border:1px solid var(--line);border-radius:var(--radius);background:var(--surface-2);box-shadow:var(--shadow)}
.sidebar__eyebrow,.eyebrow{display:inline-block;color:#8aa0d0;text-transform:uppercase;letter-spacing:.14em;font-size:.8rem;font-weight:800}
.sidebar__eyebrow{margin:0}
@@ -388,15 +461,19 @@ $marketing = app_marketing_messages();
.sidebar__subtitle,.muted,p{color:var(--text-soft)}
.content{width:min(1320px,calc(100vw - 32px));margin:0 auto;min-width:0;display:grid;gap:16px}
.hero,.card{padding:18px}
.hero{margin-bottom:0;background:linear-gradient(135deg,rgba(14,20,34,.98),rgba(10,15,26,.96))}
.hero{margin-bottom:0;background:linear-gradient(135deg,rgba(var(--brand-rgb),.20),var(--surface-2) 26%,var(--surface-1) 100%)}
.grid{display:grid;gap:18px}.grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-4{grid-template-columns:repeat(4,minmax(0,1fr))}
h1{font-size:clamp(1.9rem,4vw,2.8rem);margin:0 0 10px}h2{font-size:1.2rem;margin:0 0 10px}h3{font-size:1rem;margin:0 0 8px}p{margin:0;line-height:1.55}
.metric{padding:18px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.04)}.metric strong{display:block;font-size:1.55rem;margin-bottom:6px}
.metric{padding:18px;border:1px solid var(--line);border-radius:14px;background:color-mix(in srgb,var(--surface-3) 88%, rgba(var(--brand-rgb),.12) 12%)}.metric strong{display:block;font-size:1.55rem;margin-bottom:6px}
.list{margin:14px 0 0;padding-left:18px;color:var(--text-soft);line-height:1.7}
.alert{padding:16px 18px;margin-bottom:0}.alert-success{background:rgba(var(--brand-rgb),.08)}.alert-warning{background:rgba(var(--accent-rgb),.1)}.alert-error{background:rgba(154,31,31,.1)}.alert-info{background:rgba(var(--brand-rgb),.08)}
.badge{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;font-size:.86rem;font-weight:700}
.badge-neutral{background:rgba(88,122,255,.12);color:#d9e2ff}.badge-success{background:rgba(88,122,255,.12);color:#d9e2ff}.badge-warning{background:rgba(224,154,70,.14);color:#ffd7a1}
.badge-neutral{background:rgba(var(--brand-rgb),.12);color:var(--text-strong)}.badge-success{background:rgba(var(--brand-rgb),.12);color:var(--text-strong)}.badge-warning{background:rgba(var(--accent-rgb),.16);color:var(--text-strong)}
.table{overflow-x:auto}.table table{width:100%;border-collapse:collapse;min-width:720px}.table th,.table td{padding:12px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}.table th{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--text-soft);background:rgba(255,255,255,.03)}
.bulk-shell{display:grid;gap:18px}
.bulk-toolbar,.bulk-footer{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between}
.bulk-summary{display:flex;flex-wrap:wrap;gap:10px;align-items:center;color:var(--text-soft);font-weight:600}
.bulk-chip{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:rgba(var(--brand-rgb),.12);color:var(--text-strong);font-size:.88rem;font-weight:700}
form.grid{grid-template-columns:repeat(2,minmax(0,1fr))}label{display:flex;flex-direction:column;gap:8px;font-weight:700}input,select,textarea{width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,.08);font:inherit;background:rgba(255,255,255,.04);color:var(--text-strong)}textarea{min-height:120px}
.footer{margin-top:0;text-align:center;color:var(--text-faint);font-size:.92rem}
.section-mini{display:grid;gap:10px}
@@ -461,11 +538,11 @@ $marketing = app_marketing_messages();
.marketing-footer{position:fixed;left:0;right:0;bottom:0;z-index:25;margin-top:0;padding:10px 24px;border-top:1px solid rgba(137,154,188,.14);background:rgba(8,10,18,.88);backdrop-filter:blur(18px);color:rgba(209,217,235,.68);font-size:.92rem}
.marketing-footer__inner{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;width:min(1240px,calc(100% - 48px));margin:0 auto}
@media(max-width:960px){.marketing-main,.landing-hero,.landing-section,.landing-callout{width:calc(100vw - 24px)}.marketing-bar{height:72px;min-height:72px;margin-bottom:16px;padding:0 12px}.marketing-nav,.marketing-actions{display:none}.marketing-mobile{display:block}.landing-hero,.landing-feature-grid,.landing-step-grid,.landing-use-grid,.landing-proof-grid{grid-template-columns:1fr}.landing-hero{gap:24px;padding-top:0}.landing-section,.landing-callout{padding:20px}.marketing-shell{padding:0 0 88px}.marketing-footer{padding:10px 12px}.marketing-footer__inner{width:100%}}
@media(max-width:960px){.site-header{margin-bottom:20px}.site-header__inner{min-height:68px;width:100%;align-items:center;padding:0 16px}.site-nav{display:none}.site-mobile{display:block}.content{width:min(100vw - 20px,1460px)}.grid-2,.grid-3,.grid-4,form.grid{grid-template-columns:1fr}.table table{min-width:0}}
@media(max-width:960px){body.mobile-drawer-open{overflow:hidden}.page-shell{padding-bottom:94px}.site-header{margin-bottom:14px}.site-header__inner{min-height:68px;width:100%;align-items:center;padding:10px 16px}.site-brand__subtitle{font-size:.8rem}.site-nav{display:none}.site-mobile{display:block}.site-actions{gap:8px}.site-actions .badge, .site-actions > form{display:none}.content{width:min(100vw - 20px,1460px);gap:14px}.hero{padding:16px}.hero .eyebrow,.hero p{display:none}.hero h1{font-size:1.5rem;margin:0}.grid-2,.grid-3,.grid-4,form.grid{grid-template-columns:1fr}.table table{min-width:0}.desktop-ledger-shell{display:none}.mobile-ledger-shell{display:grid;gap:14px}.mobile-bottom-nav{position:fixed;left:10px;right:10px;bottom:max(10px,env(safe-area-inset-bottom));z-index:45;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;padding:6px;border:1px solid var(--line);border-radius:18px;background:color-mix(in srgb,var(--surface-3) 92%, rgba(var(--brand-rgb),.12) 8%);box-shadow:var(--shadow)}}
@media(min-width:961px){.site-mobile{display:none}}
</style>
</head>
<body class="<?= $isMarketingHome ? 'landing-preview' : '' ?>">
<body class="<?= $isMarketingHome ? 'landing-preview' : '' ?>" data-theme="<?= h($themeMode) ?>">
<?php if ($isMarketingHome): ?>
<main class="marketing-shell">
<header class="marketing-bar">
@@ -638,9 +715,22 @@ $marketing = app_marketing_messages();
</a>
<nav class="site-nav" aria-label="Hauptnavigation">
<?php foreach ($primaryNavItems as $item): ?>
<?php foreach ($headerNavItems as $item): ?>
<a href="<?= h((string) ($item['href'] ?? '/')) ?>" class="site-nav__link <?= $page === (string) ($item['key'] ?? '') ? 'active' : '' ?>" <?= $page === (string) ($item['key'] ?? '') ? 'aria-current="page"' : '' ?>><?= h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
<?php foreach ($navGroups as $group): ?>
<details class="site-more">
<summary class="site-more__toggle"><?= h((string) ($group['label'] ?? 'Mehr')) ?></summary>
<div class="site-more__panel">
<div class="site-more__group">
<div class="site-more__label"><?= h((string) ($group['label'] ?? 'Mehr')) ?></div>
<?php foreach (($group['items'] ?? []) as $item): ?>
<a href="<?= h((string) ($item['href'] ?? '/')) ?>" class="site-nav__link <?= $page === (string) ($item['key'] ?? '') ? 'active' : '' ?>" <?= $page === (string) ($item['key'] ?? '') ? 'aria-current="page"' : '' ?>><?= h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</div>
</details>
<?php endforeach; ?>
</nav>
<div class="site-actions">
@@ -651,25 +741,54 @@ $marketing = app_marketing_messages();
<a class="button secondary" href="/login/">Anmelden</a>
<?php endif; ?>
<details class="site-mobile">
<summary class="site-toggle">Menü</summary>
<div class="site-panel">
<nav class="site-stack" aria-label="Mobile Navigation">
<?php foreach ($primaryNavItems as $item): ?>
<a href="<?= h((string) ($item['href'] ?? '/')) ?>" class="site-nav__link <?= $page === (string) ($item['key'] ?? '') ? 'active' : '' ?>" <?= $page === (string) ($item['key'] ?? '') ? 'aria-current="page"' : '' ?>><?= h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</nav>
<?php if ($auth !== null): ?>
<div class="site-footer-actions">
<form method="post" action="/logout/"><button type="submit" class="button secondary">Abmelden</button></form>
</div>
<?php endif; ?>
</div>
</details>
<?php if ($auth !== null): ?>
<button type="button" class="site-mobile__toggle" data-mobile-drawer-open>Mehr</button>
<?php endif; ?>
</div>
</div>
</header>
<?php if ($auth !== null): ?>
<div class="mobile-drawer" data-mobile-drawer>
<button type="button" class="mobile-drawer__scrim" aria-label="Menü schließen" data-mobile-drawer-close></button>
<aside class="mobile-drawer__panel" aria-label="Mobile Navigation">
<div class="mobile-drawer__header">
<div>
<h2 class="mobile-drawer__title">Mehr</h2>
<p class="mobile-drawer__subtitle"><?= h((string) ($auth['tenant_name'] ?? 'Bereich')) ?></p>
</div>
<button type="button" class="mobile-drawer__close" aria-label="Schließen" data-mobile-drawer-close>&times;</button>
</div>
<div class="mobile-drawer__section">
<div class="mobile-drawer__label">Direkt</div>
<div class="mobile-drawer__links">
<?php foreach ($mobileBottomNavItems as $item): ?>
<a href="<?= h((string) ($item['href'] ?? '/')) ?>" class="mobile-drawer__link <?= $page === (string) ($item['key'] ?? '') ? 'active' : '' ?>"><?= h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php foreach ($navGroups as $group): ?>
<div class="mobile-drawer__section">
<div class="mobile-drawer__label"><?= h((string) ($group['label'] ?? 'Mehr')) ?></div>
<div class="mobile-drawer__links">
<?php foreach (($group['items'] ?? []) as $item): ?>
<a href="<?= h((string) ($item['href'] ?? '/')) ?>" class="mobile-drawer__link <?= $page === (string) ($item['key'] ?? '') ? 'active' : '' ?>"><?= h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<div class="mobile-drawer__footer">
<div class="mobile-drawer__meta">
<strong><?= h((string) ($auth['tenant_name'] ?? 'Bereich')) ?></strong>
<span class="muted"><?= h((string) ($auth['display_name'] ?? 'Angemeldet')) ?></span>
</div>
<a href="/settings/" class="mobile-drawer__link <?= $page === 'settings' ? 'active' : '' ?>">Einstellungen</a>
<form method="post" action="/logout/"><button type="submit" class="button secondary" style="width:100%">Abmelden</button></form>
</div>
</aside>
</div>
<?php endif; ?>
<div class="content">
<?php if ($auth !== null && !empty($auth['acting_as_platform_admin'])): ?>
<section class="alert alert-info">
@@ -1041,10 +1160,129 @@ $marketing = app_marketing_messages();
</section>
<?php endif; ?>
<?php elseif ($page === 'ledger'): ?>
<section class="hero"><div class="eyebrow">Buchungen</div><h1>Striche und Buchungen</h1><p>Kaffeeeinträge und alle zugehörigen Buchungen bleiben tenantweise nachvollziehbar.</p></section>
<section class="grid grid-2">
<article class="card">
<h2>Neuen Strich eintragen</h2>
<div class="mobile-ledger-shell">
<section class="mobile-screen-head card">
<div class="mobile-screen-head__top">
<div>
<div class="eyebrow">Buchungen</div>
<h1 class="mobile-screen-head__title">Buchungen</h1>
</div>
<span class="bulk-chip"><?= num(count($ledger)) ?> Journal</span>
</div>
<p class="mobile-screen-head__copy">Mobil steht die Erfassung im Vordergrund. Das Journal bleibt als zweite Sicht direkt erreichbar.</p>
<div class="mobile-segmented">
<a class="button <?= $ledgerViewMode === 'capture' ? '' : 'secondary' ?>" href="/ledger/?scope=<?= h($ledgerScope) ?>&mode=capture">Erfassen</a>
<a class="button <?= $ledgerViewMode === 'journal' ? '' : 'secondary' ?>" href="/ledger/?scope=<?= h($ledgerScope) ?>&mode=journal">Journal</a>
</div>
</section>
<?php if ($ledgerViewMode === 'capture'): ?>
<section class="card mobile-capture-card">
<div class="bulk-summary">
<span class="bulk-chip"><?= num(count($ledgerMembers)) ?> Teilnehmer</span>
<span><?= h(['all' => 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'][$ledgerScope] ?? 'Alle') ?></span>
</div>
<div class="mobile-chip-row">
<?php foreach (['all' => 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'] as $scopeKey => $scopeLabel): ?>
<a class="button <?= $ledgerScope === $scopeKey ? '' : 'secondary' ?>" href="/ledger/?scope=<?= h($scopeKey) ?>&mode=capture"><?= h($scopeLabel) ?></a>
<?php endforeach; ?>
</div>
<form method="post" action="/ledger/" class="mobile-form-grid">
<input type="hidden" name="action" value="bulk-record-coffee">
<input type="hidden" name="scope" value="<?= h($ledgerScope) ?>">
<label>Preis pro Strich<input type="number" name="unit_price" min="0.01" step="0.01" value="<?= h((string) ($tenantSettings['default_unit_price'] ?? '0.50')) ?>"></label>
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
<div class="mobile-entry-list">
<?php foreach ($ledgerMembers as $member): ?>
<label class="mobile-entry-row">
<div>
<strong><?= h((string) ($member['display_name'] ?? '')) ?></strong>
<span><?= num($member['recent_strokes'] ?? 0) ?> Striche in 100 Tagen</span>
</div>
<input type="number" name="strokes[<?= h((string) ($member['id'] ?? '')) ?>]" min="0" step="1" value="0">
</label>
<?php endforeach; ?>
</div>
<div class="mobile-sticky-submit">
<span class="muted">Lokaler Batch fuer alle Werte > 0</span>
<button type="submit">Speichern</button>
</div>
</form>
</section>
<?php else: ?>
<section class="card mobile-journal-card">
<div class="bulk-summary">
<span class="bulk-chip"><?= num(count($ledger)) ?> Einträge</span>
<span>Prüfen und korrigieren</span>
</div>
<div class="mobile-journal-list">
<?php foreach ($ledger as $entry): ?>
<article class="mobile-journal-item">
<div class="mobile-journal-item__head">
<div>
<strong><?= h((string) ($entry['member_name'] ?? '')) ?></strong>
<div class="mobile-journal-item__meta">
<span><?= h((string) ($entry['entry_type'] ?? '')) ?> · <?= h((string) ($entry['reference_type'] ?? '')) ?></span>
<span><?= dt((string) ($entry['booked_at'] ?? '')) ?></span>
</div>
</div>
<strong><?= money($entry['amount'] ?? 0) ?></strong>
</div>
<?php if ((string) ($entry['reference_type'] ?? '') === 'coffee_entry' && (string) ($entry['reference_id'] ?? '') !== ''): ?>
<form method="post" action="/ledger/">
<input type="hidden" name="action" value="delete-coffee">
<input type="hidden" name="reference_id" value="<?= h((string) ($entry['reference_id'] ?? '')) ?>">
<button type="submit" class="button secondary">Löschen</button>
</form>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</div>
<div class="desktop-ledger-shell">
<section class="hero"><div class="eyebrow">Buchungen</div><h1>Striche und Buchungen</h1><p>Kaffeeeinträge und alle zugehörigen Buchungen bleiben tenantweise nachvollziehbar.</p></section>
<section class="card bulk-shell">
<div class="bulk-toolbar">
<div>
<h2>Sammelerfassung wie in der Kaffeeliste</h2>
<div class="bulk-summary">
<span class="bulk-chip"><?= num(count($ledgerMembers)) ?> Teilnehmer</span>
<span>Ansicht: <?= h(['all' => 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'][$ledgerScope] ?? 'Alle') ?></span>
</div>
</div>
<div class="actions">
<?php foreach (['all' => 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'] as $scopeKey => $scopeLabel): ?>
<a class="button <?= $ledgerScope === $scopeKey ? '' : 'secondary' ?>" href="/ledger/?scope=<?= h($scopeKey) ?>"><?= h($scopeLabel) ?></a>
<?php endforeach; ?>
</div>
</div>
<form method="post" action="/ledger/" class="stack">
<input type="hidden" name="action" value="bulk-record-coffee">
<input type="hidden" name="scope" value="<?= h((string) ($_GET['scope'] ?? 'all')) ?>">
<div class="grid">
<label>Preis pro Strich<input type="number" name="unit_price" min="0.01" step="0.01" value="<?= h((string) ($tenantSettings['default_unit_price'] ?? '0.50')) ?>"></label>
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
</div>
<div class="table">
<table>
<thead><tr><th>Mitglied</th><th>Striche</th><th>100 Tage</th></tr></thead>
<tbody>
<?php foreach ($ledgerMembers as $member): ?>
<tr>
<td><strong><?= h((string) ($member['display_name'] ?? '')) ?></strong></td>
<td><input type="number" name="strokes[<?= h((string) ($member['id'] ?? '')) ?>]" min="0" step="1" value="0"></td>
<td><?= num($member['recent_strokes'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="bulk-footer"><span class="muted">Die Eingabe gilt nur für Werte größer als 0.</span><button type="submit">Sammel-Striche eintragen</button></div>
</form>
</section>
<section class="card" style="margin-top:18px">
<h2>Einzelbuchung</h2>
<form method="post" action="/ledger/" class="grid">
<input type="hidden" name="action" value="record-coffee">
<label>Mitglied<select name="member_id"><?php foreach ($members as $member): ?><option value="<?= h((string) $member['id']) ?>"><?= h((string) $member['display_name']) ?></option><?php endforeach; ?></select></label>
@@ -1053,25 +1291,62 @@ $marketing = app_marketing_messages();
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
<div class="actions"><button type="submit">Buchen</button></div>
</form>
</article>
<article class="card"><h2>Was dieser Bereich abdeckt</h2><ul class="list"><li>Einzel- und Sammelbuchungen laufen in einem gemeinsamen Ablauf zusammen.</li><li>Jeder Verbrauch erzeugt automatisch den passenden Ledger-Eintrag.</li><li>Die letzten Buchungen bleiben je Tenant nachvollziehbar.</li></ul></article>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Typ</th><th>Referenz</th><th>Betrag</th><th>Aktion</th></tr></thead><tbody><?php foreach ($ledger as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><?php if ((string) ($entry['reference_type'] ?? '') === 'coffee_entry' && (string) ($entry['reference_id'] ?? '') !== ''): ?><form method="post" action="/ledger/"><input type="hidden" name="action" value="delete-coffee"><input type="hidden" name="reference_id" value="<?= h((string) ($entry['reference_id'] ?? '')) ?>"><button type="submit" class="button secondary">Löschen</button></form><?php else: ?><span class="muted">-</span><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div></section>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Typ</th><th>Referenz</th><th>Betrag</th><th>Aktion</th></tr></thead><tbody><?php foreach ($ledger as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><?php if ((string) ($entry['reference_type'] ?? '') === 'coffee_entry' && (string) ($entry['reference_id'] ?? '') !== ''): ?><form method="post" action="/ledger/"><input type="hidden" name="action" value="delete-coffee"><input type="hidden" name="reference_id" value="<?= h((string) ($entry['reference_id'] ?? '')) ?>"><button type="submit" class="button secondary">Löschen</button></form><?php else: ?><span class="muted">-</span><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div></section>
</div>
<?php elseif ($page === 'payments'): ?>
<section class="hero"><div class="eyebrow">Einzahlungen</div><h1>Zahlungen</h1><p>Einzahlungen werden direkt in Zahlungstabelle und Ledger geschrieben.</p></section>
<section class="grid grid-2">
<article class="card">
<h2>Einzahlung buchen</h2>
<form method="post" action="/payments/" class="grid">
<input type="hidden" name="action" value="record-payment">
<label>Mitglied<select name="member_id"><?php foreach ($members as $member): ?><option value="<?= h((string) $member['id']) ?>"><?= h((string) $member['display_name']) ?></option><?php endforeach; ?></select></label>
<label>Betrag<input type="number" name="amount" min="0.01" step="0.01" value="5.00"></label>
<section class="card bulk-shell">
<div class="bulk-toolbar">
<div>
<h2>Sammel-Einzahlungen</h2>
<div class="bulk-summary">
<span class="bulk-chip"><?= num(count($paymentMembers)) ?> Teilnehmer</span>
<span>Ansicht: <?= h(['all' => 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'][$paymentScope] ?? 'Alle') ?></span>
</div>
</div>
<div class="actions">
<?php foreach (['all' => 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'] as $scopeKey => $scopeLabel): ?>
<a class="button <?= $paymentScope === $scopeKey ? '' : 'secondary' ?>" href="/payments/?scope=<?= h($scopeKey) ?>"><?= h($scopeLabel) ?></a>
<?php endforeach; ?>
<a class="button secondary" href="/imports/">CSV-Import</a>
</div>
</div>
<form method="post" action="/payments/" class="stack">
<input type="hidden" name="action" value="bulk-record-payment">
<input type="hidden" name="scope" value="<?= h((string) ($_GET['scope'] ?? 'all')) ?>">
<div class="grid">
<label>Zahlungsart<select name="payment_method"><option value="manual">Manuell</option><option value="paypal">PayPal</option><option value="bank">Bank</option></select></label>
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
<div class="actions"><button type="submit">Einzahlung speichern</button></div>
</form>
</article>
<article class="card"><h2>Wofür dieser Bereich da ist</h2><ul class="list"><li>Manuelle Einzahlungen sind direkt erfasst.</li><li>PayPal oder Bank können getrennt ausgewiesen werden.</li><li>Jede Zahlung erscheint sofort im Ledger.</li></ul><p style="margin-top:14px"><?= h((string) ($tenantSettings['payment_hint'] ?? '')) ?></p></article>
</div>
<div class="table">
<table>
<thead><tr><th>Mitglied</th><th>Betrag</th><th>100 Tage</th></tr></thead>
<tbody>
<?php foreach ($paymentMembers as $member): ?>
<tr>
<td><strong><?= h((string) ($member['display_name'] ?? '')) ?></strong></td>
<td><input type="number" name="amounts[<?= h((string) ($member['id'] ?? '')) ?>]" min="0" step="0.01" value="0.00"></td>
<td><?= num($member['recent_strokes'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="bulk-footer"><span class="muted">Die Eingabe gilt nur für Werte größer als 0.</span><button type="submit">Sammel-Einzahlungen eintragen</button></div>
</form>
<p><?= h((string) ($tenantSettings['payment_hint'] ?? '')) ?></p>
</section>
<section class="card" style="margin-top:18px">
<h2>Einzelzahlung</h2>
<form method="post" action="/payments/" class="grid">
<input type="hidden" name="action" value="record-payment">
<label>Mitglied<select name="member_id"><?php foreach ($members as $member): ?><option value="<?= h((string) $member['id']) ?>"><?= h((string) $member['display_name']) ?></option><?php endforeach; ?></select></label>
<label>Betrag<input type="number" name="amount" min="0.01" step="0.01" value="5.00"></label>
<label>Zahlungsart<select name="payment_method"><option value="manual">Manuell</option><option value="paypal">PayPal</option><option value="bank">Bank</option></select></label>
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
<div class="actions"><button type="submit">Einzahlung speichern</button></div>
</form>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Methode</th><th>Betrag</th><th>Aktion</th></tr></thead><tbody><?php foreach ($payments as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['payment_method'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><form method="post" action="/payments/"><input type="hidden" name="action" value="delete-payment"><input type="hidden" name="payment_id" value="<?= h((string) ($entry['id'] ?? '')) ?>"><button type="submit" class="button secondary">Stornieren</button></form></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php elseif ($page === 'content'): ?>
@@ -1135,14 +1410,34 @@ $marketing = app_marketing_messages();
</form>
</article>
<article class="card">
<h2>Aktuelle Lizenz</h2>
<h2>Anzeige</h2>
<div class="stack">
<div class="metric"><h3><?= h((string) ($tenantLicense['plan_name'] ?? 'Free')) ?></h3><p>Freigeschalteter Lizenzplan für diesen Mandanten.</p></div>
<div class="metric"><h3>Mitgliederrahmen</h3><p><?= isset($tenantLicense['member_limit']) && (int) $tenantLicense['member_limit'] > 0 ? h((string) $tenantLicense['member_limit']) . ' aktive Mitglieder im Standardumfang.' : 'Individuell vereinbart.' ?></p></div>
<div class="metric"><h3>Freigeschaltete Kernfunktionen</h3><ul class="list"><?php foreach (['tenant_settings' => 'Mandanten-Einstellungen', 'pdf_export' => 'PDF-Listen', 'paper_strike_entry' => 'Papierlisten-Erfassung'] as $featureKey => $featureLabel): ?><?php if (!empty($tenantLicense['features'][$featureKey])): ?><li><?= h($featureLabel) ?></li><?php endif; ?><?php endforeach; ?></ul></div>
<div class="metric"><h3>Anzeigemodus</h3><p>Der Hell/Dunkelmodus wird pro Benutzer gespeichert und gehört bewusst in die Einstellungen statt in die Hauptnavigation.</p></div>
<form method="post" action="/settings/" class="actions">
<input type="hidden" name="action" value="save-theme-mode">
<input type="hidden" name="theme_mode" value="<?= h($themeMode === 'dark' ? 'light' : 'dark') ?>">
<button type="submit"><?= h($themeMode === 'dark' ? 'Auf Hell umstellen' : 'Auf Dunkel umstellen') ?></button>
</form>
<p class="muted">Aktuell aktiv: <?= h($themeMode === 'dark' ? 'Dunkelmodus' : 'Hellmodus') ?></p>
</div>
</article>
</section>
<section class="card" style="margin-top:18px">
<h2>Daten, Hilfe und Kommunikation</h2>
<div class="grid grid-3">
<div class="metric"><h3>Daten</h3><p>Importe, Exporte und Reporting sind im Menü jetzt unter einem gemeinsamen Aufgabenraum gebündelt.</p></div>
<div class="metric"><h3>Hilfe & Inhalte</h3><p>Hinweise, FAQ, Support und Umfragen sind zusammen erreichbar, statt als verstreute Einzelpunkte aufzutauchen.</p></div>
<div class="metric"><h3>Verwaltung</h3><p>Rollen und Mandanten-Einstellungen bleiben zusammen im Verwaltungsbereich.</p></div>
</div>
</section>
<section class="card" style="margin-top:18px">
<h2>Aktuelle Lizenz</h2>
<div class="stack">
<div class="metric"><h3><?= h((string) ($tenantLicense['plan_name'] ?? 'Free')) ?></h3><p>Freigeschalteter Lizenzplan für diesen Mandanten.</p></div>
<div class="metric"><h3>Mitgliederrahmen</h3><p><?= isset($tenantLicense['member_limit']) && (int) $tenantLicense['member_limit'] > 0 ? h((string) $tenantLicense['member_limit']) . ' aktive Mitglieder im Standardumfang.' : 'Individuell vereinbart.' ?></p></div>
<div class="metric"><h3>Freigeschaltete Kernfunktionen</h3><ul class="list"><?php foreach (['tenant_settings' => 'Mandanten-Einstellungen', 'pdf_export' => 'PDF-Listen', 'paper_strike_entry' => 'Papierlisten-Erfassung'] as $featureKey => $featureLabel): ?><?php if (!empty($tenantLicense['features'][$featureKey])): ?><li><?= h($featureLabel) ?></li><?php endif; ?><?php endforeach; ?></ul></div>
</div>
</section>
<section class="card" style="margin-top:18px">
<h2>Farben im Tenant</h2>
<div class="grid grid-4">
@@ -1280,7 +1575,87 @@ $marketing = app_marketing_messages();
<p class="footer">Die Kaffeeliste</p>
</div>
<?php if ($auth !== null && $mobileBottomNavItems !== []): ?>
<nav class="mobile-bottom-nav" aria-label="Mobile Kernnavigation">
<?php foreach ($mobileBottomNavItems as $item): ?>
<a href="<?= h((string) ($item['href'] ?? '/')) ?>" class="mobile-bottom-nav__link <?= $page === (string) ($item['key'] ?? '') ? 'active' : '' ?>" <?= $page === (string) ($item['key'] ?? '') ? 'aria-current="page"' : '' ?>>
<span class="mobile-bottom-nav__icon"><?= match ((string) ($item['key'] ?? '')) {
'dashboard' => 'U',
'members' => 'M',
'ledger' => 'B',
default => '•',
} ?></span>
<span class="mobile-bottom-nav__label"><?= h((string) ($item['label'] ?? 'Link')) ?></span>
</a>
<?php endforeach; ?>
<button type="button" class="mobile-bottom-nav__toggle" data-mobile-drawer-open>
<span class="mobile-bottom-nav__icon">+</span>
<span class="mobile-bottom-nav__label">Mehr</span>
</button>
</nav>
<?php endif; ?>
</main>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const details = document.querySelectorAll('.site-more');
const drawer = document.querySelector('[data-mobile-drawer]');
const drawerOpeners = document.querySelectorAll('[data-mobile-drawer-open]');
const drawerClosers = document.querySelectorAll('[data-mobile-drawer-close]');
function closeOthers(current) {
details.forEach(function (other) {
if (other !== current) {
other.open = false;
}
});
}
details.forEach(function (detail) {
const summary = detail.querySelector('summary');
if (summary) {
summary.addEventListener('click', function () {
if (!detail.open) {
closeOthers(detail);
}
});
}
detail.addEventListener('toggle', function () {
if (detail.open) {
closeOthers(detail);
}
});
});
document.querySelectorAll('.site-more__panel a, .mobile-drawer__link').forEach(function (link) {
link.addEventListener('click', function () {
details.forEach(function (detail) {
detail.open = false;
});
if (drawer) {
drawer.classList.remove('is-open');
document.body.classList.remove('mobile-drawer-open');
}
});
});
if (drawer) {
drawerOpeners.forEach(function (button) {
button.addEventListener('click', function () {
drawer.classList.add('is-open');
document.body.classList.add('mobile-drawer-open');
});
});
drawerClosers.forEach(function (button) {
button.addEventListener('click', function () {
drawer.classList.remove('is-open');
document.body.classList.remove('mobile-drawer-open');
});
});
}
});
</script>
</body>
</html>
+9
View File
@@ -99,6 +99,15 @@ $priorities = $pageData['priorities'] ?? [];
$routeTargets = $pageData['route_targets'] ?? [];
$selectedRequestId = (string) ($_GET['request'] ?? '');
$tenantNavItems = app_tenant_navigation_items($auth, $tenantLicense);
$tenantNavGroups = app_tenant_navigation_groups($tenantNavItems);
$tenantHeaderNavItems = [];
$mobileBottomNavItems = [];
foreach ($tenantNavItems as $item) {
if (in_array((string) ($item['key'] ?? ''), ['dashboard', 'members', 'ledger', 'payments'], true)) {
$tenantHeaderNavItems[] = $item;
$mobileBottomNavItems[] = $item;
}
}
$template = dirname(__DIR__, 2) . '/resources/views/support/index.blade.php';
if (!is_file($template)) {
File diff suppressed because one or more lines are too long
+9
View File
@@ -67,6 +67,15 @@ $tenantSnapshots = $overview['tenant_snapshots'] ?? [];
$permissionGroups = $overview['permission_groups'] ?? [];
$notes = $overview['notes'] ?? [];
$tenantNavItems = app_tenant_navigation_items($auth, $tenantLicense);
$tenantNavGroups = app_tenant_navigation_groups($tenantNavItems);
$tenantHeaderNavItems = [];
$mobileBottomNavItems = [];
foreach ($tenantNavItems as $item) {
if (in_array((string) ($item['key'] ?? ''), ['dashboard', 'members', 'ledger', 'payments'], true)) {
$tenantHeaderNavItems[] = $item;
$mobileBottomNavItems[] = $item;
}
}
$template = dirname(__DIR__, 3) . '/resources/views/tenants/roles.blade.php';
if (!is_file($template)) {
+409 -87
View File
@@ -20,12 +20,16 @@
$layoutNavItems = function_exists('app_tenant_navigation_items')
? app_tenant_navigation_items($layoutAuth, $layoutLicense)
: [];
$layoutNavGroups = function_exists('app_tenant_navigation_groups')
? app_tenant_navigation_groups($layoutNavItems)
: [];
$layoutGuestItems = [
['href' => '/', 'label' => 'Start'],
['href' => '/login/', 'label' => 'Anmeldung'],
['href' => '/admin/login/', 'label' => 'Admin'],
];
$layoutPrimaryNavItems = $layoutNavItems !== [] ? $layoutNavItems : $layoutGuestItems;
$layoutHeaderNavItems = $layoutPrimaryNavItems;
$layoutCurrentLabel = 'Start';
foreach ($layoutPrimaryNavItems as $item) {
$itemHref = rtrim((string) ($item['href'] ?? '/'), '/');
@@ -38,6 +42,17 @@
$layoutThemeCss = function_exists('app_tenant_theme_root_css')
? app_tenant_theme_root_css($layoutThemeSettings)
: '';
if (is_array($layoutAuth)) {
$layoutMobilePrimaryNavItems = array_slice($layoutHeaderNavItems, 0, 3);
} else {
$layoutMobilePrimaryNavItems = array_slice($layoutPrimaryNavItems, 0, 2);
}
$layoutMobileDrawerPrimaryItems = array_slice($layoutPrimaryNavItems, count($layoutMobilePrimaryNavItems));
$layoutMobileHasDrawer = $layoutMobileDrawerPrimaryItems !== [] || $layoutNavGroups !== [] || is_array($layoutAuth);
$layoutMobileNavColumns = count($layoutMobilePrimaryNavItems) + ($layoutMobileHasDrawer ? 1 : 0);
if ($layoutMobileNavColumns < 3) {
$layoutMobileNavColumns = 3;
}
@endphp
@php
$resolvedLayoutMode = trim((string) $__env->yieldContent('layout_mode', 'app'));
@@ -122,6 +137,7 @@
.site-shell {
min-height: 100vh;
padding: 0 0 24px;
overflow-x: clip;
}
.site-header,
@@ -138,8 +154,8 @@
position: sticky;
top: 0;
z-index: 30;
width: 100vw;
margin-left: calc(50% - 50vw);
width: 100%;
margin-left: 0;
margin-bottom: 24px;
}
@@ -289,70 +305,205 @@
gap: 18px;
}
.mobile-nav {
.mobile-bottom-nav {
display: none;
position: relative;
}
.mobile-nav[open] { z-index: 40; }
.mobile-bottom-nav__bar {
display: grid;
gap: 8px;
align-items: stretch;
}
.mobile-nav__toggle {
.mobile-bottom-nav__link,
.mobile-bottom-nav__more {
display: inline-flex;
align-items: center;
gap: 10px;
list-style: none;
cursor: pointer;
min-height: 38px;
padding: 0.45rem 0.72rem;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: rgba(255, 255, 255, 0.04);
color: var(--text-strong);
font-size: 0.92rem;
font-weight: 600;
}
.mobile-nav__toggle::-webkit-details-marker { display: none; }
.mobile-nav__toggle::before {
content: "";
display: block;
width: 18px;
height: 2px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 -6px 0 currentColor, 0 6px 0 currentColor;
}
.mobile-nav__panel {
position: absolute;
right: 0;
top: calc(100% + 12px);
width: min(320px, calc(100vw - 32px));
padding: 16px;
border-radius: 18px;
justify-content: center;
gap: 4px;
min-height: 56px;
padding: 8px 6px 7px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--surface-3);
background: rgba(255, 255, 255, 0.04);
color: var(--text-soft);
font-weight: 600;
font-size: 0.74rem;
line-height: 1.15;
text-align: center;
cursor: pointer;
}
.mobile-bottom-nav__link:hover,
.mobile-bottom-nav__more:hover {
text-decoration: none;
color: var(--text-strong);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
}
.mobile-bottom-nav__link.is-active {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, #587aff 0%, #243a91 100%);
}
.mobile-bottom-nav__label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-bottom-nav__meta {
display: block;
color: var(--text-faint);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.mobile-drawer {
position: fixed;
inset: 0;
display: none;
z-index: 70;
}
.mobile-drawer.is-open {
display: block;
}
.mobile-drawer__scrim {
position: absolute;
inset: 0;
border: 0;
background: rgba(4, 10, 18, 0.62);
backdrop-filter: blur(8px);
cursor: pointer;
}
.mobile-drawer__panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(94vw, 408px);
max-width: 100%;
padding: 18px 16px calc(26px + env(safe-area-inset-bottom));
border-left: 1px solid var(--line);
background: linear-gradient(180deg, rgba(13, 18, 31, 0.99), rgba(8, 10, 18, 0.98));
box-shadow: var(--shadow);
}
.mobile-nav__stack {
display: grid;
gap: 10px;
grid-template-rows: auto 1fr auto;
gap: 16px;
overflow: auto;
}
.mobile-nav__stack .top-nav__link {
justify-content: flex-start;
padding: 0.78rem 0.92rem;
.mobile-drawer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.mobile-drawer__title {
margin: 0;
font-size: 1.08rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.mobile-drawer__copy {
margin: 4px 0 0;
color: var(--text-soft);
font-size: 0.92rem;
}
.mobile-drawer__close {
min-width: 40px;
min-height: 40px;
padding: 0.45rem 0.75rem;
border-radius: 999px;
}
.mobile-drawer__sections {
display: grid;
gap: 14px;
overflow: auto;
padding-right: 2px;
}
.mobile-drawer__section {
display: grid;
gap: 8px;
}
.mobile-drawer__label {
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.mobile-drawer__links {
display: grid;
gap: 8px;
}
.mobile-drawer__link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0.8rem 0.95rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-strong);
font-size: 0.93rem;
font-weight: 700;
}
.mobile-nav__footer {
margin-top: 12px;
display: flex;
justify-content: flex-end;
.mobile-drawer__link::after {
content: "";
color: var(--text-faint);
font-size: 1rem;
}
.mobile-drawer__link:hover {
text-decoration: none;
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
}
.mobile-drawer__link.is-active {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, #587aff 0%, #243a91 100%);
}
.mobile-drawer__link.is-active::after {
color: rgba(255, 255, 255, 0.88);
}
.mobile-drawer__empty {
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-soft);
font-size: 0.92rem;
}
.mobile-drawer__footer {
display: grid;
gap: 12px;
padding-top: 6px;
border-top: 1px solid var(--line);
}
.button,
@@ -662,6 +813,7 @@
min-height: 100vh;
padding: 0 0 92px;
gap: 12px;
overflow-x: clip;
}
.marketing-main {
@@ -679,8 +831,8 @@
gap: 14px;
height: 88px;
min-height: 88px;
width: 100vw;
margin-left: calc(50% - 50vw);
width: 100%;
margin-left: 0;
margin-bottom: 20px;
padding: 0 18px;
border: 0;
@@ -804,7 +956,7 @@
position: absolute;
right: 0;
top: calc(100% + 10px);
width: min(280px, calc(100vw - 24px));
width: min(280px, calc(100% - 24px));
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(163, 183, 255, 0.14);
@@ -861,24 +1013,51 @@
@media (max-width: 980px) {
.site-shell {
padding: 0 0 18px;
padding: 0 0 104px;
}
.site-header__inner {
width: 100%;
min-height: 68px;
padding: 0 16px;
padding: 0 12px;
align-items: center;
}
.top-nav { display: none; }
.site-actions { display: none; }
.app-shell {
width: calc(100% - 20px);
margin: 10px auto;
min-height: calc(100vh - 20px);
width: calc(100% - 16px);
margin: 8px auto;
min-height: calc(100vh - 16px);
}
.mobile-bottom-nav {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(137, 154, 188, 0.14);
background: rgba(8, 10, 18, 0.9);
backdrop-filter: blur(18px);
}
.mobile-bottom-nav__bar {
grid-template-columns: repeat(var(--mobile-nav-columns, 4), minmax(0, 1fr));
}
body.mobile-drawer-open {
overflow: hidden;
}
.mobile-drawer__panel {
width: min(96vw, 420px);
padding-left: 14px;
padding-right: 14px;
}
.mobile-nav { display: block; }
.grid--2,
.grid--3,
.grid--4,
@@ -892,26 +1071,26 @@
table { min-width: 0; }
.marketing-shell { padding: 0 0 88px; }
.marketing-main {
width: calc(100% - 24px);
width: calc(100% - 16px);
}
.marketing-bar {
height: 72px;
min-height: 72px;
margin-bottom: 16px;
padding: 0 12px;
padding: 0 10px;
}
.marketing-nav,
.marketing-actions { display: none; }
.marketing-mobile { display: block; }
.marketing-footer {
padding: 10px 12px;
padding: 10px 10px;
}
.marketing-footer__inner {
width: 100%;
}
.site-main,
.app-footer__inner {
width: calc(100% - 20px);
width: calc(100% - 16px);
}
}
@media (max-width: 980px) {
@@ -986,7 +1165,7 @@
</a>
<nav class="top-nav" aria-label="Hauptnavigation">
@foreach ($layoutPrimaryNavItems as $item)
@foreach ($layoutHeaderNavItems as $item)
@php
$itemHref = rtrim((string) ($item['href'] ?? '/'), '/');
$itemHref = $itemHref === '' ? '/' : $itemHref;
@@ -1005,29 +1184,6 @@
@else
<a class="button button--ghost" href="/login/">Anmelden</a>
@endif
<details class="mobile-nav">
<summary class="mobile-nav__toggle">Menü</summary>
<div class="mobile-nav__panel">
<nav class="mobile-nav__stack" aria-label="Mobile Hauptnavigation">
@foreach ($layoutPrimaryNavItems as $item)
@php
$itemHref = rtrim((string) ($item['href'] ?? '/'), '/');
$itemHref = $itemHref === '' ? '/' : $itemHref;
$isActive = $layoutPath === $itemHref;
@endphp
<a href="{{ $item['href'] ?? '/' }}" class="top-nav__link {{ $isActive ? 'is-active' : '' }}" @if ($isActive) aria-current="page" @endif>{{ $item['label'] ?? 'Link' }}</a>
@endforeach
</nav>
@if (is_array($layoutAuth))
<div class="mobile-nav__footer">
<form method="post" action="/logout/">
<button type="submit" class="button button--ghost">Abmelden</button>
</form>
</div>
@endif
</div>
</details>
</div>
</div>
</header>
@@ -1042,7 +1198,173 @@
<span>Klare Bereiche mit Navigation oben</span>
</div>
</footer>
<div class="mobile-bottom-nav" style="--mobile-nav-columns: {{ $layoutMobileNavColumns }};">
<nav class="mobile-bottom-nav__bar" aria-label="Mobile Navigation">
@foreach ($layoutMobilePrimaryNavItems as $item)
@php
$itemHref = rtrim((string) ($item['href'] ?? '/'), '/');
$itemHref = $itemHref === '' ? '/' : $itemHref;
$isActive = $layoutPath === $itemHref;
@endphp
<a href="{{ $item['href'] ?? '/' }}" class="mobile-bottom-nav__link {{ $isActive ? 'is-active' : '' }}" @if ($isActive) aria-current="page" @endif>
<span class="mobile-bottom-nav__label">{{ $item['label'] ?? 'Link' }}</span>
</a>
@endforeach
@if ($layoutMobileHasDrawer)
<button type="button" class="mobile-bottom-nav__more" data-mobile-drawer-open>
<span class="mobile-bottom-nav__label">Mehr</span>
<span class="mobile-bottom-nav__meta">Menü</span>
</button>
@endif
</nav>
</div>
@if ($layoutMobileHasDrawer)
<div class="mobile-drawer" data-mobile-drawer>
<button type="button" class="mobile-drawer__scrim" aria-label="Menü schließen" data-mobile-drawer-close></button>
<aside class="mobile-drawer__panel" role="dialog" aria-label="Weitere Navigation">
<div class="mobile-drawer__header">
<div>
<p class="mobile-drawer__title">Mehr Bereiche</p>
<p class="mobile-drawer__copy">
@if (is_array($layoutAuth))
{{ $layoutAuth['tenant_name'] ?? 'Tenant' }} · {{ $layoutCurrentLabel }}
@else
Zusätzliche Links und Zugangsmöglichkeiten
@endif
</p>
</div>
<button type="button" class="button button--ghost mobile-drawer__close" data-mobile-drawer-close aria-label="Schließen">×</button>
</div>
<div class="mobile-drawer__sections">
@if ($layoutMobileDrawerPrimaryItems !== [])
<section class="mobile-drawer__section">
<div class="mobile-drawer__label">Weitere Seiten</div>
<div class="mobile-drawer__links">
@foreach ($layoutMobileDrawerPrimaryItems as $item)
@php
$itemHref = rtrim((string) ($item['href'] ?? '/'), '/');
$itemHref = $itemHref === '' ? '/' : $itemHref;
$isActive = $layoutPath === $itemHref;
@endphp
<a href="{{ $item['href'] ?? '/' }}" class="mobile-drawer__link {{ $isActive ? 'is-active' : '' }}" @if ($isActive) aria-current="page" @endif>{{ $item['label'] ?? 'Link' }}</a>
@endforeach
</div>
</section>
@endif
@foreach ($layoutNavGroups as $group)
<section class="mobile-drawer__section">
<div class="mobile-drawer__label">{{ $group['label'] ?? 'Mehr' }}</div>
<div class="mobile-drawer__links">
@foreach (($group['items'] ?? []) as $item)
@php
$itemHref = rtrim((string) ($item['href'] ?? '/'), '/');
$itemHref = $itemHref === '' ? '/' : $itemHref;
$isActive = $layoutPath === $itemHref;
@endphp
<a href="{{ $item['href'] ?? '/' }}" class="mobile-drawer__link {{ $isActive ? 'is-active' : '' }}" @if ($isActive) aria-current="page" @endif>{{ $item['label'] ?? 'Link' }}</a>
@endforeach
</div>
</section>
@endforeach
<section class="mobile-drawer__section">
<div class="mobile-drawer__label">Konto</div>
@if (is_array($layoutAuth))
<div class="mobile-drawer__links">
<div class="mobile-drawer__empty">{{ $layoutAuth['tenant_name'] ?? 'Tenant' }}</div>
</div>
@else
<div class="mobile-drawer__links">
<a class="mobile-drawer__link" href="/login/">Anmelden</a>
<a class="mobile-drawer__link" href="/admin/login/">Verwaltung</a>
</div>
@endif
</section>
</div>
<div class="mobile-drawer__footer">
@if (is_array($layoutAuth))
<form method="post" action="/logout/">
<button type="submit" class="button button--ghost" style="width: 100%;">Abmelden</button>
</form>
@endif
</div>
</aside>
</div>
@endif
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const detailGroups = document.querySelectorAll('.top-nav__group');
const drawer = document.querySelector('[data-mobile-drawer]');
const drawerOpeners = document.querySelectorAll('[data-mobile-drawer-open]');
const drawerClosers = document.querySelectorAll('[data-mobile-drawer-close]');
function closeOthers(current) {
detailGroups.forEach(function (other) {
if (other !== current) {
other.open = false;
}
});
}
detailGroups.forEach(function (detail) {
const summary = detail.querySelector('summary');
if (summary) {
summary.addEventListener('click', function () {
if (!detail.open) {
closeOthers(detail);
}
});
}
detail.addEventListener('toggle', function () {
if (detail.open) {
closeOthers(detail);
}
});
});
function closeDrawer() {
if (drawer) {
drawer.classList.remove('is-open');
document.body.classList.remove('mobile-drawer-open');
}
}
document.querySelectorAll('.top-nav__panel a, .mobile-drawer__link').forEach(function (link) {
link.addEventListener('click', function () {
detailGroups.forEach(function (detail) {
detail.open = false;
});
closeDrawer();
});
});
drawerOpeners.forEach(function (button) {
button.addEventListener('click', function () {
detailGroups.forEach(function (detail) {
detail.open = false;
});
if (drawer) {
drawer.classList.add('is-open');
document.body.classList.add('mobile-drawer-open');
}
});
});
drawerClosers.forEach(function (button) {
button.addEventListener('click', function () {
closeDrawer();
});
});
});
</script>
@endif
</body>
</html>
+235 -36
View File
@@ -103,6 +103,12 @@
}
.site-nav__link:hover{text-decoration:none;color:#f4f7ff;background:rgba(255,255,255,.06);border-color:rgba(201,214,255,.12)}
.site-nav__link.active{background:rgba(255,255,255,.08);color:#f4f7ff;border-color:rgba(201,214,255,.12)}
.site-more{position:relative}
.site-more[open]{z-index:25}
.site-more__toggle{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:0 .6rem;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);color:rgba(231,238,255,.8);font-size:.96rem;font-weight:700;cursor:pointer;list-style:none}
.site-more__toggle::-webkit-details-marker{display:none}
.site-more__panel{position:absolute;right:0;top:calc(100% + 10px);min-width:240px;padding:12px;border-radius:14px;border:1px solid rgba(163,183,255,.12);background:rgba(14,20,34,.96);box-shadow:0 16px 40px rgba(2,5,12,.34);display:grid;gap:8px}
.site-more__label{font-size:.76rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:rgba(219,228,248,.52)}
.hero,.card,.alert{
border:1px solid rgba(163,183,255,.12);
border-radius:16px;
@@ -115,23 +121,22 @@
color:#f4f7ff;
}
.muted,p{color:rgba(219,228,246,.74)}
.site-mobile{display:none;position:relative}
.site-mobile[open]{z-index:20}
.site-toggle{
.site-mobile{display:none}
.site-mobile__toggle{
display:inline-flex;
align-items:center;
gap:12px;
justify-content:center;
gap:10px;
cursor:pointer;
list-style:none;
padding:10px 12px;
min-height:38px;
padding:.45rem .72rem;
border-radius:10px;
border:1px solid rgba(201,214,255,.14);
background:rgba(255,255,255,.04);
color:#f4f7ff;
font-weight:700;
}
.site-toggle::-webkit-details-marker{display:none}
.site-toggle::before{
.site-mobile__toggle::before{
content:"";
display:block;
width:18px;
@@ -140,20 +145,76 @@
background:currentColor;
box-shadow:0 -6px 0 currentColor,0 6px 0 currentColor;
}
.site-panel{
.mobile-drawer{position:fixed;inset:0;display:none;z-index:55}
.mobile-drawer.is-open{display:block}
.mobile-drawer__scrim{position:absolute;inset:0;background:rgba(4,10,18,.58);backdrop-filter:blur(6px)}
.mobile-drawer__panel{
position:absolute;
right:0;
top:calc(100% + 12px);
width:min(320px,calc(100vw - 32px));
padding:14px;
border-radius:16px;
border:1px solid rgba(163,183,255,.14);
background:rgba(8,10,18,.96);
box-shadow:0 18px 36px rgba(1,5,13,.45);
top:0;
bottom:0;
width:min(92vw,380px);
padding:18px 16px calc(28px + env(safe-area-inset-bottom));
background:rgba(14,20,34,.96);
border-left:1px solid rgba(163,183,255,.12);
box-shadow:0 16px 40px rgba(2,5,12,.34);
display:grid;
grid-template-rows:auto 1fr auto;
gap:18px;
overflow:auto;
}
.site-stack{display:grid;gap:10px}
.site-stack .site-nav__link{justify-content:flex-start;padding:.6rem .75rem;background:rgba(255,255,255,.04);border-color:rgba(201,214,255,.1);color:#f4f7ff}
.site-footer-actions{display:flex;justify-content:flex-end;margin-top:12px}
.mobile-drawer__header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.mobile-drawer__title{margin:0;font-size:1.08rem;color:#f4f7ff}
.mobile-drawer__subtitle{margin:4px 0 0;color:rgba(219,228,246,.74);font-size:.9rem}
.mobile-drawer__close{
display:inline-flex;
align-items:center;
justify-content:center;
width:40px;
height:40px;
border-radius:999px;
border:1px solid rgba(163,183,255,.12);
background:rgba(255,255,255,.04);
color:#f4f7ff;
font-size:1.15rem;
font-weight:700;
cursor:pointer;
}
.mobile-drawer__section{display:grid;gap:10px}
.mobile-drawer__label{font-size:.76rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:rgba(219,228,248,.52)}
.mobile-drawer__links{display:grid;gap:8px}
.mobile-drawer__link{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:.9rem 1rem;
border-radius:14px;
border:1px solid rgba(163,183,255,.12);
background:rgba(255,255,255,.04);
color:#f4f7ff;
font-weight:700;
}
.mobile-drawer__link.active{background:rgba(88,122,255,.16);border-color:rgba(88,122,255,.28)}
.mobile-drawer__footer{display:grid;gap:12px;padding-top:8px;border-top:1px solid rgba(163,183,255,.12)}
.mobile-drawer__meta{display:grid;gap:4px}
.mobile-bottom-nav{display:none}
.mobile-bottom-nav__link,.mobile-bottom-nav__toggle{
display:inline-flex;
align-items:center;
justify-content:center;
gap:6px;
min-height:56px;
padding:0 8px;
border:0;
background:transparent;
color:rgba(231,238,255,.8);
font:inherit;
font-weight:700;
}
.mobile-bottom-nav__link.active,.mobile-bottom-nav__toggle.active{color:#f4f7ff}
.mobile-bottom-nav__icon{font-size:1rem;line-height:1}
.mobile-bottom-nav__label{font-size:.74rem}
.content{min-width:0;display:grid;gap:18px}
.hero{
padding:24px;
@@ -251,15 +312,34 @@
.status.is-closed::before{background:#11623d}
.mono{font-family:Consolas,monospace}
.footer{margin-top:18px;text-align:center;color:rgba(209,217,235,.68);font-size:.92rem}
@media(max-width:1040px){
@media(max-width:960px){
body.mobile-drawer-open{overflow:hidden}
.page-shell{width:min(100vw - 20px,1460px)}
.page-shell{padding-bottom:94px}
.site-header__inner{align-items:center;padding:12px 16px;min-height:68px;width:100%}
.site-nav{display:none}
.site-actions{gap:8px}
.site-actions .badge,.site-actions > form{display:none}
.site-mobile{display:block}
.split,.grid--2,.grid--3,.grid--4{grid-template-columns:1fr}
table{min-width:0}
.mobile-bottom-nav{
position:fixed;
left:10px;
right:10px;
bottom:max(10px,env(safe-area-inset-bottom));
z-index:45;
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:4px;
padding:6px;
border:1px solid rgba(163,183,255,.12);
border-radius:18px;
background:rgba(14,20,34,.96);
box-shadow:0 16px 40px rgba(2,5,12,.34);
}
}
@media(min-width:1041px){
@media(min-width:961px){
.site-mobile{display:none}
}
</style>
@@ -277,32 +357,70 @@
</a>
<nav class="site-nav" aria-label="Tenant-Menü">
<?php foreach ($tenantNavItems as $item): ?>
<?php foreach ($tenantHeaderNavItems as $item): ?>
<a href="<?= support_h((string) ($item['href'] ?? '/')) ?>" class="site-nav__link <?= (($item['key'] ?? '') === 'support') ? 'active' : '' ?>" <?= (($item['key'] ?? '') === 'support') ? 'aria-current="page"' : '' ?>><?= support_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
<?php foreach ($tenantNavGroups as $group): ?>
<details class="site-more">
<summary class="site-more__toggle"><?= support_h((string) ($group['label'] ?? 'Mehr')) ?></summary>
<div class="site-more__panel">
<div class="site-more__label"><?= support_h((string) ($group['label'] ?? 'Mehr')) ?></div>
<?php foreach (($group['items'] ?? []) as $item): ?>
<a href="<?= support_h((string) ($item['href'] ?? '/')) ?>" class="site-nav__link <?= (($item['key'] ?? '') === 'support') ? 'active' : '' ?>" <?= (($item['key'] ?? '') === 'support') ? 'aria-current="page"' : '' ?>><?= support_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
</nav>
<div class="site-actions">
<?= support_badge($isManager ? 'Verantwortlichen-Sicht' : 'Mitgliedersicht', 'success') ?>
<form method="post" action="/logout/"><button type="submit" class="button button--ghost">Abmelden</button></form>
<details class="site-mobile">
<summary class="site-toggle">Menü</summary>
<div class="site-panel">
<nav class="site-stack" aria-label="Mobiles Tenant-Menü">
<?php foreach ($tenantNavItems as $item): ?>
<a href="<?= support_h((string) ($item['href'] ?? '/')) ?>" class="site-nav__link <?= (($item['key'] ?? '') === 'support') ? 'active' : '' ?>" <?= (($item['key'] ?? '') === 'support') ? 'aria-current="page"' : '' ?>><?= support_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</nav>
<div class="site-footer-actions">
<form method="post" action="/logout/"><button type="submit" class="button button--ghost">Abmelden</button></form>
</div>
</div>
</details>
<button type="button" class="site-mobile__toggle" data-mobile-drawer-open>Mehr</button>
</div>
</div>
</header>
<?php if ($mobileBottomNavItems !== []): ?>
<div class="mobile-drawer" data-mobile-drawer>
<button type="button" class="mobile-drawer__scrim" aria-label="Menü schließen" data-mobile-drawer-close></button>
<aside class="mobile-drawer__panel" aria-label="Mobile Navigation">
<div class="mobile-drawer__header">
<div>
<h2 class="mobile-drawer__title">Mehr</h2>
<p class="mobile-drawer__subtitle"><?= support_h((string) ($auth['tenant_name'] ?? 'Bereich')) ?></p>
</div>
<button type="button" class="mobile-drawer__close" aria-label="Schließen" data-mobile-drawer-close>&times;</button>
</div>
<div class="mobile-drawer__section">
<div class="mobile-drawer__label">Direkt</div>
<div class="mobile-drawer__links">
<?php foreach ($mobileBottomNavItems as $item): ?>
<a href="<?= support_h((string) ($item['href'] ?? '/')) ?>" class="mobile-drawer__link <?= (($item['key'] ?? '') === 'support') ? 'active' : '' ?>" <?= (($item['key'] ?? '') === 'support') ? 'aria-current="page"' : '' ?>><?= support_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php foreach ($tenantNavGroups as $group): ?>
<div class="mobile-drawer__section">
<div class="mobile-drawer__label"><?= support_h((string) ($group['label'] ?? 'Mehr')) ?></div>
<div class="mobile-drawer__links">
<?php foreach (($group['items'] ?? []) as $item): ?>
<a href="<?= support_h((string) ($item['href'] ?? '/')) ?>" class="mobile-drawer__link <?= (($item['key'] ?? '') === 'support') ? 'active' : '' ?>" <?= (($item['key'] ?? '') === 'support') ? 'aria-current="page"' : '' ?>><?= support_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<div class="mobile-drawer__footer">
<div class="mobile-drawer__meta">
<strong><?= support_h((string) ($auth['tenant_name'] ?? 'Bereich')) ?></strong>
<span class="muted"><?= support_h((string) ($auth['display_name'] ?? 'Angemeldet')) ?></span>
</div>
<form method="post" action="/logout/"><button type="submit" class="button button--ghost" style="width:100%">Abmelden</button></form>
</div>
</aside>
</div>
<?php endif; ?>
<div class="content">
<section class="hero">
<div>
@@ -566,5 +684,86 @@
<p class="footer">Die Kaffeeliste | Support, Hinweise und Betriebsprozesse im Tenant-Menü</p>
</div>
</main>
<?php if ($mobileBottomNavItems !== []): ?>
<nav class="mobile-bottom-nav" aria-label="Mobile Kernnavigation">
<?php foreach ($mobileBottomNavItems as $item): ?>
<a href="<?= support_h((string) ($item['href'] ?? '/')) ?>" class="mobile-bottom-nav__link <?= (($item['key'] ?? '') === 'support') ? 'active' : '' ?>" <?= (($item['key'] ?? '') === 'support') ? 'aria-current="page"' : '' ?>>
<span class="mobile-bottom-nav__icon"><?= match ((string) ($item['key'] ?? '')) {
'dashboard' => 'U',
'members' => 'M',
'ledger' => 'B',
'payments' => 'Z',
default => '•',
} ?></span>
<span class="mobile-bottom-nav__label"><?= support_h((string) ($item['label'] ?? 'Link')) ?></span>
</a>
<?php endforeach; ?>
<button type="button" class="mobile-bottom-nav__toggle" data-mobile-drawer-open>
<span class="mobile-bottom-nav__icon">+</span>
<span class="mobile-bottom-nav__label">Mehr</span>
</button>
</nav>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const details = document.querySelectorAll('.site-more');
const drawer = document.querySelector('[data-mobile-drawer]');
const drawerOpeners = document.querySelectorAll('[data-mobile-drawer-open]');
const drawerClosers = document.querySelectorAll('[data-mobile-drawer-close]');
function closeOthers(current) {
details.forEach(function (other) {
if (other !== current) {
other.open = false;
}
});
}
details.forEach(function (detail) {
const summary = detail.querySelector('summary');
if (summary) {
summary.addEventListener('click', function () {
if (!detail.open) {
closeOthers(detail);
}
});
}
detail.addEventListener('toggle', function () {
if (detail.open) {
closeOthers(detail);
}
});
});
document.querySelectorAll('.site-more__panel a, .mobile-drawer__link').forEach(function (link) {
link.addEventListener('click', function () {
details.forEach(function (detail) {
detail.open = false;
});
if (drawer) {
drawer.classList.remove('is-open');
document.body.classList.remove('mobile-drawer-open');
}
});
});
if (drawer) {
drawerOpeners.forEach(function (button) {
button.addEventListener('click', function () {
drawer.classList.add('is-open');
document.body.classList.add('mobile-drawer-open');
});
});
drawerClosers.forEach(function (button) {
button.addEventListener('click', function () {
drawer.classList.remove('is-open');
document.body.classList.remove('mobile-drawer-open');
});
});
}
});
</script>
</body>
</html>
+230 -37
View File
@@ -104,23 +104,28 @@
}
.site-nav__link:hover{text-decoration:none;color:#f4f7ff;background:rgba(255,255,255,.06);border-color:rgba(201,214,255,.12)}
.site-nav__link.active{background:rgba(255,255,255,.08);color:#f4f7ff;border-color:rgba(201,214,255,.12)}
.site-mobile{display:none;position:relative}
.site-mobile[open]{z-index:20}
.site-toggle{
.site-more{position:relative}
.site-more[open]{z-index:25}
.site-more__toggle{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:0 .6rem;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);color:rgba(231,238,255,.8);font-size:.96rem;font-weight:700;cursor:pointer;list-style:none}
.site-more__toggle::-webkit-details-marker{display:none}
.site-more__panel{position:absolute;right:0;top:calc(100% + 10px);min-width:240px;padding:12px;border-radius:14px;border:1px solid rgba(163,183,255,.12);background:rgba(14,20,34,.96);box-shadow:0 16px 40px rgba(2,5,12,.34);display:grid;gap:8px}
.site-more__label{font-size:.76rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:rgba(219,228,248,.52)}
.site-mobile{display:none}
.site-mobile__toggle{
display:inline-flex;
align-items:center;
gap:12px;
justify-content:center;
gap:10px;
cursor:pointer;
list-style:none;
padding:10px 12px;
min-height:38px;
padding:.45rem .72rem;
border-radius:10px;
border:1px solid rgba(201,214,255,.14);
background:rgba(255,255,255,.04);
color:#f4f7ff;
font-weight:700;
}
.site-toggle::-webkit-details-marker{display:none}
.site-toggle::before{
.site-mobile__toggle::before{
content:"";
display:block;
width:18px;
@@ -129,26 +134,76 @@
background:currentColor;
box-shadow:0 -6px 0 currentColor,0 6px 0 currentColor;
}
.site-panel{
.mobile-drawer{position:fixed;inset:0;display:none;z-index:55}
.mobile-drawer.is-open{display:block}
.mobile-drawer__scrim{position:absolute;inset:0;background:rgba(4,10,18,.58);backdrop-filter:blur(6px)}
.mobile-drawer__panel{
position:absolute;
right:0;
top:calc(100% + 12px);
width:min(320px,calc(100vw - 32px));
padding:14px;
border-radius:16px;
border:1px solid rgba(163,183,255,.14);
background:rgba(8,10,18,.96);
box-shadow:0 18px 36px rgba(1,5,13,.45);
top:0;
bottom:0;
width:min(92vw,380px);
padding:18px 16px calc(28px + env(safe-area-inset-bottom));
background:rgba(14,20,34,.96);
border-left:1px solid rgba(163,183,255,.12);
box-shadow:0 16px 40px rgba(2,5,12,.34);
display:grid;
grid-template-rows:auto 1fr auto;
gap:18px;
overflow:auto;
}
.site-stack{display:grid;gap:10px}
.site-stack .site-nav__link{
justify-content:flex-start;
padding:.6rem .75rem;
.mobile-drawer__header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.mobile-drawer__title{margin:0;font-size:1.08rem;color:#f4f7ff}
.mobile-drawer__subtitle{margin:4px 0 0;color:rgba(219,228,246,.74);font-size:.9rem}
.mobile-drawer__close{
display:inline-flex;
align-items:center;
justify-content:center;
width:40px;
height:40px;
border-radius:999px;
border:1px solid rgba(163,183,255,.12);
background:rgba(255,255,255,.04);
border-color:rgba(201,214,255,.1);
color:#f4f7ff;
font-size:1.15rem;
font-weight:700;
cursor:pointer;
}
.site-footer-actions{display:flex;justify-content:flex-end;margin-top:12px}
.mobile-drawer__section{display:grid;gap:10px}
.mobile-drawer__label{font-size:.76rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:rgba(219,228,248,.52)}
.mobile-drawer__links{display:grid;gap:8px}
.mobile-drawer__link{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:.9rem 1rem;
border-radius:14px;
border:1px solid rgba(163,183,255,.12);
background:rgba(255,255,255,.04);
color:#f4f7ff;
font-weight:700;
}
.mobile-drawer__link.active{background:rgba(88,122,255,.16);border-color:rgba(88,122,255,.28)}
.mobile-drawer__footer{display:grid;gap:12px;padding-top:8px;border-top:1px solid rgba(163,183,255,.12)}
.mobile-drawer__meta{display:grid;gap:4px}
.mobile-bottom-nav{display:none}
.mobile-bottom-nav__link,.mobile-bottom-nav__toggle{
display:inline-flex;
align-items:center;
justify-content:center;
gap:6px;
min-height:56px;
padding:0 8px;
border:0;
background:transparent;
color:rgba(231,238,255,.8);
font:inherit;
font-weight:700;
}
.mobile-bottom-nav__link.active,.mobile-bottom-nav__toggle.active{color:#f4f7ff}
.mobile-bottom-nav__icon{font-size:1rem;line-height:1}
.mobile-bottom-nav__label{font-size:.74rem}
.hero,.card,.table-card,.note{
border:1px solid rgba(163,183,255,.12);
border-radius:16px;
@@ -223,11 +278,30 @@
white-space:nowrap;
}
@media(max-width:960px){
body.mobile-drawer-open{overflow:hidden}
.page-shell{width:min(100vw - 20px,1460px)}
.page-shell{padding-bottom:94px}
.site-header__inner{align-items:center;padding:12px 16px;min-height:68px;width:100%}
.site-nav{display:none}
.site-actions{gap:8px}
.site-actions .pill,.site-actions > form{display:none}
.site-mobile{display:block}
.hero,.grid--2,.grid--3,.grid--4{grid-template-columns:1fr}
.mobile-bottom-nav{
position:fixed;
left:10px;
right:10px;
bottom:max(10px,env(safe-area-inset-bottom));
z-index:45;
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:4px;
padding:6px;
border:1px solid rgba(163,183,255,.12);
border-radius:18px;
background:rgba(14,20,34,.96);
box-shadow:0 16px 40px rgba(2,5,12,.34);
}
}
@media(min-width:961px){
.site-mobile{display:none}
@@ -247,32 +321,70 @@
</a>
<nav class="site-nav" aria-label="Tenant-Menü">
<?php foreach ($tenantNavItems as $item): ?>
<?php foreach ($tenantHeaderNavItems as $item): ?>
<a class="site-nav__link <?= (($item['key'] ?? '') === 'roles') ? 'active' : '' ?>" href="<?= tenant_roles_h((string) ($item['href'] ?? '/')) ?>" <?= (($item['key'] ?? '') === 'roles') ? 'aria-current="page"' : '' ?>><?= tenant_roles_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
<?php foreach ($tenantNavGroups as $group): ?>
<details class="site-more">
<summary class="site-more__toggle"><?= tenant_roles_h((string) ($group['label'] ?? 'Mehr')) ?></summary>
<div class="site-more__panel">
<div class="site-more__label"><?= tenant_roles_h((string) ($group['label'] ?? 'Mehr')) ?></div>
<?php foreach (($group['items'] ?? []) as $item): ?>
<a class="site-nav__link <?= (($item['key'] ?? '') === 'roles') ? 'active' : '' ?>" href="<?= tenant_roles_h((string) ($item['href'] ?? '/')) ?>" <?= (($item['key'] ?? '') === 'roles') ? 'aria-current="page"' : '' ?>><?= tenant_roles_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
</nav>
<div class="site-actions">
<span class="pill">Rollenmatrix</span>
<form method="post" action="/logout/"><button type="submit" class="button button--ghost">Abmelden</button></form>
<details class="site-mobile">
<summary class="site-toggle">Menü</summary>
<div class="site-panel">
<nav class="site-stack" aria-label="Mobiles Tenant-Menü">
<?php foreach ($tenantNavItems as $item): ?>
<a class="site-nav__link <?= (($item['key'] ?? '') === 'roles') ? 'active' : '' ?>" href="<?= tenant_roles_h((string) ($item['href'] ?? '/')) ?>" <?= (($item['key'] ?? '') === 'roles') ? 'aria-current="page"' : '' ?>><?= tenant_roles_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</nav>
<div class="site-footer-actions">
<form method="post" action="/logout/"><button type="submit" class="button button--ghost">Abmelden</button></form>
</div>
</div>
</details>
<button type="button" class="site-mobile__toggle" data-mobile-drawer-open>Mehr</button>
</div>
</div>
</header>
<?php if ($mobileBottomNavItems !== []): ?>
<div class="mobile-drawer" data-mobile-drawer>
<button type="button" class="mobile-drawer__scrim" aria-label="Menü schließen" data-mobile-drawer-close></button>
<aside class="mobile-drawer__panel" aria-label="Mobile Navigation">
<div class="mobile-drawer__header">
<div>
<h2 class="mobile-drawer__title">Mehr</h2>
<p class="mobile-drawer__subtitle"><?= tenant_roles_h((string) ($auth['tenant_name'] ?? 'Bereich')) ?></p>
</div>
<button type="button" class="mobile-drawer__close" aria-label="Schließen" data-mobile-drawer-close>&times;</button>
</div>
<div class="mobile-drawer__section">
<div class="mobile-drawer__label">Direkt</div>
<div class="mobile-drawer__links">
<?php foreach ($mobileBottomNavItems as $item): ?>
<a class="mobile-drawer__link <?= (($item['key'] ?? '') === 'roles') ? 'active' : '' ?>" href="<?= tenant_roles_h((string) ($item['href'] ?? '/')) ?>" <?= (($item['key'] ?? '') === 'roles') ? 'aria-current="page"' : '' ?>><?= tenant_roles_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php foreach ($tenantNavGroups as $group): ?>
<div class="mobile-drawer__section">
<div class="mobile-drawer__label"><?= tenant_roles_h((string) ($group['label'] ?? 'Mehr')) ?></div>
<div class="mobile-drawer__links">
<?php foreach (($group['items'] ?? []) as $item): ?>
<a class="mobile-drawer__link <?= (($item['key'] ?? '') === 'roles') ? 'active' : '' ?>" href="<?= tenant_roles_h((string) ($item['href'] ?? '/')) ?>" <?= (($item['key'] ?? '') === 'roles') ? 'aria-current="page"' : '' ?>><?= tenant_roles_h((string) ($item['label'] ?? 'Link')) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<div class="mobile-drawer__footer">
<div class="mobile-drawer__meta">
<strong><?= tenant_roles_h((string) ($auth['tenant_name'] ?? 'Bereich')) ?></strong>
<span class="muted"><?= tenant_roles_h((string) ($auth['display_name'] ?? 'Angemeldet')) ?></span>
</div>
<form method="post" action="/logout/"><button type="submit" class="button button--ghost" style="width:100%">Abmelden</button></form>
</div>
</aside>
</div>
<?php endif; ?>
<div class="content">
<section class="hero">
<div>
@@ -426,5 +538,86 @@
</section>
</div>
</main>
<?php if ($mobileBottomNavItems !== []): ?>
<nav class="mobile-bottom-nav" aria-label="Mobile Kernnavigation">
<?php foreach ($mobileBottomNavItems as $item): ?>
<a class="mobile-bottom-nav__link <?= (($item['key'] ?? '') === 'roles') ? 'active' : '' ?>" href="<?= tenant_roles_h((string) ($item['href'] ?? '/')) ?>" <?= (($item['key'] ?? '') === 'roles') ? 'aria-current="page"' : '' ?>>
<span class="mobile-bottom-nav__icon"><?= match ((string) ($item['key'] ?? '')) {
'dashboard' => 'U',
'members' => 'M',
'ledger' => 'B',
'payments' => 'Z',
default => '•',
} ?></span>
<span class="mobile-bottom-nav__label"><?= tenant_roles_h((string) ($item['label'] ?? 'Link')) ?></span>
</a>
<?php endforeach; ?>
<button type="button" class="mobile-bottom-nav__toggle" data-mobile-drawer-open>
<span class="mobile-bottom-nav__icon">+</span>
<span class="mobile-bottom-nav__label">Mehr</span>
</button>
</nav>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const details = document.querySelectorAll('.site-more');
const drawer = document.querySelector('[data-mobile-drawer]');
const drawerOpeners = document.querySelectorAll('[data-mobile-drawer-open]');
const drawerClosers = document.querySelectorAll('[data-mobile-drawer-close]');
function closeOthers(current) {
details.forEach(function (other) {
if (other !== current) {
other.open = false;
}
});
}
details.forEach(function (detail) {
const summary = detail.querySelector('summary');
if (summary) {
summary.addEventListener('click', function () {
if (!detail.open) {
closeOthers(detail);
}
});
}
detail.addEventListener('toggle', function () {
if (detail.open) {
closeOthers(detail);
}
});
});
document.querySelectorAll('.site-more__panel a, .mobile-drawer__link').forEach(function (link) {
link.addEventListener('click', function () {
details.forEach(function (detail) {
detail.open = false;
});
if (drawer) {
drawer.classList.remove('is-open');
document.body.classList.remove('mobile-drawer-open');
}
});
});
if (drawer) {
drawerOpeners.forEach(function (button) {
button.addEventListener('click', function () {
drawer.classList.add('is-open');
document.body.classList.add('mobile-drawer-open');
});
});
drawerClosers.forEach(function (button) {
button.addEventListener('click', function () {
drawer.classList.remove('is-open');
document.body.classList.remove('mobile-drawer-open');
});
});
}
});
</script>
</body>
</html>