Weiterentwicklung Anwendung

This commit is contained in:
2026-04-08 11:51:05 +02:00
parent 6c8b376c1e
commit 8f608fcf6b
12 changed files with 2663 additions and 657 deletions
@@ -0,0 +1,6 @@
<?php
return <<<'SQL'
ALTER TABLE survey_questions
ADD COLUMN options_json TEXT NULL AFTER sort_order;
SQL;
@@ -0,0 +1,6 @@
<?php
return <<<'SQL'
ALTER TABLE members
ADD COLUMN payment_reference VARCHAR(255) NULL AFTER email;
SQL;
@@ -0,0 +1,6 @@
<?php
return <<<'SQL'
ALTER TABLE survey_questions
ADD COLUMN options_json LONGTEXT NULL AFTER question_type;
SQL;
@@ -0,0 +1,13 @@
<?php
return <<<'SQL'
CREATE TABLE survey_question_options (
id CHAR(36) NOT NULL PRIMARY KEY,
question_id CHAR(36) NOT NULL,
option_label VARCHAR(255) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (question_id) REFERENCES survey_questions(id)
);
SQL;
+311
View File
@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
function app_reporting_rows(PDO $pdo, string $tenantId): array
{
return app_query_all(
$pdo,
<<<'SQL'
SELECT
m.id,
m.display_name,
m.email,
COALESCE(SUM(le.amount), 0) AS balance,
COALESCE((
SELECT SUM(ABS(ce.strokes))
FROM coffee_entries ce
WHERE ce.tenant_id = :tenant_id
AND ce.member_id = m.id
), 0) AS total_strokes,
COALESCE((
SELECT SUM(pe.amount)
FROM payment_entries pe
WHERE pe.tenant_id = :tenant_id
AND pe.member_id = m.id
), 0) AS total_payments,
COALESCE((
SELECT SUM(ABS(ce.total_cost))
FROM coffee_entries ce
WHERE ce.tenant_id = :tenant_id
AND ce.member_id = m.id
), 0) AS total_coffee_cost
FROM members m
LEFT JOIN ledger_entries le ON le.member_id = m.id AND le.tenant_id = m.tenant_id
WHERE m.tenant_id = :tenant_id
AND m.status = 'active'
GROUP BY m.id, m.display_name, m.email
ORDER BY m.display_name ASC
SQL,
['tenant_id' => $tenantId]
);
}
function app_bulk_stroke_entries(array $input): array
{
$entries = [];
foreach ($input as $memberId => $value) {
$strokes = max(0, (int) $value);
if ($strokes > 0) {
$entries[(string) $memberId] = $strokes;
}
}
return $entries;
}
function app_bulk_payment_entries(array $input): array
{
$entries = [];
foreach ($input as $memberId => $value) {
$amount = is_numeric($value) ? (float) $value : 0.0;
if ($amount > 0) {
$entries[(string) $memberId] = $amount;
}
}
return $entries;
}
if (!function_exists('app_create_coffee_booking')) {
function app_create_coffee_booking(PDO $pdo, string $tenantId, string $memberId, int $strokes, float $unitPrice, string $bookedAt, string $source = 'manual'): void
{
$entryId = app_uuid();
$ledgerId = app_uuid();
$totalCost = round($strokes * $unitPrice, 2);
$now = date('Y-m-d H:i:s');
app_execute(
$pdo,
'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)',
[
'id' => $entryId,
'tenant_id' => $tenantId,
'member_id' => $memberId,
'strokes' => $strokes,
'unit_price' => number_format($unitPrice, 2, '.', ''),
'total_cost' => number_format($totalCost, 2, '.', ''),
'booking_source' => $source,
'booked_at' => $bookedAt,
'created_at' => $now,
'updated_at' => $now,
]
);
app_execute(
$pdo,
'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
[
'id' => $ledgerId,
'tenant_id' => $tenantId,
'member_id' => $memberId,
'entry_type' => 'coffee_charge',
'amount' => number_format($totalCost * -1, 2, '.', ''),
'reference_type' => 'coffee_entry',
'reference_id' => $entryId,
'booked_at' => $bookedAt,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
if (!function_exists('app_create_payment_booking')) {
function app_create_payment_booking(PDO $pdo, string $tenantId, string $memberId, float $amount, string $bookedAt, string $method = 'manual'): void
{
$entryId = app_uuid();
$ledgerId = app_uuid();
$now = date('Y-m-d H:i:s');
app_execute(
$pdo,
'INSERT INTO payment_entries (id, tenant_id, member_id, amount, payment_method, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :amount, :payment_method, :booked_at, :created_at, :updated_at)',
[
'id' => $entryId,
'tenant_id' => $tenantId,
'member_id' => $memberId,
'amount' => number_format($amount, 2, '.', ''),
'payment_method' => $method,
'booked_at' => $bookedAt,
'created_at' => $now,
'updated_at' => $now,
]
);
app_execute(
$pdo,
'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
[
'id' => $ledgerId,
'tenant_id' => $tenantId,
'member_id' => $memberId,
'entry_type' => 'payment_credit',
'amount' => number_format($amount, 2, '.', ''),
'reference_type' => 'payment_entry',
'reference_id' => $entryId,
'booked_at' => $bookedAt,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
function app_handle_bulk_finance_action(PDO $pdo, array $auth): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || !app_can_manage_finance($auth)) {
return;
}
$action = (string) ($_POST['action'] ?? '');
$tenantId = (string) ($auth['tenant_id'] ?? '');
$bookedAt = app_normalize_datetime_input((string) ($_POST['booked_at'] ?? ''));
if (!in_array($action, ['bulk-record-coffee', 'bulk-record-payments'], true) || $tenantId === '') {
return;
}
try {
$pdo->beginTransaction();
if ($action === 'bulk-record-coffee') {
$unitPrice = (float) ($_POST['unit_price'] ?? 0);
$entries = app_bulk_stroke_entries((array) ($_POST['bulk_strokes'] ?? []));
if ($unitPrice <= 0 || $entries === []) {
throw new RuntimeException('Bitte gib mindestens einen Sammel-Stricheintrag und einen gültigen Preis an.');
}
foreach ($entries as $memberId => $strokes) {
app_create_coffee_booking($pdo, $tenantId, $memberId, $strokes, $unitPrice, $bookedAt, 'bulk');
}
$pdo->commit();
app_flash(count($entries) . ' Sammel-Stricheinträge wurden verbucht.', 'success');
app_redirect('/ledger/');
}
$entries = app_bulk_payment_entries((array) ($_POST['bulk_amounts'] ?? []));
$method = trim((string) ($_POST['payment_method'] ?? 'manual'));
$method = $method !== '' ? $method : 'manual';
if ($entries === []) {
throw new RuntimeException('Bitte gib mindestens eine Sammel-Einzahlung an.');
}
foreach ($entries as $memberId => $amount) {
app_create_payment_booking($pdo, $tenantId, $memberId, $amount, $bookedAt, $method);
}
$pdo->commit();
app_flash(count($entries) . ' Sammel-Einzahlungen wurden verbucht.', 'success');
app_redirect('/payments/');
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
app_flash($exception->getMessage(), 'error');
app_redirect(app_request_path());
}
}
function app_handle_csv_import(PDO $pdo, array $auth): array
{
$result = ['rows' => [], 'summary' => ['imported' => 0, 'duplicates' => 0, 'unmatched' => 0]];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || (string) ($_POST['action'] ?? '') !== 'import-paypal-csv') {
return $result;
}
if (!app_can_manage_finance($auth)) {
app_flash('Für den CSV-Import brauchst du Finanz- oder Tenant-Rechte.', 'warning');
app_redirect('/imports/');
}
if (!isset($_FILES['csv_file']) || (int) ($_FILES['csv_file']['error'] ?? 1) !== 0) {
app_flash('Bitte lade eine CSV-Datei hoch.', 'error');
app_redirect('/imports/');
}
$tenantId = (string) ($auth['tenant_id'] ?? '');
$handle = fopen((string) $_FILES['csv_file']['tmp_name'], 'rb');
if ($handle === false) {
app_flash('Die CSV-Datei konnte nicht gelesen werden.', 'error');
app_redirect('/imports/');
}
fgetcsv($handle, 0, ',');
$members = app_members_for_tenant($pdo, $tenantId);
$memberMap = [];
foreach ($members as $member) {
$display = strtolower(trim((string) ($member['display_name'] ?? '')));
$email = strtolower(trim((string) ($member['email'] ?? '')));
$reference = strtolower(trim((string) ($member['payment_reference'] ?? '')));
foreach ([$display, $email, $reference] as $key) {
if ($key !== '') {
$memberMap[$key] = $member;
}
}
}
$pdo->beginTransaction();
try {
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$bookedAt = app_normalize_datetime_input((string) ($row[0] ?? ''));
$name = strtolower(trim((string) ($row[3] ?? '')));
$amount = (float) str_replace(',', '.', (string) ($row[7] ?? '0'));
if ($name === '' || $amount <= 0) {
continue;
}
$member = $memberMap[$name] ?? null;
if (!is_array($member)) {
$result['rows'][] = ['date' => $bookedAt, 'name' => $name, 'amount' => $amount, 'status' => 'unmatched'];
$result['summary']['unmatched']++;
continue;
}
$duplicate = app_query_one(
$pdo,
'SELECT id FROM payment_entries WHERE tenant_id = :tenant_id AND member_id = :member_id AND amount = :amount AND DATE(booked_at) = DATE(:booked_at) LIMIT 1',
[
'tenant_id' => $tenantId,
'member_id' => (string) ($member['id'] ?? ''),
'amount' => number_format($amount, 2, '.', ''),
'booked_at' => $bookedAt,
]
);
if ($duplicate !== null) {
$result['rows'][] = ['date' => $bookedAt, 'name' => (string) ($member['display_name'] ?? $name), 'amount' => $amount, 'status' => 'duplicate'];
$result['summary']['duplicates']++;
continue;
}
app_create_payment_booking($pdo, $tenantId, (string) ($member['id'] ?? ''), $amount, $bookedAt, 'paypal_csv');
$result['rows'][] = ['date' => $bookedAt, 'name' => (string) ($member['display_name'] ?? $name), 'amount' => $amount, 'status' => 'imported'];
$result['summary']['imported']++;
}
fclose($handle);
$pdo->commit();
app_flash('Der CSV-Import wurde verarbeitet.', 'success');
} catch (Throwable $exception) {
fclose($handle);
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
app_flash($exception->getMessage(), 'error');
app_redirect('/imports/');
}
return $result;
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'imports';
require dirname(__DIR__) . '/index.php';
+212 -37
View File
@@ -52,6 +52,8 @@ if ($requestedPage === null) {
'/ledger' => 'ledger',
'/payments' => 'payments',
'/content' => 'content',
'/imports' => 'imports',
'/reports' => 'reports',
'/support' => 'support',
'/surveys' => 'surveys',
'/settings' => 'settings',
@@ -63,7 +65,7 @@ if ($requestedPage === null) {
$page = (string) $requestedPage;
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'support', 'surveys', 'settings', 'exports'];
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'support', 'surveys', 'settings', 'exports'];
if ($page === 'logout' && $requestMethod === 'POST') {
app_logout();
@@ -82,8 +84,20 @@ $ledger = [];
$payments = [];
$content = ['announcements' => [], 'faq' => []];
$memberSummary = null;
$memberReport = null;
$ledgerScope = 'all';
$paymentScope = 'all';
$ledgerMembers = [];
$paymentMembers = [];
$reportRows = [];
$importResult = ['rows' => [], 'summary' => ['imported' => 0, 'duplicates' => 0, 'unmatched' => 0]];
$surveyBoard = ['all' => [], 'published' => []];
$activeSurvey = null;
$surveyResults = [];
$loginFlow = ['state' => app_login_state(), 'message' => null, 'error' => null];
$editingMember = null;
$editingAnnouncement = null;
$editingFaq = null;
$memberForm = app_member_form_defaults();
$tenantLicense = ['plan_key' => 'free', 'plan_name' => 'Free', 'member_limit' => 10, 'features' => app_feature_defaults()];
$tenantSettings = app_tenant_settings_defaults();
@@ -111,10 +125,6 @@ if ($page === 'support') {
app_redirect('/support/');
}
if ($page === 'surveys') {
app_redirect('/surveys/');
}
if ($page === 'login' && $pdo instanceof PDO) {
try {
$loginFlow = app_handle_login($pdo);
@@ -137,12 +147,26 @@ if ($auth !== null && $pdo instanceof PDO) {
if (in_array($page, ['dashboard', 'ledger', 'payments'], true)) {
app_handle_tenant_action($pdo, $auth);
app_handle_bulk_finance_action($pdo, $auth);
app_handle_profile_action($pdo, $auth);
}
if ($page === 'members') {
app_handle_member_action($pdo, $auth);
}
if ($page === 'content') {
app_handle_content_action($pdo, $auth);
}
if ($page === 'imports') {
$importResult = app_handle_csv_import($pdo, $auth);
}
if ($page === 'surveys') {
app_handle_survey_action($pdo, $auth);
}
if ($page === 'settings') {
app_handle_settings_action($pdo, $auth);
}
@@ -177,8 +201,40 @@ if ($auth !== null && $pdo instanceof PDO) {
$payments = app_payments_for_tenant($pdo, (string) $auth['tenant_id']);
}
if ($page === 'ledger') {
$ledgerScope = in_array((string) ($_GET['scope'] ?? 'all'), ['all', 'front', 'back'], true)
? (string) ($_GET['scope'] ?? 'all')
: 'all';
$ledgerMembers = app_members_for_scope($pdo, (string) $auth['tenant_id'], $ledgerScope);
}
if ($page === 'payments') {
$paymentScope = in_array((string) ($_GET['scope'] ?? 'all'), ['all', 'front', 'back'], true)
? (string) ($_GET['scope'] ?? 'all')
: 'all';
$paymentMembers = app_members_for_scope($pdo, (string) $auth['tenant_id'], $paymentScope);
}
if ($page === 'content') {
$content = app_content_for_tenant($pdo, (string) $auth['tenant_id']);
if (isset($_GET['edit_announcement']) && $_GET['edit_announcement'] !== '') {
foreach (($content['announcements'] ?? []) as $announcement) {
if ((string) ($announcement['id'] ?? '') === (string) $_GET['edit_announcement']) {
$editingAnnouncement = $announcement;
break;
}
}
}
if (isset($_GET['edit_faq']) && $_GET['edit_faq'] !== '') {
foreach (($content['faq'] ?? []) as $faq) {
if ((string) ($faq['id'] ?? '') === (string) $_GET['edit_faq']) {
$editingFaq = $faq;
break;
}
}
}
}
if ($page === 'exports') {
@@ -199,6 +255,27 @@ if ($auth !== null && $pdo instanceof PDO) {
$editingMember = app_tenant_user_by_id($pdo, (string) $_GET['edit'], (string) $auth['tenant_id']);
$memberForm = app_member_form_defaults($editingMember);
}
if ($page === 'members' && isset($_GET['member']) && $_GET['member'] !== '') {
$memberReport = app_member_report($pdo, (string) $auth['tenant_id'], (string) $_GET['member']);
}
if ($page === 'reports') {
$reportRows = app_reporting_rows($pdo, (string) $auth['tenant_id']);
if ($memberReport === null && isset($_GET['member']) && $_GET['member'] !== '') {
$memberReport = app_member_report($pdo, (string) $auth['tenant_id'], (string) $_GET['member']);
}
}
if ($page === 'surveys') {
$surveyBoard = app_surveys_for_tenant($pdo, (string) $auth['tenant_id']);
if (isset($_GET['survey']) && $_GET['survey'] !== '') {
$activeSurvey = app_survey_by_id($pdo, (string) $auth['tenant_id'], (string) $_GET['survey']);
if (is_array($activeSurvey) && app_can_manage_surveys($auth)) {
$surveyResults = app_survey_results($pdo, (string) $activeSurvey['id']);
}
}
}
} catch (Throwable $exception) {
$dbError = $exception->getMessage();
}
@@ -211,6 +288,8 @@ $restrictedPages = [
'members' => static fn(array $user): bool => app_can_manage_tenant($user),
'ledger' => static fn(array $user): bool => app_can_manage_finance($user),
'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),
'settings' => static fn(array $user): bool => app_can_manage_tenant($user),
'exports' => static fn(array $user): bool => app_can_manage_tenant($user),
];
@@ -266,6 +345,7 @@ $landingProof = [
['title' => 'Im Browser und mobil', 'copy' => 'Die Oberfläche funktioniert ohne Installation auf Desktop und Handy.'],
['title' => 'Für den Alltag gebaut', 'copy' => 'Kaffeeliste, Support, Buchungen und Verwaltung greifen sinnvoll ineinander.'],
];
$marketing = app_marketing_messages();
?><!DOCTYPE html>
<html lang="de">
@@ -816,18 +896,52 @@ $landingProof = [
</article>
<?php else: ?>
<article class="card">
<div class="eyebrow">Dein Bereich</div>
<h2>Dein Bereich</h2>
<ul class="list">
<li>Hier siehst du deinen Kontostand, letzte Buchungen und aktuelle Hinweise.</li>
<li>Weitere Bereiche findest du direkt im Menü.</li>
<li>Wenn dir etwas fehlt, wende dich an den Betreiber deines Bereichs.</li>
</ul>
<div class="eyebrow">Self-Service</div>
<h2>Mein Bereich</h2>
<?php if (($tenantSettings['allow_self_service_booking'] ?? '1') === '1' && !empty($auth['member_id'])): ?>
<form method="post" action="/dashboard/" class="grid">
<input type="hidden" name="action" value="record-coffee">
<input type="hidden" name="member_id" value="<?= h((string) ($auth['member_id'] ?? '')) ?>">
<label>Anzahl Striche<input type="number" name="strokes" min="1" max="2" step="1" value="1"></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>Buchungszeit<input type="datetime-local" name="booked_at" value="<?= date('Y-m-d\TH:i') ?>"></label>
<input type="hidden" name="booking_source" value="self-service">
<div class="actions"><button type="submit">Eigene Striche buchen</button></div>
</form>
<?php else: ?>
<ul class="list">
<li>Hier siehst du deinen Kontostand, letzte Buchungen und aktuelle Hinweise.</li>
<li>Self-Service-Striche sind in diesem Mandanten aktuell deaktiviert.</li>
<li>Wenn dir etwas fehlt, wende dich an den Betreiber deines Bereichs.</li>
</ul>
<?php endif; ?>
</article>
<article class="card">
<div class="eyebrow">Nächster Schritt</div>
<h2>Direkt weiter</h2>
<p>Nutze das Menü für den nächsten Bereich.</p>
<div class="eyebrow">Profil</div>
<h2>Name und Passwort pflegen</h2>
<form method="post" action="/dashboard/" class="grid">
<input type="hidden" name="action" value="save-profile">
<label>Anzeigename<input name="display_name" value="<?= h((string) ($auth['display_name'] ?? '')) ?>" required></label>
<label>Neues Passwort<input type="password" name="password" placeholder="Nur bei Änderung ausfüllen"></label>
<label style="grid-column:1 / -1;">Hinweis zu Einzahlungen<textarea readonly><?= h((string) ($tenantSettings['payment_hint'] ?? '')) ?></textarea></label>
<div class="actions"><button type="submit">Profil speichern</button></div>
</form>
<?php $paypalBaseUrl = trim((string) ($tenantSettings['paypal_me_url'] ?? '')); ?>
<?php if ($paypalBaseUrl !== ''): ?>
<div class="section-mini" style="margin-top:18px">
<h3 class="section-mini__title">PayPal-Einzahlungen</h3>
<p class="section-mini__copy">Feste Beträge und der offene Saldo lassen sich direkt öffnen.</p>
<div class="actions">
<?php $balance = (float) ($memberSummary['balance'] ?? 0); ?>
<?php if ($balance < 0): ?>
<a class="button secondary" target="_blank" href="<?= h(rtrim($paypalBaseUrl, '/') . '/' . number_format(abs($balance), 2, '.', '')) ?>"><?= h(number_format(abs($balance), 2, ',', '.')) ?> EUR ausgleichen</a>
<?php endif; ?>
<?php foreach ([5, 10, 15] as $quickAmount): ?>
<a class="button secondary" target="_blank" href="<?= h(rtrim($paypalBaseUrl, '/') . '/' . number_format((float) $quickAmount, 2, '.', '')) ?>"><?= h(number_format((float) $quickAmount, 2, ',', '.')) ?> EUR</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</article>
<?php endif; ?>
</section>
@@ -836,8 +950,8 @@ $landingProof = [
<h2>Letzte Buchungen</h2>
<div class="table">
<table>
<thead><tr><th>Zeit</th><th>Mitglied</th><th>Typ</th><th>Referenz</th><th>Betrag</th></tr></thead>
<tbody><?php foreach ($ledger as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td></tr><?php endforeach; ?></tbody>
<thead><tr><th>Zeit</th><?php if ($canManageTenant): ?><th>Mitglied</th><?php endif; ?><th>Typ</th><th>Referenz</th><th>Betrag</th></tr></thead>
<tbody><?php foreach (($canManageTenant ? $ledger : ($memberSummary['recent_entries'] ?? [])) as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><?php if ($canManageTenant): ?><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><?php endif; ?><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td></tr><?php endforeach; ?></tbody>
</table>
</div>
</section>
@@ -845,7 +959,7 @@ $landingProof = [
<section class="hero">
<div class="eyebrow">Mitgliederverwaltung</div>
<h1>Mitglieder</h1>
<p>Hier legst du neue Personen an, gibst Zugänge frei und weist bei Bedarf die Rolle als Mandanten-Admin zu.</p>
<p>Hier legst du neue Personen an, gibst Zugänge frei und weist Rollen für Verwaltung, Finanzen, Support und Umfragen zu.</p>
<?php if (app_is_platform_admin($auth) && app_admin_user() !== null): ?>
<div class="actions" style="margin-top:18px">
<a class="button secondary" href="/admin/">Zur zentralen Verwaltung</a>
@@ -861,12 +975,13 @@ $landingProof = [
<input type="hidden" name="tenant_user_id" value="<?= h((string) $memberForm['tenant_user_id']) ?>">
<label>Name<input name="display_name" value="<?= h((string) $memberForm['display_name']) ?>" required></label>
<label>E-Mail-Adresse<input type="email" name="email" value="<?= h((string) $memberForm['email']) ?>" required></label>
<label>Zahlungsreferenz<input name="payment_reference" value="<?= h((string) ($memberForm['payment_reference'] ?? '')) ?>" placeholder="PayPal-Name oder Referenz"></label>
<label>Status<select name="status"><option value="active"<?= $memberForm['status'] === 'active' ? ' selected' : '' ?>>Aktiv</option><option value="inactive"<?= $memberForm['status'] === 'inactive' ? ' selected' : '' ?>>Inaktiv</option></select></label>
<label>Passwort<input type="password" name="password" placeholder="<?= $editingMember !== null ? 'Nur für Passwortwechsel ausfüllen' : 'Für die erste Anmeldung erforderlich' ?>"></label>
<label class="checkbox" style="grid-column:1 / -1;display:flex;flex-direction:row;align-items:center;font-weight:600;">
<input type="checkbox" name="is_tenant_admin"<?= !empty($memberForm['is_tenant_admin']) ? ' checked' : '' ?> style="width:auto">
<span>Diese Person als Mandanten-Admin freischalten</span>
</label>
<label class="checkbox" style="display:flex;flex-direction:row;align-items:center;font-weight:600;"><input type="checkbox" name="tenant_admin"<?= !empty($memberForm['is_tenant_admin']) ? ' checked' : '' ?> style="width:auto"><span>Tenant-Admin</span></label>
<label class="checkbox" style="display:flex;flex-direction:row;align-items:center;font-weight:600;"><input type="checkbox" name="finance_admin"<?= !empty($memberForm['is_finance_admin']) ? ' checked' : '' ?> style="width:auto"><span>Finanzen</span></label>
<label class="checkbox" style="display:flex;flex-direction:row;align-items:center;font-weight:600;"><input type="checkbox" name="support_contact"<?= !empty($memberForm['is_support_contact']) ? ' checked' : '' ?> style="width:auto"><span>Support</span></label>
<label class="checkbox" style="display:flex;flex-direction:row;align-items:center;font-weight:600;"><input type="checkbox" name="survey_manager"<?= !empty($memberForm['is_survey_manager']) ? ' checked' : '' ?> style="width:auto"><span>Umfragen</span></label>
<div class="actions">
<button type="submit">Speichern</button>
<?php if ($editingMember !== null): ?>
@@ -876,33 +991,55 @@ $landingProof = [
</form>
</article>
<article class="card">
<h2>Was der Global-Admin hier sehen kann</h2>
<ul class="list">
<li>Alle Personen des Mandanten inklusive Rollen und Aktivstatus.</li>
<li>Ob ein Zugang nur Mitglied ist oder als Mandanten-Admin arbeiten darf.</li>
<li>Den Mandanten aus Sicht der operativen Verwaltung, ohne den Global-Admin als Mitglied anlegen zu müssen.</li>
</ul>
<?php if (is_array($memberReport)): ?>
<h2>Mitgliedsdetail</h2>
<div class="stack">
<div class="metric"><h3><?= h((string) ($memberReport['display_name'] ?? '')) ?></h3><p><?= h((string) ($memberReport['email'] ?? '')) ?></p></div>
<div class="metric"><h3>Saldo</h3><p><?= money($memberReport['summary']['balance'] ?? 0) ?></p></div>
<div class="metric"><h3>Dieses Jahr</h3><p><?= num($memberReport['summary']['year_strokes'] ?? 0) ?> Striche, <?= money($memberReport['summary']['year_payments'] ?? 0) ?> Einzahlungen</p></div>
<div class="actions"><a class="button secondary" href="/members/">Auswahl zurücksetzen</a></div>
</div>
<?php else: ?>
<h2>Mitglied auswählen</h2>
<ul class="list">
<li>Öffne eine Person aus der Tabelle, um Jahreswerte und letzte Buchungen zu sehen.</li>
<li>Rollen können hier direkt differenziert nach Verwaltung, Finanzen, Support und Umfragen vergeben werden.</li>
<li>So bildet die neue Webseite die Rollenmatrix der Roadmap sichtbar im Tenant ab.</li>
</ul>
<?php endif; ?>
</article>
</section>
<section class="card" style="margin-top:18px">
<div class="table">
<table>
<thead><tr><th>Name</th><th>E-Mail</th><th>Status</th><th>Benutzer</th><th>Rollen</th><th>Aktion</th></tr></thead>
<thead><tr><th>Name</th><th>E-Mail</th><th>Referenz</th><th>Status</th><th>Saldo</th><th>Rollen</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($members as $member): ?>
<tr>
<td><strong><?= h((string) $member['display_name']) ?></strong></td>
<td><?= h((string) $member['email']) ?></td>
<td><?= h((string) ($member['payment_reference'] ?? '-')) ?></td>
<td><?= ((string) ($member['status'] ?? 'active')) === 'active' ? badge('Aktiv', 'success') : badge('Inaktiv', 'warning') ?></td>
<td><?= h((string) ($member['user_display_name'] ?? '')) ?></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>
<td><?= money($member['current_balance'] ?? 0) ?></td>
<td><?= h((string) (($member['roles'] ?? '') !== '' ? $member['roles'] : 'Mitglied')) ?></td>
<td><div class="actions"><a class="button secondary" href="/members/?edit=<?= h((string) ($member['tenant_user_id'] ?? '')) ?>">Bearbeiten</a><a class="button secondary" href="/members/?member=<?= h((string) ($member['id'] ?? '')) ?>">Details</a></div></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php if (is_array($memberReport) && ($memberReport['recent_entries'] ?? []) !== []): ?>
<section class="card" style="margin-top:18px">
<h2>Letzte Buchungen der ausgewählten Person</h2>
<div class="table">
<table>
<thead><tr><th>Zeit</th><th>Typ</th><th>Referenz</th><th>Betrag</th></tr></thead>
<tbody><?php foreach (($memberReport['recent_entries'] ?? []) as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td></tr><?php endforeach; ?></tbody>
</table>
</div>
</section>
<?php endif; ?>
<?php elseif ($page === 'ledger'): ?>
<section class="hero"><div class="eyebrow">Buchungen</div><h1>Striche und Buchungen</h1><p>Kaffeeeinträge und alle zugehörigen Buchungen bleiben tenantweise nachvollziehbar.</p></section>
<section class="grid grid-2">
@@ -919,7 +1056,7 @@ $landingProof = [
</article>
<article class="card"><h2>Was dieser Bereich abdeckt</h2><ul class="list"><li>Einzel- und Sammelbuchungen laufen in einem gemeinsamen Ablauf zusammen.</li><li>Jeder Verbrauch erzeugt automatisch den passenden Ledger-Eintrag.</li><li>Die letzten Buchungen bleiben je Tenant nachvollziehbar.</li></ul></article>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Typ</th><th>Referenz</th><th>Betrag</th></tr></thead><tbody><?php foreach ($ledger as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['entry_type'] ?? '')) ?></td><td><?= h((string) ($entry['reference_type'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<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>
<?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>
<section class="grid grid-2">
@@ -934,14 +1071,40 @@ $landingProof = [
<div class="actions"><button type="submit">Einzahlung speichern</button></div>
</form>
</article>
<article class="card"><h2>Wofür dieser Bereich da ist</h2><ul class="list"><li>Manuelle Einzahlungen sind direkt erfasst.</li><li>PayPal oder Bank können getrennt ausgewiesen werden.</li><li>Jede Zahlung erscheint sofort im Ledger.</li></ul></article>
<article class="card"><h2>Wofür dieser Bereich da ist</h2><ul class="list"><li>Manuelle Einzahlungen sind direkt erfasst.</li><li>PayPal oder Bank können getrennt ausgewiesen werden.</li><li>Jede Zahlung erscheint sofort im Ledger.</li></ul><p style="margin-top:14px"><?= h((string) ($tenantSettings['payment_hint'] ?? '')) ?></p></article>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Methode</th><th>Betrag</th></tr></thead><tbody><?php foreach ($payments as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['payment_method'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<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>
<?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">
<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>
<article class="card">
<h2>Hinweise</h2>
<?php if ($canManageTenant): ?>
<form method="post" action="/content/" class="grid" style="margin-bottom:18px">
<input type="hidden" name="action" value="save-announcement">
<input type="hidden" name="announcement_id" value="<?= h((string) ($editingAnnouncement['id'] ?? '')) ?>">
<label>Titel<input name="title" value="<?= h((string) ($editingAnnouncement['title'] ?? '')) ?>" required></label>
<label>Sichtbar bis<input type="datetime-local" name="visible_until" value="<?= !empty($editingAnnouncement['visible_until']) ? h(date('Y-m-d\TH:i', strtotime((string) $editingAnnouncement['visible_until']))) : '' ?>"></label>
<label style="grid-column:1 / -1;">Hinweis<textarea name="message" required><?= h((string) ($editingAnnouncement['message'] ?? '')) ?></textarea></label>
<div class="actions"><button type="submit">Hinweis speichern</button><?php if ($editingAnnouncement !== null): ?><a class="button secondary" href="/content/">Neu anlegen</a><?php endif; ?></div>
</form>
<?php endif; ?>
<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><?php if ($canManageTenant): ?><div class="actions" style="margin-top:12px"><a class="button secondary" href="/content/?edit_announcement=<?= h((string) ($entry['id'] ?? '')) ?>">Bearbeiten</a><form method="post" action="/content/"><input type="hidden" name="action" value="archive-announcement"><input type="hidden" name="announcement_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>
<article class="card">
<h2>FAQ</h2>
<?php if ($canManageTenant): ?>
<form method="post" action="/content/" class="grid" style="margin-bottom:18px">
<input type="hidden" name="action" value="save-faq">
<input type="hidden" name="faq_id" value="<?= h((string) ($editingFaq['id'] ?? '')) ?>">
<label>Frage<input name="question" value="<?= h((string) ($editingFaq['question'] ?? '')) ?>" required></label>
<label>Sortierung<input type="number" name="sort_order" min="0" step="1" value="<?= h((string) ($editingFaq['sort_order'] ?? '10')) ?>"></label>
<label style="grid-column:1 / -1;">Antwort<textarea name="answer" required><?= h((string) ($editingFaq['answer'] ?? '')) ?></textarea></label>
<div class="actions"><button type="submit">FAQ speichern</button><?php if ($editingFaq !== null): ?><a class="button secondary" href="/content/">Neu anlegen</a><?php endif; ?></div>
</form>
<?php endif; ?>
<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 === 'settings'): ?>
<section class="hero"><div class="eyebrow">Mandanten-Einstellungen</div><h1>Einstellungen</h1><p>Diese Einstellungen gelten nur für den aktuell geöffneten Mandanten und bleiben unabhängig von anderen Bereichen.</p></section>
@@ -957,6 +1120,7 @@ $landingProof = [
<label>Zeilenhöhe PDF-Liste in mm<input type="number" name="pdf_row_height_mm" min="3.2" max="12" step="0.1" value="<?= h((string) ($tenantSettings['pdf_row_height_mm'] ?? '4.00')) ?>"></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>PayPal-Link<input type="url" name="paypal_me_url" value="<?= h((string) ($tenantSettings['paypal_me_url'] ?? '')) ?>" placeholder="https://paypal.me/dein-tenant"></label>
<label>Primärfarbe<input type="color" name="brand_color" value="<?= h((string) ($tenantSettings['brand_color'] ?? '#005e3f')) ?>"></label>
<label>Dunkle Primärfarbe<input type="color" name="brand_strong_color" value="<?= h((string) ($tenantSettings['brand_strong_color'] ?? '#00452f')) ?>"></label>
<label>Akzentfarbe<input type="color" name="accent_color" value="<?= h((string) ($tenantSettings['accent_color'] ?? '#c18a00')) ?>"></label>
@@ -1044,6 +1208,17 @@ $landingProof = [
</ul>
<?php endif; ?>
</article>
<?php if ($hasBasicExportsFeature): ?>
<article class="card">
<h2>Mitgliederauswertung exportieren</h2>
<p>Die Detailauswertung eines einzelnen Mitglieds lässt sich direkt als CSV herunterladen.</p>
<form method="get" action="/exports/" class="grid" style="margin-top:18px">
<input type="hidden" name="download" value="member-report-csv">
<label>Mitglied<select name="member"><?php foreach ($members as $member): ?><option value="<?= h((string) ($member['id'] ?? '')) ?>"><?= h((string) ($member['display_name'] ?? '')) ?></option><?php endforeach; ?></select></label>
<div class="actions"><button type="submit">Detail-CSV herunterladen</button></div>
</form>
</article>
<?php endif; ?>
</section>
<?php if ($hasPdfExportFeature || $hasPaperStrikeEntryFeature): ?>
<section class="card" style="margin-top:18px">
+3
View File
@@ -1215,6 +1215,9 @@ function scripts_ensure_core_roles(PDO $pdo): array
{
return [
'tenant_admin' => scripts_ensure_role($pdo, 'tenant_admin', 'Tenant Admin', 'tenant'),
'finance_admin' => scripts_ensure_role($pdo, 'finance_admin', 'Finance Admin', 'tenant'),
'support_contact' => scripts_ensure_role($pdo, 'support_contact', 'Support Contact', 'tenant'),
'survey_manager' => scripts_ensure_role($pdo, 'survey_manager', 'Survey Manager', 'tenant'),
'platform_admin' => scripts_ensure_role($pdo, 'platform_admin', 'Platform Admin', 'platform'),
];
}
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
$_GET['page'] = 'reports';
require dirname(__DIR__) . '/index.php';
File diff suppressed because it is too large Load Diff
@@ -347,7 +347,7 @@
<label>Betreff<input name="subject" maxlength="255" placeholder="Worum geht es?" required></label>
<label>Kategorie
<select name="category">
<?php foreach ($tenantNavItems as $item): ?>
<?php foreach ($categories as $category): ?>
<option value="<?= support_h($category) ?>"><?= support_h(ucfirst(str_replace('_', ' ', $category))) ?></option>
<?php endforeach; ?>
</select>