tenant Einrichtung
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'tenants';
|
||||
|
||||
require dirname(__DIR__, 2) . '/index.php';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'content';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'dashboard';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
+426
-367
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'ledger';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'login';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'members';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'payments';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'tenants';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
Reference in New Issue
Block a user