diff --git a/saas-app/public/admin/tenants/index.php b/saas-app/public/admin/tenants/index.php new file mode 100644 index 0000000..26baf07 --- /dev/null +++ b/saas-app/public/admin/tenants/index.php @@ -0,0 +1,7 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + return $pdo; +} + +function app_query_all(PDO $pdo, string $sql, array $params = []): array +{ + $statement = $pdo->prepare($sql); + $statement->execute($params); + + return $statement->fetchAll() ?: []; +} + +function app_query_one(PDO $pdo, string $sql, array $params = []): ?array +{ + $statement = $pdo->prepare($sql); + $statement->execute($params); + $row = $statement->fetch(); + + return is_array($row) ? $row : null; +} + +function app_execute(PDO $pdo, string $sql, array $params = []): void +{ + $statement = $pdo->prepare($sql); + $statement->execute($params); +} + +function app_flash(?string $message = null, string $type = 'info'): ?array +{ + if ($message !== null) { + $_SESSION['app_flash'] = [ + 'message' => $message, + 'type' => $type, + ]; + + return null; + } + + $flash = $_SESSION['app_flash'] ?? null; + unset($_SESSION['app_flash']); + + return is_array($flash) ? $flash : null; +} + +function app_login_state(): array +{ + return $_SESSION['login_state'] ?? []; +} + +function app_set_login_state(array $state): void +{ + $_SESSION['login_state'] = $state; +} + +function app_clear_login_state(): void +{ + unset($_SESSION['login_state']); +} + +function app_auth_user(): ?array +{ + return $_SESSION['auth_user'] ?? null; +} + +function app_set_auth_user(array $user): void +{ + $_SESSION['auth_user'] = $user; +} + +function app_logout(): void +{ + unset($_SESSION['auth_user'], $_SESSION['login_state']); +} + +function app_redirect(string $path): never +{ + header('Location: ' . $path); + exit; +} + +function app_uuid(): string +{ + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); +} + +function app_require_auth(): array +{ + $auth = app_auth_user(); + + if ($auth === null) { + app_flash('Bitte zuerst ueber die zentrale Anmeldung einloggen.', 'warning'); + app_redirect('/login'); + } + + return $auth; +} + +function app_is_platform_admin(?array $auth): bool +{ + return is_array($auth) && !empty($auth['is_platform_admin']); +} + +function app_is_tenant_admin(?array $auth): bool +{ + if (!is_array($auth)) { + return false; + } + + foreach (($auth['roles'] ?? []) as $role) { + if (str_contains((string) $role, 'admin')) { + return true; + } + } + + return false; +} + +function app_can_manage_tenant(?array $auth): bool +{ + return app_is_platform_admin($auth) || app_is_tenant_admin($auth); +} + +function app_memberships_by_email(PDO $pdo, string $email): array +{ + $sql = <<<'SQL' +SELECT + u.id AS user_id, + u.email AS user_email, + u.password_hash, + u.display_name, + u.is_platform_admin, + t.id AS tenant_id, + t.tenant_key, + t.name AS tenant_name, + t.status AS tenant_status, + tu.id AS tenant_user_id, + tu.status AS tenant_user_status, + m.id AS member_id, + m.email AS member_email, + COUNT(DISTINCT tip.id) AS oidc_provider_count, + GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ', ') AS role_keys +FROM users u +INNER JOIN tenant_users tu ON tu.user_id = u.id +INNER JOIN tenants t ON t.id = tu.tenant_id +LEFT JOIN members m ON m.tenant_user_id = tu.id +LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id +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, + 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]); +} + +function app_verify_password(?string $hash, string $password): bool +{ + if ($hash === null || trim($hash) === '') { + return false; + } + + return password_verify($password, $hash); +} + +function app_handle_login(PDO $pdo): array +{ + $state = app_login_state(); + $message = null; + $error = null; + + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + $action = (string) ($_POST['action'] ?? 'discover'); + + if ($action === 'discover') { + $email = strtolower(trim((string) ($_POST['email'] ?? ''))); + + if ($email === '') { + $error = 'Bitte zuerst die E-Mail-Adresse eingeben.'; + } else { + $memberships = app_memberships_by_email($pdo, $email); + + if ($memberships === []) { + $error = 'Zu dieser E-Mail-Adresse wurde kein Zugang gefunden. Bitte Einladung oder Support pruefen.'; + app_clear_login_state(); + } elseif (count($memberships) === 1) { + app_set_login_state([ + 'step' => 'password', + 'email' => $email, + 'selected_membership' => $memberships[0], + ]); + $state = app_login_state(); + $message = 'Tenant erkannt. Bitte jetzt mit Passwort fortfahren.'; + } else { + app_set_login_state([ + 'step' => 'choose-tenant', + 'email' => $email, + 'memberships' => $memberships, + ]); + $state = app_login_state(); + $message = 'Mehrere Tenant-Zuordnungen erkannt. Bitte zuerst den gewuenschten Bereich auswaehlen.'; + } + } + } + + if ($action === 'choose-tenant') { + $tenantUserId = (string) ($_POST['tenant_user_id'] ?? ''); + $state = app_login_state(); + $memberships = $state['memberships'] ?? []; + $selected = null; + + foreach ($memberships as $membership) { + if (($membership['tenant_user_id'] ?? '') === $tenantUserId) { + $selected = $membership; + break; + } + } + + if (!is_array($selected)) { + $error = 'Die ausgewaehlte Tenant-Zuordnung konnte nicht gefunden werden.'; + } else { + app_set_login_state([ + 'step' => 'password', + 'email' => $state['email'] ?? ($selected['user_email'] ?? ''), + 'selected_membership' => $selected, + 'memberships' => $memberships, + ]); + $state = app_login_state(); + $message = 'Bereich ausgewaehlt. Bitte jetzt mit Passwort fortfahren.'; + } + } + + if ($action === 'authenticate') { + $password = (string) ($_POST['password'] ?? ''); + $state = app_login_state(); + $selected = $state['selected_membership'] ?? null; + + if (!is_array($selected)) { + $error = 'Der Anmeldekontext ist abgelaufen. Bitte erneut mit der E-Mail-Adresse starten.'; + } elseif ($password === '') { + $error = 'Bitte jetzt das Passwort eingeben.'; + } else { + $passwordHash = $selected['password_hash'] ?? null; + + if (!app_verify_password(is_string($passwordHash) ? $passwordHash : null, $password)) { + $error = 'Das Passwort konnte nicht bestaetigt werden oder fuer diesen Benutzer ist noch kein Passwort gesetzt.'; + } else { + app_finalize_login($selected); + app_flash('Anmeldung erfolgreich. Willkommen in ' . ($selected['tenant_name'] ?? 'deinem Bereich') . '.', 'success'); + app_clear_login_state(); + app_redirect('/dashboard'); + } + } + } + } + + return [ + 'state' => $state, + 'message' => $message, + 'error' => $error, + ]; +} + +function app_finalize_login(array $membership): void +{ + app_set_auth_user([ + 'user_id' => $membership['user_id'], + 'email' => $membership['user_email'], + 'display_name' => $membership['display_name'], + 'is_platform_admin' => (int) ($membership['is_platform_admin'] ?? 0) === 1, + 'tenant_id' => $membership['tenant_id'], + 'tenant_key' => $membership['tenant_key'], + 'tenant_name' => $membership['tenant_name'], + 'tenant_user_id' => $membership['tenant_user_id'], + 'member_id' => $membership['member_id'], + 'roles' => array_filter(array_map('trim', explode(',', (string) ($membership['role_keys'] ?? '')))), + ]); +} + +function app_tenant_overview(PDO $pdo): array +{ + $metrics = [ + ['label' => 'Aktive Tenants', 'value' => '0', 'detail' => 'Mandanten mit aktivem Produktbetrieb.'], + ['label' => 'Mitglieder gesamt', 'value' => '0', 'detail' => 'Aktive Mitgliedschaften ueber alle Tenants.'], + ['label' => 'SSO-Abdeckung', 'value' => '0', 'detail' => 'Aktive OIDC-Provider fuer zentrale oder tenantbezogene Anmeldungen.'], + ['label' => 'Betriebsstatus', 'value' => 'Live', 'detail' => 'Die zentrale Runtime laeuft direkt aus public/.'], + ]; + + $metricSql = <<<'SQL' +SELECT + COUNT(DISTINCT CASE WHEN t.status = 'active' THEN t.id END) AS active_tenants, + COUNT(DISTINCT m.id) AS member_count, + COUNT(DISTINCT CASE WHEN tip.is_enabled = 1 THEN tip.id END) AS provider_count +FROM tenants t +LEFT JOIN members m ON m.tenant_id = t.id AND m.status = 'active' +LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id +SQL; + + $metricRow = app_query_one($pdo, $metricSql) ?? []; + $metrics[0]['value'] = (string) ($metricRow['active_tenants'] ?? '0'); + $metrics[1]['value'] = (string) ($metricRow['member_count'] ?? '0'); + $metrics[2]['value'] = (string) ($metricRow['provider_count'] ?? '0'); + + $tenantSql = <<<'SQL' +SELECT + t.id, + t.tenant_key, + t.name, + t.status, + CONCAT(t.tenant_key, '.', REPLACE(:host, 'www.', '')) AS domain, + COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS member_count, + COUNT(DISTINCT CASE WHEN r.role_key LIKE '%admin%' THEN tu.id END) AS admin_count, + COUNT(DISTINCT CASE WHEN tip.is_enabled = 1 THEN tip.id END) AS oidc_provider_count +FROM tenants t +LEFT JOIN tenant_users tu ON tu.tenant_id = t.id AND tu.status = 'active' +LEFT JOIN members m ON m.tenant_id = t.id +LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id +LEFT JOIN roles r ON r.id = tur.role_id +LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id +GROUP BY t.id, t.tenant_key, t.name, t.status +ORDER BY t.name ASC +SQL; + + $host = (string) ($_SERVER['HTTP_HOST'] ?? 'kaffeeliste.local'); + $tenants = app_query_all($pdo, $tenantSql, ['host' => $host]); + + foreach ($tenants as &$tenant) { + $tenant['login_mode'] = ((int) $tenant['oidc_provider_count']) > 0 ? 'oidc-first' : 'password-fallback'; + $tenant['primary_contact'] = 'Tenant Admin'; + } + unset($tenant); + + $sharedAccess = []; + $sharedSql = <<<'SQL' +SELECT + base.email, + base.tenant_count, + GROUP_CONCAT(base.tenant_name ORDER BY base.tenant_name SEPARATOR ', ') AS tenant_names +FROM ( + SELECT DISTINCT + LOWER(COALESCE(m.email, u.email)) AS email, + t.name AS tenant_name + FROM users u + INNER JOIN tenant_users tu ON tu.user_id = u.id + INNER JOIN tenants t ON t.id = tu.tenant_id + LEFT JOIN members m ON m.tenant_user_id = tu.id + WHERE tu.status = 'active' +) base +INNER JOIN ( + SELECT + LOWER(COALESCE(m.email, u.email)) AS email, + COUNT(DISTINCT t.id) AS tenant_count + FROM users u + INNER JOIN tenant_users tu ON tu.user_id = u.id + INNER JOIN tenants t ON t.id = tu.tenant_id + LEFT JOIN members m ON m.tenant_user_id = tu.id + WHERE tu.status = 'active' + GROUP BY LOWER(COALESCE(m.email, u.email)) +) agg ON agg.email = base.email +GROUP BY base.email, agg.tenant_count +ORDER BY agg.tenant_count DESC, base.email ASC +LIMIT 6 +SQL; + + foreach (app_query_all($pdo, $sharedSql) as $row) { + $tenantCount = (int) ($row['tenant_count'] ?? 0); + $sharedAccess[] = [ + 'email' => $row['email'], + 'tenants' => $row['tenant_names'] !== null ? explode(', ', (string) $row['tenant_names']) : [], + 'next_step' => $tenantCount > 1 ? 'Tenant-Auswahl vor der finalen Weiterleitung anzeigen' : 'Direkt in den zugeordneten Tenant weiterleiten', + 'status' => $tenantCount > 1 ? 'mehrfach' : 'einzeln', + ]; + } + + return [ + 'metrics' => $metrics, + 'tenants' => $tenants, + 'shared_access' => $sharedAccess, + 'operations' => [ + [ + 'title' => 'Zentrale Anmeldung stabil halten', + 'detail' => 'Tenant-Erkennung, Mehrfachzuordnung und Passwort-/SSO-Pfade aus einer Hand pflegen.', + 'state' => 'Live', + ], + [ + 'title' => 'Legacy-Kernprozesse tenantweise nachziehen', + 'detail' => 'Dashboard, Mitglieder, Ledger und Zahlungen Schritt fuer Schritt produktiv weiter ausbauen.', + 'state' => 'Naechster Schritt', + ], + [ + 'title' => 'Marketing und Onboarding schaerfen', + 'detail' => 'Landingpage, Testzugang und Produktnutzen fuer neue Mieter klarer kommunizieren.', + 'state' => 'Laufend', + ], + ], + ]; +} + +function app_tenant_dashboard(PDO $pdo, string $tenantId): array +{ + $summarySql = <<<'SQL' +SELECT + COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS active_members, + COALESCE(SUM(CASE WHEN le.entry_type LIKE 'coffee%' THEN ABS(le.amount) ELSE 0 END), 0) AS coffee_volume, + COALESCE(SUM(CASE WHEN le.entry_type LIKE 'payment%' THEN le.amount ELSE 0 END), 0) AS payment_volume, + COALESCE(SUM(le.amount), 0) AS open_balance +FROM tenants t +LEFT JOIN members m ON m.tenant_id = t.id +LEFT JOIN ledger_entries le ON le.tenant_id = t.id +WHERE t.id = :tenant_id +GROUP BY t.id +SQL; + + $summary = app_query_one($pdo, $summarySql, ['tenant_id' => $tenantId]) ?? []; + + $recentSql = <<<'SQL' +SELECT + le.booked_at, + le.entry_type, + le.amount, + le.reference_type, + m.display_name AS member_name +FROM ledger_entries le +LEFT JOIN members m ON m.id = le.member_id +WHERE le.tenant_id = :tenant_id +ORDER BY le.booked_at DESC +LIMIT 10 +SQL; + + 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'), + 'recent_entries' => app_query_all($pdo, $recentSql, ['tenant_id' => $tenantId]), + ]; +} + +function app_member_summary(PDO $pdo, array $auth): array +{ + $tenantId = $auth['tenant_id']; + $memberId = $auth['member_id']; + + if ($memberId === null || $memberId === '') { + return [ + 'balance' => '0.00', + 'coffee_strokes_this_month' => '0', + 'payments_this_month' => '0', + 'latest_booking_at' => null, + 'recent_entries' => [], + ]; + } + + $summarySql = <<<'SQL' +SELECT + COALESCE(SUM(le.amount), 0) AS balance, + ( + SELECT COALESCE(SUM(ce.strokes), 0) + FROM coffee_entries ce + WHERE ce.member_id = :member_id + AND ce.tenant_id = :tenant_id + AND DATE_FORMAT(ce.booked_at, '%Y-%m') = DATE_FORMAT(CURRENT_DATE(), '%Y-%m') + ) AS coffee_strokes_this_month, + ( + SELECT COUNT(*) + FROM payment_entries pe + WHERE pe.member_id = :member_id + AND pe.tenant_id = :tenant_id + AND DATE_FORMAT(pe.booked_at, '%Y-%m') = DATE_FORMAT(CURRENT_DATE(), '%Y-%m') + ) AS payments_this_month, + MAX(le.booked_at) AS latest_booking_at +FROM ledger_entries le +WHERE le.member_id = :member_id + AND le.tenant_id = :tenant_id +SQL; + + $summary = app_query_one($pdo, $summarySql, [ + 'member_id' => $memberId, + 'tenant_id' => $tenantId, + ]) ?? []; + + $recentSql = <<<'SQL' +SELECT + le.booked_at, + le.entry_type, + le.amount, + le.reference_type +FROM ledger_entries le +WHERE le.member_id = :member_id + AND le.tenant_id = :tenant_id +ORDER BY le.booked_at DESC +LIMIT 8 +SQL; + + $summary['recent_entries'] = app_query_all($pdo, $recentSql, [ + 'member_id' => $memberId, + 'tenant_id' => $tenantId, + ]); + + return $summary; +} + +function app_members_for_tenant(PDO $pdo, string $tenantId): array +{ + $sql = <<<'SQL' +SELECT + m.id, + m.display_name, + m.email, + m.status, + u.display_name AS user_display_name, + GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS roles +FROM members m +LEFT JOIN tenant_users tu ON tu.id = m.tenant_user_id +LEFT JOIN users u ON u.id = tu.user_id +LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id +LEFT JOIN roles r ON r.id = tur.role_id +WHERE m.tenant_id = :tenant_id +GROUP BY m.id, m.display_name, m.email, m.status, u.display_name +ORDER BY m.display_name ASC +SQL; + + return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]); +} + +function app_ledger_for_tenant(PDO $pdo, string $tenantId): array +{ + $sql = <<<'SQL' +SELECT + le.booked_at, + le.entry_type, + le.amount, + le.reference_type, + m.display_name AS member_name +FROM ledger_entries le +LEFT JOIN members m ON m.id = le.member_id +WHERE le.tenant_id = :tenant_id +ORDER BY le.booked_at DESC +LIMIT 25 +SQL; + + return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]); +} + +function app_payments_for_tenant(PDO $pdo, string $tenantId): array +{ + $sql = <<<'SQL' +SELECT + pe.booked_at, + pe.amount, + pe.payment_method, + m.display_name AS member_name +FROM payment_entries pe +LEFT JOIN members m ON m.id = pe.member_id +WHERE pe.tenant_id = :tenant_id +ORDER BY pe.booked_at DESC +LIMIT 25 +SQL; + + return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]); +} + +function app_content_for_tenant(PDO $pdo, string $tenantId): array +{ + $announcements = app_query_all( + $pdo, + 'SELECT title, message, visible_until FROM announcements WHERE tenant_id = :tenant_id AND is_active = 1 ORDER BY created_at DESC LIMIT 8', + ['tenant_id' => $tenantId] + ); + + $faq = app_query_all( + $pdo, + 'SELECT question, answer FROM faq_items WHERE tenant_id = :tenant_id AND is_active = 1 ORDER BY sort_order ASC, created_at DESC LIMIT 8', + ['tenant_id' => $tenantId] + ); + + return [ + 'announcements' => $announcements, + 'faq' => $faq, + ]; +} + +function app_member_exists(PDO $pdo, string $tenantId, string $memberId): ?array +{ + return app_query_one( + $pdo, + 'SELECT id, display_name, email FROM members WHERE tenant_id = :tenant_id AND id = :member_id LIMIT 1', + ['tenant_id' => $tenantId, 'member_id' => $memberId] + ); +} + +function app_handle_tenant_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + $action = (string) ($_POST['action'] ?? ''); + $tenantId = (string) ($auth['tenant_id'] ?? ''); + + if (!in_array($action, ['record-coffee', 'record-payment'], true) || $tenantId === '') { + return; + } + + if (!app_can_manage_tenant($auth)) { + app_flash('Fuer diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning'); + app_redirect('/dashboard'); + } + + $memberId = trim((string) ($_POST['member_id'] ?? '')); + $member = app_member_exists($pdo, $tenantId, $memberId); + + if ($member === null) { + app_flash('Das ausgewaehlte Mitglied konnte in diesem Tenant nicht gefunden werden.', 'error'); + app_redirect(app_request_path()); + } + + $bookedAt = trim((string) ($_POST['booked_at'] ?? '')); + $bookedAt = $bookedAt !== '' ? str_replace('T', ' ', $bookedAt) . ':00' : date('Y-m-d H:i:s'); + $now = date('Y-m-d H:i:s'); + + try { + $pdo->beginTransaction(); + + if ($action === 'record-coffee') { + $strokes = max(0, (int) ($_POST['strokes'] ?? 0)); + $unitPrice = (float) ($_POST['unit_price'] ?? 0); + + if ($strokes < 1 || $unitPrice <= 0) { + throw new RuntimeException('Bitte mindestens einen Strich und einen gueltigen Preis angeben.'); + } + + $entryId = app_uuid(); + $ledgerId = app_uuid(); + $totalCost = round($strokes * $unitPrice, 2); + $source = trim((string) ($_POST['booking_source'] ?? 'manual')); + $source = $source !== '' ? $source : 'manual'; + + app_execute( + $pdo, + 'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)', + [ + 'id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'strokes' => $strokes, + 'unit_price' => number_format($unitPrice, 2, '.', ''), + 'total_cost' => number_format($totalCost, 2, '.', ''), + 'booking_source' => $source, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + 'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)', + [ + 'id' => $ledgerId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'entry_type' => 'coffee_charge', + 'amount' => number_format($totalCost * -1, 2, '.', ''), + 'reference_type' => 'coffee_entry', + 'reference_id' => $entryId, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + $pdo->commit(); + app_flash($strokes . ' Strich(e) fuer ' . $member['display_name'] . ' wurden eingetragen.', 'success'); + app_redirect('/ledger'); + } + + if ($action === 'record-payment') { + $amount = (float) ($_POST['amount'] ?? 0); + $method = trim((string) ($_POST['payment_method'] ?? 'manual')); + $method = $method !== '' ? $method : 'manual'; + + if ($amount <= 0) { + throw new RuntimeException('Bitte einen gueltigen Einzahlungsbetrag angeben.'); + } + + $entryId = app_uuid(); + $ledgerId = app_uuid(); + + app_execute( + $pdo, + 'INSERT INTO payment_entries (id, tenant_id, member_id, amount, payment_method, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :amount, :payment_method, :booked_at, :created_at, :updated_at)', + [ + 'id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'amount' => number_format($amount, 2, '.', ''), + 'payment_method' => $method, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + 'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)', + [ + 'id' => $ledgerId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'entry_type' => 'payment_credit', + 'amount' => number_format($amount, 2, '.', ''), + 'reference_type' => 'payment_entry', + 'reference_id' => $entryId, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + $pdo->commit(); + app_flash('Die Einzahlung fuer ' . $member['display_name'] . ' wurde gebucht.', 'success'); + app_redirect('/payments'); + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + app_flash($exception->getMessage(), 'error'); + app_redirect(app_request_path()); + } +} + +function app_marketing_messages(): array +{ + return [ + 'hero' => 'Die tenantfaehige Kaffeeliste fuer Teams, Standorte und geteilte Services.', + 'subhero' => 'Zentrale Anmeldung, transparente Kontostaende, Einzahlungen, Striche und Hinweise in einer modernen, webspace-tauglichen SaaS-Oberflaeche.', + 'benefits' => [ + 'Ein Login fuer alle Mitgliedschaften statt verstreuter Tenant-URLs.', + 'Klare Admin-Console fuer Mandanten, Domains und Mehrfachzuordnungen.', + 'Kernprozesse aus der alten Kaffeeliste tenantfaehig und zentral steuerbar.', + ], + ]; +} + +function app_h(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES); +} diff --git a/saas-app/public/content/index.php b/saas-app/public/content/index.php new file mode 100644 index 0000000..ff3933b --- /dev/null +++ b/saas-app/public/content/index.php @@ -0,0 +1,7 @@ + 'Dashboard', - 'copy' => 'Kontostand, Monatsverbrauch, letzte Buchungen und Self-Service fuer Kaffee-Striche.', - 'tone' => 'core', - ], - [ - 'title' => 'Members', - 'copy' => 'Mitglieder, Rollen, Aktivstatus und Identitaeten pro Mandant.', - 'tone' => 'core', - ], - [ - 'title' => 'Ledger', - 'copy' => 'Kaffee-Striche, Einzahlungen und Korrekturen in einer nachvollziehbaren Sicht.', - 'tone' => 'core', - ], - [ - 'title' => 'Payments', - 'copy' => 'Sammelerfassung, PayPal-Pfade und spaetere Zahlungsreferenzen.', - 'tone' => 'core', - ], - [ - 'title' => 'Content', - 'copy' => 'Hinweise, FAQ und tenantbezogene Inhalte statt Root-Einzeldateien.', - 'tone' => 'ops', - ], - [ - 'title' => 'Operations', - 'copy' => 'Importe, Exporte und Benachrichtigungen als saubere Backoffice-Module.', - 'tone' => 'ops', - ], -]; +require_once __DIR__ . '/app-support.php'; -$installSteps = [ - '1. Den Webserver auf `saas-app/public/` zeigen lassen.', - '2. Den Installer unter `/install/` oeffnen.', - '3. `.env`, DB-, Mail-, Tenancy- und OIDC-Werte im Formular speichern.', - '4. SQL-Bundle erzeugen und optional Migrationen direkt im Installer ausfuehren.', - '5. Den Installer danach sperren.', - '6. Ersten Tenant, ersten Benutzer und erste Member-Zuordnung anlegen.', -]; - -$migrationSteps = [ - 'Die Dateien unter `saas-app/database/migrations/*.php` sind keine Laravel-Migrationsklassen, sondern SQL-Skizzen.', - 'Das Bundle wird ueber `php scripts/build-migration-bundle.php` erzeugt.', - 'Empfohlener Weg: `php scripts/install-saas.php`.', - 'Direkte MySQL/MariaDB-Ausfuehrung: `php scripts/run-sql-migrations.php --connection=mysql --server= --database= --username= --password=`.', - 'Ohne `pdo_mysql` die erzeugte SQL-Datei manuell im Datenbank-Tool des Hosters importieren.', -]; - -function renderList(array $items): void +function h(string $value): string { - echo '
    '; + return app_h($value); +} - foreach ($items as $item) { - echo '
  • ' . htmlspecialchars($item, ENT_QUOTES) . '
  • '; +function money(string|int|float|null $value): string +{ + return number_format((float) $value, 2, ',', '.') . ' EUR'; +} + +function num(string|int|float|null $value): string +{ + return number_format((float) $value, 0, ',', '.'); +} + +function dt(?string $value): string +{ + if ($value === null || trim($value) === '') { + return '-'; } - echo '
'; + $time = strtotime($value); + + return $time === false ? $value : date('d.m.Y H:i', $time); } +function badge(string $label, string $tone = 'neutral'): string +{ + return '' . h($label) . ''; +} + +$requestedPage = $_GET['page'] ?? null; + +if ($requestedPage === null) { + $path = rtrim(app_request_path(), '/'); + $requestedPage = match ($path) { + '', '/', '/index.php' => 'home', + '/login' => 'login', + '/tenants', '/admin/tenants' => 'tenants', + '/dashboard' => 'dashboard', + '/members' => 'members', + '/ledger' => 'ledger', + '/payments' => 'payments', + '/content' => 'content', + '/logout' => 'logout', + default => 'home', + }; +} + +$page = (string) $requestedPage; +$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content']; + +if ($page === 'logout' && $requestMethod === 'POST') { + app_logout(); + app_flash('Du wurdest erfolgreich abgemeldet.', 'success'); + app_redirect('/'); +} + +$marketing = app_marketing_messages(); +$flash = app_flash(); +$auth = app_auth_user(); +$pdo = null; +$dbError = null; +$tenantOverview = null; +$tenantDashboard = null; +$members = []; +$ledger = []; +$payments = []; +$content = ['announcements' => [], 'faq' => []]; +$memberSummary = null; +$loginFlow = ['state' => app_login_state(), 'message' => null, 'error' => null]; + +try { + $pdo = app_pdo(); + $tenantOverview = app_tenant_overview($pdo); +} catch (Throwable $exception) { + $dbError = $exception->getMessage(); +} + +if (in_array($page, $tenantPages, true)) { + $auth = app_require_auth(); +} + +if ($page === 'login' && $pdo instanceof PDO) { + try { + $loginFlow = app_handle_login($pdo); + $auth = app_auth_user(); + } catch (Throwable $exception) { + $loginFlow['error'] = $exception->getMessage(); + } +} elseif ($page === 'login' && $dbError !== null) { + $loginFlow['error'] = $dbError; +} + +if ($auth !== null && $pdo instanceof PDO) { + if (in_array($page, ['dashboard', 'ledger', 'payments'], true)) { + app_handle_tenant_action($pdo, $auth); + } + + try { + $tenantDashboard = app_tenant_dashboard($pdo, (string) $auth['tenant_id']); + $members = app_members_for_tenant($pdo, (string) $auth['tenant_id']); + $memberSummary = app_member_summary($pdo, $auth); + + if (in_array($page, ['dashboard', 'ledger'], true)) { + $ledger = app_ledger_for_tenant($pdo, (string) $auth['tenant_id']); + } + + if (in_array($page, ['dashboard', 'payments'], true)) { + $payments = app_payments_for_tenant($pdo, (string) $auth['tenant_id']); + } + + if ($page === 'content') { + $content = app_content_for_tenant($pdo, (string) $auth['tenant_id']); + } + } catch (Throwable $exception) { + $dbError = $exception->getMessage(); + } +} + +$loginState = $loginFlow['state'] ?? []; +$loginStep = (string) ($loginState['step'] ?? 'discover'); +$selectedMembership = $loginState['selected_membership'] ?? null; +$restrictedPages = ['members', 'ledger', 'payments']; + +if ($auth !== null && in_array($page, $restrictedPages, true) && !app_can_manage_tenant($auth)) { + app_flash('Dieser Bereich ist nur fuer Tenant-Admins verfuegbar.', 'warning'); + app_redirect('/dashboard'); +} + +$canManageTenant = app_can_manage_tenant($auth); + ?> - Kaffeeliste SaaS Preview + Kaffeeliste SaaS
- + + - -
-
Installation
-

Die Installationsschritte laufen jetzt ueber interne Preview-Seiten.

-

- Der bevorzugte Webspace-Weg laeuft jetzt ueber den gefuehrten Installer - unter /install/. Das ist noetig, weil Dateien ausserhalb von - saas-app/public/ im Browser nicht direkt verlinkt werden - sollten und Shell-Zugriff auf vielen Hostings fehlt. -

-
- Installer starten - Zur Startseite + +
+
+ + ist angemeldet als +
- -
- -
-
Migrationen
-

Die Migrationen werden ueber ein SQL-Bundle erzeugt und ausgefuehrt.

-

- Aktuell gibt es hier keine lauffaehige Laravel-artisan migrate-Strecke. - Die Migrationsdateien liefern SQL und werden deshalb im Installer oder - ueber PHP-Skripte zu einer ausfuehrbaren Datei zusammengefuehrt. -

- - -
- Ergebnisdatei: saas-app/database/migrations/generated/all-migrations.sql -
-
- -
-
Kaffeeliste SaaS Preview
-

Der Root ist jetzt SaaS-first aufgebaut.

-

- Diese Preview-Seite markiert den neuen Document-Root unter - saas-app/public/. Der Legacy-Bestand wurde nach - legacy-app/ verschoben, die Zielarchitektur und die neu - gestalteten Produktseiten liegen unter saas-app/. -

- - -
- Mandantenfaehig - Webspace-tauglich - Zentrales Login - Legacy archiviert -
-
- Die eigentliche Runtime bleibt der naechste Schritt nach Composer-Bootstrap. - Layout, Module, Dokumentation und Hosting-Zielbild sind bereits auf die - SaaS-Zielstruktur umgestellt. -
-
- -
- -
-

-

-
-
-
-
-

Verfuegbare Skripte

-
    -
  • scripts/check-prerequisites.php
  • -
  • scripts/prepare-saas-env.php
  • -
  • scripts/install-saas.php
  • -
  • scripts/build-migration-bundle.php
  • -
  • scripts/run-sql-migrations.php
  • -
  • public/install/index.php
  • -
-
-
-

Technischer Stand

-
    -
  • Composer ist fuer den finalen Bootstrap weiterhin erforderlich.
  • -
  • Die Migrationen sind aktuell SQL-basiert, nicht artisan-basiert.
  • -
  • Fuer automatische Ausfuehrung auf MySQL/MariaDB wird die PHP-Erweiterung pdo_mysql benoetigt.
  • -
  • Der bevorzugte Hosting-Pfad ist jetzt /install/ als gefuehrter Einmal-Installer.
  • -
-
-
+ +
+ - + +
+
Marketing und Endanwender
+

+

+ +
    + +
  • + +
+
+ + +
+ +
+ +

+

+
+ +
+ + +
+

Fuer Mitglieder

Zentral mit E-Mail starten, Tenant erkennen lassen und direkt den eigenen Kontostand oder die letzten Buchungen sehen.

+

Fuer Tenant-Admins

Mitglieder, Striche, Einzahlungen und Hinweise bleiben tenantweise sauber getrennt und professionell bedienbar.

+

Fuer Betreiber

Das Produkt wirkt vermietbar, weil Portfolio, Routing und Betrieb des Gesamtangebots an einer Stelle sichtbar werden.

+
+ + +
Die Datenbank konnte noch nicht gelesen werden:
+ + +
+
Zentrale Anmeldung
+

Mitglieder starten jetzt wirklich E-Mail first.

+

Erst Tenant finden, dann bei Bedarf zwischen mehreren Zugehoerigkeiten waehlen und erst danach das Passwort eingeben.

+
+ + + +
+
+ + +
+ + +
+ + +
+
+ +
Schritt 2
+

Passenden Tenant waehlen

+
+ +
+ + +

+

+

+
+
+ +
+ +
Schritt 2
+

Passwort eingeben

+

Der Tenant wurde erkannt:

+
+ + +
+ + Neu starten +
+
+ +
Schritt 1
+

E-Mail eingeben

+
+ + +
+
+ +
+
+
Hilfe beim Einstieg
+

Wenn die Anmeldung nicht sofort klappt

+
    +
  • Pruefe zuerst, ob du die richtige berufliche E-Mail-Adresse verwendest.
  • +
  • Wenn du in mehreren Bereichen arbeitest, wirst du automatisch zur passenden Auswahl gefuehrt.
  • +
  • Falls kein Zugang gefunden wird, brauchst du meist nur eine Einladung oder den Kontakt zum verantwortlichen Betreiber.
  • +
+
+
+ +
+
Betreiber-Sicht
+

Die Tenant-Console zeigt Portfolio, Login-Wege und Mehrfachzugriffe in einer professionellen Uebersicht.

+

Die Landingpage verkauft das Produkt, die Anmeldung dient Mitgliedern und diese Sicht bleibt die zentrale Betreiber-Ebene.

+
+ + +
+ +
+ +

+

+
+ +
+ +
+
+ + + + + + + + + + + + + + +
TenantDomainMitgliederAdminsLoginStatus

+
+
+ +
+ + +
+
Tenant-Dashboard
+

auf einen Blick

+

Kontostand, Striche, Einzahlungen und die letzten Buchungen sind damit direkt tenantweise verfuegbar.

+
+ +
+

Aktive Mitglieder

Aktive Mitgliedschaften im Tenant.

+

Kaffeeverbrauch

Summe der Kaffee-Buchungen.

+

Einzahlungen

Summe der Zahlungsbuchungen.

+

Mein Kontostand

Saldo fuer den aktuell angemeldeten Nutzer.

+
+ +
+ +
+
Legacy-Kernfunktion
+

Striche verbuchen

+
+ + + + + + +
+
+
+
+
Legacy-Kernfunktion
+

Einzahlung erfassen

+
+ + + + + +
+
+
+ +
+
Mitgliedersicht
+

Dein Bereich

+
    +
  • Hier siehst du deinen Kontostand, letzte Buchungen und aktuelle Hinweise.
  • +
  • Tenant-weite Buchungen und Mitgliedsverwaltung bleiben bei den verantwortlichen Admins.
  • +
  • Wenn dir etwas fehlt, wende dich an den Betreiber deines Bereichs.
  • +
+
+ + +
+ +
+

Letzte Buchungen

+
+ + + +
ZeitMitgliedTypReferenzBetrag
+
+
+ +
Mitgliederverwaltung

Mitglieder, Rollen und Aktivstatus

Die Tenant-Variante der alten Mitarbeiterverwaltung mit Rollen und Benutzerbezug.

+
NameE-MailStatusBenutzerRollen
+ +
Ledger

Striche und Buchungen

Kaffeeeintraege und Folgebuchungen bleiben tenantweise nachvollziehbar.

+
+
+

Neuen Strich-Eintrag buchen

+
+ + + + + +
+
+
+

Abgedeckte Alt-Funktionen

  • Sammel- und Self-Service-Striche sind in einer Logik zusammengefuehrt.
  • Jeder Verbrauch erzeugt direkt auch den Ledger-Eintrag.
  • Die letzten Buchungen sind tenantweise sichtbar.
+
+
ZeitMitgliedTypReferenzBetrag
+ +
Einzahlungen

Zahlungen tenantweit verwalten

Einzahlungen werden direkt in Zahlungstabelle und Ledger geschrieben.

+
+
+

Einzahlung buchen

+
+ + + + + +
+
+
+

Wofuer dieser Bereich da ist

  • Manuelle Einzahlungen sind direkt erfasst.
  • PayPal oder Bank koennen getrennt ausgewiesen werden.
  • Jede Zahlung erscheint sofort im Ledger.
+
+
ZeitMitgliedMethodeBetrag
+ +
Hinweise und FAQ

Tenant-Inhalte zentral aus der Datenbank

Hinweise und FAQ aus dem alten Programm sind jetzt als tenantbezogene Inhalte vorbereitet.

+
+

Hinweise

Aktuell keine Hinweise vorhanden.

Sichtbar bis

+

FAQ

Aktuell keine FAQ vorhanden.

+
+ +
Die angeforderte Seite konnte nicht gefunden werden.
+ + +
diff --git a/saas-app/public/ledger/index.php b/saas-app/public/ledger/index.php new file mode 100644 index 0000000..46e8416 --- /dev/null +++ b/saas-app/public/ledger/index.php @@ -0,0 +1,7 @@ +