From 629a1f9e51c9ae0bc82811e987bd8537e54fa744 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Wed, 8 Apr 2026 17:40:23 +0200 Subject: [PATCH] =?UTF-8?q?=C3=84nderung=20Men=C3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mobile-konzept-3-buchungsseite.md | 312 +++++++++++ ...options_json_to_survey_questions_table.php | 2 +- ...8_000003_add_theme_mode_to_users_table.php | 6 + saas-app/public/app-support.php | 134 ++++- saas-app/public/index.php | 495 ++++++++++++++--- saas-app/public/support/index.php | 9 + saas-app/public/surveys/index.php | 147 +++++- saas-app/public/tenants/roles/index.php | 9 + .../resources/views/layouts/app.blade.php | 496 +++++++++++++++--- .../resources/views/support/index.blade.php | 271 ++++++++-- .../resources/views/tenants/roles.blade.php | 267 ++++++++-- 11 files changed, 1918 insertions(+), 230 deletions(-) create mode 100644 docs/mobile-konzept-3-buchungsseite.md create mode 100644 saas-app/database/migrations/2026_04_08_000003_add_theme_mode_to_users_table.php diff --git a/docs/mobile-konzept-3-buchungsseite.md b/docs/mobile-konzept-3-buchungsseite.md new file mode 100644 index 0000000..1e1e0f5 --- /dev/null +++ b/docs/mobile-konzept-3-buchungsseite.md @@ -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 diff --git a/saas-app/database/migrations/2026_04_08_000002_add_options_json_to_survey_questions_table.php b/saas-app/database/migrations/2026_04_08_000002_add_options_json_to_survey_questions_table.php index 06485b2..0c4b8c2 100644 --- a/saas-app/database/migrations/2026_04_08_000002_add_options_json_to_survey_questions_table.php +++ b/saas-app/database/migrations/2026_04_08_000002_add_options_json_to_survey_questions_table.php @@ -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; diff --git a/saas-app/database/migrations/2026_04_08_000003_add_theme_mode_to_users_table.php b/saas-app/database/migrations/2026_04_08_000003_add_theme_mode_to_users_table.php new file mode 100644 index 0000000..2662188 --- /dev/null +++ b/saas-app/database/migrations/2026_04_08_000003_add_theme_mode_to_users_table.php @@ -0,0 +1,6 @@ + '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}> + */ +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'], diff --git a/saas-app/public/index.php b/saas-app/public/index.php index df6c5b6..dae273f 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -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(); Die Kaffeeliste - +
@@ -638,9 +715,22 @@ $marketing = app_marketing_messages();
@@ -651,25 +741,54 @@ $marketing = app_marketing_messages(); Anmelden -
- Menü -
- - - - -
-
+ + +
+ +
+ + +
+ +
@@ -1041,10 +1160,129 @@ $marketing = app_marketing_messages();
-
Buchungen

Striche und Buchungen

Kaffeeeinträge und alle zugehörigen Buchungen bleiben tenantweise nachvollziehbar.

-
-
-

Neuen Strich eintragen

+
+
+
+
+
Buchungen
+

Buchungen

+
+ Journal +
+

Mobil steht die Erfassung im Vordergrund. Das Journal bleibt als zweite Sicht direkt erreichbar.

+ +
+ +
+
+ Teilnehmer + 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'][$ledgerScope] ?? 'Alle') ?> +
+
+ 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'] as $scopeKey => $scopeLabel): ?> + + +
+
+ + + + +
+ + + +
+
+ Lokaler Batch fuer alle Werte > 0 + +
+
+
+ +
+
+ Einträge + Prüfen und korrigieren +
+
+ +
+
+
+ +
+ · + +
+
+ +
+ +
+ + + +
+ +
+ +
+
+ +
+
+
Buchungen

Striche und Buchungen

Kaffeeeinträge und alle zugehörigen Buchungen bleiben tenantweise nachvollziehbar.

+
+
+
+

Sammelerfassung wie in der Kaffeeliste

+
+ Teilnehmer + Ansicht: 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'][$ledgerScope] ?? 'Alle') ?> +
+
+
+ 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'] as $scopeKey => $scopeLabel): ?> + + +
+
+
+ + +
+ + +
+
+ + + + + + + + + + + +
MitgliedStriche100 Tage
+
+ +
+
+
+

Einzelbuchung

@@ -1053,25 +1291,62 @@ $marketing = app_marketing_messages();
-
-

Was dieser Bereich abdeckt

  • Einzel- und Sammelbuchungen laufen in einem gemeinsamen Ablauf zusammen.
  • Jeder Verbrauch erzeugt automatisch den passenden Ledger-Eintrag.
  • Die letzten Buchungen bleiben je Tenant nachvollziehbar.
-
-
ZeitMitgliedTypReferenzBetragAktion
-
+ +
ZeitMitgliedTypReferenzBetragAktion
-
+
Einzahlungen

Zahlungen

Einzahlungen werden direkt in Zahlungstabelle und Ledger geschrieben.

-
-
-

Einzahlung buchen

-
- - - +
+
+
+

Sammel-Einzahlungen

+
+ Teilnehmer + Ansicht: 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'][$paymentScope] ?? 'Alle') ?> +
+
+
+ 'Alle', 'front' => 'Vorderseite', 'back' => 'Rückseite'] as $scopeKey => $scopeLabel): ?> + + + CSV-Import +
+
+ + + +
-
- -
-

