Initial CAT application and deployment update

This commit is contained in:
2026-09-19 23:27:40 +00:00
commit 6684062400
22 changed files with 3690 additions and 0 deletions
+948
View File
@@ -0,0 +1,948 @@
<?php
declare(strict_types=1);
session_set_cookie_params([
'httponly' => true,
'samesite' => 'Strict',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'use_strict_mode' => true,
]);
session_start();
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
require_once __DIR__ . '/inc/db.inc.php';
require_once __DIR__ . '/inc/mail.inc.php';
require_once __DIR__ . '/inc/room_planner.php';
function postString(string $key): string
{
$value = $_POST[$key] ?? '';
return is_string($value) ? trim($value) : '';
}
function escape(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function accommodationLabel(?string $value): string
{
return match ($value) {
'single' => 'Einzelzimmer',
'double' => 'Doppelzimmer',
'multi' => 'Mehrbettzimmer',
null, '' => '',
default => $value,
};
}
function dayLabel(mixed $value): string
{
return match ((string) $value) {
'1' => 'Fr.',
'2' => 'Sa.',
'3' => 'So.',
default => (string) $value,
};
}
function mealLabel(mixed $value): string
{
return match ((string) $value) {
'breakfast' => 'Frühstück',
'lunch' => 'Mittagessen',
'coffee' => 'Kaffee',
'drinks' => 'Getränkepauschale',
'dinner' => 'Abendessen',
default => (string) $value,
};
}
function earlyDepartureLabel(mixed $value): string
{
return match ((string) $value) {
'sat_breakfast' => 'Samstag nach dem Frühstück',
'sat_lunch' => 'Samstag nach dem Mittagessen',
'sat_coffee' => 'Samstag nach dem Kaffee',
'sat_dinner' => 'Samstag nach dem Abendessen',
'sun_breakfast' => 'Sonntag nach dem Frühstück',
default => (string) $value,
};
}
function mealSummary(array $days, array $mealsByDay): string
{
$parts = [];
foreach ($days as $day) {
$meals = array_map('mealLabel', $mealsByDay[(string) $day] ?? []);
$meals[] = 'Getränkepauschale';
$meals[] = 'Proberaumpauschale';
$parts[] = dayLabel($day) . ': ' . implode(', ', $meals);
}
return implode('; ', $parts);
}
function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float
{
$ageSuffix = $under27 ? 'under_27' : 'over_27';
$keys = [];
if ($guestType === 'regular_guest' && $roomType !== null) {
$keys[] = $roomType . '_' . $ageSuffix;
}
if ($guestType === 'day_guest') {
foreach ($mealsByDay as $meals) {
foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) {
if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) {
$keys[] = $meal . '_' . $ageSuffix;
} elseif ($meal === 'drinks') {
$keys[] = 'drinks';
}
}
$keys[] = 'rehearsal_room';
}
}
if ($keys === []) {
return 0.0;
}
$uniqueKeys = array_values(array_unique($keys));
$placeholders = implode(',', array_fill(0, count($uniqueKeys), '?'));
$statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)");
$statement->execute($uniqueKeys);
$prices = [];
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
$prices[(string) $row['setting_key']] = (float) $row['price'];
}
return round(array_sum(array_map(
static fn (string $key): float => $prices[$key] ?? 0.0,
$keys
)), 2);
}
function fetchUnpaidRecipients(PDO $pdo): array
{
return $pdo->query(
"SELECT p.name, p.email, COALESCE(b.manual_total_price, b.total_price) AS effective_price
FROM bookings b
INNER JOIN participants p ON p.id = b.participant_id
WHERE b.is_paid = 0 AND p.email IS NOT NULL AND p.email <> ''
ORDER BY p.name"
)->fetchAll(PDO::FETCH_ASSOC);
}
function personalizeBulkEmail(string $template, array $recipient): string
{
return str_replace(
['{{preis}}', '{{name}}'],
[
number_format((float) $recipient['effective_price'], 2, ',', '.') . ' €',
(string) $recipient['name'],
],
$template
);
}
function redirectToAdmin(): never
{
header('Location: admin.php');
exit;
}
function csrfToken(): string
{
if (!isset($_SESSION['admin_csrf'])) {
$_SESSION['admin_csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['admin_csrf'];
}
function requireCsrf(): void
{
$submitted = postString('csrf_token');
$stored = $_SESSION['admin_csrf'] ?? '';
if ($stored === '' || !hash_equals($stored, $submitted)) {
http_response_code(403);
exit('Ungültige Anfrage.');
}
}
function createPdo(array $dbConfig): PDO
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$dbConfig['host'],
$dbConfig['port'],
$dbConfig['database'],
$dbConfig['charset']
);
return new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
$pdo = createPdo($dbConfig);
if (isset($_SESSION['admin_id'])) {
$lastActivity = (int) ($_SESSION['admin_last_activity'] ?? 0);
if ($lastActivity > 0 && $lastActivity < time() - 1800) {
$_SESSION = [];
session_regenerate_id(true);
} else {
$_SESSION['admin_last_activity'] = time();
}
}
if (isset($_SESSION['admin_id'])) {
$activeStatement = $pdo->prepare('SELECT id FROM admin_users WHERE id = :id AND is_active = 1 LIMIT 1');
$activeStatement->execute([':id' => (int) $_SESSION['admin_id']]);
if ($activeStatement->fetchColumn() === false) {
$_SESSION = [];
session_regenerate_id(true);
}
}
$message = $_SESSION['admin_message'] ?? '';
unset($_SESSION['admin_message']);
if (postString('action') === 'login') {
$username = postString('username');
$password = postString('password');
$lockedUntil = (int) ($_SESSION['admin_locked_until'] ?? 0);
if ($lockedUntil > time()) {
$message = 'Zu viele Fehlversuche. Bitte später erneut versuchen.';
} else {
$statement = $pdo->prepare(
'SELECT id, username, password_hash
FROM admin_users
WHERE username = :username AND is_active = 1
LIMIT 1'
);
$statement->execute([':username' => $username]);
$admin = $statement->fetch(PDO::FETCH_ASSOC);
if ($admin !== false && password_verify($password, $admin['password_hash'])) {
if (password_needs_rehash($admin['password_hash'], PASSWORD_DEFAULT)) {
$rehash = $pdo->prepare('UPDATE admin_users SET password_hash = :password_hash WHERE id = :id');
$rehash->execute([
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
':id' => (int) $admin['id'],
]);
}
unset($_SESSION['admin_login_failures'], $_SESSION['admin_locked_until']);
session_regenerate_id(true);
$_SESSION['admin_id'] = (int) $admin['id'];
$_SESSION['admin_username'] = $admin['username'];
$_SESSION['admin_last_activity'] = time();
$_SESSION['admin_csrf'] = bin2hex(random_bytes(32));
redirectToAdmin();
}
$failures = (int) ($_SESSION['admin_login_failures'] ?? 0) + 1;
$_SESSION['admin_login_failures'] = $failures;
if ($failures >= 5) {
$_SESSION['admin_locked_until'] = time() + 300;
}
usleep(500000);
$message = 'Benutzername oder Passwort ist ungültig.';
}
}
if (!isset($_SESSION['admin_id'])) {
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin-Login</title>
<link rel="stylesheet" href="./css/admin.css?v=20260918-3">
</head>
<body class="admin-page">
<main class="login-card">
<p class="eyebrow">Chorwochenende</p>
<h1>Admin-Login</h1>
<?php if ($message !== ''): ?>
<p class="message error"><?= escape($message) ?></p>
<?php endif; ?>
<form method="post" autocomplete="off">
<input type="hidden" name="action" value="login">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autocomplete="username">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Anmelden</button>
</form>
</main>
<script src="./scripts/security.js?v=20260918-3"></script>
</body>
</html><?php
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
requireCsrf();
$action = postString('action');
try {
if ($action === 'logout') {
$_SESSION = [];
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Strict',
]);
session_destroy();
redirectToAdmin();
}
if ($action === 'delete_booking') {
$bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT);
if ($bookingId === false || $bookingId === null) {
throw new InvalidArgumentException('Ungültige Registrierung.');
}
$pdo->beginTransaction();
$participantStatement = $pdo->prepare(
'SELECT participant_id FROM bookings WHERE id = :id FOR UPDATE'
);
$participantStatement->execute([':id' => $bookingId]);
$participantId = $participantStatement->fetchColumn();
if ($participantId === false) {
throw new InvalidArgumentException('Registrierung nicht gefunden.');
}
$deleteBookings = $pdo->prepare('DELETE FROM bookings WHERE participant_id = :participant_id');
$deleteBookings->execute([':participant_id' => $participantId]);
$deleteParticipant = $pdo->prepare('DELETE FROM participants WHERE id = :id');
$deleteParticipant->execute([':id' => $participantId]);
$pdo->commit();
$_SESSION['admin_message'] = 'Registrierung wurde gelöscht.';
}
if ($action === 'toggle_paid') {
$bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT);
$paid = filter_var($_POST['is_paid'] ?? null, FILTER_VALIDATE_INT);
if ($bookingId === false || $bookingId === null || !in_array($paid, [0, 1], true)) {
throw new InvalidArgumentException('Ungültiger Zahlungsstatus.');
}
$statement = $pdo->prepare('UPDATE bookings SET is_paid = :is_paid WHERE id = :id');
$statement->execute([':is_paid' => $paid, ':id' => $bookingId]);
$_SESSION['admin_message'] = $paid === 1
? 'Registrierung als bezahlt markiert.'
: 'Zahlungsmarkierung entfernt.';
}
if ($action === 'create_user') {
$username = postString('new_username');
$email = postString('new_email');
$password = postString('new_password');
if (!preg_match('/^[A-Za-z0-9_.-]{3,80}$/', $username)) {
throw new InvalidArgumentException('Der Benutzername enthält ungültige Zeichen.');
}
if (strlen($password) < 12) {
throw new InvalidArgumentException('Passwörter müssen mindestens 12 Zeichen lang sein.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
throw new InvalidArgumentException('Bitte eine gültige Admin-E-Mail-Adresse eingeben.');
}
$statement = $pdo->prepare(
'INSERT INTO admin_users (username, email, password_hash) VALUES (:username, :email, :password_hash)'
);
$statement->execute([
':username' => $username,
':email' => $email,
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
]);
$_SESSION['admin_message'] = 'Admin-Benutzer wurde angelegt.';
}
if ($action === 'update_room_settings') {
$roomSettings = [
'single' => postString('room_count_single'),
'double' => postString('room_count_double'),
'multi' => postString('room_count_multi'),
];
$upsertRoomSetting = $pdo->prepare(
'INSERT INTO pricing_settings (setting_key, price) VALUES (:key, :price)
ON DUPLICATE KEY UPDATE price = VALUES(price)'
);
foreach ($roomSettings as $roomType => $rawCount) {
if ($rawCount === '' || !ctype_digit($rawCount) || (int) $rawCount > 9999) {
throw new InvalidArgumentException('Bitte gültige Zimmeranzahlen zwischen 0 und 9.999 eingeben.');
}
$upsertRoomSetting->execute([
':key' => 'room_count_' . $roomType,
':price' => $rawCount,
]);
}
$_SESSION['admin_message'] = 'Zimmeranzahlen wurden gespeichert.';
}
if ($action === 'prepare_unpaid_email') {
$template = postString('bulk_email_text');
if ($template === '' || mb_strlen($template) > 10000) {
throw new InvalidArgumentException('Bitte einen Nachrichtentext mit höchstens 10.000 Zeichen eingeben.');
}
$recipients = fetchUnpaidRecipients($pdo);
if ($recipients === []) {
throw new InvalidArgumentException('Es wurden keine unbezahlten Registrierungen mit E-Mail-Adresse gefunden.');
}
$_SESSION['bulk_email_draft'] = [
'text' => $template,
'created_at' => time(),
];
$_SESSION['admin_message'] = 'Vorschau vorbereitet. Bitte Empfängerzahl und Nachricht prüfen.';
}
if ($action === 'send_unpaid_email') {
$draft = $_SESSION['bulk_email_draft'] ?? null;
if (!is_array($draft) || !is_string($draft['text'] ?? null) || (int) ($draft['created_at'] ?? 0) < time() - 1800) {
unset($_SESSION['bulk_email_draft']);
throw new InvalidArgumentException('Die Vorschau ist abgelaufen. Bitte die Nachricht erneut vorbereiten.');
}
$recipients = fetchUnpaidRecipients($pdo);
$sent = 0;
$failed = 0;
foreach ($recipients as $recipient) {
try {
sendConfiguredTextEmail(
$mailConfig,
(string) $recipient['email'],
'One Voice - Chorwochenende - Deine Teilnahmegebühr',
personalizeBulkEmail($draft['text'], $recipient)
);
$sent++;
} catch (Throwable $exception) {
$failed++;
error_log('Massenmail konnte nicht an ' . (string) $recipient['email'] . ' versendet werden: ' . $exception->getMessage());
}
}
unset($_SESSION['bulk_email_draft']);
$_SESSION['admin_message'] = $failed === 0
? $sent . ' Zahlungserinnerung(en) wurden versendet.'
: $sent . ' Zahlungserinnerung(en) versendet, ' . $failed . ' Versand/Versände fehlgeschlagen.';
}
if ($action === 'update_booking_price') {
$bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT);
if ($bookingId === false || $bookingId === null) {
throw new InvalidArgumentException('Ungültige Registrierung.');
}
$correctPrice = isset($_POST['correct_price']) && $_POST['correct_price'] === '1';
$rawManualPrice = $correctPrice ? str_replace(',', '.', postString('manual_total_price')) : '';
$bookingStatement = $pdo->prepare(
'SELECT b.id, b.type, b.accommodation_type, b.meal_preferences, p.under_27
FROM bookings b
INNER JOIN participants p ON p.id = b.participant_id
WHERE b.id = :id'
);
$bookingStatement->execute([':id' => $bookingId]);
$bookingRow = $bookingStatement->fetch(PDO::FETCH_ASSOC);
if ($bookingRow === false) {
throw new InvalidArgumentException('Registrierung nicht gefunden.');
}
if ($rawManualPrice !== '' && (!is_numeric($rawManualPrice) || (float) $rawManualPrice < 0 || (float) $rawManualPrice > 999999.99)) {
throw new InvalidArgumentException('Bitte einen gültigen manuellen Gesamtpreis zwischen 0,00 und 999.999,99 eingeben.');
}
$manualPrice = $rawManualPrice === '' ? null : number_format((float) $rawManualPrice, 2, '.', '');
$mealData = $bookingRow['meal_preferences'] !== null
? json_decode($bookingRow['meal_preferences'], true)
: [];
$mealData = is_array($mealData) ? $mealData : [];
$mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) {
foreach ($mealData['days'] ?? [] as $day) {
$mealsByDay[(string) $day] = $mealData['meals'];
}
}
foreach ($mealData['days'] ?? [] as $day) {
$mealsByDay[(string) $day] ??= [];
}
$automaticPrice = calculateBookingTotal(
$pdo,
(int) $bookingRow['under_27'] === 1,
$bookingRow['type'] === 'Day' ? 'day_guest' : 'regular_guest',
$bookingRow['accommodation_type'],
$mealsByDay
);
$statement = $pdo->prepare(
'UPDATE bookings SET manual_total_price = :manual_total_price, total_price = :total_price WHERE id = :id'
);
$statement->execute([
':manual_total_price' => $manualPrice,
':total_price' => $manualPrice ?? number_format($automaticPrice, 2, '.', ''),
':id' => $bookingId,
]);
$_SESSION['admin_message'] = $manualPrice === null
? 'Manueller Preis entfernt; automatische Berechnung wurde wiederhergestellt.'
: 'Preis wurde korrigiert.';
}
if ($action === 'update_prices') {
$priceKeys = [];
foreach (['single', 'double', 'multi', 'breakfast', 'lunch', 'coffee', 'dinner'] as $baseKey) {
$priceKeys[] = $baseKey . '_under_27';
$priceKeys[] = $baseKey . '_over_27';
}
$priceKeys[] = 'drinks';
$priceKeys[] = 'rehearsal_room';
$upsert = $pdo->prepare(
'INSERT INTO pricing_settings (setting_key, price) VALUES (:key, :price)
ON DUPLICATE KEY UPDATE price = VALUES(price)'
);
foreach ($priceKeys as $priceKey) {
$rawPrice = str_replace(',', '.', postString('price_' . $priceKey));
if ($rawPrice === '' || !is_numeric($rawPrice) || (float) $rawPrice < 0 || (float) $rawPrice > 999999.99) {
throw new InvalidArgumentException('Bitte gültige Preise zwischen 0,00 und 999.999,99 eingeben.');
}
$upsert->execute([':key' => $priceKey, ':price' => number_format((float) $rawPrice, 2, '.', '')]);
}
$bookingRows = $pdo->query(
'SELECT b.id, b.type, b.accommodation_type, b.meal_preferences, b.manual_total_price, p.under_27
FROM bookings b
INNER JOIN participants p ON p.id = b.participant_id'
)->fetchAll(PDO::FETCH_ASSOC);
$updateTotal = $pdo->prepare('UPDATE bookings SET total_price = :total_price WHERE id = :id');
foreach ($bookingRows as $bookingRow) {
if ($bookingRow['manual_total_price'] !== null) {
continue;
}
$mealData = $bookingRow['meal_preferences'] !== null
? json_decode($bookingRow['meal_preferences'], true)
: [];
$mealData = is_array($mealData) ? $mealData : [];
$guestType = $bookingRow['type'] === 'Day' ? 'day_guest' : 'regular_guest';
$mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) {
foreach ($mealData['days'] ?? [] as $day) {
$mealsByDay[(string) $day] = $mealData['meals'];
}
}
foreach ($mealData['days'] ?? [] as $day) {
$mealsByDay[(string) $day] ??= [];
}
$totalPrice = calculateBookingTotal(
$pdo,
(int) $bookingRow['under_27'] === 1,
$guestType,
$bookingRow['accommodation_type'],
$mealsByDay
);
$updateTotal->execute([
':total_price' => number_format($totalPrice, 2, '.', ''),
':id' => (int) $bookingRow['id'],
]);
}
$_SESSION['admin_message'] = 'Preise wurden gespeichert.';
}
if ($action === 'change_password') {
$adminId = filter_var($_POST['admin_id'] ?? null, FILTER_VALIDATE_INT);
$password = postString('changed_password');
if ($adminId === false || $adminId === null || strlen($password) < 12) {
throw new InvalidArgumentException('Passwort muss mindestens 12 Zeichen lang sein.');
}
$statement = $pdo->prepare(
'UPDATE admin_users SET password_hash = :password_hash, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND is_active = 1'
);
$statement->execute([
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
':id' => $adminId,
]);
$_SESSION['admin_message'] = 'Passwort wurde geändert.';
}
if ($action === 'delete_user') {
$adminId = filter_var($_POST['admin_id'] ?? null, FILTER_VALIDATE_INT);
if ($adminId === false || $adminId === null || $adminId === (int) $_SESSION['admin_id']) {
throw new InvalidArgumentException('Der eigene Benutzer kann nicht gelöscht werden.');
}
$activeCount = (int) $pdo->query('SELECT COUNT(*) FROM admin_users WHERE is_active = 1')->fetchColumn();
if ($activeCount <= 1) {
throw new InvalidArgumentException('Der letzte Admin-Benutzer kann nicht gelöscht werden.');
}
$statement = $pdo->prepare('DELETE FROM admin_users WHERE id = :id AND is_active = 1');
$statement->execute([':id' => $adminId]);
$_SESSION['admin_message'] = 'Admin-Benutzer wurde gelöscht.';
}
} catch (InvalidArgumentException $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$_SESSION['admin_message'] = $exception->getMessage();
} catch (PDOException $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log($exception->getMessage());
$_SESSION['admin_message'] = 'Die Änderung konnte nicht gespeichert werden.';
}
redirectToAdmin();
}
$bookings = $pdo->query(
'SELECT b.id AS booking_id, p.name, p.email, p.role, p.under_27, p.allergies,
b.type, b.accommodation_type, b.meal_preferences, b.is_paid, b.total_price,
b.manual_total_price, b.roommate_requests, b.early_departure, b.other_notes
FROM bookings b
INNER JOIN participants p ON p.id = b.participant_id
ORDER BY b.id DESC'
)->fetchAll(PDO::FETCH_ASSOC);
$roomPlanningSettings = loadRoomPlanningSettings($pdo);
$roomPlanningParticipants = $pdo->query(
"SELECT p.id, p.name, p.email, b.accommodation_type AS room_type,
b.roommate_requests
FROM bookings b
INNER JOIN participants p ON p.id = b.participant_id
WHERE b.type = 'Stay'
AND b.accommodation_type IN ('single', 'double', 'multi')
ORDER BY p.name"
)->fetchAll(PDO::FETCH_ASSOC);
$roomPlan = buildRoomPlan($roomPlanningParticipants, $roomPlanningSettings);
$admins = $pdo->query(
'SELECT id, username, email, created_at, updated_at
FROM admin_users
WHERE is_active = 1
ORDER BY username'
)->fetchAll(PDO::FETCH_ASSOC);
$pricingRows = $pdo->query('SELECT setting_key, price FROM pricing_settings')->fetchAll(PDO::FETCH_KEY_PAIR);
$priceGroups = [
'Zimmer' => ['single' => 'Einzelzimmer', 'double' => 'Doppelzimmer', 'multi' => 'Mehrbettzimmer'],
'Mahlzeiten' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen', 'coffee' => 'Kaffee', 'dinner' => 'Abendessen'],
];
$flatPriceItems = [
'drinks' => 'Getränkepauschale',
'rehearsal_room' => 'Proberaumpauschale',
];
$csrf = csrfToken();
$bulkEmailDraft = $_SESSION['bulk_email_draft'] ?? null;
$bulkEmailRecipients = is_array($bulkEmailDraft) && (int) ($bulkEmailDraft['created_at'] ?? 0) >= time() - 1800
? fetchUnpaidRecipients($pdo)
: [];
$bulkEmailPreview = $bulkEmailRecipients !== [] && is_array($bulkEmailDraft)
? personalizeBulkEmail((string) $bulkEmailDraft['text'], $bulkEmailRecipients[0])
: '';
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Administration</title>
<link rel="stylesheet" href="./css/admin.css?v=20260918-3">
</head>
<body class="admin-page">
<header class="admin-header">
<div>
<p class="eyebrow">Chorwochenende</p>
<h1>Administration</h1>
</div>
<form method="post">
<input type="hidden" name="action" value="logout">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<button class="button-secondary" type="submit">Abmelden</button>
</form>
</header>
<main class="admin-content">
<?php if ($message !== ''): ?>
<p class="message"><?= escape($message) ?></p>
<?php endif; ?>
<section class="panel">
<div class="section-heading">
<div>
<p class="eyebrow">Anmeldungen</p>
<h2>Registrierungen</h2>
</div>
<div>
<span class="count"><?= count($bookings) ?></span>
<a class="button-secondary export-button" href="export.php">Excel-Export</a>
</div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Altersgruppe</th>
<th>Bezahlt</th>
<th>Preis</th>
<th>Typ</th>
<th>Zimmer</th>
<th>Zimmerpartner-Wunsch</th>
<th>Frühzeitige Abreise</th>
<th>Mahlzeiten / Tage</th>
<th>Allergien</th>
<th>Sonstiges</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $booking):
$mealData = $booking['meal_preferences'] !== null
? json_decode($booking['meal_preferences'], true)
: null;
$mealData = is_array($mealData) ? $mealData : [];
$days = is_array($mealData['days'] ?? null) ? $mealData['days'] : [];
$mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) {
foreach ($days as $day) {
$mealsByDay[(string) $day] = $mealData['meals'];
}
}
$mealText = mealSummary($days, $mealsByDay);
?>
<tr>
<td><?= escape($booking['name']) ?></td>
<td><a href="mailto:<?= escape($booking['email']) ?>"><?= escape($booking['email']) ?></a></td>
<td><?= (int) $booking['under_27'] === 1 ? 'Unter 27' : 'Ab 27' ?></td>
<td>
<form method="post" class="paid-form">
<input type="hidden" name="action" value="toggle_paid">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
<input type="hidden" name="is_paid" value="0">
<input type="checkbox" name="is_paid" value="1" data-submit-on-change="true" <?= (int) $booking['is_paid'] === 1 ? 'checked' : '' ?> aria-label="Als bezahlt markieren">
</form>
</td>
<td>
<div><?= number_format((float) $booking['total_price'], 2, ',', '.') ?> €</div>
<small><?= $booking['manual_total_price'] !== null ? 'korrigiert' : 'automatisch' ?></small>
<form method="post" class="manual-price-form">
<input type="hidden" name="action" value="update_booking_price">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
<label class="checkbox-label" for="correct_price_<?= (int) $booking['booking_id'] ?>">
<input type="checkbox" id="correct_price_<?= (int) $booking['booking_id'] ?>" name="correct_price" value="1" data-toggle-target="manual_total_price_<?= (int) $booking['booking_id'] ?>" <?= $booking['manual_total_price'] !== null ? 'checked' : '' ?>>
Preis korrigieren
</label>
<div class="manual-price-input" data-toggle-content="manual_total_price_<?= (int) $booking['booking_id'] ?>" <?= $booking['manual_total_price'] === null ? 'hidden' : '' ?>>
<label class="sr-only" for="manual_total_price_<?= (int) $booking['booking_id'] ?>">Preis korrigieren</label>
<input type="number" id="manual_total_price_<?= (int) $booking['booking_id'] ?>" name="manual_total_price" min="0" max="999999.99" step="0.01" placeholder="Neuer Gesamtpreis" value="<?= $booking['manual_total_price'] !== null ? escape((string) $booking['manual_total_price']) : '' ?>" <?= $booking['manual_total_price'] === null ? 'disabled' : '' ?>>
<button class="button-secondary" type="submit">Speichern</button>
</div>
</form>
</td>
<td><?= escape($booking['role']) ?></td>
<td><?= escape(accommodationLabel($booking['accommodation_type'])) ?></td>
<td><?= escape((string) ($booking['roommate_requests'] ?? '')) ?></td>
<td><?= escape($booking['early_departure'] !== null && $booking['early_departure'] !== '' ? earlyDepartureLabel($booking['early_departure']) : '') ?></td>
<td><?= escape($mealText !== '' ? $mealText : '') ?></td>
<td><?= escape((string) ($booking['allergies'] ?? '')) ?></td>
<td><?= escape((string) ($booking['other_notes'] ?? '')) ?></td>
<td>
<form method="post" data-confirm="Diese Registrierung wirklich löschen?">
<input type="hidden" name="action" value="delete_booking">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
<button class="button-danger" type="submit">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if ($bookings === []): ?>
<tr><td colspan="13">Noch keine Registrierungen vorhanden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="panel">
<p class="eyebrow">Auswertung</p>
<h2>Zahlungserinnerung an Nichtzahler</h2>
<p>Angeschrieben werden alle angemeldeten Teilnehmer mit hinterlegter E-Mail-Adresse, deren Registrierung noch nicht als bezahlt markiert ist.</p>
<form method="post" class="stacked-form">
<input type="hidden" name="action" value="prepare_unpaid_email">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<label for="bulk_email_text">Nachrichtentext</label>
<textarea id="bulk_email_text" name="bulk_email_text" rows="8" maxlength="10000" required><?= is_array($bulkEmailDraft) ? escape((string) ($bulkEmailDraft['text'] ?? '')) : '' ?></textarea>
<small>Platzhalter: <code>{{name}}</code> für den Namen und <code>{{preis}}</code> für den jeweils offenen Preis.</small>
<button type="submit">Vorschau vorbereiten</button>
</form>
<?php if ($bulkEmailRecipients !== []): ?>
<div class="bulk-email-preview">
<h3>Versand bestätigen</h3>
<p><strong><?= count($bulkEmailRecipients) ?></strong> Empfänger gefunden. Beispiel für <?= escape((string) $bulkEmailRecipients[0]['name']) ?>:</p>
<pre><?= escape($bulkEmailPreview) ?></pre>
<form method="post" data-confirm="Die Zahlungserinnerung jetzt an alle <?= count($bulkEmailRecipients) ?> Empfänger senden?">
<input type="hidden" name="action" value="send_unpaid_email">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<button type="submit">Jetzt an alle Nichtzahler senden</button>
</form>
</div>
<?php endif; ?>
</section>
<section class="panel">
<p class="eyebrow">Auswertung</p>
<h2>Zimmerbelegung planen</h2>
<p>Berücksichtigt werden nur Übernachtungsgäste. Mehrbettzimmer werden mit maximal vier Schlafplätzen geplant.</p>
<form method="post" class="price-grid">
<input type="hidden" name="action" value="update_room_settings">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<div class="price-group">
<h3>Verfügbare Zimmer</h3>
<div class="price-item">
<label for="room_count_single">Einzelzimmer</label>
<input type="number" id="room_count_single" name="room_count_single" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['single'] ?>" required>
</div>
<div class="price-item">
<label for="room_count_double">Doppelzimmer</label>
<input type="number" id="room_count_double" name="room_count_double" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['double'] ?>" required>
</div>
<div class="price-item">
<label for="room_count_multi">Mehrbettzimmer (max. 4 Plätze)</label>
<input type="number" id="room_count_multi" name="room_count_multi" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['multi'] ?>" required>
</div>
</div>
<button type="submit">Zimmeranzahlen speichern</button>
</form>
<?php if ($roomPlan['warnings'] !== []): ?>
<div class="message error">
<strong>Planungshinweise</strong>
<ul>
<?php foreach ($roomPlan['warnings'] as $warning): ?>
<li><?= escape($warning) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="table-wrapper">
<table>
<thead><tr><th>Zimmer</th><th>Belegung</th><th>Kapazität</th></tr></thead>
<tbody>
<?php foreach ($roomPlan['rooms'] as $room): ?>
<tr>
<td><?= escape(accommodationLabel($room['type'])) ?> <?= (int) $room['number'] ?></td>
<td><?= escape(implode(', ', array_column($room['members'], 'name'))) ?></td>
<td><?= count($room['members']) ?> / <?= (int) $room['capacity'] ?></td>
</tr>
<?php endforeach; ?>
<?php if ($roomPlan['rooms'] === []): ?>
<tr><td colspan="3">Noch keine Zimmerplanung möglich. Bitte Zimmeranzahlen und Übernachtungsgäste prüfen.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Preise</h2>
<form method="post" class="price-grid">
<input type="hidden" name="action" value="update_prices">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<?php foreach ($priceGroups as $groupLabel => $priceItems): ?>
<div class="price-group">
<h3><?= escape($groupLabel) ?></h3>
<?php foreach ($priceItems as $priceKey => $priceLabel): ?>
<div class="price-item">
<strong><?= escape($priceLabel) ?></strong>
<label for="price_<?= $priceKey ?>_under_27">Unter 27</label>
<input type="number" id="price_<?= $priceKey ?>_under_27" name="price_<?= $priceKey ?>_under_27" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey . '_under_27'] ?? '0.00')) ?>" required>
<label for="price_<?= $priceKey ?>_over_27">Ab 27</label>
<input type="number" id="price_<?= $priceKey ?>_over_27" name="price_<?= $priceKey ?>_over_27" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey . '_over_27'] ?? '0.00')) ?>" required>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<div class="price-group">
<h3>Pauschalen</h3>
<?php foreach ($flatPriceItems as $priceKey => $priceLabel): ?>
<div class="price-item">
<strong><?= escape($priceLabel) ?></strong>
<label for="price_<?= $priceKey ?>">Preis</label>
<input type="number" id="price_<?= $priceKey ?>" name="price_<?= $priceKey ?>" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey] ?? '0.00')) ?>" required>
</div>
<?php endforeach; ?>
</div>
<button type="submit">Preise speichern</button>
</form>
</section>
<div class="admin-grid">
<section class="panel">
<p class="eyebrow">Zugänge</p>
<h2>Admin-Benutzer</h2>
<div class="user-list">
<?php foreach ($admins as $admin): ?>
<div class="user-row">
<div><strong><?= escape($admin['username']) ?></strong><br><small><?= escape($admin['email']) ?></small></div>
<div class="user-actions">
<form method="post">
<input type="hidden" name="action" value="change_password">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="admin_id" value="<?= (int) $admin['id'] ?>">
<input type="password" name="changed_password" minlength="12" placeholder="Neues Passwort" required>
<button class="button-secondary" type="submit">Ändern</button>
</form>
<?php if ((int) $admin['id'] !== (int) $_SESSION['admin_id']): ?>
<form method="post">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="admin_id" value="<?= (int) $admin['id'] ?>">
<button class="button-danger" type="submit">Löschen</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<section class="panel">
<p class="eyebrow">Zugänge</p>
<h2>Benutzer anlegen</h2>
<form method="post" class="stacked-form">
<input type="hidden" name="action" value="create_user">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<label for="new_username">Benutzername</label>
<input type="text" id="new_username" name="new_username" pattern="[A-Za-z0-9_.-]{3,80}" required>
<label for="new_email">E-Mail-Adresse</label>
<input type="email" id="new_email" name="new_email" maxlength="255" required>
<label for="new_password">Passwort</label>
<input type="password" id="new_password" name="new_password" minlength="12" required>
<button type="submit">Benutzer anlegen</button>
</form>
</section>
</div>
</main>
<script src="./scripts/security.js?v=20260918-3"></script>
</body>
</html>