diff --git a/saas-app/public/app-support.php b/saas-app/public/app-support.php index df4d678..ee23b8d 100644 --- a/saas-app/public/app-support.php +++ b/saas-app/public/app-support.php @@ -424,12 +424,13 @@ function app_tenant_navigation_items(?array $auth, array $license = []): array if ($canManage) { $items[] = ['key' => 'content', 'label' => 'Hinweise & FAQ', 'href' => '/content/']; $items[] = ['key' => 'reports', 'label' => 'Reporting', 'href' => '/reports/']; - $items[] = ['key' => 'roles', 'label' => 'Rollen', 'href' => '/tenants/roles/']; if (!empty($features['tenant_settings'])) { - $items[] = ['key' => 'settings', 'label' => 'Einstellungen', 'href' => '/settings/']; + $items[] = ['key' => 'settings', 'label' => 'Mandanten-Einstellungen', 'href' => '/settings/']; } + $items[] = ['key' => 'roles', 'label' => 'Rollen', 'href' => '/tenants/roles/']; + if ($hasExports) { $items[] = ['key' => 'exports', 'label' => 'Exporte', 'href' => '/exports/']; } @@ -454,7 +455,7 @@ function app_tenant_navigation_groups(array $items): array $groupOrder = ['data', 'content', 'management']; $groupLabels = [ 'data' => 'Daten', - 'content' => 'Hilfe & Inhalte', + 'content' => 'Inhalte & Support', 'management' => 'Verwaltung', ]; $groupMap = [ @@ -464,6 +465,7 @@ function app_tenant_navigation_groups(array $items): array 'content' => 'content', 'support' => 'content', 'surveys' => 'content', + 'members' => 'management', 'roles' => 'management', 'settings' => 'management', ]; @@ -496,6 +498,20 @@ function app_tenant_navigation_groups(array $items): array return $result; } +/** + * @return array + */ +function app_tenant_account_items(?array $auth): array +{ + if (!is_array($auth)) { + return []; + } + + return [ + ['key' => 'profile', 'label' => 'Persönliche Einstellungen', 'href' => '/profile/'], + ]; +} + function app_users_support_theme_mode(PDO $pdo): bool { static $cache = null; @@ -1234,19 +1250,19 @@ function app_handle_profile_action(PDO $pdo, array $auth): void if ($tenantId === '' || $tenantUserId === '') { app_flash('Das Profil konnte nicht aktualisiert werden.', 'error'); - app_redirect('/dashboard/'); + app_redirect(app_request_path()); } if ($displayName === '') { app_flash('Bitte gib einen Anzeigenamen an.', 'error'); - app_redirect('/dashboard/'); + app_redirect(app_request_path()); } $membership = app_tenant_user_by_id($pdo, $tenantUserId, $tenantId); if ($membership === null) { app_flash('Die zugehörige Person konnte nicht gefunden werden.', 'error'); - app_redirect('/dashboard/'); + app_redirect(app_request_path()); } $now = date('Y-m-d H:i:s'); @@ -1278,7 +1294,7 @@ function app_handle_profile_action(PDO $pdo, array $auth): void app_set_auth_user(array_merge($auth, ['display_name' => $displayName])); app_flash('Dein Profil wurde aktualisiert.', 'success'); - app_redirect('/dashboard/'); + app_redirect(app_request_path()); } function app_handle_theme_action(PDO $pdo, array $auth): void @@ -2368,6 +2384,38 @@ SQL; $summary = app_query_one($pdo, $summarySql, ['tenant_id' => $tenantId]) ?? []; + $yearSummary = app_query_one( + $pdo, + <<<'SQL' +SELECT + COALESCE(( + SELECT SUM(ce.strokes) + FROM coffee_entries ce + WHERE ce.tenant_id = :tenant_id + AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) + ), 0) AS year_strokes, + COALESCE(( + SELECT SUM(ce.total_cost) + FROM coffee_entries ce + WHERE ce.tenant_id = :tenant_id + AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) + ), 0) AS year_spend, + COALESCE(( + SELECT SUM(pe.amount) + FROM payment_entries pe + WHERE pe.tenant_id = :tenant_id + AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE()) + ), 0) AS year_payments, + COALESCE(( + SELECT COUNT(DISTINCT ce.member_id) + FROM coffee_entries ce + WHERE ce.tenant_id = :tenant_id + AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) + ), 0) AS year_active_members +SQL, + ['tenant_id' => $tenantId] + ) ?? []; + $recentSql = <<<'SQL' SELECT le.booked_at, @@ -2382,11 +2430,68 @@ ORDER BY le.booked_at DESC LIMIT 10 SQL; + $memberOverviewSql = <<<'SQL' +SELECT + m.id, + m.display_name, + COALESCE(SUM(le.amount), 0) AS balance, + COALESCE(( + SELECT SUM(ce.strokes) + FROM coffee_entries ce + WHERE ce.member_id = m.id + AND ce.tenant_id = m.tenant_id + AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) + ), 0) AS year_strokes, + COALESCE(( + SELECT SUM(pe.amount) + FROM payment_entries pe + WHERE pe.member_id = m.id + AND pe.tenant_id = m.tenant_id + AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE()) + ), 0) AS year_payments, + MAX(le.booked_at) AS last_booking_at +FROM members m +LEFT JOIN ledger_entries le ON le.member_id = m.id AND le.tenant_id = m.tenant_id +WHERE m.tenant_id = :tenant_id + AND m.status = 'active' +GROUP BY m.id, m.display_name +ORDER BY balance ASC, m.display_name ASC +LIMIT 12 +SQL; + + $memberRows = app_query_all($pdo, $memberOverviewSql, ['tenant_id' => $tenantId]); + $largestDebtors = array_values(array_filter( + $memberRows, + static fn(array $row): bool => (float) ($row['balance'] ?? 0) < 0 + )); + usort( + $largestDebtors, + static fn(array $left, array $right): int => ((float) ($left['balance'] ?? 0) <=> (float) ($right['balance'] ?? 0)) + ); + $largestDebtors = array_slice($largestDebtors, 0, 3); + + $largestCredits = array_values(array_filter( + $memberRows, + static fn(array $row): bool => (float) ($row['balance'] ?? 0) > 0 + )); + usort( + $largestCredits, + static fn(array $left, array $right): int => ((float) ($right['balance'] ?? 0) <=> (float) ($left['balance'] ?? 0)) + ); + $largestCredits = array_slice($largestCredits, 0, 3); + return [ 'active_members' => (string) ($summary['active_members'] ?? '0'), 'coffee_volume' => (string) ($summary['coffee_volume'] ?? '0.00'), 'payment_volume' => (string) ($summary['payment_volume'] ?? '0.00'), 'open_balance' => (string) ($summary['open_balance'] ?? '0.00'), + 'year_strokes' => (string) ($yearSummary['year_strokes'] ?? '0'), + 'year_spend' => (string) ($yearSummary['year_spend'] ?? '0.00'), + 'year_payments' => (string) ($yearSummary['year_payments'] ?? '0.00'), + 'year_active_members' => (string) ($yearSummary['year_active_members'] ?? '0'), + 'member_rows' => $memberRows, + 'largest_debtors' => $largestDebtors, + 'largest_credits' => $largestCredits, 'recent_entries' => app_query_all($pdo, $recentSql, ['tenant_id' => $tenantId]), ]; } @@ -2834,17 +2939,21 @@ function app_delete_payment_entry(PDO $pdo, string $tenantId, string $paymentId) function app_content_for_tenant(PDO $pdo, string $tenantId): array { - $announcements = app_query_all( - $pdo, - 'SELECT id, title, message, visible_until, is_active FROM announcements WHERE tenant_id = :tenant_id ORDER BY is_active DESC, created_at DESC LIMIT 12', - ['tenant_id' => $tenantId] - ); + $announcements = scripts_table_exists($pdo, 'announcements') + ? app_query_all( + $pdo, + 'SELECT id, title, message, visible_until, is_active FROM announcements WHERE tenant_id = :tenant_id ORDER BY is_active DESC, created_at DESC LIMIT 12', + ['tenant_id' => $tenantId] + ) + : []; - $faq = app_query_all( - $pdo, - 'SELECT id, question, answer, sort_order, is_active FROM faq_items WHERE tenant_id = :tenant_id ORDER BY is_active DESC, sort_order ASC, created_at DESC LIMIT 12', - ['tenant_id' => $tenantId] - ); + $faq = scripts_table_exists($pdo, 'faq_items') + ? app_query_all( + $pdo, + 'SELECT id, question, answer, sort_order, is_active FROM faq_items WHERE tenant_id = :tenant_id ORDER BY is_active DESC, sort_order ASC, created_at DESC LIMIT 12', + ['tenant_id' => $tenantId] + ) + : []; return [ 'announcements' => $announcements, diff --git a/saas-app/public/index.php b/saas-app/public/index.php index da81698..bb38efe 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -56,6 +56,7 @@ if ($requestedPage === null) { '/reports' => 'reports', '/support' => 'support', '/surveys' => 'surveys', + '/profil', '/profile' => 'profile', '/settings' => 'settings', '/exports' => 'exports', '/logout' => 'logout', @@ -65,7 +66,7 @@ if ($requestedPage === null) { $page = (string) $requestedPage; $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; -$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'support', 'surveys', 'settings', 'exports']; +$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'support', 'surveys', 'profile', 'settings', 'exports']; if ($page === 'logout' && $requestMethod === 'POST') { app_logout(); @@ -83,6 +84,7 @@ $members = []; $ledger = []; $payments = []; $content = ['announcements' => [], 'faq' => []]; +$dashboardAnnouncements = []; $memberSummary = null; $memberReport = null; $ledgerScope = 'all'; @@ -149,6 +151,9 @@ if ($auth !== null && $pdo instanceof PDO) { if (in_array($page, ['dashboard', 'ledger', 'payments'], true)) { app_handle_tenant_action($pdo, $auth); app_handle_bulk_finance_action($pdo, $auth); + } + + if ($page === 'profile') { app_handle_profile_action($pdo, $auth); } @@ -216,8 +221,21 @@ if ($auth !== null && $pdo instanceof PDO) { $paymentMembers = app_members_for_scope($pdo, (string) $auth['tenant_id'], $paymentScope); } - if ($page === 'content') { + if ($page === 'content' || $page === 'dashboard') { $content = app_content_for_tenant($pdo, (string) $auth['tenant_id']); + $dashboardAnnouncements = array_slice( + array_values( + array_filter( + $content['announcements'] ?? [], + static fn(array $announcement): bool => (int) ($announcement['is_active'] ?? 0) === 1 + ) + ), + 0, + 3 + ); + } + + if ($page === 'content') { if (isset($_GET['edit_announcement']) && $_GET['edit_announcement'] !== '') { foreach (($content['announcements'] ?? []) as $announcement) { @@ -309,10 +327,11 @@ $guestNavItems = [ ]; $primaryNavItems = $auth === null ? $guestNavItems : $tenantNavItems; $navGroups = $auth === null ? [] : app_tenant_navigation_groups($primaryNavItems); +$accountNavItems = $auth === null ? [] : app_tenant_account_items($auth); $headerNavItems = $primaryNavItems; if ($auth !== null) { $headerNavItems = []; - $primaryKeys = ['dashboard', 'members', 'ledger', 'payments']; + $primaryKeys = ['dashboard', 'ledger', 'payments']; foreach ($primaryNavItems as $item) { if (in_array((string) ($item['key'] ?? ''), $primaryKeys, true)) { $headerNavItems[] = $item; @@ -324,8 +343,9 @@ $ledgerViewMode = in_array((string) ($_GET['mode'] ?? 'capture'), ['capture', 'j ? (string) ($_GET['mode'] ?? 'capture') : 'capture'; $mobileBottomNavItems = []; +$mobileDrawerPrimaryItems = []; if ($auth !== null) { - foreach (['dashboard', 'members', 'ledger'] as $mobileKey) { + foreach (['dashboard', 'ledger', 'payments'] as $mobileKey) { foreach ($primaryNavItems as $item) { if ((string) ($item['key'] ?? '') === $mobileKey) { $mobileBottomNavItems[] = $item; @@ -341,6 +361,9 @@ foreach ($primaryNavItems as $item) { break; } } +if ($page === 'profile') { + $currentNavLabel = 'Persönliche Einstellungen'; +} $themeCss = app_tenant_theme_root_css($tenantSettings); $isMarketingHome = $page === 'home' && $auth === null; $heroEyebrow = 'Für Teams, Büros und Standorte'; @@ -409,7 +432,7 @@ $marketing = app_marketing_messages(); .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{display:none;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} @@ -538,7 +561,7 @@ $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:1180px){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}.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(max-width:1180px){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 .site-more{display:none}.site-mobile__toggle{display:inline-flex}.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:1181px){.site-mobile{display:none}} @@ -737,6 +760,18 @@ $marketing = app_marketing_messages(); Anmelden +
+ Konto +
+
+
Konto
+ + > + +
+
+
+
@@ -754,14 +789,16 @@ $marketing = app_marketing_messages(); -
-
Direkt
- +
@@ -773,11 +810,14 @@ $marketing = app_marketing_messages();
@@ -538,14 +552,16 @@ $themeCss = app_tenant_theme_root_css($tenantSettings);
-
-
Direkt
- +
@@ -557,9 +573,11 @@ $themeCss = app_tenant_theme_root_css($tenantSettings);