Update Prozess eingebaut
This commit is contained in:
@@ -36,6 +36,17 @@ Nach dem Setup gibt es jetzt zwei zentrale Web-Einstiege:
|
||||
|
||||
- `saas-app/public/admin/login/` fuer den Global-Admin
|
||||
- `saas-app/public/admin/` fuer die zentrale Verwaltung
|
||||
- `saas-app/public/admin/updates/` fuer den webbasierten Update-Prozess
|
||||
|
||||
## SaaS-Betrieb
|
||||
|
||||
Die aktuelle Ausbaustufe enthaelt bereits die wichtigsten SaaS-Bausteine:
|
||||
|
||||
- Lizenzplaene pro Mandant mit Funktionsfreischaltung
|
||||
- Mandanten-Einstellungen fuer Preise, PDF-Listen und Kommunikation
|
||||
- Drucklisten als PDF-Ansicht plus Nacherfassung von Vorder- und Rueckseite
|
||||
- Basis-Exporte fuer Mitglieder und Ledger als CSV
|
||||
- Global-Admin-Zugriff zum direkten Oeffnen eines Mandanten
|
||||
|
||||
## Hilfsskripte
|
||||
|
||||
@@ -44,6 +55,7 @@ Nach dem Setup gibt es jetzt zwei zentrale Web-Einstiege:
|
||||
- `scripts/install-saas.php` fuehrt den lokalen Setup-Grundlauf aus.
|
||||
- `scripts/build-migration-bundle.php` baut die SQL-Migrationen zu einer Datei.
|
||||
- `scripts/run-sql-migrations.php` fuehrt die SQL-Migrationen direkt per PDO fuer den konfigurierten DB-Treiber aus.
|
||||
- `scripts/run-updates.php` fuehrt nachtraegliche System-Updates per PHP aus.
|
||||
|
||||
## Hinweise Zum Umbau
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Der Installer kann:
|
||||
- die `.env` speichern
|
||||
- das SQL-Bundle erzeugen
|
||||
- Migrationen direkt per PHP ausfuehren, wenn die zum Treiber passende PDO-Erweiterung verfuegbar ist
|
||||
- nachtraegliche System-Updates automatisch einspielen
|
||||
- den ersten Global-Admin direkt anlegen
|
||||
- sich nach erfolgreicher Einrichtung sperren
|
||||
|
||||
@@ -93,7 +94,7 @@ eigene Umgebung angepasst werden:
|
||||
4. Sicherstellen, dass `open_basedir` nicht nur auf `public/` eingeschraenkt ist. PHP muss mindestens auf den kompletten Ordner `saas-app/` zugreifen duerfen, obwohl der Document-Root auf `public/` zeigt.
|
||||
5. Den Installer unter `/install/` aufrufen und die Einrichtung durchfuehren.
|
||||
6. Danach den Global-Admin unter `/admin/login` anmelden.
|
||||
7. In der zentralen Verwaltung unter `/admin/` die ersten Tenants anlegen.
|
||||
7. In der zentralen Verwaltung unter `/admin/tenants` die ersten Tenants inklusive Lizenzplan anlegen.
|
||||
8. Nach erfolgreicher Einrichtung den Installer sperren.
|
||||
9. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen.
|
||||
|
||||
@@ -131,6 +132,35 @@ Nach der Installation stehen diese Web-Einstiege bereit:
|
||||
- `/admin/login` fuer den Global-Admin
|
||||
- `/admin/` fuer die zentrale Verwaltungsuebersicht
|
||||
- `/admin/tenants` fuer die Tenant-Verwaltung
|
||||
- `/admin/updates` fuer System-Updates und Datenbankerweiterungen
|
||||
|
||||
## Lizenzplaene
|
||||
|
||||
Die SaaS-Anwendung arbeitet jetzt mit Lizenzplaenen pro Mandant. Darueber kann
|
||||
gesteuert werden, welche Zusatzfunktionen im jeweiligen Tenant verfuegbar sind.
|
||||
|
||||
- `Starter`: Basisfunktionen fuer Mitglieder, Buchungen, Einzahlungen und Inhalte
|
||||
- `Team`: zusaetzlich Mandanten-Einstellungen, PDF-Drucklisten, Papierlisten-Erfassung und Basis-Exporte
|
||||
- `Business`: vorbereitet fuer SSO, Importe, Exporte, Benachrichtigungen und Auswertungen
|
||||
- `Enterprise`: erweitert um White-Labeling, Sonderfunktionen und priorisierte Updates
|
||||
|
||||
Mandanten-Einstellungen und Exportfunktionen muessen daher nicht fuer jede
|
||||
Lizenz freigeschaltet sein.
|
||||
|
||||
## Update-Prozess
|
||||
|
||||
Schemaaenderungen und spaetere Datenanpassungen laufen ueber versionierte
|
||||
PHP-Update-Dateien unter `saas-app/updates/`.
|
||||
|
||||
Der bevorzugte Weg auf Webspace ist:
|
||||
|
||||
1. Als Global-Admin anmelden.
|
||||
2. `/admin/updates` aufrufen.
|
||||
3. Ausstehende Updates per Klick ausfuehren.
|
||||
|
||||
Alternativ steht lokal oder auf Servern mit Shell-Zugriff weiter zur Verfuegung:
|
||||
|
||||
- `php scripts/run-updates.php`
|
||||
|
||||
## Betriebscheck
|
||||
|
||||
|
||||
@@ -52,6 +52,30 @@ Nach der Installation erfolgt die zentrale Administration ueber:
|
||||
- `public/admin/login/`
|
||||
- `public/admin/`
|
||||
- `public/admin/tenants/`
|
||||
- `public/admin/updates/`
|
||||
|
||||
## Lizenzen und Mandantenbetrieb
|
||||
|
||||
Mandanten koennen jetzt direkt mit einem Lizenzplan angelegt werden. Der
|
||||
Lizenzplan steuert, welche Bereiche im Tenant sichtbar und nutzbar sind.
|
||||
|
||||
- `Starter`: Basisfunktionen fuer Mitglieder, Ledger, Einzahlungen und Inhalte
|
||||
- `Team`: zusaetzlich Mandanten-Einstellungen, PDF-Listen, Papierlisten-Erfassung und Basis-Exporte
|
||||
- `Business`: vorbereitet fuer SSO, Importe, Exporte, Benachrichtigungen und Auswertungen
|
||||
- `Enterprise`: erweitert um White-Labeling, Sonderfunktionen und priorisierte Updates
|
||||
|
||||
Der Global-Admin kann jeden Mandanten oeffnen und ihn mit erweiterten Rechten
|
||||
aus Sicht des jeweiligen Tenant-Admins pruefen.
|
||||
|
||||
## Update-Prozess
|
||||
|
||||
Nachtraegliche Schema- und Datenupdates laufen versioniert ueber:
|
||||
|
||||
- `public/admin/updates/` im Browser
|
||||
- `php ../scripts/run-updates.php` per CLI
|
||||
|
||||
Die Update-Dateien liegen in `updates/` und werden in `app_updates`
|
||||
protokolliert.
|
||||
|
||||
## Hosting-Hinweise
|
||||
|
||||
|
||||
+184
-34
@@ -22,7 +22,27 @@ function admin_badge(string $label, string $tone = 'neutral'): string
|
||||
return '<span class="badge badge-' . admin_h($tone) . '">' . admin_h($label) . '</span>';
|
||||
}
|
||||
|
||||
function admin_summary_metrics(array $tenants): array
|
||||
function admin_update_summary(array $items): array
|
||||
{
|
||||
$summary = [
|
||||
'pending' => 0,
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'running' => 0,
|
||||
];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$status = (string) ($item['status'] ?? 'pending');
|
||||
|
||||
if (array_key_exists($status, $summary)) {
|
||||
$summary[$status]++;
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function admin_summary_metrics(array $tenants, array $updates): array
|
||||
{
|
||||
$tenantCount = count($tenants);
|
||||
$activeTenants = 0;
|
||||
@@ -40,12 +60,15 @@ function admin_summary_metrics(array $tenants): array
|
||||
$providerCount += (int) ($tenant['provider_count'] ?? 0);
|
||||
}
|
||||
|
||||
$updateSummary = admin_update_summary($updates);
|
||||
|
||||
return [
|
||||
['label' => 'Mandanten gesamt', 'value' => (string) $tenantCount, 'detail' => 'Mandanten im zentralen Portfolio.'],
|
||||
['label' => 'Aktive Mandanten', 'value' => (string) $activeTenants, 'detail' => 'Bereiche mit aktivem Status.'],
|
||||
['label' => 'Mitglieder gesamt', 'value' => (string) $memberCount, 'detail' => 'Aktive Zuordnungen über alle Mandanten.'],
|
||||
['label' => 'SSO-Provider', 'value' => (string) $providerCount, 'detail' => 'Aktive Identitätsanbieter für die Anmeldung.'],
|
||||
['label' => 'Tenant-Admins', 'value' => (string) $adminCount, 'detail' => 'Verwaltungszugänge in den Mandanten.'],
|
||||
['label' => 'Offene Updates', 'value' => (string) $updateSummary['pending'], 'detail' => 'Noch nicht eingespielte System-Updates.'],
|
||||
['label' => 'SSO-Provider', 'value' => (string) $providerCount, 'detail' => 'Aktive Identitätsanbieter für die Anmeldung.'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -55,6 +78,7 @@ $page = match ($path) {
|
||||
'/admin', '/admin/' => 'overview',
|
||||
'/admin/login' => 'login',
|
||||
'/admin/tenants' => 'tenants',
|
||||
'/admin/updates' => 'updates',
|
||||
'/admin/logout' => 'logout',
|
||||
default => 'login',
|
||||
};
|
||||
@@ -67,6 +91,10 @@ $adminLogin = ['state' => app_admin_login_state(), 'message' => null, 'error' =>
|
||||
$tenants = [];
|
||||
$editingTenant = null;
|
||||
$summaryMetrics = [];
|
||||
$licensePlans = [];
|
||||
$updateItems = [];
|
||||
$updateSummary = ['pending' => 0, 'success' => 0, 'failed' => 0, 'running' => 0];
|
||||
$appVersion = is_file(dirname(__DIR__) . '/version.php') ? (string) require dirname(__DIR__) . '/version.php' : 'unbekannt';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && $page === 'logout') {
|
||||
if (hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) {
|
||||
@@ -88,7 +116,7 @@ if ($page === 'login' && $pdo instanceof PDO) {
|
||||
$admin = app_admin_user();
|
||||
}
|
||||
|
||||
if (in_array($page, ['overview', 'tenants'], true)) {
|
||||
if (in_array($page, ['overview', 'tenants', 'updates'], true)) {
|
||||
$admin = app_require_platform_admin();
|
||||
|
||||
if (!$pdo instanceof PDO) {
|
||||
@@ -96,25 +124,69 @@ if (in_array($page, ['overview', 'tenants'], true)) {
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
if (in_array($page, ['overview', 'tenants', 'updates'], true) && $pdo instanceof PDO) {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) {
|
||||
app_flash('Die Sitzung ist abgelaufen. Bitte lade die Seite neu.', 'error');
|
||||
app_redirect('/admin/tenants/');
|
||||
app_redirect('/admin/' . ($page === 'overview' ? '' : $page . '/'));
|
||||
}
|
||||
|
||||
if ($page === 'tenants') {
|
||||
app_handle_platform_tenant_action($pdo);
|
||||
}
|
||||
|
||||
if ($page === 'updates' && (string) ($_POST['action'] ?? '') === 'run-updates') {
|
||||
try {
|
||||
$results = scripts_run_pending_updates(
|
||||
scripts_update_config_from_env(app_env()),
|
||||
'admin:' . (string) ($admin['email'] ?? 'platform')
|
||||
);
|
||||
|
||||
$successful = count(array_filter($results, static fn(array $result): bool => ($result['status'] ?? '') === 'success'));
|
||||
$skipped = count(array_filter($results, static fn(array $result): bool => ($result['status'] ?? '') === 'skipped'));
|
||||
|
||||
app_flash(
|
||||
$successful . ' Update(s) ausgeführt, ' . $skipped . ' bereits erledigt.',
|
||||
'success'
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
app_flash('Die Updates konnten nicht abgeschlossen werden: ' . $exception->getMessage(), 'error');
|
||||
}
|
||||
|
||||
app_redirect('/admin/updates/');
|
||||
}
|
||||
|
||||
app_handle_platform_tenant_action($pdo);
|
||||
$tenants = app_admin_tenant_list($pdo);
|
||||
$summaryMetrics = admin_summary_metrics($tenants);
|
||||
$licensePlans = app_license_plan_options($pdo);
|
||||
$updateItems = scripts_list_updates_status(scripts_update_config_from_env(app_env()));
|
||||
$updateSummary = admin_update_summary($updateItems);
|
||||
$summaryMetrics = admin_summary_metrics($tenants, $updateItems);
|
||||
|
||||
if ($page === 'tenants' && isset($_GET['edit']) && $_GET['edit'] !== '') {
|
||||
$editingTenant = app_query_one(
|
||||
$pdo,
|
||||
'SELECT id, tenant_key, name, status FROM tenants WHERE id = :id LIMIT 1',
|
||||
['id' => (string) $_GET['edit']]
|
||||
);
|
||||
$editingTenant = $licensePlans !== []
|
||||
? app_query_one(
|
||||
$pdo,
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
t.id,
|
||||
t.tenant_key,
|
||||
t.name,
|
||||
t.status,
|
||||
COALESCE(lp.plan_key, 'team') AS plan_key
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_licenses tl ON tl.tenant_id = t.id AND tl.status = 'active'
|
||||
LEFT JOIN license_plans lp ON lp.id = tl.license_plan_id
|
||||
WHERE t.id = :id
|
||||
LIMIT 1
|
||||
SQL,
|
||||
['id' => (string) $_GET['edit']]
|
||||
)
|
||||
: app_query_one(
|
||||
$pdo,
|
||||
"SELECT id, tenant_key, name, status, 'team' AS plan_key FROM tenants WHERE id = :id LIMIT 1",
|
||||
['id' => (string) $_GET['edit']]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -123,7 +195,7 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
<title>Kaffeeliste Admin</title>
|
||||
<style>
|
||||
:root{--bg:#f4efe7;--card:#fffaf4;--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:"Aptos","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}.hero,.card{padding:24px}.hero{margin-bottom:18px}.grid{display:grid;gap:18px}.grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-5{grid-template-columns:repeat(5,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)}h1,h2,h3{margin:0 0 12px;font-family:Georgia,serif}h1{font-size:clamp(2rem,4vw,3.4rem)}h2{font-size:1.35rem}h3{font-size:1.02rem}.eyebrow{display:inline-block;margin-bottom:12px;color:var(--accent);font-size:.82rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}p,.muted{margin:0;color:var(--muted);line-height:1.65}.stack{display:grid;gap:12px}.metric{padding:18px;border:1px solid var(--line);border-radius:18px;background:#fffdf9}.metric strong{display:block;font-size:1.8rem;margin-bottom:8px}.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)}.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{width:100%;padding:12px 14px;border-radius:16px;border:1px solid rgba(36,22,14,.15);font:inherit;background:#fffdfa;color:var(--ink)}ul{margin:0;padding-left:18px;color:var(--muted);line-height:1.7}.footer{margin-top:18px;text-align:center;color:var(--muted);font-size:.92rem}@media(max-width:960px){.grid-2,.grid-5,form.grid{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}.table table{min-width:0}}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:"Aptos","Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%)}a{color:inherit}.shell{width:min(1240px,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}.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-6{grid-template-columns:repeat(6,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)}h1,h2,h3{margin:0 0 12px;font-family:Georgia,serif}h1{font-size:clamp(2rem,4vw,3.4rem)}h2{font-size:1.35rem}h3{font-size:1.02rem}.eyebrow{display:inline-block;margin-bottom:12px;color:var(--accent);font-size:.82rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}p,.muted{margin:0;color:var(--muted);line-height:1.65}.stack{display:grid;gap:12px}.metric{padding:18px;border:1px solid var(--line);border-radius:18px;background:#fffdf9}.metric strong{display:block;font-size:1.8rem;margin-bottom:8px}.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)}.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}.badge-error{background:rgba(154,31,31,.12);color:#9a1f1f}.table{overflow-x:auto}.table table{width:100%;border-collapse:collapse;min-width:820px}.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{width:100%;padding:12px 14px;border-radius:16px;border:1px solid rgba(36,22,14,.15);font:inherit;background:#fffdfa;color:var(--ink)}ul{margin:0;padding-left:18px;color:var(--muted);line-height:1.7}.footer{margin-top:18px;text-align:center;color:var(--muted);font-size:.92rem}code{font-family:Consolas,monospace}.mono{font-family:Consolas,monospace;font-size:.92rem}.status-block{display:grid;gap:8px}.table small{display:block;color:var(--muted);margin-top:4px;line-height:1.5}@media(max-width:980px){.grid-2,.grid-3,.grid-6,form.grid{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}.table table{min-width:0}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -131,13 +203,14 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
<header class="bar">
|
||||
<div>
|
||||
<strong style="font-family:Georgia,serif;font-size:1.28rem;">Kaffeeliste Admin</strong>
|
||||
<p>Zentrale Verwaltung für Mandanten, Zugänge und Betriebsstatus.</p>
|
||||
<p>Zentrale Verwaltung für Mandanten, Lizenzen, Updates und Betriebsstatus.</p>
|
||||
</div>
|
||||
<nav class="links">
|
||||
<a href="/admin/login/" class="<?= $page === 'login' ? 'active' : '' ?>">Login</a>
|
||||
<?php if ($admin !== null): ?>
|
||||
<a href="/admin/" class="<?= $page === 'overview' ? 'active' : '' ?>">Übersicht</a>
|
||||
<a href="/admin/tenants/" class="<?= $page === 'tenants' ? 'active' : '' ?>">Mandanten</a>
|
||||
<a href="/admin/updates/" class="<?= $page === 'updates' ? 'active' : '' ?>">Updates</a>
|
||||
<form method="post" action="/admin/logout/">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<button type="submit" class="button secondary">Abmelden</button>
|
||||
@@ -173,19 +246,19 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
<h2>Hinweise zur Einrichtung</h2>
|
||||
<ul>
|
||||
<li>Der erste Global-Admin wird im Installer angelegt oder aktualisiert.</li>
|
||||
<li>Die Anmeldung prüft `users.is_platform_admin = 1` und das gespeicherte Passwort.</li>
|
||||
<li>Nach dem Login stehen Übersicht, Mandanten-Verwaltung und Betriebsdaten zentral bereit.</li>
|
||||
<li>Die Anmeldung prüft <code>users.is_platform_admin = 1</code> und das gespeicherte Passwort.</li>
|
||||
<li>Nach dem Login stehen Übersicht, Mandanten-Verwaltung und Update-Center zentral bereit.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
<?php elseif ($page === 'overview'): ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Zentrale Übersicht</div>
|
||||
<h1>Alle Mandanten, Zugänge und Kennzahlen an einer Stelle.</h1>
|
||||
<p>Die globale Verwaltung bündelt die wichtigsten Betriebsdaten und führt direkt in die Pflege der Mandanten.</p>
|
||||
<h1>Alle Mandanten, Lizenzen und Systemstände an einer Stelle.</h1>
|
||||
<p>Die globale Verwaltung bündelt die wichtigsten Betriebsdaten und führt direkt in Lizenzpflege, Mandanten-Einstieg und Updates.</p>
|
||||
</section>
|
||||
<?php if ($dbError !== null): ?><section class="alert alert-warning"><?= admin_h($dbError) ?></section><?php endif; ?>
|
||||
<section class="grid grid-5">
|
||||
<section class="grid grid-6">
|
||||
<?php foreach ($summaryMetrics as $metric): ?>
|
||||
<article class="metric">
|
||||
<strong><?= admin_h((string) $metric['value']) ?></strong>
|
||||
@@ -194,7 +267,7 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<section class="grid grid-2" style="margin-top:18px">
|
||||
<section class="grid grid-3" style="margin-top:18px">
|
||||
<article class="card">
|
||||
<h2>Angemeldet als</h2>
|
||||
<div class="stack">
|
||||
@@ -203,8 +276,8 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
<p><?= admin_h((string) ($admin['email'] ?? '')) ?></p>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<h3>Nächster Schritt</h3>
|
||||
<p>Lege neue Mandanten an, bearbeite bestehende Bereiche und prüfe den Betriebsstatus zentral.</p>
|
||||
<h3>Aktuelle Version</h3>
|
||||
<p class="mono"><?= admin_h($appVersion) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -212,20 +285,29 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
<h2>Schnellzugriffe</h2>
|
||||
<div class="actions">
|
||||
<a class="button" href="/admin/tenants/">Mandanten verwalten</a>
|
||||
<a class="button secondary" href="/admin/updates/">Updates prüfen</a>
|
||||
<a class="button secondary" href="/login/">Mitglieder-Login prüfen</a>
|
||||
</div>
|
||||
<ul style="margin-top:16px">
|
||||
<li>Die Mitglieder-Anmeldung bleibt zentral und mandantenfähig.</li>
|
||||
<li>Jeder Mandant behält seine eigenen Mitglieder, Buchungen und Inhalte.</li>
|
||||
<li>Die globale Verwaltung steuert nur die übergreifenden Stammdaten.</li>
|
||||
<li>Mandanten können direkt mit einem passenden Lizenzplan angelegt werden.</li>
|
||||
<li>Als Global-Admin kannst du jeden Mandanten öffnen und operativ prüfen.</li>
|
||||
<li>System-Updates laufen über die Weboberfläche ohne Shell-Zugriff.</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Update-Lage</h2>
|
||||
<div class="stack">
|
||||
<div class="metric"><h3>Offen</h3><p><?= admin_h((string) $updateSummary['pending']) ?> Update(s) warten auf Ausführung.</p></div>
|
||||
<div class="metric"><h3>Fehlgeschlagen</h3><p><?= admin_h((string) $updateSummary['failed']) ?> Update(s) brauchen Aufmerksamkeit.</p></div>
|
||||
<div class="metric"><h3>Erfolgreich</h3><p><?= admin_h((string) $updateSummary['success']) ?> Update(s) sind bereits dokumentiert.</p></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<?php elseif ($page === 'tenants'): ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Mandanten-Verwaltung</div>
|
||||
<h1>Mandanten zentral anlegen und pflegen.</h1>
|
||||
<p>Hier verwaltet der Global-Admin die Stammdaten aller Mandanten und behält Rollen, Mitgliederzahlen und den Login-Betrieb im Blick.</p>
|
||||
<h1>Mandanten zentral anlegen, lizenzieren und betreten.</h1>
|
||||
<p>Hier pflegt der Global-Admin die Stammdaten aller Mandanten und steuert, welche Funktionspakete pro Lizenz freigeschaltet sind.</p>
|
||||
</section>
|
||||
<?php if ($dbError !== null): ?><section class="alert alert-warning"><?= admin_h($dbError) ?></section><?php endif; ?>
|
||||
<section class="grid grid-2">
|
||||
@@ -238,17 +320,22 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
<label>Mandanten-Key<input name="tenant_key" value="<?= admin_h((string) ($editingTenant['tenant_key'] ?? '')) ?>" placeholder="werk-berlin"></label>
|
||||
<label>Name<input name="name" value="<?= admin_h((string) ($editingTenant['name'] ?? '')) ?>" placeholder="Werk Berlin"></label>
|
||||
<label>Status<select name="status"><?php foreach (['active' => 'Aktiv', 'inactive' => 'Inaktiv', 'sandbox' => 'Sandbox'] as $value => $label): ?><option value="<?= admin_h($value) ?>"<?= (($editingTenant['status'] ?? 'active') === $value) ? ' selected' : '' ?>><?= admin_h($label) ?></option><?php endforeach; ?></select></label>
|
||||
<label>Lizenzplan<select name="plan_key"><?php foreach ($licensePlans as $plan): ?><option value="<?= admin_h((string) $plan['plan_key']) ?>"<?= ((string) ($editingTenant['plan_key'] ?? 'team') === (string) $plan['plan_key']) ? ' selected' : '' ?>><?= admin_h((string) $plan['name']) ?></option><?php endforeach; ?></select></label>
|
||||
<div class="actions">
|
||||
<button type="submit">Speichern</button>
|
||||
<a class="button secondary" href="/admin/">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php if ($licensePlans === []): ?>
|
||||
<p style="margin-top:14px">Die Lizenzpläne sind noch nicht verfügbar. Bitte zuerst die System-Updates ausführen.</p>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Verwaltungsrahmen</h2>
|
||||
<h2>Lizenzrahmen</h2>
|
||||
<div class="stack">
|
||||
<div class="metric"><h3>Rollenmodell</h3><p>Der Global-Admin pflegt Mandanten zentral. Die operative Arbeit bleibt bei den jeweiligen Tenant-Admins.</p></div>
|
||||
<div class="metric"><h3>Login-Strategie</h3><p>Mitglieder melden sich weiterhin zentral an und werden anschließend automatisch dem passenden Mandanten zugeordnet.</p></div>
|
||||
<div class="metric"><h3>Starter</h3><p>Basisfunktionen wie Mitglieder, Buchungen, Einzahlungen und Inhalte.</p></div>
|
||||
<div class="metric"><h3>Team</h3><p>Zusätzlich Mandanten-Einstellungen, PDF-Listen, Papierlisten-Erfassung und Basis-Exporte.</p></div>
|
||||
<div class="metric"><h3>Business & Enterprise</h3><p>Vorbereitung für SSO, Importe, erweiterte Exporte, Benachrichtigungen und priorisierte Updates.</p></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
@@ -256,13 +343,14 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
<h2>Vorhandene Mandanten</h2>
|
||||
<div class="table">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Status</th><th>Mitglieder</th><th>Admins</th><th>SSO</th><th>Aktionen</th></tr></thead>
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Status</th><th>Lizenz</th><th>Mitglieder</th><th>Admins</th><th>SSO</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($tenants as $tenant): ?>
|
||||
<tr>
|
||||
<td><strong><?= admin_h((string) $tenant['name']) ?></strong></td>
|
||||
<td><?= admin_h((string) $tenant['tenant_key']) ?></td>
|
||||
<td><?= admin_badge(((string) $tenant['status']) === 'active' ? 'Aktiv' : ucfirst((string) $tenant['status']), ((string) $tenant['status']) === 'active' ? 'success' : 'warning') ?></td>
|
||||
<td><?= admin_badge((string) ($tenant['plan_name'] ?? 'Starter')) ?></td>
|
||||
<td><?= admin_h((string) $tenant['member_count']) ?></td>
|
||||
<td><?= admin_h((string) $tenant['admin_count']) ?></td>
|
||||
<td><?= admin_h((string) $tenant['provider_count']) ?></td>
|
||||
@@ -283,9 +371,71 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Update-Center</div>
|
||||
<h1>System-Updates zentral prüfen und ausführen.</h1>
|
||||
<p>Schema-Erweiterungen und Datenanpassungen werden versioniert dokumentiert und können direkt über die Weboberfläche angestoßen werden.</p>
|
||||
</section>
|
||||
<?php if ($dbError !== null): ?><section class="alert alert-warning"><?= admin_h($dbError) ?></section><?php endif; ?>
|
||||
<section class="grid grid-3">
|
||||
<article class="card">
|
||||
<h2>Stand der Anwendung</h2>
|
||||
<div class="stack">
|
||||
<div class="metric"><h3>Version</h3><p class="mono"><?= admin_h($appVersion) ?></p></div>
|
||||
<div class="metric"><h3>Offene Updates</h3><p><?= admin_h((string) $updateSummary['pending']) ?></p></div>
|
||||
<div class="metric"><h3>Fehlgeschlagen</h3><p><?= admin_h((string) $updateSummary['failed']) ?></p></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Update-Ausführung</h2>
|
||||
<p>Updates laufen idempotent. Bereits erfolgreiche Updates werden übersprungen und bleiben im Protokoll sichtbar.</p>
|
||||
<form method="post" action="/admin/updates/" style="margin-top:18px">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<input type="hidden" name="action" value="run-updates">
|
||||
<button type="submit">Ausstehende Updates jetzt ausführen</button>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Betriebsmodell</h2>
|
||||
<ul>
|
||||
<li>Updates liegen versioniert unter <code>saas-app/updates/</code>.</li>
|
||||
<li>Jede Ausführung wird in <code>app_updates</code> dokumentiert.</li>
|
||||
<li>Dasselbe Verfahren kann lokal auch per <code>php scripts/run-updates.php</code> laufen.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
<section class="card" style="margin-top:18px">
|
||||
<h2>Update-Protokoll</h2>
|
||||
<div class="table">
|
||||
<table>
|
||||
<thead><tr><th>Update</th><th>Status</th><th>Beschreibung</th><th>Ausgeführt</th><th>Durch</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($updateItems as $item): ?>
|
||||
<?php
|
||||
$status = (string) ($item['status'] ?? 'pending');
|
||||
$tone = match ($status) {
|
||||
'success' => 'success',
|
||||
'failed' => 'error',
|
||||
'running' => 'warning',
|
||||
default => 'neutral',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td><strong><?= admin_h((string) $item['title']) ?></strong><small class="mono"><?= admin_h((string) $item['key']) ?></small></td>
|
||||
<td><?= admin_badge($status === 'pending' ? 'Offen' : ucfirst($status), $tone) ?></td>
|
||||
<td><?= admin_h((string) ($item['description'] ?? '')) ?><?php if (!empty($item['error_message'])): ?><small><?= admin_h((string) $item['error_message']) ?></small><?php endif; ?></td>
|
||||
<td><?= admin_h((string) ($item['executed_at'] ?? '')) ?></td>
|
||||
<td><?= admin_h((string) ($item['executed_by'] ?? '')) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="footer">Kaffeeliste Admin | zentrale Mandanten-Verwaltung und globaler Zugriff über PHP-Webseiten</p>
|
||||
<p class="footer">Kaffeeliste Admin | zentrale Mandanten-Verwaltung, Lizenzsteuerung und Update-Prozess über PHP-Webseiten</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__, 2) . '/admin.php';
|
||||
@@ -323,14 +323,26 @@ function app_handle_platform_admin_login(PDO $pdo): array
|
||||
|
||||
function app_admin_tenant_list(PDO $pdo): array
|
||||
{
|
||||
$licenseSelect = "'starter' AS plan_key, 'Starter' AS plan_name";
|
||||
$licenseJoins = '';
|
||||
|
||||
if (scripts_table_exists($pdo, 'tenant_licenses') && scripts_table_exists($pdo, 'license_plans')) {
|
||||
$licenseSelect = "COALESCE(lp.plan_key, 'starter') AS plan_key, COALESCE(lp.name, 'Starter') AS plan_name";
|
||||
$licenseJoins = "\nLEFT JOIN tenant_licenses tl ON tl.tenant_id = t.id AND tl.status = 'active'\nLEFT JOIN license_plans lp ON lp.id = tl.license_plan_id";
|
||||
}
|
||||
|
||||
return app_query_all(
|
||||
$pdo,
|
||||
<<<'SQL'
|
||||
str_replace(
|
||||
['__LICENSE_SELECT__', '__LICENSE_JOINS__'],
|
||||
[$licenseSelect, $licenseJoins],
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
t.id,
|
||||
t.tenant_key,
|
||||
t.name,
|
||||
t.status,
|
||||
__LICENSE_SELECT__,
|
||||
COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS member_count,
|
||||
COUNT(DISTINCT CASE WHEN r.role_key = 'tenant_admin' THEN tu.id END) AS admin_count,
|
||||
COUNT(DISTINCT CASE WHEN tip.is_enabled = 1 THEN tip.id END) AS provider_count
|
||||
@@ -340,9 +352,11 @@ 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
|
||||
__LICENSE_JOINS__
|
||||
GROUP BY t.id, t.tenant_key, t.name, t.status, plan_key, plan_name
|
||||
ORDER BY t.name ASC
|
||||
SQL
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,6 +366,7 @@ function app_upsert_tenant(PDO $pdo, array $data): void
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$status = trim((string) ($data['status'] ?? 'active'));
|
||||
$tenantId = trim((string) ($data['tenant_id'] ?? ''));
|
||||
$planKey = trim((string) ($data['plan_key'] ?? ''));
|
||||
|
||||
if ($tenantKey === '' || !preg_match('/^[a-z0-9-]+$/', $tenantKey)) {
|
||||
throw new RuntimeException('Der Mandanten-Key darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.');
|
||||
@@ -390,16 +405,22 @@ function app_upsert_tenant(PDO $pdo, array $data): void
|
||||
]
|
||||
);
|
||||
|
||||
if ($planKey !== '') {
|
||||
app_assign_tenant_license($pdo, $tenantId, $planKey);
|
||||
}
|
||||
|
||||
app_flash('Der Mandant wurde aktualisiert.', 'success');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$newTenantId = app_uuid();
|
||||
|
||||
app_execute(
|
||||
$pdo,
|
||||
'INSERT INTO tenants (id, tenant_key, name, status, created_at, updated_at) VALUES (:id, :tenant_key, :name, :status, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => app_uuid(),
|
||||
'id' => $newTenantId,
|
||||
'tenant_key' => $tenantKey,
|
||||
'name' => $name,
|
||||
'status' => $status,
|
||||
@@ -408,6 +429,10 @@ function app_upsert_tenant(PDO $pdo, array $data): void
|
||||
]
|
||||
);
|
||||
|
||||
if ($planKey !== '') {
|
||||
app_assign_tenant_license($pdo, $newTenantId, $planKey);
|
||||
}
|
||||
|
||||
app_flash('Der Mandant wurde angelegt.', 'success');
|
||||
}
|
||||
|
||||
@@ -428,6 +453,7 @@ function app_handle_platform_tenant_action(PDO $pdo): void
|
||||
'tenant_key' => (string) ($_POST['tenant_key'] ?? ''),
|
||||
'name' => (string) ($_POST['name'] ?? ''),
|
||||
'status' => (string) ($_POST['status'] ?? 'active'),
|
||||
'plan_key' => (string) ($_POST['plan_key'] ?? ''),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
app_flash($exception->getMessage(), 'error');
|
||||
@@ -809,6 +835,179 @@ function app_handle_member_action(PDO $pdo, array $auth): void
|
||||
app_redirect('/members/');
|
||||
}
|
||||
|
||||
function app_handle_settings_action(PDO $pdo, array $auth): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((string) ($_POST['action'] ?? '') !== 'save-settings') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app_can_manage_tenant($auth)) {
|
||||
app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning');
|
||||
app_redirect('/settings/');
|
||||
}
|
||||
|
||||
$tenantId = (string) ($auth['tenant_id'] ?? '');
|
||||
|
||||
if (!app_tenant_has_feature($pdo, $tenantId, 'tenant_settings')) {
|
||||
app_flash('Die Mandanten-Einstellungen sind in deiner Lizenz nicht freigeschaltet.', 'warning');
|
||||
app_redirect('/settings/');
|
||||
}
|
||||
|
||||
try {
|
||||
app_save_tenant_settings($pdo, $tenantId, $_POST);
|
||||
app_flash('Die Mandanten-Einstellungen wurden gespeichert.', 'success');
|
||||
} catch (Throwable $exception) {
|
||||
app_flash($exception->getMessage(), 'error');
|
||||
}
|
||||
|
||||
app_redirect('/settings/');
|
||||
}
|
||||
|
||||
function app_handle_export_action(PDO $pdo, array $auth, array $settings): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app_can_manage_tenant($auth)) {
|
||||
app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning');
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
|
||||
$tenantId = (string) ($auth['tenant_id'] ?? '');
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
|
||||
if ($action === 'create-print-list') {
|
||||
if (!app_tenant_has_feature($pdo, $tenantId, 'pdf_export')) {
|
||||
app_flash('Der PDF-Export ist in deiner Lizenz nicht freigeschaltet.', 'warning');
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
|
||||
try {
|
||||
app_create_print_list($pdo, $auth, $settings, $_POST);
|
||||
app_flash('Die Druckliste wurde erzeugt. Du kannst sie jetzt als PDF speichern oder später ausfüllen.', 'success');
|
||||
} catch (Throwable $exception) {
|
||||
app_flash($exception->getMessage(), 'error');
|
||||
}
|
||||
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
|
||||
if ($action === 'apply-print-list') {
|
||||
if (!app_tenant_has_feature($pdo, $tenantId, 'paper_strike_entry')) {
|
||||
app_flash('Die Papierlisten-Erfassung ist in deiner Lizenz nicht freigeschaltet.', 'warning');
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
|
||||
try {
|
||||
app_apply_print_list($pdo, $auth, $_POST);
|
||||
app_flash('Die Striche aus der Druckliste wurden verbucht.', 'success');
|
||||
} catch (Throwable $exception) {
|
||||
app_flash($exception->getMessage(), 'error');
|
||||
}
|
||||
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
}
|
||||
|
||||
function app_send_csv_download(string $filename, array $header, array $rows): void
|
||||
{
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
|
||||
$output = fopen('php://output', 'wb');
|
||||
|
||||
if ($output === false) {
|
||||
throw new RuntimeException('Die CSV-Datei konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
fwrite($output, "\xEF\xBB\xBF");
|
||||
fputcsv($output, $header, ';');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($output, $row, ';');
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
}
|
||||
|
||||
function app_handle_export_download(PDO $pdo, array $auth): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
$download = trim((string) ($_GET['download'] ?? ''));
|
||||
|
||||
if ($download === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app_can_manage_tenant($auth)) {
|
||||
app_flash('Für diesen Export brauchst du Tenant-Admin-Rechte.', 'warning');
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
|
||||
$tenantId = (string) ($auth['tenant_id'] ?? '');
|
||||
|
||||
if (!app_tenant_has_feature($pdo, $tenantId, 'basic_exports')) {
|
||||
app_flash('Die Basis-Exporte sind in deiner Lizenz nicht freigeschaltet.', 'warning');
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
|
||||
$tenant = app_tenant_by_id($pdo, $tenantId);
|
||||
$tenantKey = (string) ($tenant['tenant_key'] ?? 'tenant');
|
||||
$dateSuffix = date('Y-m-d');
|
||||
|
||||
if ($download === 'members-csv') {
|
||||
$rows = [];
|
||||
|
||||
foreach (app_members_for_tenant($pdo, $tenantId) as $member) {
|
||||
$rows[] = [
|
||||
(string) ($member['display_name'] ?? ''),
|
||||
(string) ($member['email'] ?? ''),
|
||||
(string) ($member['status'] ?? ''),
|
||||
(string) ($member['roles'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
app_send_csv_download(
|
||||
$tenantKey . '-mitglieder-' . $dateSuffix . '.csv',
|
||||
['Name', 'E-Mail', 'Status', 'Rollen'],
|
||||
$rows
|
||||
);
|
||||
}
|
||||
|
||||
if ($download === 'ledger-csv') {
|
||||
$rows = [];
|
||||
|
||||
foreach (app_ledger_for_tenant($pdo, $tenantId) as $entry) {
|
||||
$rows[] = [
|
||||
(string) ($entry['booked_at'] ?? ''),
|
||||
(string) ($entry['member_name'] ?? ''),
|
||||
(string) ($entry['entry_type'] ?? ''),
|
||||
(string) ($entry['reference_type'] ?? ''),
|
||||
number_format((float) ($entry['amount'] ?? 0), 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
app_send_csv_download(
|
||||
$tenantKey . '-ledger-' . $dateSuffix . '.csv',
|
||||
['Buchungszeit', 'Mitglied', 'Typ', 'Referenz', 'Betrag'],
|
||||
$rows
|
||||
);
|
||||
}
|
||||
|
||||
app_flash('Der angeforderte Export ist nicht verfügbar.', 'warning');
|
||||
app_redirect('/exports/');
|
||||
}
|
||||
|
||||
function app_memberships_by_email(PDO $pdo, string $email): array
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
@@ -1275,6 +1474,480 @@ function app_content_for_tenant(PDO $pdo, string $tenantId): array
|
||||
];
|
||||
}
|
||||
|
||||
function app_supports_license_features(PDO $pdo): bool
|
||||
{
|
||||
return scripts_table_exists($pdo, 'tenant_licenses')
|
||||
&& scripts_table_exists($pdo, 'license_plans')
|
||||
&& scripts_table_exists($pdo, 'features');
|
||||
}
|
||||
|
||||
function app_feature_defaults(): array
|
||||
{
|
||||
return [
|
||||
'members' => true,
|
||||
'ledger' => true,
|
||||
'payments' => true,
|
||||
'content' => true,
|
||||
'tenant_settings' => false,
|
||||
'pdf_export' => false,
|
||||
'paper_strike_entry' => false,
|
||||
'basic_exports' => false,
|
||||
'oidc' => false,
|
||||
'imports' => false,
|
||||
'exports' => false,
|
||||
'notifications' => false,
|
||||
'surveys' => false,
|
||||
'advanced_reporting' => false,
|
||||
'white_label' => false,
|
||||
'custom_features' => false,
|
||||
'priority_updates' => false,
|
||||
];
|
||||
}
|
||||
|
||||
function app_tenant_license(PDO $pdo, string $tenantId): array
|
||||
{
|
||||
if (!app_supports_license_features($pdo)) {
|
||||
return [
|
||||
'plan_key' => 'starter',
|
||||
'plan_name' => 'Starter',
|
||||
'features' => app_feature_defaults(),
|
||||
];
|
||||
}
|
||||
|
||||
$row = app_query_one(
|
||||
$pdo,
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
lp.id AS plan_id,
|
||||
lp.plan_key,
|
||||
lp.name AS plan_name
|
||||
FROM tenant_licenses tl
|
||||
INNER JOIN license_plans lp ON lp.id = tl.license_plan_id
|
||||
WHERE tl.tenant_id = :tenant_id
|
||||
AND tl.status = 'active'
|
||||
LIMIT 1
|
||||
SQL,
|
||||
['tenant_id' => $tenantId]
|
||||
);
|
||||
|
||||
$features = app_feature_defaults();
|
||||
|
||||
if ($row === null) {
|
||||
return [
|
||||
'plan_key' => 'starter',
|
||||
'plan_name' => 'Starter',
|
||||
'features' => $features,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (app_query_all(
|
||||
$pdo,
|
||||
<<<'SQL'
|
||||
SELECT f.feature_key
|
||||
FROM license_plan_features lpf
|
||||
INNER JOIN features f ON f.id = lpf.feature_id
|
||||
WHERE lpf.license_plan_id = :plan_id
|
||||
SQL,
|
||||
['plan_id' => $row['plan_id']]
|
||||
) as $featureRow) {
|
||||
$features[(string) $featureRow['feature_key']] = true;
|
||||
}
|
||||
|
||||
foreach (app_query_all(
|
||||
$pdo,
|
||||
<<<'SQL'
|
||||
SELECT f.feature_key, tfo.is_enabled
|
||||
FROM tenant_feature_overrides tfo
|
||||
INNER JOIN features f ON f.id = tfo.feature_id
|
||||
WHERE tfo.tenant_id = :tenant_id
|
||||
SQL,
|
||||
['tenant_id' => $tenantId]
|
||||
) as $override) {
|
||||
$features[(string) $override['feature_key']] = (int) ($override['is_enabled'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
return [
|
||||
'plan_key' => (string) $row['plan_key'],
|
||||
'plan_name' => (string) $row['plan_name'],
|
||||
'features' => $features,
|
||||
];
|
||||
}
|
||||
|
||||
function app_tenant_has_feature(PDO $pdo, string $tenantId, string $featureKey): bool
|
||||
{
|
||||
$license = app_tenant_license($pdo, $tenantId);
|
||||
|
||||
return !empty($license['features'][$featureKey]);
|
||||
}
|
||||
|
||||
function app_license_plan_options(PDO $pdo): array
|
||||
{
|
||||
if (!app_supports_license_features($pdo)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app_query_all(
|
||||
$pdo,
|
||||
'SELECT id, plan_key, name FROM license_plans WHERE is_active = 1 ORDER BY sort_order ASC, name ASC'
|
||||
);
|
||||
}
|
||||
|
||||
function app_assign_tenant_license(PDO $pdo, string $tenantId, string $planKey): void
|
||||
{
|
||||
if (!app_supports_license_features($pdo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$plan = app_query_one($pdo, 'SELECT id FROM license_plans WHERE plan_key = :plan_key LIMIT 1', ['plan_key' => $planKey]);
|
||||
|
||||
if ($plan === null) {
|
||||
throw new RuntimeException('Der ausgewählte Lizenzplan ist nicht vorhanden.');
|
||||
}
|
||||
|
||||
$existing = app_query_one($pdo, 'SELECT id FROM tenant_licenses WHERE tenant_id = :tenant_id LIMIT 1', ['tenant_id' => $tenantId]);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
if ($existing !== null) {
|
||||
app_execute(
|
||||
$pdo,
|
||||
'UPDATE tenant_licenses SET license_plan_id = :license_plan_id, status = :status, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'license_plan_id' => $plan['id'],
|
||||
'status' => 'active',
|
||||
'updated_at' => $now,
|
||||
'id' => $existing['id'],
|
||||
]
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
app_execute(
|
||||
$pdo,
|
||||
'INSERT INTO tenant_licenses (id, tenant_id, license_plan_id, status, starts_at, ends_at, created_at, updated_at) VALUES (:id, :tenant_id, :license_plan_id, :status, :starts_at, NULL, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => app_uuid(),
|
||||
'tenant_id' => $tenantId,
|
||||
'license_plan_id' => $plan['id'],
|
||||
'status' => 'active',
|
||||
'starts_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function app_tenant_settings_defaults(): array
|
||||
{
|
||||
return [
|
||||
'default_unit_price' => '0.50',
|
||||
'pdf_list_title' => 'Kaffeeliste',
|
||||
'front_page_label' => 'Vorderseite',
|
||||
'back_page_label' => 'Rückseite',
|
||||
'support_email' => '',
|
||||
'location_label' => '',
|
||||
'allow_self_service_booking' => '1',
|
||||
'payment_hint' => 'Bitte Einzahlungen zeitnah verbuchen.',
|
||||
];
|
||||
}
|
||||
|
||||
function app_tenant_settings(PDO $pdo, string $tenantId): array
|
||||
{
|
||||
$settings = app_tenant_settings_defaults();
|
||||
|
||||
if (!scripts_table_exists($pdo, 'tenant_settings')) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
foreach (app_query_all($pdo, 'SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = :tenant_id', ['tenant_id' => $tenantId]) as $row) {
|
||||
$settings[(string) $row['setting_key']] = (string) ($row['setting_value'] ?? '');
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
function app_save_tenant_settings(PDO $pdo, string $tenantId, array $data): void
|
||||
{
|
||||
if (!scripts_table_exists($pdo, 'tenant_settings')) {
|
||||
throw new RuntimeException('Die Tenant-Einstellungen sind noch nicht verfügbar. Bitte zuerst die System-Updates ausführen.');
|
||||
}
|
||||
|
||||
$values = [
|
||||
'default_unit_price' => trim((string) ($data['default_unit_price'] ?? '0.50')),
|
||||
'pdf_list_title' => trim((string) ($data['pdf_list_title'] ?? 'Kaffeeliste')),
|
||||
'front_page_label' => trim((string) ($data['front_page_label'] ?? 'Vorderseite')),
|
||||
'back_page_label' => trim((string) ($data['back_page_label'] ?? 'Rückseite')),
|
||||
'support_email' => trim((string) ($data['support_email'] ?? '')),
|
||||
'location_label' => trim((string) ($data['location_label'] ?? '')),
|
||||
'allow_self_service_booking' => !empty($data['allow_self_service_booking']) ? '1' : '0',
|
||||
'payment_hint' => trim((string) ($data['payment_hint'] ?? '')),
|
||||
];
|
||||
|
||||
if (!is_numeric($values['default_unit_price']) || (float) $values['default_unit_price'] <= 0) {
|
||||
throw new RuntimeException('Bitte gib einen gültigen Standardpreis pro Strich an.');
|
||||
}
|
||||
|
||||
if ($values['support_email'] !== '' && !filter_var($values['support_email'], FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Bitte gib eine gültige Support-E-Mail-Adresse an.');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$existing = app_query_one(
|
||||
$pdo,
|
||||
'SELECT id FROM tenant_settings WHERE tenant_id = :tenant_id AND setting_key = :setting_key LIMIT 1',
|
||||
['tenant_id' => $tenantId, 'setting_key' => $key]
|
||||
);
|
||||
|
||||
if ($existing !== null) {
|
||||
app_execute(
|
||||
$pdo,
|
||||
'UPDATE tenant_settings SET setting_value = :setting_value, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'setting_value' => $value,
|
||||
'updated_at' => $now,
|
||||
'id' => $existing['id'],
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
app_execute(
|
||||
$pdo,
|
||||
'INSERT INTO tenant_settings (id, tenant_id, setting_key, setting_value, created_at, updated_at) VALUES (:id, :tenant_id, :setting_key, :setting_value, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => app_uuid(),
|
||||
'tenant_id' => $tenantId,
|
||||
'setting_key' => $key,
|
||||
'setting_value' => $value,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function app_print_lists_for_tenant(PDO $pdo, string $tenantId): array
|
||||
{
|
||||
if (!scripts_table_exists($pdo, 'print_lists')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app_query_all(
|
||||
$pdo,
|
||||
'SELECT id, title, unit_price, status, imported_at, created_at FROM print_lists WHERE tenant_id = :tenant_id ORDER BY created_at DESC LIMIT 20',
|
||||
['tenant_id' => $tenantId]
|
||||
);
|
||||
}
|
||||
|
||||
function app_print_list_detail(PDO $pdo, string $tenantId, string $listId): ?array
|
||||
{
|
||||
if (!scripts_table_exists($pdo, 'print_lists') || !scripts_table_exists($pdo, 'print_list_items')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$list = app_query_one(
|
||||
$pdo,
|
||||
'SELECT id, title, unit_price, status, notes, imported_at, created_at FROM print_lists WHERE tenant_id = :tenant_id AND id = :id LIMIT 1',
|
||||
['tenant_id' => $tenantId, 'id' => $listId]
|
||||
);
|
||||
|
||||
if ($list === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$list['items'] = app_query_all(
|
||||
$pdo,
|
||||
'SELECT id, member_id, member_name, row_number, front_strokes, back_strokes, total_strokes FROM print_list_items WHERE print_list_id = :print_list_id ORDER BY row_number ASC',
|
||||
['print_list_id' => $listId]
|
||||
);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
function app_create_print_list(PDO $pdo, array $auth, array $settings, array $data): void
|
||||
{
|
||||
if (!scripts_table_exists($pdo, 'print_lists') || !scripts_table_exists($pdo, 'print_list_items')) {
|
||||
throw new RuntimeException('Die Drucklisten-Funktion ist noch nicht verfügbar. Bitte zuerst die System-Updates ausführen.');
|
||||
}
|
||||
|
||||
$tenantId = (string) ($auth['tenant_id'] ?? '');
|
||||
$title = trim((string) ($data['title'] ?? ''));
|
||||
$notes = trim((string) ($data['notes'] ?? ''));
|
||||
$unitPrice = (float) ($data['unit_price'] ?? ($settings['default_unit_price'] ?? '0.50'));
|
||||
|
||||
if ($title === '') {
|
||||
$title = (string) ($settings['pdf_list_title'] ?? 'Kaffeeliste') . ' ' . date('d.m.Y');
|
||||
}
|
||||
|
||||
if ($unitPrice <= 0) {
|
||||
throw new RuntimeException('Bitte gib einen gültigen Preis pro Strich für die Liste an.');
|
||||
}
|
||||
|
||||
$members = app_query_all(
|
||||
$pdo,
|
||||
'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = :status ORDER BY display_name ASC',
|
||||
['tenant_id' => $tenantId, 'status' => 'active']
|
||||
);
|
||||
|
||||
if ($members === []) {
|
||||
throw new RuntimeException('Für diesen Mandanten sind keine aktiven Personen vorhanden.');
|
||||
}
|
||||
|
||||
$listId = app_uuid();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
app_execute(
|
||||
$pdo,
|
||||
'INSERT INTO print_lists (id, tenant_id, title, unit_price, status, created_by_user_id, notes, imported_at, created_at, updated_at) VALUES (:id, :tenant_id, :title, :unit_price, :status, :created_by_user_id, :notes, NULL, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => $listId,
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => $title,
|
||||
'unit_price' => number_format($unitPrice, 2, '.', ''),
|
||||
'status' => 'draft',
|
||||
'created_by_user_id' => $auth['user_id'] ?? null,
|
||||
'notes' => $notes,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
||||
$rowNumber = 1;
|
||||
foreach ($members as $member) {
|
||||
app_execute(
|
||||
$pdo,
|
||||
'INSERT INTO print_list_items (id, print_list_id, member_id, member_name, row_number, front_strokes, back_strokes, total_strokes, created_at, updated_at) VALUES (:id, :print_list_id, :member_id, :member_name, :row_number, 0, 0, 0, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => app_uuid(),
|
||||
'print_list_id' => $listId,
|
||||
'member_id' => $member['id'],
|
||||
'member_name' => $member['display_name'],
|
||||
'row_number' => $rowNumber++,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
function app_apply_print_list(PDO $pdo, array $auth, array $data): void
|
||||
{
|
||||
$tenantId = (string) ($auth['tenant_id'] ?? '');
|
||||
$listId = trim((string) ($data['print_list_id'] ?? ''));
|
||||
$list = app_print_list_detail($pdo, $tenantId, $listId);
|
||||
|
||||
if ($list === null) {
|
||||
throw new RuntimeException('Die ausgewählte Druckliste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ((string) ($list['status'] ?? 'draft') === 'imported') {
|
||||
throw new RuntimeException('Diese Druckliste wurde bereits verbucht.');
|
||||
}
|
||||
|
||||
$bookedAt = trim((string) ($data['booked_at'] ?? ''));
|
||||
$bookedAt = $bookedAt !== '' ? str_replace('T', ' ', $bookedAt) . ':00' : date('Y-m-d H:i:s');
|
||||
$unitPrice = (float) ($list['unit_price'] ?? 0.50);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$entries = $data['strokes'] ?? [];
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($list['items'] as $item) {
|
||||
$itemId = (string) $item['id'];
|
||||
$front = max(0, (int) ($entries[$itemId]['front'] ?? 0));
|
||||
$back = max(0, (int) ($entries[$itemId]['back'] ?? 0));
|
||||
$total = $front + $back;
|
||||
|
||||
app_execute(
|
||||
$pdo,
|
||||
'UPDATE print_list_items SET front_strokes = :front_strokes, back_strokes = :back_strokes, total_strokes = :total_strokes, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'front_strokes' => $front,
|
||||
'back_strokes' => $back,
|
||||
'total_strokes' => $total,
|
||||
'updated_at' => $now,
|
||||
'id' => $itemId,
|
||||
]
|
||||
);
|
||||
|
||||
if ($total < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entryId = app_uuid();
|
||||
$ledgerId = app_uuid();
|
||||
$amount = round($unitPrice * $total, 2);
|
||||
|
||||
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' => $item['member_id'],
|
||||
'strokes' => $total,
|
||||
'unit_price' => number_format($unitPrice, 2, '.', ''),
|
||||
'total_cost' => number_format($amount, 2, '.', ''),
|
||||
'booking_source' => 'print-list',
|
||||
'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' => $item['member_id'],
|
||||
'entry_type' => 'coffee_charge',
|
||||
'amount' => number_format($amount * -1, 2, '.', ''),
|
||||
'reference_type' => 'print_list',
|
||||
'reference_id' => $listId,
|
||||
'booked_at' => $bookedAt,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
app_execute(
|
||||
$pdo,
|
||||
'UPDATE print_lists SET status = :status, imported_at = :imported_at, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'status' => 'imported',
|
||||
'imported_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'id' => $listId,
|
||||
]
|
||||
);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
function app_member_exists(PDO $pdo, string $tenantId, string $memberId): ?array
|
||||
{
|
||||
return app_query_one(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'exports';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
+170
-12
@@ -52,6 +52,8 @@ if ($requestedPage === null) {
|
||||
'/ledger' => 'ledger',
|
||||
'/payments' => 'payments',
|
||||
'/content' => 'content',
|
||||
'/settings' => 'settings',
|
||||
'/exports' => 'exports',
|
||||
'/logout' => 'logout',
|
||||
default => 'home',
|
||||
};
|
||||
@@ -59,7 +61,7 @@ if ($requestedPage === null) {
|
||||
|
||||
$page = (string) $requestedPage;
|
||||
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content'];
|
||||
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'settings', 'exports'];
|
||||
|
||||
if ($page === 'logout' && $requestMethod === 'POST') {
|
||||
app_logout();
|
||||
@@ -82,6 +84,14 @@ $memberSummary = null;
|
||||
$loginFlow = ['state' => app_login_state(), 'message' => null, 'error' => null];
|
||||
$editingMember = null;
|
||||
$memberForm = app_member_form_defaults();
|
||||
$tenantLicense = ['plan_key' => 'starter', 'plan_name' => 'Starter', 'features' => app_feature_defaults()];
|
||||
$tenantSettings = app_tenant_settings_defaults();
|
||||
$printLists = [];
|
||||
$activePrintList = null;
|
||||
$hasTenantSettingsFeature = false;
|
||||
$hasPdfExportFeature = false;
|
||||
$hasPaperStrikeEntryFeature = false;
|
||||
$hasBasicExportsFeature = false;
|
||||
|
||||
try {
|
||||
$pdo = app_pdo();
|
||||
@@ -106,15 +116,44 @@ if ($page === 'login' && $pdo instanceof PDO) {
|
||||
}
|
||||
|
||||
if ($auth !== null && $pdo instanceof PDO) {
|
||||
if (in_array($page, ['dashboard', 'ledger', 'payments'], true)) {
|
||||
app_handle_tenant_action($pdo, $auth);
|
||||
}
|
||||
|
||||
if ($page === 'members') {
|
||||
app_handle_member_action($pdo, $auth);
|
||||
}
|
||||
|
||||
try {
|
||||
$tenantLicense = app_tenant_license($pdo, (string) $auth['tenant_id']);
|
||||
$tenantSettings = app_tenant_settings($pdo, (string) $auth['tenant_id']);
|
||||
$hasTenantSettingsFeature = !empty($tenantLicense['features']['tenant_settings']);
|
||||
$hasPdfExportFeature = !empty($tenantLicense['features']['pdf_export']);
|
||||
$hasPaperStrikeEntryFeature = !empty($tenantLicense['features']['paper_strike_entry']);
|
||||
$hasBasicExportsFeature = !empty($tenantLicense['features']['basic_exports']);
|
||||
|
||||
if (in_array($page, ['dashboard', 'ledger', 'payments'], true)) {
|
||||
app_handle_tenant_action($pdo, $auth);
|
||||
}
|
||||
|
||||
if ($page === 'members') {
|
||||
app_handle_member_action($pdo, $auth);
|
||||
}
|
||||
|
||||
if ($page === 'settings') {
|
||||
app_handle_settings_action($pdo, $auth);
|
||||
}
|
||||
|
||||
if ($page === 'exports') {
|
||||
app_handle_export_action($pdo, $auth, $tenantSettings);
|
||||
app_handle_export_download($pdo, $auth);
|
||||
}
|
||||
|
||||
if ($page === 'settings' && !$hasTenantSettingsFeature) {
|
||||
app_flash('Die Mandanten-Einstellungen sind in der aktuellen Lizenz nicht freigeschaltet.', 'warning');
|
||||
app_redirect('/dashboard/');
|
||||
}
|
||||
|
||||
if ($page === 'exports'
|
||||
&& !$hasPdfExportFeature
|
||||
&& !$hasPaperStrikeEntryFeature
|
||||
&& !$hasBasicExportsFeature) {
|
||||
app_flash('Die Exportfunktionen sind in der aktuellen Lizenz nicht freigeschaltet.', 'warning');
|
||||
app_redirect('/dashboard/');
|
||||
}
|
||||
|
||||
$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);
|
||||
@@ -131,6 +170,14 @@ if ($auth !== null && $pdo instanceof PDO) {
|
||||
$content = app_content_for_tenant($pdo, (string) $auth['tenant_id']);
|
||||
}
|
||||
|
||||
if ($page === 'exports') {
|
||||
$printLists = app_print_lists_for_tenant($pdo, (string) $auth['tenant_id']);
|
||||
|
||||
if (isset($_GET['list']) && $_GET['list'] !== '') {
|
||||
$activePrintList = app_print_list_detail($pdo, (string) $auth['tenant_id'], (string) $_GET['list']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($page === 'members' && isset($_GET['edit']) && $_GET['edit'] !== '') {
|
||||
$editingMember = app_tenant_user_by_id($pdo, (string) $_GET['edit'], (string) $auth['tenant_id']);
|
||||
$memberForm = app_member_form_defaults($editingMember);
|
||||
@@ -143,7 +190,7 @@ if ($auth !== null && $pdo instanceof PDO) {
|
||||
$loginState = $loginFlow['state'] ?? [];
|
||||
$loginStep = (string) ($loginState['step'] ?? 'discover');
|
||||
$selectedMembership = $loginState['selected_membership'] ?? null;
|
||||
$restrictedPages = ['members', 'ledger', 'payments'];
|
||||
$restrictedPages = ['members', 'ledger', 'payments', 'settings', 'exports'];
|
||||
|
||||
if ($auth !== null && in_array($page, $restrictedPages, true) && !app_can_manage_tenant($auth)) {
|
||||
app_flash('Dieser Bereich ist nur für Tenant-Admins verfügbar.', 'warning');
|
||||
@@ -184,6 +231,8 @@ $canManageTenant = app_can_manage_tenant($auth);
|
||||
<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 if ($hasTenantSettingsFeature): ?><a href="/settings/" class="<?= $page === 'settings' ? 'active' : '' ?>">Einstellungen</a><?php endif; ?>
|
||||
<?php if ($hasPdfExportFeature || $hasPaperStrikeEntryFeature || $hasBasicExportsFeature): ?><a href="/exports/" class="<?= $page === 'exports' ? 'active' : '' ?>">Exporte</a><?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/logout/"><button type="submit" class="button secondary">Abmelden</button></form>
|
||||
<?php endif; ?>
|
||||
@@ -196,10 +245,18 @@ $canManageTenant = app_can_manage_tenant($auth);
|
||||
<?= 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('Global-Admin', 'success') : (app_is_tenant_admin($auth) ? badge('Tenant-Admin', 'success') : badge('Mitglied')) ?>
|
||||
<?= badge('Lizenz ' . (string) ($tenantLicense['plan_name'] ?? 'Starter')) ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($auth !== null && !empty($auth['acting_as_platform_admin'])): ?>
|
||||
<section class="alert alert-info">
|
||||
Du siehst diesen Mandanten mit erweitertem Global-Admin-Zugriff.
|
||||
<a href="/admin/" style="font-weight:700;text-decoration:underline;">Zur zentralen Verwaltung</a>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($flash !== null): ?>
|
||||
<section class="alert alert-<?= h((string) ($flash['type'] ?? 'info')) ?>"><?= h((string) ($flash['message'] ?? '')) ?></section>
|
||||
<?php endif; ?>
|
||||
@@ -366,7 +423,11 @@ $canManageTenant = app_can_manage_tenant($auth);
|
||||
<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 für den aktuell angemeldeten Nutzer.</p></article>
|
||||
<?php if (!empty($auth['acting_as_platform_admin'])): ?>
|
||||
<article class="metric"><strong>Global</strong><h3>Zugriffsmodus</h3><p>Du verwaltest diesen Mandanten mit Global-Admin-Rechten.</p></article>
|
||||
<?php else: ?>
|
||||
<article class="metric"><strong><?= money($memberSummary['balance'] ?? 0) ?></strong><h3>Mein Kontostand</h3><p>Saldo für den aktuell angemeldeten Nutzer.</p></article>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-2" style="margin-top:18px">
|
||||
@@ -479,7 +540,7 @@ $canManageTenant = app_can_manage_tenant($auth);
|
||||
<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>
|
||||
<td><?= str_contains((string) ($member['role_keys'] ?? ''), 'tenant_admin') ? badge('Tenant-Admin', 'success') : badge('Mitglied') ?></td>
|
||||
<td><a class="button secondary" href="/members/?edit=<?= h((string) ($member['tenant_user_id'] ?? '')) ?>">Bearbeiten</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@@ -527,6 +588,103 @@ $canManageTenant = app_can_manage_tenant($auth);
|
||||
<article class="card"><h2>Hinweise</h2><div class="stack"><?php if (($content['announcements'] ?? []) === []): ?><div class="metric"><p>Aktuell sind 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 elseif ($page === 'settings'): ?>
|
||||
<section class="hero"><div class="eyebrow">Mandanten-Einstellungen</div><h1>Einstellungen für Betrieb, Drucklisten und Kommunikation</h1><p>Diese Einstellungen gelten nur für den aktuell geöffneten Mandanten und bleiben unabhängig von anderen Bereichen.</p></section>
|
||||
<section class="grid grid-2">
|
||||
<article class="card">
|
||||
<h2>Einstellungen speichern</h2>
|
||||
<form method="post" action="/settings/" class="grid">
|
||||
<input type="hidden" name="action" value="save-settings">
|
||||
<label>Standardpreis pro Strich<input type="number" name="default_unit_price" min="0.01" step="0.01" value="<?= h((string) ($tenantSettings['default_unit_price'] ?? '0.50')) ?>"></label>
|
||||
<label>PDF-Listentitel<input name="pdf_list_title" value="<?= h((string) ($tenantSettings['pdf_list_title'] ?? 'Kaffeeliste')) ?>"></label>
|
||||
<label>Beschriftung Vorderseite<input name="front_page_label" value="<?= h((string) ($tenantSettings['front_page_label'] ?? 'Vorderseite')) ?>"></label>
|
||||
<label>Beschriftung Rückseite<input name="back_page_label" value="<?= h((string) ($tenantSettings['back_page_label'] ?? 'Rückseite')) ?>"></label>
|
||||
<label>Standortbezeichnung<input name="location_label" value="<?= h((string) ($tenantSettings['location_label'] ?? '')) ?>"></label>
|
||||
<label>Support-E-Mail<input type="email" name="support_email" value="<?= h((string) ($tenantSettings['support_email'] ?? '')) ?>"></label>
|
||||
<label class="checkbox" style="grid-column:1 / -1;display:flex;flex-direction:row;align-items:center;font-weight:600;">
|
||||
<input type="checkbox" name="allow_self_service_booking"<?= (($tenantSettings['allow_self_service_booking'] ?? '1') === '1') ? ' checked' : '' ?> style="width:auto">
|
||||
<span>Selbstbedienungs-Buchungen im Mandanten aktiv lassen</span>
|
||||
</label>
|
||||
<label style="grid-column:1 / -1;">Hinweis zu Einzahlungen<textarea name="payment_hint"><?= h((string) ($tenantSettings['payment_hint'] ?? '')) ?></textarea></label>
|
||||
<div class="actions"><button type="submit">Einstellungen speichern</button></div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Aktuelle Lizenz</h2>
|
||||
<div class="stack">
|
||||
<div class="metric"><h3><?= h((string) ($tenantLicense['plan_name'] ?? 'Starter')) ?></h3><p>Freigeschalteter Lizenzplan für diesen Mandanten.</p></div>
|
||||
<div class="metric"><h3>Freigeschaltete Zusatzfunktionen</h3><ul class="list"><?php foreach (['tenant_settings' => 'Mandanten-Einstellungen', 'pdf_export' => 'PDF-Listen', 'paper_strike_entry' => 'Papierlisten-Erfassung', 'basic_exports' => 'Basis-Exporte'] as $featureKey => $featureLabel): ?><?php if (!empty($tenantLicense['features'][$featureKey])): ?><li><?= h($featureLabel) ?></li><?php endif; ?><?php endforeach; ?></ul></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<?php elseif ($page === 'exports'): ?>
|
||||
<?php if (isset($_GET['view']) && $_GET['view'] === 'print' && is_array($activePrintList)): ?>
|
||||
<section class="hero"><div class="eyebrow">PDF-Ansicht</div><h1><?= h((string) $activePrintList['title']) ?></h1><p>Diese Ansicht ist druckfertig vorbereitet. Du kannst sie im Browser direkt als PDF speichern.</p><div class="actions" style="margin-top:18px"><a class="button secondary" href="/exports/?list=<?= h((string) $activePrintList['id']) ?>">Zurück</a><button type="button" onclick="window.print()">Jetzt drucken / als PDF speichern</button></div></section>
|
||||
<section class="card"><div class="grid grid-2"><article><h2><?= h((string) ($tenantSettings['front_page_label'] ?? 'Vorderseite')) ?></h2><div class="table"><table><thead><tr><th>#</th><th>Person</th><th>Striche</th></tr></thead><tbody><?php foreach (($activePrintList['items'] ?? []) as $item): ?><tr><td><?= h((string) $item['row_number']) ?></td><td><?= h((string) $item['member_name']) ?></td><td>________</td></tr><?php endforeach; ?></tbody></table></div></article><article><h2><?= h((string) ($tenantSettings['back_page_label'] ?? 'Rückseite')) ?></h2><div class="table"><table><thead><tr><th>#</th><th>Person</th><th>Striche</th></tr></thead><tbody><?php foreach (($activePrintList['items'] ?? []) as $item): ?><tr><td><?= h((string) $item['row_number']) ?></td><td><?= h((string) $item['member_name']) ?></td><td>________</td></tr><?php endforeach; ?></tbody></table></div></article></div></section>
|
||||
<?php else: ?>
|
||||
<section class="hero"><div class="eyebrow">Exporte und Drucklisten</div><h1>Drucklisten als PDF vorbereiten und später aus Papier nacherfassen</h1><p>Hier erzeugst du druckfertige Listen, speicherst sie als PDF und buchst anschließend die Striche aus Vorder- und Rückseite zurück ins System.</p></section>
|
||||
<section class="grid grid-2">
|
||||
<?php if ($hasPdfExportFeature): ?>
|
||||
<article class="card">
|
||||
<h2>Neue Druckliste erzeugen</h2>
|
||||
<form method="post" action="/exports/" class="grid">
|
||||
<input type="hidden" name="action" value="create-print-list">
|
||||
<label>Titel<input name="title" value="<?= h((string) ($tenantSettings['pdf_list_title'] ?? 'Kaffeeliste')) ?>"></label>
|
||||
<label>Preis pro Strich<input type="number" name="unit_price" min="0.01" step="0.01" value="<?= h((string) ($tenantSettings['default_unit_price'] ?? '0.50')) ?>"></label>
|
||||
<label style="grid-column:1 / -1;">Notiz<textarea name="notes"></textarea></label>
|
||||
<div class="actions"><button type="submit">Druckliste erzeugen</button></div>
|
||||
</form>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
<article class="card">
|
||||
<h2><?= $hasBasicExportsFeature ? 'Basis-Exporte' : 'So funktioniert der PDF-Prozess' ?></h2>
|
||||
<?php if ($hasBasicExportsFeature): ?>
|
||||
<p>Für den laufenden Betrieb kannst du Mitglieder und Buchungen direkt als CSV exportieren.</p>
|
||||
<div class="actions" style="margin-top:18px">
|
||||
<a class="button secondary" href="/exports/?download=members-csv">Mitglieder als CSV</a>
|
||||
<a class="button secondary" href="/exports/?download=ledger-csv">Ledger als CSV</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<ul class="list">
|
||||
<li>Druckliste erzeugen und über die Druckansicht im Browser als PDF speichern.</li>
|
||||
<li>Ausgedruckte Vorder- und Rückseite manuell ausfüllen lassen.</li>
|
||||
<li>Die Striche später gesammelt in derselben Liste nachtragen und verbuchen.</li>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</section>
|
||||
<?php if ($hasPdfExportFeature || $hasPaperStrikeEntryFeature): ?>
|
||||
<section class="card" style="margin-top:18px">
|
||||
<h2>Vorhandene Drucklisten</h2>
|
||||
<div class="table">
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Preis</th><th>Status</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
|
||||
<tbody><?php foreach ($printLists as $list): ?><tr><td><strong><?= h((string) $list['title']) ?></strong></td><td><?= money($list['unit_price'] ?? 0) ?></td><td><?= ((string) ($list['status'] ?? 'draft')) === 'imported' ? badge('Verbucht', 'success') : badge('Offen') ?></td><td><?= dt((string) ($list['created_at'] ?? '')) ?></td><td><div class="actions"><a class="button secondary" href="/exports/?list=<?= h((string) $list['id']) ?>">Bearbeiten</a><a class="button secondary" href="/exports/?list=<?= h((string) $list['id']) ?>&view=print">PDF-Ansicht</a></div></td></tr><?php endforeach; ?></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasPaperStrikeEntryFeature && is_array($activePrintList)): ?>
|
||||
<section class="card" style="margin-top:18px">
|
||||
<h2>Druckliste nachtragen</h2>
|
||||
<form method="post" action="/exports/">
|
||||
<input type="hidden" name="action" value="apply-print-list">
|
||||
<input type="hidden" name="print_list_id" value="<?= h((string) $activePrintList['id']) ?>">
|
||||
<div class="grid grid-2">
|
||||
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
|
||||
<div class="metric"><h3>Status</h3><p><?= h((string) ($activePrintList['status'] ?? 'draft')) ?></p></div>
|
||||
</div>
|
||||
<div class="table" style="margin-top:18px">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Person</th><th>Vorderseite</th><th>Rückseite</th><th>Gesamt</th></tr></thead>
|
||||
<tbody><?php foreach (($activePrintList['items'] ?? []) as $item): ?><tr><td><?= h((string) $item['row_number']) ?></td><td><?= h((string) $item['member_name']) ?></td><td><input type="number" min="0" step="1" name="strokes[<?= h((string) $item['id']) ?>][front]" value="<?= h((string) ($item['front_strokes'] ?? 0)) ?>"></td><td><input type="number" min="0" step="1" name="strokes[<?= h((string) $item['id']) ?>][back]" value="<?= h((string) ($item['back_strokes'] ?? 0)) ?>"></td><td><?= h((string) ($item['total_strokes'] ?? 0)) ?></td></tr><?php endforeach; ?></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:18px"><button type="submit">Striche verbuchen</button><a class="button secondary" href="/exports/?list=<?= h((string) $activePrintList['id']) ?>&view=print">Zur PDF-Ansicht</a></div>
|
||||
</form>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<section class="alert alert-warning">Die angeforderte Seite konnte nicht gefunden werden.</section>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -609,6 +609,265 @@ function scripts_ensure_schema(array $config): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
function scripts_updates_path(): string
|
||||
{
|
||||
return scripts_saas_app_path() . DIRECTORY_SEPARATOR . 'updates';
|
||||
}
|
||||
|
||||
function scripts_ensure_updates_table(PDO $pdo, string $connection): void
|
||||
{
|
||||
if (scripts_table_exists($pdo, 'app_updates')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($connection === 'sqlsrv') {
|
||||
$pdo->exec(
|
||||
<<<'SQL'
|
||||
CREATE TABLE app_updates (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
update_key VARCHAR(190) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
checksum VARCHAR(64) NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
executed_at DATETIME NULL,
|
||||
execution_time_ms INT NULL,
|
||||
executed_by VARCHAR(255) NULL,
|
||||
error_message NVARCHAR(MAX) NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
CONSTRAINT app_updates_update_key_unique UNIQUE (update_key)
|
||||
);
|
||||
SQL
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo->exec(
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS app_updates (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
update_key VARCHAR(190) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
checksum VARCHAR(64) NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
executed_at DATETIME NULL,
|
||||
execution_time_ms INT NULL,
|
||||
executed_by VARCHAR(255) NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY app_updates_update_key_unique (update_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL
|
||||
);
|
||||
}
|
||||
|
||||
function scripts_update_files(): array
|
||||
{
|
||||
$dir = scripts_updates_path();
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($dir . DIRECTORY_SEPARATOR . '*.php') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
function scripts_load_update_definition(string $file): array
|
||||
{
|
||||
$definition = require $file;
|
||||
|
||||
if (!is_array($definition)) {
|
||||
throw new RuntimeException('Ungültige Update-Datei: ' . $file);
|
||||
}
|
||||
|
||||
$key = trim((string) ($definition['key'] ?? pathinfo($file, PATHINFO_FILENAME)));
|
||||
$title = trim((string) ($definition['title'] ?? $key));
|
||||
$description = trim((string) ($definition['description'] ?? ''));
|
||||
$statements = $definition['statements'] ?? [];
|
||||
|
||||
if ($key === '' || !is_array($statements) || $statements === []) {
|
||||
throw new RuntimeException('Update-Datei ohne gültige Metadaten: ' . $file);
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'statements' => array_values(array_filter(array_map(
|
||||
static fn(mixed $statement): string => trim((string) $statement),
|
||||
$statements
|
||||
), static fn(string $statement): bool => $statement !== '')),
|
||||
'file' => $file,
|
||||
'checksum' => sha1_file($file) ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
function scripts_applied_updates(PDO $pdo): array
|
||||
{
|
||||
if (!scripts_table_exists($pdo, 'app_updates')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = scripts_query_all($pdo, 'SELECT update_key, status, checksum, executed_at, execution_time_ms, executed_by, error_message, title FROM app_updates ORDER BY update_key ASC');
|
||||
$applied = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$applied[(string) $row['update_key']] = $row;
|
||||
}
|
||||
|
||||
return $applied;
|
||||
}
|
||||
|
||||
function scripts_update_config_from_env(array $env): array
|
||||
{
|
||||
return [
|
||||
'connection' => scripts_normalize_db_connection((string) ($env['DB_CONNECTION'] ?? 'mysql')),
|
||||
'server' => (string) ($env['DB_HOST'] ?? '127.0.0.1'),
|
||||
'database' => (string) ($env['DB_DATABASE'] ?? ''),
|
||||
'port' => (string) ($env['DB_PORT'] ?? scripts_default_db_port((string) ($env['DB_CONNECTION'] ?? 'mysql'))),
|
||||
'username' => (string) ($env['DB_USERNAME'] ?? ''),
|
||||
'password' => (string) ($env['DB_PASSWORD'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
function scripts_list_updates_status(array $config): array
|
||||
{
|
||||
$pdo = scripts_connect_pdo($config);
|
||||
$connection = scripts_normalize_db_connection((string) ($config['connection'] ?? 'mysql'));
|
||||
scripts_ensure_updates_table($pdo, $connection);
|
||||
|
||||
$applied = scripts_applied_updates($pdo);
|
||||
$items = [];
|
||||
|
||||
foreach (scripts_update_files() as $file) {
|
||||
$definition = scripts_load_update_definition($file);
|
||||
$current = $applied[$definition['key']] ?? null;
|
||||
$status = $current['status'] ?? 'pending';
|
||||
|
||||
if ($status === 'success') {
|
||||
$status = 'success';
|
||||
} elseif ($status === 'running') {
|
||||
$status = 'running';
|
||||
} elseif ($current !== null) {
|
||||
$status = 'failed';
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'description' => $definition['description'],
|
||||
'status' => $status,
|
||||
'checksum' => $definition['checksum'],
|
||||
'executed_at' => $current['executed_at'] ?? null,
|
||||
'execution_time_ms' => $current['execution_time_ms'] ?? null,
|
||||
'executed_by' => $current['executed_by'] ?? null,
|
||||
'error_message' => $current['error_message'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
function scripts_run_pending_updates(array $config, ?string $executedBy = null): array
|
||||
{
|
||||
$pdo = scripts_connect_pdo($config);
|
||||
$connection = scripts_normalize_db_connection((string) ($config['connection'] ?? 'mysql'));
|
||||
scripts_ensure_updates_table($pdo, $connection);
|
||||
$applied = scripts_applied_updates($pdo);
|
||||
$results = [];
|
||||
|
||||
foreach (scripts_update_files() as $file) {
|
||||
$definition = scripts_load_update_definition($file);
|
||||
$current = $applied[$definition['key']] ?? null;
|
||||
|
||||
if (($current['status'] ?? null) === 'success') {
|
||||
$results[] = [
|
||||
'key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'status' => 'skipped',
|
||||
'message' => 'Bereits erfolgreich ausgeführt.',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
$updateId = (string) ($current['id'] ?? scripts_uuid());
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'DELETE FROM app_updates WHERE update_key = :update_key',
|
||||
['update_key' => $definition['key']]
|
||||
);
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'INSERT INTO app_updates (id, update_key, title, checksum, status, executed_at, execution_time_ms, executed_by, error_message, created_at, updated_at) VALUES (:id, :update_key, :title, :checksum, :status, NULL, NULL, :executed_by, NULL, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => $updateId,
|
||||
'update_key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'checksum' => $definition['checksum'],
|
||||
'status' => 'running',
|
||||
'executed_by' => $executedBy,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
||||
try {
|
||||
foreach ($definition['statements'] as $statement) {
|
||||
$pdo->exec($statement);
|
||||
}
|
||||
|
||||
$duration = (int) round((microtime(true) - $start) * 1000);
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'UPDATE app_updates SET status = :status, executed_at = :executed_at, execution_time_ms = :execution_time_ms, error_message = NULL, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'status' => 'success',
|
||||
'executed_at' => date('Y-m-d H:i:s'),
|
||||
'execution_time_ms' => $duration,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'id' => $updateId,
|
||||
]
|
||||
);
|
||||
|
||||
$results[] = [
|
||||
'key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'status' => 'success',
|
||||
'message' => 'Erfolgreich ausgeführt.',
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
$duration = (int) round((microtime(true) - $start) * 1000);
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'UPDATE app_updates SET status = :status, executed_at = :executed_at, execution_time_ms = :execution_time_ms, error_message = :error_message, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'status' => 'failed',
|
||||
'executed_at' => date('Y-m-d H:i:s'),
|
||||
'execution_time_ms' => $duration,
|
||||
'error_message' => $exception->getMessage(),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'id' => $updateId,
|
||||
]
|
||||
);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
function scripts_role_id(string $roleKey, string $scope): string
|
||||
{
|
||||
return scripts_uuid_from_string('role:' . $scope . ':' . $roleKey);
|
||||
|
||||
@@ -166,6 +166,16 @@ if ($requestMethod === 'POST' && !$locked) {
|
||||
'username' => $values['DB_USERNAME'],
|
||||
'password' => $values['DB_PASSWORD'],
|
||||
]);
|
||||
$updateResults = scripts_run_pending_updates([
|
||||
'connection' => $values['DB_CONNECTION'],
|
||||
'server' => $values['DB_HOST'],
|
||||
'database' => $values['DB_DATABASE'],
|
||||
'port' => $values['DB_PORT'],
|
||||
'username' => $values['DB_USERNAME'],
|
||||
'password' => $values['DB_PASSWORD'],
|
||||
], 'installer');
|
||||
$messages[] = count(array_filter($updateResults, static fn(array $result): bool => ($result['status'] ?? '') === 'success'))
|
||||
. ' System-Update(s) wurden eingespielt.';
|
||||
|
||||
$adminResult = scripts_create_platform_admin(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'settings';
|
||||
|
||||
require dirname(__DIR__) . '/index.php';
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'key' => '2026_03_22_000001_add_tenant_settings_and_license_tables',
|
||||
'title' => 'Tenant-Einstellungen und Lizenztabellen anlegen',
|
||||
'description' => 'Ergänzt Tenant-Einstellungen, Lizenzpläne, Feature-Zuordnungen und Overrides.',
|
||||
'statements' => [
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
tenant_id CHAR(36) NOT NULL,
|
||||
setting_key VARCHAR(120) NOT NULL,
|
||||
setting_value TEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY tenant_settings_unique (tenant_id, setting_key),
|
||||
CONSTRAINT tenant_settings_tenant_fk FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS license_plans (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
plan_key VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY license_plans_key_unique (plan_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS features (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
feature_key VARCHAR(120) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY features_key_unique (feature_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS license_plan_features (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
license_plan_id CHAR(36) NOT NULL,
|
||||
feature_id CHAR(36) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
UNIQUE KEY license_plan_features_unique (license_plan_id, feature_id),
|
||||
CONSTRAINT license_plan_features_plan_fk FOREIGN KEY (license_plan_id) REFERENCES license_plans(id),
|
||||
CONSTRAINT license_plan_features_feature_fk FOREIGN KEY (feature_id) REFERENCES features(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS tenant_feature_overrides (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
tenant_id CHAR(36) NOT NULL,
|
||||
feature_id CHAR(36) NOT NULL,
|
||||
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY tenant_feature_overrides_unique (tenant_id, feature_id),
|
||||
CONSTRAINT tenant_feature_overrides_tenant_fk FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||
CONSTRAINT tenant_feature_overrides_feature_fk FOREIGN KEY (feature_id) REFERENCES features(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS tenant_licenses (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
tenant_id CHAR(36) NOT NULL,
|
||||
license_plan_id CHAR(36) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
starts_at DATETIME NULL,
|
||||
ends_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY tenant_licenses_tenant_unique (tenant_id),
|
||||
CONSTRAINT tenant_licenses_tenant_fk FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||
CONSTRAINT tenant_licenses_plan_fk FOREIGN KEY (license_plan_id) REFERENCES license_plans(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
$plans = [
|
||||
'starter' => ['id' => '0f6e004d-ec4c-4f12-8f91-fdf795b80a01', 'name' => 'Starter', 'sort_order' => 10],
|
||||
'team' => ['id' => '0f6e004d-ec4c-4f12-8f91-fdf795b80a02', 'name' => 'Team', 'sort_order' => 20],
|
||||
'business' => ['id' => '0f6e004d-ec4c-4f12-8f91-fdf795b80a03', 'name' => 'Business', 'sort_order' => 30],
|
||||
'enterprise' => ['id' => '0f6e004d-ec4c-4f12-8f91-fdf795b80a04', 'name' => 'Enterprise', 'sort_order' => 40],
|
||||
];
|
||||
|
||||
$features = [
|
||||
'members' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc1001', 'name' => 'Mitglieder', 'description' => 'Mitgliederverwaltung und zentrale Personenpflege.'],
|
||||
'ledger' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc1002', 'name' => 'Ledger', 'description' => 'Kontostand, Buchungen und Verlauf pro Mandant.'],
|
||||
'payments' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc1003', 'name' => 'Einzahlungen', 'description' => 'Einzahlungen erfassen und zuordnen.'],
|
||||
'content' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc1004', 'name' => 'Inhalte', 'description' => 'Hinweise, FAQ und einfache Inhalte pro Mandant.'],
|
||||
'tenant_settings' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2001', 'name' => 'Mandanten-Einstellungen', 'description' => 'Eigene Einstellungen pro Mandant verwalten.'],
|
||||
'pdf_export' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2002', 'name' => 'PDF-Listen', 'description' => 'Druckfertige Listen für den Export bereitstellen.'],
|
||||
'paper_strike_entry' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2003', 'name' => 'Papierlisten-Erfassung', 'description' => 'Striche aus Vorder- und Rückseiten-Listen nacherfassen.'],
|
||||
'basic_exports' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2004', 'name' => 'Basis-Exporte', 'description' => 'Grundlegende Exporte für operative Nutzung.'],
|
||||
'oidc' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2005', 'name' => 'OIDC/SSO', 'description' => 'Zentrale Anmeldung über OIDC oder SSO.'],
|
||||
'imports' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2006', 'name' => 'Importe', 'description' => 'Import-Jobs für strukturierte Daten.'],
|
||||
'exports' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2007', 'name' => 'Erweiterte Exporte', 'description' => 'Zusätzliche Export-Jobs und Datenexporte.'],
|
||||
'notifications' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2008', 'name' => 'Benachrichtigungen', 'description' => 'Systemische Benachrichtigungen pro Mandant.'],
|
||||
'surveys' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2009', 'name' => 'Umfragen', 'description' => 'Umfragen und Rückmeldungen erfassen.'],
|
||||
'advanced_reporting' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2010', 'name' => 'Erweiterte Auswertungen', 'description' => 'Vertiefte Berichte und Auswertungen.'],
|
||||
'white_label' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2011', 'name' => 'White-Labeling', 'description' => 'Kundenspezifische Farb- und Markenanpassung.'],
|
||||
'custom_features' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2012', 'name' => 'Sonderfunktionen', 'description' => 'Mandantenspezifische Freischaltungen.'],
|
||||
'priority_updates' => ['id' => '8f6c468f-8c14-4681-b262-4c8ffacc2013', 'name' => 'Priorisierte Updates', 'description' => 'Bevorzugte Update-Betreuung für Enterprise-Tenants.'],
|
||||
];
|
||||
|
||||
$planFeatures = [
|
||||
'starter' => ['members', 'ledger', 'payments', 'content'],
|
||||
'team' => ['members', 'ledger', 'payments', 'content', 'tenant_settings', 'pdf_export', 'paper_strike_entry', 'basic_exports'],
|
||||
'business' => ['members', 'ledger', 'payments', 'content', 'tenant_settings', 'pdf_export', 'paper_strike_entry', 'basic_exports', 'oidc', 'imports', 'exports', 'notifications', 'surveys', 'advanced_reporting'],
|
||||
'enterprise' => array_keys($features),
|
||||
];
|
||||
|
||||
$statements = [];
|
||||
|
||||
foreach ($plans as $planKey => $plan) {
|
||||
$statements[] = sprintf(
|
||||
"INSERT INTO license_plans (id, plan_key, name, sort_order, is_active, created_at, updated_at)\nSELECT '%s', '%s', '%s', %d, 1, NOW(), NOW()\nWHERE NOT EXISTS (SELECT 1 FROM license_plans WHERE plan_key = '%s');",
|
||||
$plan['id'],
|
||||
$planKey,
|
||||
addslashes($plan['name']),
|
||||
$plan['sort_order'],
|
||||
$planKey
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($features as $featureKey => $feature) {
|
||||
$statements[] = sprintf(
|
||||
"INSERT INTO features (id, feature_key, name, description, is_active, created_at, updated_at)\nSELECT '%s', '%s', '%s', '%s', 1, NOW(), NOW()\nWHERE NOT EXISTS (SELECT 1 FROM features WHERE feature_key = '%s');",
|
||||
$feature['id'],
|
||||
$featureKey,
|
||||
addslashes($feature['name']),
|
||||
addslashes($feature['description']),
|
||||
$featureKey
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($planFeatures as $planKey => $featureKeys) {
|
||||
foreach ($featureKeys as $featureKey) {
|
||||
$mappingId = scripts_uuid_from_string('license-plan-feature:' . $planKey . ':' . $featureKey);
|
||||
$statements[] = sprintf(
|
||||
"INSERT INTO license_plan_features (id, license_plan_id, feature_id, created_at)\nSELECT '%s', '%s', id, NOW()\nFROM features\nWHERE feature_key = '%s'\n AND NOT EXISTS (\n SELECT 1 FROM license_plan_features lpf WHERE lpf.license_plan_id = '%s' AND lpf.feature_id = features.id\n );",
|
||||
$mappingId,
|
||||
$plans[$planKey]['id'],
|
||||
$featureKey,
|
||||
$plans[$planKey]['id']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => '2026_03_22_000002_seed_license_plans_and_features',
|
||||
'title' => 'Lizenzpläne und Features füllen',
|
||||
'description' => 'Legt die SaaS-Lizenzstufen und die dazugehörigen Feature-Flags an.',
|
||||
'statements' => $statements,
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'key' => '2026_03_22_000003_backfill_default_team_license',
|
||||
'title' => 'Standardlizenz Team für vorhandene Mandanten setzen',
|
||||
'description' => 'Alle bestehenden Mandanten erhalten standardmäßig die Team-Lizenz.',
|
||||
'statements' => [
|
||||
<<<'SQL'
|
||||
INSERT INTO tenant_licenses (id, tenant_id, license_plan_id, status, starts_at, ends_at, created_at, updated_at)
|
||||
SELECT
|
||||
CONCAT('tenant-license-', t.id),
|
||||
t.id,
|
||||
'0f6e004d-ec4c-4f12-8f91-fdf795b80a02',
|
||||
'active',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM tenants t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tenant_licenses tl WHERE tl.tenant_id = t.id
|
||||
);
|
||||
SQL,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'key' => '2026_03_22_000004_add_print_list_tables',
|
||||
'title' => 'Drucklisten für PDF-Export und Papiererfassung anlegen',
|
||||
'description' => 'Ergänzt Snapshots für druckfertige Listen und die Nacherfassung von Vorder- und Rückseiten.',
|
||||
'statements' => [
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS print_lists (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
tenant_id CHAR(36) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
unit_price DECIMAL(10,2) NOT NULL DEFAULT 0.50,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||
created_by_user_id CHAR(36) NULL,
|
||||
notes TEXT NULL,
|
||||
imported_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
CONSTRAINT print_lists_tenant_fk FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS print_list_items (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
print_list_id CHAR(36) NOT NULL,
|
||||
member_id CHAR(36) NOT NULL,
|
||||
member_name VARCHAR(255) NOT NULL,
|
||||
row_number INT NOT NULL,
|
||||
front_strokes INT NOT NULL DEFAULT 0,
|
||||
back_strokes INT NOT NULL DEFAULT 0,
|
||||
total_strokes INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY print_list_items_unique (print_list_id, member_id),
|
||||
CONSTRAINT print_list_items_list_fk FOREIGN KEY (print_list_id) REFERENCES print_lists(id),
|
||||
CONSTRAINT print_list_items_member_fk FOREIGN KEY (member_id) REFERENCES members(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
return '1.4.0';
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../saas-app/public/install-support.php';
|
||||
|
||||
$env = scripts_read_env_file(scripts_env_path());
|
||||
|
||||
if ($env === []) {
|
||||
fwrite(STDERR, "Keine .env gefunden.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$config = scripts_update_config_from_env($env);
|
||||
$results = scripts_run_pending_updates($config, 'cli');
|
||||
|
||||
foreach ($results as $result) {
|
||||
fwrite(STDOUT, sprintf("[%s] %s - %s\n", $result['status'], $result['key'], $result['message']));
|
||||
}
|
||||
Reference in New Issue
Block a user