tenant Einrichtung

This commit is contained in:
2026-03-21 23:27:49 +01:00
parent 43bbfe85d8
commit 550518760c
10 changed files with 1333 additions and 367 deletions
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'tenants';
require dirname(__DIR__, 2) . '/index.php';
+851
View File
@@ -0,0 +1,851 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/install-support.php';
function app_env(): array
{
static $env;
if ($env === null) {
$env = scripts_read_env_file(scripts_env_path());
if ($env === []) {
$env = scripts_read_env_file(scripts_env_example_path());
}
}
return $env;
}
function app_connection(): string
{
return scripts_normalize_db_connection(app_env()['DB_CONNECTION'] ?? 'mysql');
}
function app_uses_mysql(): bool
{
return in_array(app_connection(), ['mysql', 'mariadb'], true);
}
function app_request_path(): string
{
$path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
if (!is_string($path) || $path === '') {
return '/';
}
$scriptName = (string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php');
$baseDir = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
if ($baseDir !== '' && $baseDir !== '.' && $baseDir !== '/' && str_starts_with($path, $baseDir)) {
$path = substr($path, strlen($baseDir)) ?: '/';
}
return $path === '' ? '/' : $path;
}
function app_current_url(string $path = '/'): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
return $scheme . '://' . $host . $path;
}
function app_pdo(): PDO
{
static $pdo;
if ($pdo instanceof PDO) {
return $pdo;
}
if (!app_uses_mysql()) {
throw new RuntimeException('Diese Public-Runtime ist aktuell fuer MySQL/MariaDB konzipiert.');
}
if (!extension_loaded('pdo_mysql')) {
throw new RuntimeException('Die PHP-Erweiterung pdo_mysql ist nicht geladen.');
}
$env = app_env();
$host = $env['DB_HOST'] ?? '127.0.0.1';
$port = $env['DB_PORT'] ?? '3306';
$database = $env['DB_DATABASE'] ?? '';
$username = $env['DB_USERNAME'] ?? '';
$password = $env['DB_PASSWORD'] ?? '';
if ($database === '' || $username === '') {
throw new RuntimeException('Die Datenbankkonfiguration in saas-app/.env ist unvollstaendig.');
}
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => 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);
}
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'content';
require dirname(__DIR__) . '/index.php';
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'dashboard';
require dirname(__DIR__) . '/index.php';
+426 -367
View File
@@ -2,398 +2,457 @@
declare(strict_types=1);
$page = $_GET['page'] ?? 'home';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$modules = [
[
'title' => '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=<server> --database=<db> --username=<user> --password=<pass>`.',
'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 '<ul class="list">';
return app_h($value);
}
foreach ($items as $item) {
echo '<li>' . htmlspecialchars($item, ENT_QUOTES) . '</li>';
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 '</ul>';
$time = strtotime($value);
return $time === false ? $value : date('d.m.Y H:i', $time);
}
function badge(string $label, string $tone = 'neutral'): string
{
return '<span class="badge badge-' . h($tone) . '">' . h($label) . '</span>';
}
$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);
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kaffeeliste SaaS Preview</title>
<title>Kaffeeliste SaaS</title>
<style>
:root {
--bg: #f7f1e8;
--ink: #24170f;
--muted: #6a5649;
--brand: #0f766e;
--accent: #b45309;
--card: rgba(255, 252, 247, 0.9);
--line: rgba(36, 23, 15, 0.12);
--shadow: 0 24px 60px rgba(61, 38, 24, 0.12);
--radius: 28px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Aptos", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(180, 83, 9, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.14), transparent 24%),
linear-gradient(180deg, #fbf7f0 0%, var(--bg) 100%);
}
.shell {
width: min(1180px, calc(100vw - 32px));
margin: 24px auto 40px;
}
.nav,
.hero,
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--card);
box-shadow: var(--shadow);
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
margin-bottom: 18px;
}
.nav-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.nav a,
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 11px 16px;
text-decoration: none;
font-weight: 700;
}
.nav a {
color: var(--brand);
background: rgba(15, 118, 110, 0.08);
}
.hero {
padding: 34px;
background:
linear-gradient(135deg, rgba(255, 252, 247, 0.96), rgba(255, 252, 247, 0.82)),
radial-gradient(circle at bottom left, rgba(15, 118, 110, 0.12), transparent 26%);
}
.eyebrow {
display: inline-block;
margin-bottom: 14px;
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1, h2 {
margin: 0 0 14px;
font-family: Georgia, serif;
line-height: 1.04;
}
h1 { font-size: clamp(2.1rem, 4vw, 3.8rem); }
h2 { font-size: 1.35rem; }
p {
margin: 0;
color: var(--muted);
line-height: 1.65;
}
code {
padding: 2px 6px;
border-radius: 8px;
background: rgba(15, 118, 110, 0.08);
color: var(--brand);
font-family: Consolas, monospace;
}
.actions,
.meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 22px;
}
.button {
background: linear-gradient(135deg, var(--brand), #115e59);
color: #fff;
}
.button.secondary {
background: transparent;
border: 1px solid rgba(15, 118, 110, 0.2);
color: var(--brand);
}
.pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 9px 14px;
background: rgba(15, 118, 110, 0.08);
color: var(--brand);
font-size: 0.9rem;
font-weight: 700;
}
.grid {
display: grid;
gap: 18px;
margin-top: 22px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-2 {
display: grid;
gap: 18px;
margin-top: 22px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
padding: 22px;
}
.tone-core {
border-top: 5px solid rgba(15, 118, 110, 0.72);
}
.tone-ops {
border-top: 5px solid rgba(180, 83, 9, 0.72);
}
.note {
margin-top: 22px;
padding: 18px 20px;
border-radius: 20px;
background: rgba(15, 118, 110, 0.08);
border: 1px solid rgba(15, 118, 110, 0.16);
}
.list {
margin: 18px 0 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.7;
}
.list li + li {
margin-top: 10px;
}
.footer {
margin-top: 18px;
text-align: center;
color: var(--muted);
font-size: 0.92rem;
}
@media (max-width: 900px) {
.grid,
.grid-2 {
grid-template-columns: 1fr;
}
.hero {
padding: 24px;
}
.nav {
align-items: flex-start;
flex-direction: column;
}
}
:root{--bg:#f5efe6;--card:#fffaf3;--ink:#24160e;--muted:#675546;--brand:#0c6b66;--accent:#a34b12;--line:rgba(36,22,14,.12);--radius:24px;--shadow:0 24px 54px rgba(57,35,22,.11)}
*{box-sizing:border-box}body{margin:0;font-family:"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%)}a{color:inherit}.shell{width:min(1180px,calc(100vw - 32px));margin:24px auto 40px}.bar,.hero,.card,.alert{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow)}.bar,.links,.actions,.context{display:flex;flex-wrap:wrap;gap:10px}.bar{justify-content:space-between;align-items:center;padding:18px 22px;margin-bottom:18px}.brand strong,h1,h2,h3{font-family:Georgia,serif}.brand strong{font-size:1.28rem}.brand span,p,.muted{color:var(--muted)}.hero,.card{padding:24px}.hero{margin-bottom:18px}.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))}.links a,.button,button{display:inline-flex;align-items:center;justify-content:center;padding:11px 16px;border-radius:999px;text-decoration:none;font-weight:700;border:0;cursor:pointer}.links a{background:rgba(12,107,102,.08);color:var(--brand)}.links a.active{background:linear-gradient(135deg,var(--brand),#084d49);color:#fff}.button,button{background:linear-gradient(135deg,var(--brand),#084d49);color:#fff}.button.secondary{background:transparent;color:var(--brand);border:1px solid rgba(12,107,102,.18)}.eyebrow{display:inline-block;margin-bottom:12px;color:var(--accent);font-size:.82rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{font-size:clamp(2.1rem,4vw,3.8rem);margin:0 0 12px}h2{font-size:1.4rem;margin:0 0 12px}h3{font-size:1.05rem;margin:0 0 10px}p{margin:0;line-height:1.65}.stack{display:grid;gap:12px}.metric{padding:18px;border:1px solid var(--line);border-radius:20px;background:#fffdf9}.metric strong{display:block;font-size:1.8rem;margin-bottom:8px}.list{margin:14px 0 0;padding-left:18px;color:var(--muted);line-height:1.7}.alert{padding:16px 18px;margin-bottom:18px}.alert-success{background:rgba(17,98,61,.08)}.alert-warning{background:rgba(163,75,18,.08)}.alert-error{background:rgba(154,31,31,.08)}.alert-info{background:rgba(12,107,102,.08)}.badge{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;font-size:.86rem;font-weight:700}.badge-neutral{background:rgba(12,107,102,.1);color:var(--brand)}.badge-success{background:rgba(17,98,61,.12);color:#11623d}.badge-warning{background:rgba(163,75,18,.12);color:#98510c}.table{overflow-x:auto}.table table{width:100%;border-collapse:collapse;min-width:720px}.table th,.table td{padding:13px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}.table th{font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}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:16px;border:1px solid rgba(36,22,14,.15);font:inherit;background:#fffdfa;color:var(--ink)}textarea{min-height:120px}.footer{margin-top:18px;text-align:center;color:var(--muted);font-size:.92rem}@media(max-width:960px){.grid-2,.grid-3,.grid-4,form.grid{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}.table table{min-width:0}}
</style>
</head>
<body>
<main class="shell">
<nav class="nav" aria-label="Preview Navigation">
<strong>Kaffeeliste SaaS Preview</strong>
<div class="nav-links">
<a href="?page=home">Start</a>
<a href="/login">Anmeldung</a>
<a href="/tenants">Tenant Console</a>
<a href="?page=install">Installation</a>
<a href="?page=migrations">Migrationen</a>
<a href="install/">Installer</a>
<header class="bar">
<div class="brand">
<strong>Kaffeeliste SaaS</strong>
<span>Zentrale Anmeldung, Tenant-Console und tenantweise Kaffeelisten-Funktionen.</span>
</div>
</nav>
<nav class="links" aria-label="Navigation">
<a href="/" class="<?= $page === 'home' ? 'active' : '' ?>">Start</a>
<a href="/login" class="<?= $page === 'login' ? 'active' : '' ?>">Anmeldung</a>
<a href="/tenants" class="<?= $page === 'tenants' ? 'active' : '' ?>"><?= $auth === null ? 'Fuer Betreiber' : 'Betreiber-Uebersicht' ?></a>
<?php if ($auth !== null): ?>
<a href="/dashboard" class="<?= $page === 'dashboard' ? 'active' : '' ?>">Dashboard</a>
<a href="/content" class="<?= $page === 'content' ? 'active' : '' ?>">Hinweise</a>
<?php if ($canManageTenant): ?>
<a href="/members" class="<?= $page === 'members' ? 'active' : '' ?>">Mitglieder</a>
<a href="/ledger" class="<?= $page === 'ledger' ? 'active' : '' ?>">Buchungen</a>
<a href="/payments" class="<?= $page === 'payments' ? 'active' : '' ?>">Einzahlungen</a>
<?php endif; ?>
<form method="post" action="/logout"><button type="submit" class="button secondary">Abmelden</button></form>
<?php endif; ?>
</nav>
</header>
<?php if ($page === 'install'): ?>
<section class="hero">
<div class="eyebrow">Installation</div>
<h1>Die Installationsschritte laufen jetzt ueber interne Preview-Seiten.</h1>
<p>
Der bevorzugte Webspace-Weg laeuft jetzt ueber den gefuehrten Installer
unter <code>/install/</code>. Das ist noetig, weil Dateien ausserhalb von
<code>saas-app/public/</code> im Browser nicht direkt verlinkt werden
sollten und Shell-Zugriff auf vielen Hostings fehlt.
</p>
<div class="actions">
<a class="button" href="install/">Installer starten</a>
<a class="button secondary" href="?page=home">Zur Startseite</a>
<?php if ($auth !== null): ?>
<section class="card">
<div class="context">
<?= badge((string) $auth['tenant_name']) ?>
<span class="muted"><?= h((string) $auth['display_name']) ?> ist angemeldet als <?= h((string) $auth['email']) ?></span>
<?= app_is_platform_admin($auth) ? badge('Platform Admin', 'success') : (app_is_tenant_admin($auth) ? badge('Tenant Admin', 'success') : badge('Mitglied')) ?>
</div>
<?php renderList($installSteps); ?>
</section>
<?php elseif ($page === 'migrations'): ?>
<section class="hero">
<div class="eyebrow">Migrationen</div>
<h1>Die Migrationen werden ueber ein SQL-Bundle erzeugt und ausgefuehrt.</h1>
<p>
Aktuell gibt es hier keine lauffaehige Laravel-<code>artisan migrate</code>-Strecke.
Die Migrationsdateien liefern SQL und werden deshalb im Installer oder
ueber PHP-Skripte zu einer ausfuehrbaren Datei zusammengefuehrt.
</p>
<div class="actions">
<a class="button" href="install/">Installer starten</a>
<a class="button secondary" href="?page=home">Zur Startseite</a>
</div>
<?php renderList($migrationSteps); ?>
<div class="note">
Ergebnisdatei: <code>saas-app/database/migrations/generated/all-migrations.sql</code>
</div>
</section>
<?php else: ?>
<section class="hero">
<div class="eyebrow">Kaffeeliste SaaS Preview</div>
<h1>Der Root ist jetzt SaaS-first aufgebaut.</h1>
<p>
Diese Preview-Seite markiert den neuen Document-Root unter
<code>saas-app/public/</code>. Der Legacy-Bestand wurde nach
<code>legacy-app/</code> verschoben, die Zielarchitektur und die neu
gestalteten Produktseiten liegen unter <code>saas-app/</code>.
</p>
<div class="actions">
<a class="button" href="/login">Zur Anmeldung</a>
<a class="button secondary" href="/tenants">Tenant Console</a>
</div>
<div class="actions">
<a class="button" href="install/">Installer starten</a>
<a class="button secondary" href="?page=migrations">Migrationen ansehen</a>
</div>
<div class="meta">
<span class="pill">Mandantenfaehig</span>
<span class="pill">Webspace-tauglich</span>
<span class="pill">Zentrales Login</span>
<span class="pill">Legacy archiviert</span>
</div>
<div class="note">
Die eigentliche Runtime bleibt der naechste Schritt nach Composer-Bootstrap.
Layout, Module, Dokumentation und Hosting-Zielbild sind bereits auf die
SaaS-Zielstruktur umgestellt.
</div>
</section>
<section class="grid">
<?php foreach ($modules as $module): ?>
<article class="card tone-<?= htmlspecialchars($module['tone'], ENT_QUOTES) ?>">
<h2><?= htmlspecialchars($module['title'], ENT_QUOTES) ?></h2>
<p><?= htmlspecialchars($module['copy'], ENT_QUOTES) ?></p>
</article>
<?php endforeach; ?>
</section>
<?php endif; ?>
<section class="grid-2">
<article class="card">
<h2>Verfuegbare Skripte</h2>
<ul class="list">
<li><code>scripts/check-prerequisites.php</code></li>
<li><code>scripts/prepare-saas-env.php</code></li>
<li><code>scripts/install-saas.php</code></li>
<li><code>scripts/build-migration-bundle.php</code></li>
<li><code>scripts/run-sql-migrations.php</code></li>
<li><code>public/install/index.php</code></li>
</ul>
</article>
<article class="card">
<h2>Technischer Stand</h2>
<ul class="list">
<li>Composer ist fuer den finalen Bootstrap weiterhin erforderlich.</li>
<li>Die Migrationen sind aktuell SQL-basiert, nicht <code>artisan</code>-basiert.</li>
<li>Fuer automatische Ausfuehrung auf MySQL/MariaDB wird die PHP-Erweiterung <code>pdo_mysql</code> benoetigt.</li>
<li>Der bevorzugte Hosting-Pfad ist jetzt <code>/install/</code> als gefuehrter Einmal-Installer.</li>
</ul>
</article>
</section>
<?php if ($flash !== null): ?>
<section class="alert alert-<?= h((string) ($flash['type'] ?? 'info')) ?>"><?= h((string) ($flash['message'] ?? '')) ?></section>
<?php endif; ?>
<p class="footer">Kaffeeliste SaaS Preview - Einstieg fuer Hosting, Installation und weitere Implementierung</p>
<?php if ($page === 'home'): ?>
<section class="hero">
<div class="eyebrow">Marketing und Endanwender</div>
<h1><?= h((string) $marketing['hero']) ?></h1>
<p><?= h((string) $marketing['subhero']) ?></p>
<div class="actions" style="margin-top:18px">
<a class="button" href="/login">Mitglieder anmelden</a>
<a class="button secondary" href="/tenants">Fuer Betreiber ansehen</a>
</div>
<ul class="list">
<?php foreach (($marketing['benefits'] ?? []) as $benefit): ?>
<li><?= h((string) $benefit) ?></li>
<?php endforeach; ?>
</ul>
</section>
<?php if ($tenantOverview !== null): ?>
<section class="grid grid-4">
<?php foreach (($tenantOverview['metrics'] ?? []) as $metric): ?>
<article class="metric">
<strong><?= h((string) $metric['value']) ?></strong>
<h3><?= h((string) $metric['label']) ?></h3>
<p><?= h((string) $metric['detail']) ?></p>
</article>
<?php endforeach; ?>
</section>
<?php endif; ?>
<section class="grid grid-3" style="margin-top:18px">
<article class="card"><h2>Fuer Mitglieder</h2><p>Zentral mit E-Mail starten, Tenant erkennen lassen und direkt den eigenen Kontostand oder die letzten Buchungen sehen.</p></article>
<article class="card"><h2>Fuer Tenant-Admins</h2><p>Mitglieder, Striche, Einzahlungen und Hinweise bleiben tenantweise sauber getrennt und professionell bedienbar.</p></article>
<article class="card"><h2>Fuer Betreiber</h2><p>Das Produkt wirkt vermietbar, weil Portfolio, Routing und Betrieb des Gesamtangebots an einer Stelle sichtbar werden.</p></article>
</section>
<?php if ($dbError !== null): ?>
<section class="alert alert-warning">Die Datenbank konnte noch nicht gelesen werden: <?= h($dbError) ?></section>
<?php endif; ?>
<?php elseif ($page === 'login'): ?>
<section class="hero">
<div class="eyebrow">Zentrale Anmeldung</div>
<h1>Mitglieder starten jetzt wirklich E-Mail first.</h1>
<p>Erst Tenant finden, dann bei Bedarf zwischen mehreren Zugehoerigkeiten waehlen und erst danach das Passwort eingeben.</p>
<div class="context" style="margin-top:16px">
<?= badge('E-Mail first') ?>
<?= badge('Tenant-Erkennung') ?>
<?= badge('Mehrfachzuordnung') ?>
</div>
</section>
<?php if ($loginFlow['message'] !== null): ?>
<section class="alert alert-info"><?= h((string) $loginFlow['message']) ?></section>
<?php endif; ?>
<?php if ($loginFlow['error'] !== null): ?>
<section class="alert alert-error"><?= h((string) $loginFlow['error']) ?></section>
<?php endif; ?>
<section class="grid grid-2">
<article class="card">
<?php if ($loginStep === 'choose-tenant'): ?>
<div class="eyebrow">Schritt 2</div>
<h2>Passenden Tenant waehlen</h2>
<div class="stack">
<?php foreach (($loginState['memberships'] ?? []) as $membership): ?>
<form method="post" action="/login" class="card">
<input type="hidden" name="action" value="choose-tenant">
<input type="hidden" name="tenant_user_id" value="<?= h((string) ($membership['tenant_user_id'] ?? '')) ?>">
<h3><?= h((string) ($membership['tenant_name'] ?? 'Tenant')) ?></h3>
<p><?= h((string) ($membership['display_name'] ?? $membership['user_email'] ?? '')) ?></p>
<p class="muted"><?= h((string) ($membership['role_keys'] ?? 'Mitglied')) ?></p>
<div class="actions" style="margin-top:12px"><button type="submit">Diesen Tenant oeffnen</button></div>
</form>
<?php endforeach; ?>
</div>
<?php elseif ($loginStep === 'password' && is_array($selectedMembership)): ?>
<div class="eyebrow">Schritt 2</div>
<h2>Passwort eingeben</h2>
<p>Der Tenant wurde erkannt: <?= h((string) ($selectedMembership['tenant_name'] ?? '')) ?></p>
<form method="post" action="/login" class="stack" style="margin-top:16px">
<input type="hidden" name="action" value="authenticate">
<label>Passwort<input type="password" name="password" autocomplete="current-password" placeholder="Passwort eingeben"></label>
<div class="actions">
<button type="submit">Anmelden</button>
<a class="button secondary" href="/login">Neu starten</a>
</div>
</form>
<?php else: ?>
<div class="eyebrow">Schritt 1</div>
<h2>E-Mail eingeben</h2>
<form method="post" action="/login" class="stack" style="margin-top:16px">
<input type="hidden" name="action" value="discover">
<label>E-Mail-Adresse<input type="email" name="email" value="<?= h((string) ($loginState['email'] ?? '')) ?>" autocomplete="email" placeholder="mitglied@unternehmen.de"></label>
<div class="actions"><button type="submit">Weiter</button></div>
</form>
<?php endif; ?>
</article>
<article class="card">
<div class="eyebrow">Hilfe beim Einstieg</div>
<h2>Wenn die Anmeldung nicht sofort klappt</h2>
<ul class="list">
<li>Pruefe zuerst, ob du die richtige berufliche E-Mail-Adresse verwendest.</li>
<li>Wenn du in mehreren Bereichen arbeitest, wirst du automatisch zur passenden Auswahl gefuehrt.</li>
<li>Falls kein Zugang gefunden wird, brauchst du meist nur eine Einladung oder den Kontakt zum verantwortlichen Betreiber.</li>
</ul>
</article>
</section>
<?php elseif ($page === 'tenants'): ?>
<section class="hero">
<div class="eyebrow">Betreiber-Sicht</div>
<h1>Die Tenant-Console zeigt Portfolio, Login-Wege und Mehrfachzugriffe in einer professionellen Uebersicht.</h1>
<p>Die Landingpage verkauft das Produkt, die Anmeldung dient Mitgliedern und diese Sicht bleibt die zentrale Betreiber-Ebene.</p>
</section>
<?php if ($tenantOverview !== null): ?>
<section class="grid grid-4">
<?php foreach (($tenantOverview['metrics'] ?? []) as $metric): ?>
<article class="metric">
<strong><?= h((string) $metric['value']) ?></strong>
<h3><?= h((string) $metric['label']) ?></h3>
<p><?= h((string) $metric['detail']) ?></p>
</article>
<?php endforeach; ?>
</section>
<section class="card" style="margin-top:18px">
<div class="table">
<table>
<thead><tr><th>Tenant</th><th>Domain</th><th>Mitglieder</th><th>Admins</th><th>Login</th><th>Status</th></tr></thead>
<tbody>
<?php foreach (($tenantOverview['tenants'] ?? []) as $tenant): ?>
<tr>
<td><strong><?= h((string) $tenant['name']) ?></strong><br><span class="muted"><?= h((string) $tenant['tenant_key']) ?></span></td>
<td><?= h((string) $tenant['domain']) ?></td>
<td><?= num($tenant['member_count'] ?? 0) ?></td>
<td><?= num($tenant['admin_count'] ?? 0) ?></td>
<td><?= h((string) $tenant['login_mode']) ?></td>
<td><?= ((string) ($tenant['status'] ?? 'active')) === 'active' ? badge('Aktiv', 'success') : badge('Pruefen', 'warning') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php else: ?>
<section class="alert alert-warning"><?= h((string) $dbError) ?></section>
<?php endif; ?>
<?php elseif ($page === 'dashboard'): ?>
<section class="hero">
<div class="eyebrow">Tenant-Dashboard</div>
<h1><?= h((string) $auth['tenant_name']) ?> auf einen Blick</h1>
<p>Kontostand, Striche, Einzahlungen und die letzten Buchungen sind damit direkt tenantweise verfuegbar.</p>
</section>
<section class="grid grid-4">
<article class="metric"><strong><?= num($tenantDashboard['active_members'] ?? 0) ?></strong><h3>Aktive Mitglieder</h3><p>Aktive Mitgliedschaften im Tenant.</p></article>
<article class="metric"><strong><?= money($tenantDashboard['coffee_volume'] ?? 0) ?></strong><h3>Kaffeeverbrauch</h3><p>Summe der Kaffee-Buchungen.</p></article>
<article class="metric"><strong><?= money($tenantDashboard['payment_volume'] ?? 0) ?></strong><h3>Einzahlungen</h3><p>Summe der Zahlungsbuchungen.</p></article>
<article class="metric"><strong><?= money($memberSummary['balance'] ?? 0) ?></strong><h3>Mein Kontostand</h3><p>Saldo fuer den aktuell angemeldeten Nutzer.</p></article>
</section>
<section class="grid grid-2" style="margin-top:18px">
<?php if ($canManageTenant): ?>
<article class="card">
<div class="eyebrow">Legacy-Kernfunktion</div>
<h2>Striche verbuchen</h2>
<form method="post" action="/dashboard" 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']) ?>" <?= ((string) ($auth['member_id'] ?? '')) === (string) $member['id'] ? 'selected' : '' ?>><?= h((string) $member['display_name']) ?></option><?php endforeach; ?></select></label>
<label>Anzahl Striche<input type="number" name="strokes" min="1" step="1" value="1"></label>
<label>Preis pro Strich<input type="number" name="unit_price" min="0.01" step="0.01" value="0.50"></label>
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
<label>Quelle<input type="text" name="booking_source" value="self-service"></label>
<div class="actions"><button type="submit">Striche buchen</button></div>
</form>
</article>
<article class="card">
<div class="eyebrow">Legacy-Kernfunktion</div>
<h2>Einzahlung erfassen</h2>
<form method="post" action="/dashboard" 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']) ?>" <?= ((string) ($auth['member_id'] ?? '')) === (string) $member['id'] ? 'selected' : '' ?>><?= 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 buchen</button></div>
</form>
</article>
<?php else: ?>
<article class="card">
<div class="eyebrow">Mitgliedersicht</div>
<h2>Dein Bereich</h2>
<ul class="list">
<li>Hier siehst du deinen Kontostand, letzte Buchungen und aktuelle Hinweise.</li>
<li>Tenant-weite Buchungen und Mitgliedsverwaltung bleiben bei den verantwortlichen Admins.</li>
<li>Wenn dir etwas fehlt, wende dich an den Betreiber deines Bereichs.</li>
</ul>
</article>
<article class="card">
<div class="eyebrow">Naechster Schritt</div>
<h2>Womit du direkt weiterkommst</h2>
<div class="actions">
<a class="button" href="/content">Hinweise ansehen</a>
</div>
</article>
<?php endif; ?>
</section>
<section class="card" style="margin-top:18px">
<h2>Letzte Buchungen</h2>
<div class="table">
<table>
<thead><tr><th>Zeit</th><th>Mitglied</th><th>Typ</th><th>Referenz</th><th>Betrag</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></tr><?php endforeach; ?></tbody>
</table>
</div>
</section>
<?php elseif ($page === 'members'): ?>
<section class="hero"><div class="eyebrow">Mitgliederverwaltung</div><h1>Mitglieder, Rollen und Aktivstatus</h1><p>Die Tenant-Variante der alten Mitarbeiterverwaltung mit Rollen und Benutzerbezug.</p></section>
<section class="card"><div class="table"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Status</th><th>Benutzer</th><th>Rollen</th></tr></thead><tbody><?php foreach ($members as $member): ?><tr><td><strong><?= h((string) $member['display_name']) ?></strong></td><td><?= h((string) $member['email']) ?></td><td><?= ((string) ($member['status'] ?? 'active')) === 'active' ? badge('Aktiv', 'success') : badge('Inaktiv', 'warning') ?></td><td><?= h((string) ($member['user_display_name'] ?? '')) ?></td><td><?= h((string) ($member['roles'] ?? 'Mitglied')) ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php elseif ($page === 'ledger'): ?>
<section class="hero"><div class="eyebrow">Ledger</div><h1>Striche und Buchungen</h1><p>Kaffeeeintraege und Folgebuchungen bleiben tenantweise nachvollziehbar.</p></section>
<section class="grid grid-2">
<article class="card">
<h2>Neuen Strich-Eintrag buchen</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>
<label>Anzahl Striche<input type="number" name="strokes" min="1" step="1" value="1"></label>
<label>Preis pro Strich<input type="number" name="unit_price" min="0.01" step="0.01" value="0.50"></label>
<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>Abgedeckte Alt-Funktionen</h2><ul class="list"><li>Sammel- und Self-Service-Striche sind in einer Logik zusammengefuehrt.</li><li>Jeder Verbrauch erzeugt direkt auch den Ledger-Eintrag.</li><li>Die letzten Buchungen sind tenantweise sichtbar.</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></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></tr><?php endforeach; ?></tbody></table></div></section>
<?php elseif ($page === 'payments'): ?>
<section class="hero"><div class="eyebrow">Einzahlungen</div><h1>Zahlungen tenantweit verwalten</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>
<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>Wofuer dieser Bereich da ist</h2><ul class="list"><li>Manuelle Einzahlungen sind direkt erfasst.</li><li>PayPal oder Bank koennen getrennt ausgewiesen werden.</li><li>Jede Zahlung erscheint sofort im Ledger.</li></ul></article>
</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></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></tr><?php endforeach; ?></tbody></table></div></section>
<?php elseif ($page === 'content'): ?>
<section class="hero"><div class="eyebrow">Hinweise und FAQ</div><h1>Tenant-Inhalte zentral aus der Datenbank</h1><p>Hinweise und FAQ aus dem alten Programm sind jetzt als tenantbezogene Inhalte vorbereitet.</p></section>
<section class="grid grid-2">
<article class="card"><h2>Hinweise</h2><div class="stack"><?php if (($content['announcements'] ?? []) === []): ?><div class="metric"><p>Aktuell keine Hinweise vorhanden.</p></div><?php endif; ?><?php foreach (($content['announcements'] ?? []) as $entry): ?><div class="metric"><h3><?= h((string) $entry['title']) ?></h3><p><?= nl2br(h((string) $entry['message'])) ?></p><p class="muted">Sichtbar bis <?= dt((string) ($entry['visible_until'] ?? '')) ?></p></div><?php endforeach; ?></div></article>
<article class="card"><h2>FAQ</h2><div class="stack"><?php if (($content['faq'] ?? []) === []): ?><div class="metric"><p>Aktuell keine FAQ vorhanden.</p></div><?php endif; ?><?php foreach (($content['faq'] ?? []) as $entry): ?><div class="metric"><h3><?= h((string) $entry['question']) ?></h3><p><?= nl2br(h((string) $entry['answer'])) ?></p></div><?php endforeach; ?></div></article>
</section>
<?php else: ?>
<section class="alert alert-warning">Die angeforderte Seite konnte nicht gefunden werden.</section>
<?php endif; ?>
<p class="footer">Kaffeeliste SaaS | zentrale Anmeldung, Tenant-Console und tenantweise Kernprozesse fuer die moderne Kaffeeliste</p>
</main>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'ledger';
require dirname(__DIR__) . '/index.php';
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'login';
require dirname(__DIR__) . '/index.php';
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'members';
require dirname(__DIR__) . '/index.php';
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'payments';
require dirname(__DIR__) . '/index.php';
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'tenants';
require dirname(__DIR__) . '/index.php';