Jahresabschluss und Benachrichitgungseinrichtung

This commit is contained in:
2026-04-09 00:24:05 +02:00
parent c297709489
commit e6146da778
10 changed files with 1986 additions and 143 deletions
@@ -0,0 +1,20 @@
<?php
return <<<'SQL'
CREATE TABLE notification_messages (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
title VARCHAR(180) NOT NULL,
message TEXT NOT NULL,
channel VARCHAR(50) NOT NULL DEFAULT 'email',
recipient_scope VARCHAR(50) NOT NULL DEFAULT 'all',
recipient_count INT NOT NULL DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
scheduled_at DATETIME NULL,
sent_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX notification_messages_tenant_status_idx (tenant_id, status, created_at),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,22 @@
<?php
return <<<'SQL'
CREATE TABLE survey_publications (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
survey_id CHAR(36) NOT NULL,
version_no INT NOT NULL DEFAULT 1,
title VARCHAR(255) NOT NULL,
member_visible TINYINT(1) NOT NULL DEFAULT 1,
published_by VARCHAR(255) NOT NULL,
published_at DATETIME NOT NULL,
snapshot_json LONGTEXT NOT NULL,
results_json LONGTEXT NOT NULL,
response_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (survey_id, version_no),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (survey_id) REFERENCES surveys(id)
);
SQL;
@@ -0,0 +1,26 @@
<?php
return <<<'SQL'
CREATE TABLE year_end_runs (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
year INT NOT NULL,
bonus_mode VARCHAR(30) NOT NULL DEFAULT 'proportional',
bonus_pool_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
bonus_per_member_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
booked_at DATETIME NOT NULL,
total_strokes INT NOT NULL DEFAULT 0,
recipient_count INT NOT NULL DEFAULT 0,
total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
preview_json LONGTEXT NOT NULL,
notes LONGTEXT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'executed',
created_by_user_id CHAR(36) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
executed_at DATETIME NOT NULL,
UNIQUE (tenant_id, year),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
);
SQL;
@@ -0,0 +1,21 @@
<?php
return <<<'SQL'
ALTER TABLE coffee_entries
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'booked' AFTER total_cost,
ADD COLUMN cancelled_at DATETIME NULL AFTER status,
ADD COLUMN cancelled_by_member_id CHAR(36) NULL AFTER cancelled_at,
ADD COLUMN cancellation_reason VARCHAR(255) NULL AFTER cancelled_by_member_id,
ADD COLUMN cancellation_ledger_entry_id CHAR(36) NULL AFTER cancellation_reason,
ADD INDEX coffee_entries_tenant_status_idx (tenant_id, status),
ADD INDEX coffee_entries_tenant_member_status_idx (tenant_id, member_id, status);
ALTER TABLE payment_entries
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'booked' AFTER amount,
ADD COLUMN cancelled_at DATETIME NULL AFTER status,
ADD COLUMN cancelled_by_member_id CHAR(36) NULL AFTER cancelled_at,
ADD COLUMN cancellation_reason VARCHAR(255) NULL AFTER cancelled_by_member_id,
ADD COLUMN cancellation_ledger_entry_id CHAR(36) NULL AFTER cancellation_reason,
ADD INDEX payment_entries_tenant_status_idx (tenant_id, status),
ADD INDEX payment_entries_tenant_member_status_idx (tenant_id, member_id, status);
SQL;
File diff suppressed because it is too large Load Diff
+335 -5
View File
@@ -34,6 +34,17 @@ function dt(?string $value): string
return $time === false ? $value : date('d.m.Y H:i', $time);
}
function dt_local(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
$time = strtotime($value);
return $time === false ? '' : date('Y-m-d\TH:i', $time);
}
function badge(string $label, string $tone = 'neutral'): string
{
return '<span class="badge badge-' . h($tone) . '">' . h($label) . '</span>';
@@ -54,8 +65,10 @@ if ($requestedPage === null) {
'/content' => 'content',
'/imports' => 'imports',
'/reports' => 'reports',
'/year-end' => 'year-end',
'/support' => 'support',
'/surveys' => 'surveys',
'/notifications' => 'notifications',
'/profil', '/profile' => 'profile',
'/settings' => 'settings',
'/exports' => 'exports',
@@ -66,7 +79,7 @@ if ($requestedPage === null) {
$page = (string) $requestedPage;
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'support', 'surveys', 'profile', 'settings', 'exports'];
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'year-end', 'support', 'surveys', 'notifications', 'profile', 'settings', 'exports'];
if ($page === 'logout' && $requestMethod === 'POST') {
app_logout();
@@ -111,6 +124,49 @@ $hasTenantSettingsFeature = false;
$hasPdfExportFeature = false;
$hasPaperStrikeEntryFeature = false;
$hasBasicExportsFeature = false;
$yearEndPreview = null;
$yearEndPreviewError = null;
$yearEndRuns = [];
$notificationStorageReady = false;
$notificationMessages = [];
$notificationLogs = [];
$notificationPreview = [];
$editingNotification = null;
$notificationSummary = [
'total' => 0,
'draft' => 0,
'scheduled' => 0,
'sent' => 0,
'recipient_count' => 0,
'log_count' => 0,
];
$notificationForm = [
'notification_id' => '',
'title' => '',
'message' => '',
'channel' => 'email',
'recipient_scope' => 'all',
'scheduled_at' => '',
];
$notificationScopeOptions = [
['value' => 'all', 'label' => 'Alle Mitglieder'],
['value' => 'front', 'label' => 'Vorderseite'],
['value' => 'back', 'label' => 'Rückseite'],
];
$notificationChannelOptions = [
['value' => 'email', 'label' => 'E-Mail'],
['value' => 'inapp', 'label' => 'In-App'],
['value' => 'sms', 'label' => 'SMS'],
];
$yearEndForm = [
'year' => (int) date('Y') - 1,
'bonus_mode' => 'proportional',
'bonus_pool_amount' => '100.00',
'bonus_per_member_amount' => '2.50',
'booked_at' => sprintf('%04d-12-31T23:59', (int) date('Y') - 1),
'notes' => '',
];
$yearEndExistingRun = null;
try {
$pdo = app_pdo();
@@ -177,6 +233,100 @@ if ($auth !== null && $pdo instanceof PDO) {
app_handle_settings_action($pdo, $auth);
}
if ($page === 'notifications') {
if (!isset($_SESSION['notifications_csrf'])) {
$_SESSION['notifications_csrf'] = bin2hex(random_bytes(24));
}
$notificationStorageReady = app_notifications_supports_storage($pdo);
app_handle_notifications_action($pdo, $auth);
$notificationMessages = app_notifications_for_tenant($pdo, (string) $auth['tenant_id']);
$notificationLogs = app_notification_logs_for_tenant($pdo, (string) $auth['tenant_id']);
$notificationSummary['total'] = count($notificationMessages);
$notificationSummary['draft'] = count(array_filter($notificationMessages, static fn(array $row): bool => (string) ($row['status'] ?? '') === 'draft'));
$notificationSummary['scheduled'] = count(array_filter($notificationMessages, static fn(array $row): bool => (string) ($row['status'] ?? '') === 'scheduled'));
$notificationSummary['sent'] = count(array_filter($notificationMessages, static fn(array $row): bool => (string) ($row['status'] ?? '') === 'sent'));
$notificationSummary['recipient_count'] = array_sum(array_map(static fn(array $row): int => (int) ($row['recipient_count'] ?? 0), $notificationMessages));
$notificationSummary['log_count'] = count($notificationLogs);
$editNotificationId = trim((string) ($_GET['edit'] ?? ''));
if ($editNotificationId !== '') {
$editingNotification = app_notification_message_by_id($pdo, (string) $auth['tenant_id'], $editNotificationId);
}
if ($editingNotification !== null) {
$notificationForm = [
'notification_id' => (string) ($editingNotification['id'] ?? ''),
'title' => (string) ($editingNotification['title'] ?? ''),
'message' => (string) ($editingNotification['message'] ?? ''),
'channel' => (string) ($editingNotification['channel'] ?? 'email'),
'recipient_scope' => (string) ($editingNotification['recipient_scope'] ?? 'all'),
'scheduled_at' => dt_local((string) ($editingNotification['scheduled_at'] ?? '')),
];
} else {
$notificationForm['scheduled_at'] = date('Y-m-d\TH:i', time() + 3600);
}
$selectedScope = in_array((string) ($_GET['scope'] ?? $notificationForm['recipient_scope']), ['all', 'front', 'back'], true)
? (string) ($_GET['scope'] ?? $notificationForm['recipient_scope'])
: 'all';
$notificationPreview = app_notification_recipients_for_scope($pdo, (string) $auth['tenant_id'], $selectedScope);
$notificationForm['recipient_scope'] = $selectedScope;
}
if ($page === 'year-end') {
if ($requestMethod === 'POST') {
$yearEndForm = array_merge($yearEndForm, [
'year' => max(2000, (int) ($_POST['year'] ?? $yearEndForm['year'])),
'bonus_mode' => in_array((string) ($_POST['bonus_mode'] ?? 'proportional'), ['proportional', 'flat'], true)
? (string) ($_POST['bonus_mode'] ?? 'proportional')
: 'proportional',
'bonus_pool_amount' => number_format(max(0.0, (float) ($_POST['bonus_pool_amount'] ?? 0)), 2, '.', ''),
'bonus_per_member_amount' => number_format(max(0.0, (float) ($_POST['bonus_per_member_amount'] ?? 0)), 2, '.', ''),
'booked_at' => str_replace(' ', 'T', (string) ($_POST['booked_at'] ?? $yearEndForm['booked_at'])),
'notes' => trim((string) ($_POST['notes'] ?? '')),
]);
$yearEndForm['booked_at'] = substr($yearEndForm['booked_at'], 0, 16);
if ((string) ($_POST['action'] ?? '') === 'preview-year-end') {
try {
$yearEndPreview = app_year_end_preview($pdo, (string) $auth['tenant_id'], [
'year' => $yearEndForm['year'],
'bonus_mode' => $yearEndForm['bonus_mode'],
'bonus_pool_amount' => $yearEndForm['bonus_pool_amount'],
'bonus_per_member_amount' => $yearEndForm['bonus_per_member_amount'],
'booked_at' => $yearEndForm['booked_at'],
'notes' => $yearEndForm['notes'],
]);
} catch (Throwable $exception) {
$yearEndPreviewError = $exception->getMessage();
}
}
if ((string) ($_POST['action'] ?? '') === 'execute-year-end') {
try {
$result = app_year_end_execute($pdo, $auth, [
'year' => $yearEndForm['year'],
'bonus_mode' => $yearEndForm['bonus_mode'],
'bonus_pool_amount' => $yearEndForm['bonus_pool_amount'],
'bonus_per_member_amount' => $yearEndForm['bonus_per_member_amount'],
'booked_at' => $yearEndForm['booked_at'],
'notes' => $yearEndForm['notes'],
]);
app_flash('Der Jahresabschluss wurde verbucht. ' . (string) ($result['payment_count'] ?? 0) . ' Auszahlungen wurden erzeugt.', 'success');
app_redirect('/year-end/');
} catch (Throwable $exception) {
app_flash($exception->getMessage(), 'error');
app_redirect('/year-end/');
}
}
}
$yearEndRuns = app_year_end_runs_for_tenant($pdo, (string) $auth['tenant_id']);
$yearEndExistingRun = app_year_end_run_exists($pdo, (string) $auth['tenant_id'], (int) $yearEndForm['year']);
}
if ($page === 'exports') {
app_handle_export_action($pdo, $auth, $tenantSettings);
app_handle_export_download($pdo, $auth);
@@ -309,6 +459,8 @@ $restrictedPages = [
'payments' => static fn(array $user): bool => app_can_manage_finance($user),
'imports' => static fn(array $user): bool => app_can_manage_finance($user),
'reports' => static fn(array $user): bool => app_can_manage_tenant($user),
'year-end' => static fn(array $user): bool => app_can_manage_finance($user),
'notifications' => static fn(array $user): bool => app_can_manage_support($user),
'settings' => static fn(array $user): bool => app_can_manage_tenant($user),
'exports' => static fn(array $user): bool => app_can_manage_tenant($user),
];
@@ -905,6 +1057,7 @@ $marketing = app_marketing_messages();
<h3><?= h((string) ($membership['tenant_name'] ?? 'Tenant')) ?></h3>
<p><?= h((string) ($membership['display_name'] ?? $membership['user_email'] ?? '')) ?></p>
<p class="muted"><?= h((string) ($membership['role_keys'] ?? 'Mitglied')) ?></p>
<p class="muted"><?= (int) ($membership['oidc_provider_count'] ?? 0) > 0 ? 'Anmeldung per Passwort oder ADFS/OIDC vorbereitet.' : 'Lokale Passwort-Anmeldung.' ?></p>
<div class="actions" style="margin-top:12px"><button type="submit">Diesen Tenant öffnen</button></div>
</form>
<?php endforeach; ?>
@@ -921,6 +1074,12 @@ $marketing = app_marketing_messages();
<a class="button secondary" href="/login/">Neu starten</a>
</div>
</form>
<?php if ((int) ($selectedMembership['oidc_provider_count'] ?? 0) > 0): ?>
<div class="metric" style="margin-top:16px">
<h3>ADFS / OIDC im Tenant</h3>
<p>Für diesen Bereich ist Single-Sign-on vorbereitet. Die produktive Weiterleitung hängt von der aktivierten Provider-Konfiguration des Tenants ab; die lokale Passwort-Anmeldung bleibt als Fallback verfügbar.</p>
</div>
<?php endif; ?>
<?php else: ?>
<div class="eyebrow">Schritt 1</div>
<h2>E-Mail eingeben</h2>
@@ -1312,11 +1471,13 @@ $marketing = app_marketing_messages();
</div>
<strong><?= money($entry['amount'] ?? 0) ?></strong>
</div>
<?php if ((string) ($entry['reference_type'] ?? '') === 'coffee_entry' && (string) ($entry['reference_id'] ?? '') !== ''): ?>
<?php if ((string) ($entry['reference_status'] ?? 'booked') === 'cancelled'): ?>
<span class="muted">Storniert am <?= dt((string) ($entry['reference_cancelled_at'] ?? '')) ?></span>
<?php elseif ((string) ($entry['reference_type'] ?? '') === 'coffee_entry' && (string) ($entry['reference_id'] ?? '') !== ''): ?>
<form method="post" action="/ledger/">
<input type="hidden" name="action" value="delete-coffee">
<input type="hidden" name="reference_id" value="<?= h((string) ($entry['reference_id'] ?? '')) ?>">
<button type="submit" class="button secondary">Löschen</button>
<button type="submit" class="button secondary">Stornieren</button>
</form>
<?php endif; ?>
</article>
@@ -1377,7 +1538,7 @@ $marketing = app_marketing_messages();
<div class="actions"><button type="submit">Buchen</button></div>
</form>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Typ</th><th>Referenz</th><th>Betrag</th><th>Aktion</th></tr></thead><tbody><?php foreach ($ledger as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><?php if ((string) ($entry['reference_type'] ?? '') === 'coffee_entry' && (string) ($entry['reference_id'] ?? '') !== ''): ?><form method="post" action="/ledger/"><input type="hidden" name="action" value="delete-coffee"><input type="hidden" name="reference_id" value="<?= h((string) ($entry['reference_id'] ?? '')) ?>"><button type="submit" class="button secondary">Löschen</button></form><?php else: ?><span class="muted">-</span><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Typ</th><th>Referenz</th><th>Betrag</th><th>Aktion</th></tr></thead><tbody><?php foreach ($ledger as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?><?php if ((string) ($entry['reference_status'] ?? 'booked') === 'cancelled'): ?><br><span class="muted">Storniert</span><?php endif; ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><?php if ((string) ($entry['reference_status'] ?? 'booked') === 'cancelled'): ?><span class="muted">Bereits storniert</span><?php elseif ((string) ($entry['reference_type'] ?? '') === 'coffee_entry' && (string) ($entry['reference_id'] ?? '') !== ''): ?><form method="post" action="/ledger/"><input type="hidden" name="action" value="delete-coffee"><input type="hidden" name="reference_id" value="<?= h((string) ($entry['reference_id'] ?? '')) ?>"><button type="submit" class="button secondary">Stornieren</button></form><?php else: ?><span class="muted">-</span><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div></section>
</div>
<?php elseif ($page === 'payments'): ?>
<section class="hero"><div class="eyebrow">Einzahlungen</div><h1>Zahlungen</h1><p>Einzahlungen werden direkt in Zahlungstabelle und Ledger geschrieben.</p></section>
@@ -1433,7 +1594,7 @@ $marketing = app_marketing_messages();
<div class="actions"><button type="submit">Einzahlung speichern</button></div>
</form>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Methode</th><th>Betrag</th><th>Aktion</th></tr></thead><tbody><?php foreach ($payments as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['payment_method'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><form method="post" action="/payments/"><input type="hidden" name="action" value="delete-payment"><input type="hidden" name="payment_id" value="<?= h((string) ($entry['id'] ?? '')) ?>"><button type="submit" class="button secondary">Stornieren</button></form></td></tr><?php endforeach; ?></tbody></table></div></section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Methode</th><th>Betrag</th><th>Status</th><th>Aktion</th></tr></thead><tbody><?php foreach ($payments as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['payment_method'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><?php if ((string) ($entry['status'] ?? 'booked') === 'cancelled'): ?><?= badge('Storniert', 'warning') ?><?php else: ?><?= badge('Aktiv', 'success') ?><?php endif; ?><?php if ((string) ($entry['status'] ?? 'booked') === 'cancelled' && (string) ($entry['cancelled_at'] ?? '') !== ''): ?><br><span class="muted"><?= dt((string) ($entry['cancelled_at'] ?? '')) ?></span><?php endif; ?></td><td><?php if ((string) ($entry['status'] ?? 'booked') === 'cancelled'): ?><span class="muted">Bereits storniert</span><?php else: ?><form method="post" action="/payments/"><input type="hidden" name="action" value="delete-payment"><input type="hidden" name="payment_id" value="<?= h((string) ($entry['id'] ?? '')) ?>"><button type="submit" class="button secondary">Stornieren</button></form><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php elseif ($page === 'content'): ?>
<section class="hero"><div class="eyebrow">Hinweise und FAQ</div><h1>Hinweise und FAQ</h1><p>Hinweise und häufige Fragen werden pro Tenant gepflegt und direkt an die Mitglieder ausgespielt.</p></section>
<section class="grid grid-2">
@@ -1466,6 +1627,39 @@ $marketing = app_marketing_messages();
<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><?php if ($canManageTenant): ?><div class="actions" style="margin-top:12px"><a class="button secondary" href="/content/?edit_faq=<?= h((string) ($entry['id'] ?? '')) ?>">Bearbeiten</a><form method="post" action="/content/"><input type="hidden" name="action" value="archive-faq"><input type="hidden" name="faq_id" value="<?= h((string) ($entry['id'] ?? '')) ?>"><button type="submit" class="button secondary"><?= !empty($entry['is_active']) ? 'Archivieren' : 'Ausblenden' ?></button></form></div><?php endif; ?></div><?php endforeach; ?></div>
</article>
</section>
<?php elseif ($page === 'notifications'): ?>
<section class="hero"><div class="eyebrow">Benachrichtigungen</div><h1>Massenkommunikation pro Tenant</h1><p>Hinweise, Erinnerungen und Service-Meldungen werden als Entwurf, geplanter Versand oder direkte Auslieferung pro Tenant geführt.</p></section>
<?php if (!$notificationStorageReady): ?><section class="alert alert-warning">Die Tabelle für Benachrichtigungen fehlt noch. Solange die Migration nicht gelaufen ist, werden Demo-Daten gezeigt und kein echter Versand gespeichert.</section><?php endif; ?>
<section class="grid grid-4">
<article class="metric"><strong><?= num((int) ($notificationSummary['total'] ?? 0)) ?></strong><h3>Kampagnen</h3><p>Gespeicherte Benachrichtigungen im Tenant.</p></article>
<article class="metric"><strong><?= num((int) ($notificationSummary['draft'] ?? 0)) ?></strong><h3>Entwürfe</h3><p>Noch nicht ausgelöste Nachrichten.</p></article>
<article class="metric"><strong><?= num((int) ($notificationSummary['scheduled'] ?? 0)) ?></strong><h3>Geplant</h3><p>Nachrichten mit Versandzeitpunkt.</p></article>
<article class="metric"><strong><?= num((int) ($notificationSummary['recipient_count'] ?? 0)) ?></strong><h3>Empfänger</h3><p>Geplante oder erreichte Mitglieder.</p></article>
</section>
<section class="grid grid-2" style="margin-top:18px">
<article class="card">
<h2><?= $editingNotification !== null ? 'Benachrichtigung bearbeiten' : 'Neue Benachrichtigung' ?></h2>
<form method="post" action="/notifications/" class="grid">
<input type="hidden" name="csrf" value="<?= h((string) ($_SESSION['notifications_csrf'] ?? '')) ?>">
<input type="hidden" name="notification_id" value="<?= h((string) ($notificationForm['notification_id'] ?? '')) ?>">
<label>Titel<input type="text" name="title" value="<?= h((string) ($notificationForm['title'] ?? '')) ?>" placeholder="Saldo-Erinnerung"></label>
<label>Kanal<select name="channel"><?php foreach ($notificationChannelOptions as $option): ?><option value="<?= h((string) $option['value']) ?>"<?= (($notificationForm['channel'] ?? 'email') === $option['value']) ? ' selected' : '' ?>><?= h((string) $option['label']) ?></option><?php endforeach; ?></select></label>
<label>Empfänger<select name="recipient_scope"><?php foreach ($notificationScopeOptions as $option): ?><option value="<?= h((string) $option['value']) ?>"<?= (($notificationForm['recipient_scope'] ?? 'all') === $option['value']) ? ' selected' : '' ?>><?= h((string) $option['label']) ?></option><?php endforeach; ?></select></label>
<label>Geplanter Versand<input type="datetime-local" name="scheduled_at" value="<?= h((string) ($notificationForm['scheduled_at'] ?? '')) ?>"></label>
<label style="grid-column:1 / -1;">Nachricht<textarea name="message" rows="8" placeholder="Freundliche Erinnerung oder Service-Hinweis"><?= h((string) ($notificationForm['message'] ?? '')) ?></textarea></label>
<div class="actions" style="grid-column:1 / -1;"><button type="submit" name="action" value="save-notification" class="button secondary">Entwurf speichern</button><button type="submit" name="action" value="send-notification">Jetzt senden</button></div>
</form>
</article>
<article class="card">
<h2>Empfängervorschau</h2>
<div class="actions" style="margin-bottom:18px"><?php foreach ($notificationScopeOptions as $option): ?><a class="button <?= (($notificationForm['recipient_scope'] ?? 'all') === $option['value']) ? '' : 'secondary' ?>" href="/notifications/?scope=<?= h((string) $option['value']) ?><?= !empty($notificationForm['notification_id']) ? '&edit=' . h((string) $notificationForm['notification_id']) : '' ?>"><?= h((string) $option['label']) ?></a><?php endforeach; ?></div>
<div class="stack"><?php foreach ($notificationPreview as $member): ?><div class="metric"><h3><?= h((string) ($member['display_name'] ?? 'Mitglied')) ?></h3><p><?= num($member['recent_strokes'] ?? 0) ?> Striche in 100 Tagen</p></div><?php endforeach; ?><?php if ($notificationPreview === []): ?><div class="metric"><h3>Keine aktiven Mitglieder</h3><p>Für die gewählte Zielgruppe wurden derzeit keine Empfänger gefunden.</p></div><?php endif; ?></div>
</article>
</section>
<section class="grid grid-2" style="margin-top:18px">
<article class="card"><h2>Kampagnen</h2><div class="table"><table><thead><tr><th>Titel</th><th>Kanal</th><th>Empfänger</th><th>Status</th><th>Aktion</th></tr></thead><tbody><?php foreach ($notificationMessages as $message): ?><tr><td><strong><?= h((string) ($message['title'] ?? '')) ?></strong><br><span class="muted"><?= h((string) ($message['message'] ?? '')) ?></span></td><td><?= h((string) ($message['channel_label'] ?? 'E-Mail')) ?></td><td><?= h((string) ($message['scope_label'] ?? 'Alle Mitglieder')) ?><br><span class="muted"><?= num((int) ($message['recipient_count'] ?? 0)) ?> Personen</span></td><td><?= badge((string) ($message['status_label'] ?? 'Entwurf'), (string) ($message['status_tone'] ?? 'neutral')) ?></td><td><a class="button secondary" href="/notifications/?edit=<?= h((string) ($message['id'] ?? '')) ?>">Bearbeiten</a></td></tr><?php endforeach; ?><?php if ($notificationMessages === []): ?><tr><td colspan="5">Noch keine Benachrichtigungen gespeichert.</td></tr><?php endif; ?></tbody></table></div></article>
<article class="card"><h2>Versandprotokoll</h2><div class="table"><table><thead><tr><th>Kanal</th><th>Empfänger</th><th>Typ</th><th>Status</th></tr></thead><tbody><?php foreach ($notificationLogs as $log): ?><tr><td><?= h((string) ($log['channel_label'] ?? 'E-Mail')) ?></td><td><?= h((string) ($log['recipient'] ?? '-')) ?></td><td><?= h((string) ($log['template_key'] ?? '-')) ?></td><td><?= badge((string) ($log['status_label'] ?? 'Geplant'), (string) ($log['status_tone'] ?? 'neutral')) ?></td></tr><?php endforeach; ?><?php if ($notificationLogs === []): ?><tr><td colspan="4">Noch keine Versandprotokolle vorhanden.</td></tr><?php endif; ?></tbody></table></div></article>
</section>
<?php elseif ($page === 'profile'): ?>
<section class="hero"><div class="eyebrow">Konto</div><h1>Persönliche Einstellungen</h1><p>Hier pflegst du deinen Anzeigenamen, dein Passwort und deinen persönlichen Anzeigemodus. Diese Einstellungen gelten nur für dich.</p></section>
<section class="grid grid-2">
@@ -1584,6 +1778,142 @@ $marketing = app_marketing_messages();
<?php endforeach; ?>
</div>
</section>
<?php elseif ($page === 'year-end'): ?>
<section class="hero">
<div class="eyebrow">Sonderprozess</div>
<h1>Jahresabschluss und Bonus</h1>
<p>Hier berechnest du die Jahresverteilung auf Basis der im Jahr gebuchten Striche und verbuchst sie als nachvollziehbare Auszahlungen.</p>
</section>
<?php if (!($pdo instanceof PDO) || !scripts_table_exists($pdo, 'year_end_runs')): ?>
<section class="alert alert-warning">Die Migration für den Jahresabschluss fehlt noch. Bitte die Datenbankmigrationen ausführen.</section>
<?php endif; ?>
<?php if ($yearEndPreviewError !== null): ?>
<section class="alert alert-warning"><?= h($yearEndPreviewError) ?></section>
<?php endif; ?>
<section class="grid grid-2">
<article class="card">
<h2>Vorschau berechnen</h2>
<form method="post" action="/year-end/" class="grid">
<input type="hidden" name="action" value="preview-year-end">
<label>Jahr<input type="number" name="year" min="2000" max="2100" step="1" value="<?= h((string) $yearEndForm['year']) ?>"></label>
<label>Bonusmodus<select name="bonus_mode"><option value="proportional"<?= ($yearEndForm['bonus_mode'] ?? 'proportional') === 'proportional' ? ' selected' : '' ?>>Proportional nach Strichen</option><option value="flat"<?= ($yearEndForm['bonus_mode'] ?? '') === 'flat' ? ' selected' : '' ?>>Fix pro Mitglied</option></select></label>
<p class="muted" style="grid-column:1 / -1;">Im proportionalen Modus wird der Bonus-Topf nach Jahresstrichen verteilt. Im fixen Modus erhält jedes aktive Mitglied denselben Betrag.</p>
<label>Bonus-Topf<input type="number" name="bonus_pool_amount" min="0" step="0.01" value="<?= h((string) $yearEndForm['bonus_pool_amount']) ?>"></label>
<label>Fixer Bonus pro Mitglied<input type="number" name="bonus_per_member_amount" min="0" step="0.01" value="<?= h((string) $yearEndForm['bonus_per_member_amount']) ?>"></label>
<label>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= h((string) $yearEndForm['booked_at']) ?>"></label>
<label style="grid-column:1 / -1;">Hinweis<textarea name="notes" placeholder="Optionaler Hinweis für die Jahresabschlussbuchung"><?= h((string) $yearEndForm['notes']) ?></textarea></label>
<div class="actions" style="grid-column:1 / -1;"><button type="submit">Vorschau berechnen</button></div>
</form>
</article>
<article class="card">
<h2>Was die Vorschau zeigt</h2>
<div class="grid grid-2">
<article class="metric">
<strong><?= num($yearEndPreview['total_strokes'] ?? 0) ?></strong>
<h3>Jahresstriche</h3>
<p>Aktive Striche im ausgewählten Jahr.</p>
</article>
<article class="metric">
<strong><?= num((int) ($yearEndPreview['member_count'] ?? 0)) ?></strong>
<h3>Aktive Mitglieder</h3>
<p>Mitglieder, die in die Berechnung eingehen.</p>
</article>
<article class="metric">
<strong><?= money($yearEndPreview['total_amount'] ?? 0) ?></strong>
<h3>Verteilte Summe</h3>
<p>Effektiv berechnete Bonusgesamtsumme.</p>
</article>
<article class="metric">
<strong><?= num((int) ($yearEndPreview['recipient_count'] ?? 0)) ?></strong>
<h3>Empfänger</h3>
<p>Mitglieder mit einer Auszahlung größer 0.</p>
</article>
</div>
<?php if ($yearEndExistingRun !== null): ?>
<div class="alert alert-info" style="margin-top:16px;">Für dieses Jahr existiert bereits ein verbuchter Jahresabschluss vom <?= dt((string) ($yearEndExistingRun['executed_at'] ?? '')) ?>.</div>
<?php endif; ?>
</article>
</section>
<?php if (is_array($yearEndPreview)): ?>
<section class="card" style="margin-top:18px">
<div class="bulk-toolbar">
<div>
<div class="eyebrow">Vorschau</div>
<h2 style="margin:0">Berechnete Verteilung für <?= h((string) ($yearEndPreview['year'] ?? '')) ?></h2>
<div class="bulk-summary">
<span class="bulk-chip"><?= h((string) ($yearEndPreview['mode'] === 'flat' ? 'Fix pro Mitglied' : 'Proportional nach Strichen')) ?></span>
<span><?= num((int) ($yearEndPreview['member_count'] ?? 0)) ?> aktive Mitglieder</span>
</div>
</div>
<?php if ($yearEndExistingRun === null): ?>
<form method="post" action="/year-end/">
<input type="hidden" name="action" value="execute-year-end">
<input type="hidden" name="year" value="<?= h((string) ($yearEndPreview['year'] ?? $yearEndForm['year'])) ?>">
<input type="hidden" name="bonus_mode" value="<?= h((string) ($yearEndPreview['mode'] ?? 'proportional')) ?>">
<input type="hidden" name="bonus_pool_amount" value="<?= h((string) ($yearEndPreview['bonus_pool_amount'] ?? '0.00')) ?>">
<input type="hidden" name="bonus_per_member_amount" value="<?= h((string) ($yearEndPreview['bonus_per_member_amount'] ?? '0.00')) ?>">
<input type="hidden" name="booked_at" value="<?= h((string) ($yearEndPreview['booked_at'] ?? '')) ?>">
<input type="hidden" name="notes" value="<?= h((string) ($yearEndPreview['notes'] ?? '')) ?>">
<button type="submit">Jahresabschluss verbuchen</button>
</form>
<?php else: ?>
<div class="alert alert-info" style="margin-top:8px;">Für dieses Jahr wurde bereits ein Lauf abgeschlossen. Neue Buchungen werden hier deshalb nicht mehr angeboten.</div>
<?php endif; ?>
</div>
<div class="table" style="margin-top:16px">
<table>
<thead><tr><th>Mitglied</th><th>Jahresstriche</th><th>Anteil</th><th>Bonus</th><th>Saldo vorher</th><th>Saldo nachher</th></tr></thead>
<tbody>
<?php foreach (($yearEndPreview['rows'] ?? []) as $row): ?>
<tr>
<td><strong><?= h((string) ($row['display_name'] ?? '')) ?></strong><br><span class="muted"><?= h((string) ($row['email'] ?? '')) ?></span></td>
<td><?= num($row['year_strokes'] ?? 0) ?></td>
<td><?= ($row['share_percent'] ?? null) !== null ? h((string) $row['share_percent']) . ' %' : '—' ?></td>
<td><?= money($row['bonus_amount'] ?? 0) ?></td>
<td><?= money($row['current_balance'] ?? 0) ?></td>
<td><?= money($row['projected_balance'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php endif; ?>
<section class="card" style="margin-top:18px">
<div class="bulk-toolbar">
<div>
<div class="eyebrow">Verlauf</div>
<h2 style="margin:0">Letzte Jahresabschlüsse</h2>
</div>
<span class="bulk-chip"><?= num(count($yearEndRuns)) ?> Läufe</span>
</div>
<div class="table" style="margin-top:16px">
<table>
<thead><tr><th>Jahr</th><th>Modus</th><th>Summe</th><th>Striche</th><th>Empfänger</th><th>Ausgeführt</th><th>Von</th></tr></thead>
<tbody>
<?php foreach ($yearEndRuns as $run): ?>
<tr>
<td><strong><?= h((string) ($run['year'] ?? '')) ?></strong></td>
<td><?= h((string) (($run['bonus_mode'] ?? '') === 'flat' ? 'Fix pro Mitglied' : 'Proportional')) ?></td>
<td><?= money($run['total_amount'] ?? 0) ?></td>
<td><?= num($run['total_strokes'] ?? 0) ?></td>
<td><?= num($run['recipient_count'] ?? 0) ?></td>
<td><?= dt((string) ($run['executed_at'] ?? $run['created_at'] ?? '')) ?></td>
<td><?= h((string) ($run['created_by_name'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php if ($yearEndRuns === []): ?>
<tr><td colspan="7">Noch kein Jahresabschluss verbucht.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<?php elseif ($page === 'exports'): ?>
<?php if (isset($_GET['view']) && $_GET['view'] === 'print' && is_array($activePrintList)): ?>
<section class="hero"><div class="eyebrow">PDF-Vorschau</div><h1><?= h((string) $activePrintList['title']) ?></h1><p><?= (($printListGroups['mode'] ?? 'single') === 'single') ? 'Diese Liste wird als einzelne PDF-Seite erzeugt.' : 'Diese Liste wird als Vorder- und Rückseite erzeugt.' ?> Die Zeilenhöhe liegt aktuell bei <?= h((string) ($printListGroups['row_height_mm'] ?? ($tenantSettings['pdf_row_height_mm'] ?? '4.00'))) ?> mm.</p><div class="actions" style="margin-top:18px"><a class="button secondary" href="/exports/?list=<?= h((string) $activePrintList['id']) ?>">Zurück</a><a class="button" href="/exports/?download=print-list-pdf&list=<?= h((string) $activePrintList['id']) ?>">PDF herunterladen</a></div></section>
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'notifications';
require dirname(__DIR__) . '/index.php';
+352 -64
View File
@@ -56,9 +56,260 @@ function survey_option_text_to_json(string $raw, string $type): ?string
return $options === [] ? null : json_encode($options, JSON_UNESCAPED_UNICODE);
}
function survey_publications_ready(PDO $pdo): bool
{
return scripts_table_exists($pdo, 'survey_publications');
}
function survey_fetch_questions(PDO $pdo, string $surveyId): array
{
$hasOptions = app_table_has_column($pdo, 'survey_questions', 'options_json');
$questionSelect = $hasOptions ? 'options_json' : 'NULL AS options_json';
$questions = app_query_all(
$pdo,
str_replace(
'__OPTIONS__',
$questionSelect,
<<<'SQL'
SELECT id, question, question_type, is_required, sort_order, __OPTIONS__
FROM survey_questions
WHERE survey_id = :survey_id
ORDER BY sort_order ASC, created_at ASC
SQL
),
['survey_id' => $surveyId]
);
foreach ($questions as &$question) {
$question['options'] = survey_options_from_storage((string) ($question['options_json'] ?? ''), (string) ($question['question_type'] ?? 'text'));
}
unset($question);
return $questions;
}
function survey_build_results(PDO $pdo, array $questions, string $surveyId): array
{
$results = [];
foreach ($questions as $question) {
$questionId = (string) ($question['id'] ?? '');
$questionType = (string) ($question['question_type'] ?? 'text');
$entries = [];
if ($questionType === 'text') {
$entries = app_query_all(
$pdo,
'SELECT answer_text, created_at FROM survey_answers WHERE survey_id = :survey_id AND question_id = :question_id AND answer_text <> "" ORDER BY created_at DESC LIMIT 5',
['survey_id' => $surveyId, 'question_id' => $questionId]
);
} elseif ($questionType === 'multi_select') {
$summary = [];
foreach (app_query_all(
$pdo,
'SELECT answer_text FROM survey_answers WHERE survey_id = :survey_id AND question_id = :question_id ORDER BY created_at ASC',
['survey_id' => $surveyId, 'question_id' => $questionId]
) as $answer) {
$answerText = trim((string) ($answer['answer_text'] ?? ''));
if ($answerText === '') {
continue;
}
foreach (array_filter(array_map('trim', preg_split('/\R+/', str_replace([",", "\r\n"], "\n", $answerText)) ?: [])) as $item) {
$summary[$item] = ($summary[$item] ?? 0) + 1;
}
}
$options = is_array($question['options'] ?? null) ? array_values(array_map('strval', $question['options'])) : [];
$orderedSummary = [];
if ($options !== []) {
foreach ($options as $option) {
$orderedSummary[$option] = $summary[$option] ?? 0;
}
foreach ($summary as $label => $count) {
if (!array_key_exists($label, $orderedSummary)) {
$orderedSummary[$label] = $count;
}
}
} else {
ksort($summary);
$orderedSummary = $summary;
}
foreach ($orderedSummary as $label => $count) {
$entries[] = [
'answer_text' => $label,
'answer_count' => $count,
];
}
} else {
$entries = app_query_all(
$pdo,
'SELECT answer_text, COUNT(*) AS answer_count FROM survey_answers WHERE survey_id = :survey_id AND question_id = :question_id GROUP BY answer_text ORDER BY answer_count DESC, answer_text ASC',
['survey_id' => $surveyId, 'question_id' => $questionId]
);
}
$results[] = [
'question' => $question,
'entries' => $entries,
];
}
return $results;
}
function survey_build_snapshot(PDO $pdo, array $survey): array
{
$questions = is_array($survey['questions'] ?? null) ? $survey['questions'] : [];
$surveyId = (string) ($survey['id'] ?? '');
return [
'survey' => [
'id' => $surveyId,
'tenant_id' => (string) ($survey['tenant_id'] ?? ''),
'title' => (string) ($survey['title'] ?? ''),
'status' => (string) ($survey['status'] ?? 'draft'),
'starts_at' => (string) ($survey['starts_at'] ?? ''),
'ends_at' => (string) ($survey['ends_at'] ?? ''),
'created_at' => (string) ($survey['created_at'] ?? ''),
'updated_at' => (string) ($survey['updated_at'] ?? ''),
],
'questions' => array_map(static function (array $question): array {
return [
'id' => (string) ($question['id'] ?? ''),
'question' => (string) ($question['question'] ?? ''),
'question_type' => (string) ($question['question_type'] ?? 'text'),
'is_required' => (int) ($question['is_required'] ?? 0),
'sort_order' => (int) ($question['sort_order'] ?? 0),
'options' => is_array($question['options'] ?? null) ? array_values($question['options']) : [],
];
}, $questions),
'results' => survey_build_results($pdo, $questions, $surveyId),
];
}
function survey_decode_publication_row(array $row): array
{
$snapshot = json_decode((string) ($row['snapshot_json'] ?? '[]'), true);
$results = json_decode((string) ($row['results_json'] ?? '[]'), true);
return [
'id' => (string) ($row['id'] ?? ''),
'survey_id' => (string) ($row['survey_id'] ?? ''),
'tenant_id' => (string) ($row['tenant_id'] ?? ''),
'version_no' => (int) ($row['version_no'] ?? 0),
'title' => (string) ($row['title'] ?? ''),
'member_visible' => !empty($row['member_visible']),
'published_by' => (string) ($row['published_by'] ?? ''),
'published_at' => (string) ($row['published_at'] ?? ''),
'response_count' => (int) ($row['response_count'] ?? 0),
'snapshot' => is_array($snapshot) ? $snapshot : [],
'results' => is_array($results) ? $results : [],
];
}
function survey_fetch_publications(PDO $pdo, string $surveyId): array
{
if (!survey_publications_ready($pdo) || $surveyId === '') {
return [];
}
$rows = app_query_all(
$pdo,
'SELECT id, survey_id, tenant_id, version_no, title, member_visible, published_by, published_at, snapshot_json, results_json, response_count, created_at, updated_at FROM survey_publications WHERE survey_id = :survey_id ORDER BY version_no DESC, published_at DESC',
['survey_id' => $surveyId]
);
return array_map('survey_decode_publication_row', $rows);
}
function survey_latest_publication(PDO $pdo, string $surveyId): ?array
{
$publications = survey_fetch_publications($pdo, $surveyId);
return $publications[0] ?? null;
}
function survey_publish_snapshot(PDO $pdo, string $tenantId, array $auth, array $survey, bool $memberVisible): array
{
if (!survey_publications_ready($pdo)) {
throw new RuntimeException('Die Freigabe kann erst nach dem Datenbank-Update verwendet werden.');
}
$surveyId = (string) ($survey['id'] ?? '');
$questions = is_array($survey['questions'] ?? null) ? $survey['questions'] : [];
if ($surveyId === '') {
throw new RuntimeException('Die gewählte Umfrage konnte nicht freigegeben werden.');
}
if ($questions === []) {
throw new RuntimeException('Eine Umfrage braucht mindestens eine Frage, bevor sie freigegeben werden kann.');
}
$now = date('Y-m-d H:i:s');
$snapshot = survey_build_snapshot($pdo, $survey);
$results = $snapshot['results'];
$versionNo = (int) (app_query_value(
$pdo,
'SELECT COALESCE(MAX(version_no), 0) FROM survey_publications WHERE survey_id = :survey_id',
['survey_id' => $surveyId],
0
) ?? 0) + 1;
$publishedBy = trim((string) ($auth['display_name'] ?? ''));
if ($publishedBy === '') {
$publishedBy = trim((string) ($auth['name'] ?? ''));
}
if ($publishedBy === '') {
$publishedBy = 'Unbekannt';
}
app_execute(
$pdo,
'INSERT INTO survey_publications (id, tenant_id, survey_id, version_no, title, member_visible, published_by, published_at, snapshot_json, results_json, response_count, created_at, updated_at) VALUES (:id, :tenant_id, :survey_id, :version_no, :title, :member_visible, :published_by, :published_at, :snapshot_json, :results_json, :response_count, :created_at, :updated_at)',
[
'id' => app_uuid(),
'tenant_id' => $tenantId,
'survey_id' => $surveyId,
'version_no' => $versionNo,
'title' => (string) ($survey['title'] ?? ''),
'member_visible' => $memberVisible ? 1 : 0,
'published_by' => $publishedBy,
'published_at' => $now,
'snapshot_json' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'results_json' => json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'response_count' => (int) app_query_value(
$pdo,
'SELECT COUNT(DISTINCT tenant_user_id) FROM survey_answers WHERE survey_id = :survey_id',
['survey_id' => $surveyId],
0
),
'created_at' => $now,
'updated_at' => $now,
]
);
app_execute(
$pdo,
'UPDATE surveys SET status = :status, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id',
[
'status' => 'published',
'updated_at' => $now,
'tenant_id' => $tenantId,
'id' => $surveyId,
]
);
return survey_latest_publication($pdo, $surveyId) ?? [];
}
function survey_fetch_list(PDO $pdo, string $tenantId): array
{
return app_query_all(
$surveys = app_query_all(
$pdo,
<<<'SQL'
SELECT
@@ -85,6 +336,11 @@ ORDER BY
SQL,
['tenant_id' => $tenantId]
);
return array_map(static function (array $survey) use ($pdo): array {
$survey['questions'] = survey_fetch_questions($pdo, (string) ($survey['id'] ?? ''));
return $survey;
}, $surveys);
}
function survey_fetch_detail(PDO $pdo, string $tenantId, string $surveyId): ?array
@@ -103,22 +359,7 @@ function survey_fetch_detail(PDO $pdo, string $tenantId, string $surveyId): ?arr
return null;
}
$hasOptions = app_table_has_column($pdo, 'survey_questions', 'options_json');
$questionSelect = $hasOptions ? 'options_json' : 'NULL AS options_json';
$survey['questions'] = app_query_all(
$pdo,
str_replace(
'__OPTIONS__',
$questionSelect,
<<<'SQL'
SELECT id, question, question_type, is_required, sort_order, __OPTIONS__
FROM survey_questions
WHERE survey_id = :survey_id
ORDER BY sort_order ASC, created_at ASC
SQL
),
['survey_id' => $surveyId]
);
$survey['questions'] = survey_fetch_questions($pdo, $surveyId);
return $survey;
}
@@ -143,49 +384,7 @@ function survey_fetch_answer_map(PDO $pdo, string $surveyId, string $tenantUserI
function survey_fetch_results(PDO $pdo, string $surveyId): array
{
$results = [];
$hasOptions = app_table_has_column($pdo, 'survey_questions', 'options_json');
$questionSelect = $hasOptions ? 'options_json' : 'NULL AS options_json';
foreach (app_query_all(
$pdo,
str_replace(
'__OPTIONS__',
$questionSelect,
<<<'SQL'
SELECT id, question, question_type, is_required, sort_order, __OPTIONS__
FROM survey_questions
WHERE survey_id = :survey_id
ORDER BY sort_order ASC, created_at ASC
SQL
),
['survey_id' => $surveyId]
) as $question) {
$questionId = (string) ($question['id'] ?? '');
$questionType = (string) ($question['question_type'] ?? 'text');
$entries = [];
if ($questionType === 'text') {
$entries = app_query_all(
$pdo,
'SELECT answer_text, created_at FROM survey_answers WHERE question_id = :question_id AND answer_text <> "" ORDER BY created_at DESC LIMIT 5',
['question_id' => $questionId]
);
} else {
$entries = app_query_all(
$pdo,
'SELECT answer_text, COUNT(*) AS answer_count FROM survey_answers WHERE question_id = :question_id GROUP BY answer_text ORDER BY answer_count DESC, answer_text ASC',
['question_id' => $questionId]
);
}
$results[] = [
'question' => $question,
'entries' => $entries,
];
}
return $results;
return survey_build_results($pdo, survey_fetch_questions($pdo, $surveyId), $surveyId);
}
function survey_save_survey(PDO $pdo, string $tenantId, array $input): void
@@ -306,7 +505,7 @@ function survey_submit_answers(PDO $pdo, array $auth, array $survey): void
throw new RuntimeException('Die Teilnahme konnte nicht zugeordnet werden.');
}
if (!in_array((string) ($survey['status'] ?? 'draft'), ['active'], true)) {
if (!in_array((string) ($survey['status'] ?? 'draft'), ['active', 'published'], true)) {
throw new RuntimeException('Diese Umfrage ist aktuell nicht für Teilnahmen freigegeben.');
}
@@ -383,9 +582,12 @@ $surveys = [];
$selectedSurvey = null;
$selectedSurveyAnswers = [];
$selectedSurveyResults = [];
$selectedSurveyPublication = null;
$selectedSurveyPublications = [];
$editingQuestion = null;
$canManage = app_can_manage_surveys($auth);
$surveyTablesReady = false;
$publicationTablesReady = false;
$csrf = (string) ($_SESSION['survey_csrf'] ?? '');
try {
@@ -403,6 +605,7 @@ try {
$surveyTablesReady = scripts_table_exists($pdo, 'surveys')
&& scripts_table_exists($pdo, 'survey_questions')
&& scripts_table_exists($pdo, 'survey_answers');
$publicationTablesReady = survey_publications_ready($pdo);
} catch (Throwable $exception) {
$dbError = $exception->getMessage();
}
@@ -428,7 +631,7 @@ if ($pdo instanceof PDO && $surveyTablesReady && ($_SERVER['REQUEST_METHOD'] ??
if ($action === 'change-survey-status' && $canManage) {
$status = (string) ($_POST['status'] ?? 'draft');
if (!in_array($status, ['draft', 'active', 'closed'], true)) {
if (!in_array($status, ['draft', 'active', 'published', 'closed'], true)) {
$status = 'draft';
}
app_execute(
@@ -444,6 +647,16 @@ if ($pdo instanceof PDO && $surveyTablesReady && ($_SERVER['REQUEST_METHOD'] ??
app_flash('Der Umfragestatus wurde aktualisiert.', 'success');
}
if ($action === 'publish-survey' && $canManage) {
$survey = survey_fetch_detail($pdo, $tenantId, (string) ($_POST['survey_id'] ?? ''));
if ($survey === null) {
throw new RuntimeException('Die ausgewählte Umfrage konnte nicht gefunden werden.');
}
$publication = survey_publish_snapshot($pdo, $tenantId, $auth, $survey, !empty($_POST['member_visible']));
app_flash('Der Snapshot wurde freigegeben (Version ' . (string) ($publication['version_no'] ?? 1) . ').', 'success');
}
if ($action === 'answer-survey') {
$survey = survey_fetch_detail($pdo, $tenantId, (string) ($_POST['survey_id'] ?? ''));
if ($survey === null) {
@@ -471,6 +684,8 @@ if ($pdo instanceof PDO && $surveyTablesReady) {
$selectedSurvey = survey_fetch_detail($pdo, $tenantId, $selectedSurveyId);
$selectedSurveyAnswers = survey_fetch_answer_map($pdo, $selectedSurveyId, (string) ($auth['tenant_user_id'] ?? ''));
$selectedSurveyResults = $canManage && $selectedSurvey !== null ? survey_fetch_results($pdo, $selectedSurveyId) : [];
$selectedSurveyPublication = $selectedSurvey !== null ? survey_latest_publication($pdo, $selectedSurveyId) : null;
$selectedSurveyPublications = $selectedSurvey !== null ? survey_fetch_publications($pdo, $selectedSurveyId) : [];
if ($selectedSurvey !== null && isset($_GET['question']) && $_GET['question'] !== '') {
foreach (($selectedSurvey['questions'] ?? []) as $question) {
@@ -671,7 +886,32 @@ $themeCss = app_tenant_theme_root_css($tenantSettings);
<?php endif; ?>
</div>
<?php if ($selectedSurveyPublication !== null && ($canManage || !empty($selectedSurveyPublication['member_visible']))): ?>
<div class="metric" style="margin-top:18px">
<div class="eyebrow">Freigegebener Snapshot</div>
<strong>Version <?= survey_h((string) ($selectedSurveyPublication['version_no'] ?? 0)) ?></strong>
<p>Freigegeben am <?= survey_dt((string) ($selectedSurveyPublication['published_at'] ?? '')) ?> von <?= survey_h((string) ($selectedSurveyPublication['published_by'] ?? '')) ?>.</p>
<p><?= !empty($selectedSurveyPublication['member_visible']) ? 'Mitglieder sehen diesen Stand in der Freigabeansicht.' : 'Dieser Stand ist für Mitglieder noch nicht sichtbar.' ?></p>
</div>
<?php endif; ?>
<?php if ($canManage): ?>
<?php if ($publicationTablesReady): ?>
<form method="post" action="/surveys/" class="grid" style="margin-top:18px">
<input type="hidden" name="csrf" value="<?= survey_h($csrf) ?>">
<input type="hidden" name="action" value="publish-survey">
<input type="hidden" name="survey_id" value="<?= survey_h((string) ($selectedSurvey['id'] ?? '')) ?>">
<label style="display:flex;flex-direction:row;align-items:center;gap:10px"><input type="checkbox" name="member_visible" value="1"<?= $selectedSurveyPublication === null || !empty($selectedSurveyPublication['member_visible']) ? ' checked' : '' ?> style="width:auto">Mitglieder dürfen den Snapshot sehen</label>
<div class="actions"><button type="submit">Snapshot freigeben</button></div>
</form>
<?php else: ?>
<div class="metric" style="margin-top:18px">
<div class="eyebrow">Freigabe</div>
<strong>Freigabe noch nicht verfügbar</strong>
<p>Bitte die neue Publication-Migration ausführen, dann kann der Snapshot veröffentlicht werden.</p>
</div>
<?php endif; ?>
<form method="post" action="/surveys/" class="grid" style="margin-top:18px">
<input type="hidden" name="csrf" value="<?= survey_h($csrf) ?>">
<input type="hidden" name="action" value="save-question">
@@ -701,10 +941,28 @@ $themeCss = app_tenant_theme_root_css($tenantSettings);
</tbody>
</table>
</div>
<?php if ($canManage && $selectedSurveyPublications !== []): ?>
<div class="table" style="margin-top:18px">
<table>
<thead><tr><th>Version</th><th>Freigegeben</th><th>Sichtbarkeit</th><th>Rückmeldungen</th></tr></thead>
<tbody>
<?php foreach ($selectedSurveyPublications as $publication): ?>
<tr>
<td><strong>v<?= survey_h((string) ($publication['version_no'] ?? 0)) ?></strong></td>
<td><?= survey_dt((string) ($publication['published_at'] ?? '')) ?><br><span class="muted">von <?= survey_h((string) ($publication['published_by'] ?? '')) ?></span></td>
<td><?= !empty($publication['member_visible']) ? 'Mitglieder' : 'Nur Verwaltung' ?></td>
<td><?= survey_h((string) ($publication['response_count'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</article>
<article class="card">
<?php if ($canManage || in_array((string) ($selectedSurvey['status'] ?? 'draft'), ['active'], true)): ?>
<?php if ($canManage || in_array((string) ($selectedSurvey['status'] ?? 'draft'), ['active', 'published'], true)): ?>
<h2><?= $canManage ? 'Mitgliedersicht und Teilnahme' : 'Umfrage beantworten' ?></h2>
<form method="post" action="/surveys/" class="stack">
<input type="hidden" name="csrf" value="<?= survey_h($csrf) ?>">
@@ -742,9 +1000,39 @@ $themeCss = app_tenant_theme_root_css($tenantSettings);
<div class="actions"><button type="submit">Antworten speichern</button></div>
<?php endif; ?>
</form>
<?php if ($selectedSurveyPublication !== null): ?>
<div class="metric" style="margin-top:18px">
<div class="eyebrow">Freigegebene Ergebnisse</div>
<strong><?= survey_h((string) ($selectedSurveyPublication['title'] ?? ($selectedSurvey['title'] ?? '')) ) ?></strong>
<p>Snapshot v<?= survey_h((string) ($selectedSurveyPublication['version_no'] ?? 0)) ?> mit <?= survey_h((string) ($selectedSurveyPublication['response_count'] ?? 0)) ?> Rückmeldungen.</p>
<?php if ($canManage || !empty($selectedSurveyPublication['member_visible'])): ?>
<div class="grid grid-2">
<?php foreach ((array) ($selectedSurveyPublication['results'] ?? []) as $result): ?>
<?php $question = $result['question'] ?? []; ?>
<article class="metric">
<h3><?= survey_h((string) ($question['question'] ?? '')) ?></h3>
<?php if (($question['question_type'] ?? 'text') === 'text'): ?>
<ul class="list"><?php foreach (($result['entries'] ?? []) as $entry): ?><li><?= survey_h((string) ($entry['answer_text'] ?? '')) ?> <span class="muted">(<?= survey_dt((string) ($entry['created_at'] ?? '')) ?>)</span></li><?php endforeach; ?></ul>
<?php else: ?>
<ul class="list"><?php foreach (($result['entries'] ?? []) as $entry): ?><li><?= survey_h((string) ($entry['answer_text'] ?? '')) ?>: <?= survey_h((string) ($entry['answer_count'] ?? '0')) ?></li><?php endforeach; ?></ul>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php else: ?>
<p>Dieser Stand ist für Mitglieder noch nicht sichtbar.</p>
<?php endif; ?>
</div>
<?php elseif (!$canManage): ?>
<div class="metric" style="margin-top:18px">
<div class="eyebrow">Freigegebene Ergebnisse</div>
<strong>Noch nicht veröffentlicht</strong>
<p>Mitglieder sehen nur Ergebnisse aus einem freigegebenen Snapshot.</p>
</div>
<?php endif; ?>
<?php else: ?>
<h2>Diese Umfrage ist derzeit nicht freigegeben</h2>
<p>Mitglieder können nur Umfragen mit Status <code>active</code> beantworten.</p>
<p>Mitglieder können nur Umfragen mit Status <code>active</code> oder <code>published</code> beantworten.</p>
<?php endif; ?>
</article>
</section>
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'year-end';
require dirname(__DIR__) . '/index.php';
@@ -6,44 +6,117 @@
<section class="hero">
<div>
<p class="hero__kicker">Benachrichtigungen</p>
<h2 class="hero__title">Benachrichtigungen</h2>
<h2 class="hero__title">Massenkommunikation pro Tenant</h2>
<p class="hero__lead">
Die alte Sammelmail-Funktion geht in ein Modul ueber, das Versandregeln,
Cron-Ausfuehrung und Ergebnisprotokolle sauber trennt. Damit werden
Schuldenhinweise und Service-Meldungen tenantfaehig.
Hier entstehen Hinweise, Erinnerungen und Service-Meldungen fuer alle oder
eine Teilmenge der Mitglieder. Entweder als Entwurf, geplant oder direkt
als Versandprotokoll.
</p>
</div>
<div class="toolbar">
<span class="badge">Mail templates</span>
<span class="badge">Cron dispatch</span>
<span class="badge badge--solid">Audit logs</span>
<span class="badge">Kampagnen: {{ number_format((int) ($notificationSummary['total'] ?? 0), 0, ',', '.') }}</span>
<span class="badge">Empfaenger: {{ number_format((int) ($notificationSummary['recipient_count'] ?? 0), 0, ',', '.') }}</span>
<span class="badge badge--solid">{{ $tenantLicense['plan_name'] ?? 'Free' }}</span>
</div>
</section>
@if(!$notificationStorageReady)
<section class="panel">
<h3>Fallback aktiv</h3>
<p class="muted">
Die Datenbanktabelle fuer Benachrichtigungen ist noch nicht vorhanden.
Solange die Migration nicht gelaufen ist, werden Demo-Daten angezeigt.
</p>
</section>
@endif
@if(!empty($dbError))
<section class="panel">
<h3>Datenbankhinweis</h3>
<p class="muted">{{ $dbError }}</p>
</section>
@endif
<section class="split">
<article class="panel">
<h3>Benachrichtigungsarten</h3>
<div class="timeline">
<div class="timeline__item">
<p class="timeline__title">Saldo-Erinnerung</p>
<p class="timeline__meta">Automatisch bei negativem Kontostand oder Schwellwerten.</p>
<h3>Neue Benachrichtigung</h3>
<p class="muted">Damit lassen sich Erinnerungen und Hinweise direkt als Entwurf oder Sofortversand anlegen.</p>
<form method="post" class="stack">
<input type="hidden" name="csrf" value="{{ $csrf }}">
<input type="hidden" name="notification_id" value="{{ $notificationForm['notification_id'] ?? '' }}">
<label>Titel
<input type="text" name="title" value="{{ $notificationForm['title'] ?? '' }}" placeholder="Saldo-Erinnerung">
</label>
<label>Nachricht
<textarea name="message" rows="8" placeholder="Freundliche Erinnerung an offene Beträge oder wichtige Hinweise.">{{ $notificationForm['message'] ?? '' }}</textarea>
</label>
<div class="grid grid--2">
<label>Kanal
<select name="channel">
@foreach($notificationChannelOptions as $option)
<option value="{{ $option['value'] }}" @selected(($notificationForm['channel'] ?? 'email') === $option['value'])>{{ $option['label'] }}</option>
@endforeach
</select>
</label>
<label>Empfaenger
<select name="recipient_scope">
@foreach($notificationScopeOptions as $option)
<option value="{{ $option['value'] }}" @selected(($notificationForm['recipient_scope'] ?? 'all') === $option['value'])>{{ $option['label'] }}</option>
@endforeach
</select>
</label>
</div>
<div class="timeline__item">
<p class="timeline__title">Import- und Exportstatus</p>
<p class="timeline__meta">Backoffice meldet Erfolge, Warnungen und Fehler an Admins.</p>
<label>Geplanter Versand
<input type="datetime-local" name="scheduled_at" value="{{ $notificationForm['scheduled_at'] ?? '' }}">
</label>
<div class="toolbar">
<button class="button button--ghost" type="submit" name="action" value="save-notification">Entwurf speichern</button>
<button class="button" type="submit" name="action" value="send-notification">Jetzt senden</button>
</div>
<div class="timeline__item">
<p class="timeline__title">Service-Kommunikation</p>
<p class="timeline__meta">Hinweise zu Preisen, Wartung oder Monatsabschluss zentral ausspielen.</p>
</div>
</div>
</form>
</article>
<article class="panel">
<h3>Empfängervorschau</h3>
<p class="muted">Die Vorschau folgt dem aktuellen Filter und dient als schnelle Kontrolle vor dem Versand.</p>
<div class="toolbar">
@foreach($notificationScopeOptions as $option)
<a class="badge {{ ($notificationForm['recipient_scope'] ?? 'all') === $option['value'] ? 'badge--solid' : '' }}" href="/notifications/?scope={{ urlencode($option['value']) }}@if(!empty($notificationForm['notification_id']))&amp;edit={{ urlencode((string) $notificationForm['notification_id']) }}@endif">{{ $option['label'] }}</a>
@endforeach
</div>
<div class="timeline">
@forelse($notificationPreview as $member)
<div class="timeline__item">
<p class="timeline__title">{{ $member['display_name'] ?? 'Mitglied' }}</p>
<p class="timeline__meta">
{{ number_format((int) ($member['recent_strokes'] ?? 0), 0, ',', '.') }} Striche in 100 Tagen
</p>
</div>
@empty
<div class="timeline__item">
<p class="timeline__title">Keine aktiven Mitglieder</p>
<p class="timeline__meta">Fuer die gewaehlte Zielgruppe stehen gerade keine Empfaenger zur Verfuegung.</p>
</div>
@endforelse
</div>
</article>
</section>
<section class="split">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Versandprotokoll</p>
<h3>Letzte Benachrichtigungen</h3>
<p class="card__eyebrow">Kampagnen</p>
<h3>Geplante und gesendete Nachrichten</h3>
</div>
<span class="pill">Tenant scoped</span>
</div>
@@ -51,34 +124,82 @@
<table>
<thead>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Kanal</th>
<th>Empfaenger</th>
<th>Ausloeser</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
@forelse($notificationMessages as $message)
<tr>
<td>
<strong>{{ $message['title'] ?? '' }}</strong><br>
<span class="muted">{{ $message['message'] ?? '' }}</span>
</td>
<td>{{ $message['channel_label'] ?? 'E-Mail' }}</td>
<td>
{{ $message['scope_label'] ?? 'Alle Mitglieder' }}<br>
<span class="muted">{{ number_format((int) ($message['recipient_count'] ?? 0), 0, ',', '.') }} Personen</span>
</td>
<td><span class="status status--{{ $message['status_tone'] ?? 'neutral' }}">{{ $message['status_label'] ?? 'Entwurf' }}</span></td>
<td>
<a class="status status--warning" href="/notifications/?edit={{ urlencode((string) ($message['id'] ?? '')) }}">Bearbeiten</a>
</td>
</tr>
@empty
<tr>
<td colspan="5">Noch keine Benachrichtigungen gespeichert.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</article>
<article class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Versandprotokoll</p>
<h3>Letzte Logeintraege</h3>
</div>
<span class="pill">Audit trail</span>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Kanal</th>
<th>Empfaenger</th>
<th>Typ</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Saldo-Erinnerung</td>
<td>6 Mitglieder</td>
<td>negativer Kontostand</td>
<td><span class="status">Gesendet</span></td>
</tr>
<tr>
<td>Import-Report</td>
<td>Tenant Admin</td>
<td>Importjob fertig</td>
<td><span class="status">Bereitgestellt</span></td>
</tr>
<tr>
<td>Service-Hinweis</td>
<td>alle Mitglieder</td>
<td>Preisanpassung</td>
<td><span class="status status--warning">Eingeplant</span></td>
</tr>
@forelse($notificationLogs as $log)
<tr>
<td>{{ $log['channel_label'] ?? 'E-Mail' }}</td>
<td>{{ $log['recipient'] ?? '-' }}</td>
<td>{{ $log['template_key'] ?? '-' }}</td>
<td><span class="status status--{{ $log['status_tone'] ?? 'neutral' }}">{{ $log['status_label'] ?? 'Geplant' }}</span></td>
</tr>
@empty
<tr>
<td colspan="4">Noch keine Versandprotokolle vorhanden.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</article>
</section>
<section class="panel">
<h3>Einordnung</h3>
<p class="muted">
Das Modul ist bewusst schlank gehalten: Kampagne anlegen, Empfaenger pruefen, speichern oder sofort versenden.
Die eigentliche Auslieferung kann spaeter ueber den Cronjob mit der vorhandenen Dispositionslogik erweitert werden.
</p>
</section>
@endsection