Wofür dieser Bereich da ist

  • Manuelle Einzahlungen sind direkt erfasst.
  • PayPal oder Bank können getrennt ausgewiesen werden.
  • Jede Zahlung erscheint sofort im Ledger.

+ +
+ + + + + + + + + + + +
MitgliedBetrag100 Tage
+
+ + +

+
+
+

Einzelzahlung

+
+ + + + + +
+
ZeitMitgliedMethodeBetragAktion
@@ -1135,14 +1410,34 @@ $marketing = app_marketing_messages();
-

Aktuelle Lizenz

+

Anzeige

-

Freigeschalteter Lizenzplan für diesen Mandanten.

-

Mitgliederrahmen

0 ? h((string) $tenantLicense['member_limit']) . ' aktive Mitglieder im Standardumfang.' : 'Individuell vereinbart.' ?>

-

Freigeschaltete Kernfunktionen

    'Mandanten-Einstellungen', 'pdf_export' => 'PDF-Listen', 'paper_strike_entry' => 'Papierlisten-Erfassung'] as $featureKey => $featureLabel): ?>
+

Anzeigemodus

Der Hell/Dunkelmodus wird pro Benutzer gespeichert und gehört bewusst in die Einstellungen statt in die Hauptnavigation.

+
+ + + +
+

Aktuell aktiv:

+
+

Daten, Hilfe und Kommunikation

+
+

Daten

Importe, Exporte und Reporting sind im Menü jetzt unter einem gemeinsamen Aufgabenraum gebündelt.

+

Hilfe & Inhalte

Hinweise, FAQ, Support und Umfragen sind zusammen erreichbar, statt als verstreute Einzelpunkte aufzutauchen.

+

Verwaltung

Rollen und Mandanten-Einstellungen bleiben zusammen im Verwaltungsbereich.

+
+
+
+

Aktuelle Lizenz

+
+

Freigeschalteter Lizenzplan für diesen Mandanten.

+

Mitgliederrahmen

0 ? h((string) $tenantLicense['member_limit']) . ' aktive Mitglieder im Standardumfang.' : 'Individuell vereinbart.' ?>

+

Freigeschaltete Kernfunktionen

    'Mandanten-Einstellungen', 'pdf_export' => 'PDF-Listen', 'paper_strike_entry' => 'Papierlisten-Erfassung'] as $featureKey => $featureLabel): ?>
+
+

Farben im Tenant

@@ -1280,7 +1575,87 @@ $marketing = app_marketing_messages();
+ + +
+ diff --git a/saas-app/public/support/index.php b/saas-app/public/support/index.php index a15bed0..4e3db14 100644 --- a/saas-app/public/support/index.php +++ b/saas-app/public/support/index.php @@ -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)) { diff --git a/saas-app/public/surveys/index.php b/saas-app/public/surveys/index.php index 9bb9eeb..3f37fa2 100644 --- a/saas-app/public/surveys/index.php +++ b/saas-app/public/surveys/index.php @@ -372,6 +372,9 @@ $tenantId = (string) ($auth['tenant_id'] ?? ''); $tenantLicense = ['plan_name' => 'Free', 'features' => app_feature_defaults()]; $tenantSettings = app_tenant_settings_defaults(); $tenantNavItems = []; +$tenantNavGroups = []; +$tenantHeaderNavItems = []; +$mobileBottomNavItems = []; $flash = app_flash(); $dbError = null; $pdo = null; @@ -389,6 +392,13 @@ try { $tenantLicense = app_tenant_license($pdo, $tenantId); $tenantSettings = app_tenant_settings($pdo, $tenantId); $tenantNavItems = app_tenant_navigation_items($auth, $tenantLicense); + $tenantNavGroups = app_tenant_navigation_groups($tenantNavItems); + foreach ($tenantNavItems as $item) { + if (in_array((string) ($item['key'] ?? ''), ['dashboard', 'members', 'ledger', 'payments'], true)) { + $tenantHeaderNavItems[] = $item; + $mobileBottomNavItems[] = $item; + } + } $surveyTablesReady = scripts_table_exists($pdo, 'surveys') && scripts_table_exists($pdo, 'survey_questions') && scripts_table_exists($pdo, 'survey_answers'); @@ -480,7 +490,7 @@ $themeCss = app_tenant_theme_root_css($tenantSettings); Die Kaffeeliste @@ -495,17 +505,69 @@ $themeCss = app_tenant_theme_root_css($tenantSettings);
+
+ +
+ + +
+ +
@@ -696,5 +758,86 @@ $themeCss = app_tenant_theme_root_css($tenantSettings); + + + + diff --git a/saas-app/public/tenants/roles/index.php b/saas-app/public/tenants/roles/index.php index c1584ff..b96d6f2 100644 --- a/saas-app/public/tenants/roles/index.php +++ b/saas-app/public/tenants/roles/index.php @@ -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)) { diff --git a/saas-app/resources/views/layouts/app.blade.php b/saas-app/resources/views/layouts/app.blade.php index 82e0e25..c604bbc 100644 --- a/saas-app/resources/views/layouts/app.blade.php +++ b/saas-app/resources/views/layouts/app.blade.php @@ -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 @@