| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/seaside2.pacim.de/web/public/ |
Upload File : |
<?php
// Default-Timezone explizit setzen — sonst läuft die App auf einigen Servern in UTC,
// und `date('Y-m-d')` zeigt abends einen Tag zu früh (z.B. 22:50 UTC = 00:50 Europe/Berlin → bereits Folgetag).
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/../app/core/Database.php';
require_once __DIR__ . '/../app/core/QueryProfiler.php';
require_once __DIR__ . '/../app/core/ProductCategories.php';
require_once __DIR__ . '/../app/core/SchemaSelfHeal.php';
require_once __DIR__ . '/../app/core/helpers.php';
require_once __DIR__ . '/../app/core/status_helpers.php';
require_once __DIR__ . '/../app/core/Normalize.php';
require_once __DIR__ . '/../app/core/ErrorHandler.php';
require_once __DIR__ . '/../app/core/PdfRenderer.php';
require_once __DIR__ . '/../app/core/ImageProcessor.php';
require_once __DIR__ . '/../app/core/Permission.php';
require_once __DIR__ . '/../app/core/Auth.php';
require_once __DIR__ . '/../app/core/AuditLog.php';
require_once __DIR__ . '/../app/core/XlsxGrid.php';
require_once __DIR__ . '/../app/core/HealthCheck.php';
ErrorHandler::register();
require_once __DIR__ . '/../app/models/Customer.php';
require_once __DIR__ . '/../app/models/Event.php';
require_once __DIR__ . '/../app/models/Product.php';
require_once __DIR__ . '/../app/models/Booking.php';
require_once __DIR__ . '/../app/models/Payment.php';
require_once __DIR__ . '/../app/models/PaymentMethod.php';
require_once __DIR__ . '/../app/models/Message.php';
require_once __DIR__ . '/../app/models/Invoice.php';
require_once __DIR__ . '/../app/models/Note.php';
require_once __DIR__ . '/../app/models/Checkin.php';
require_once __DIR__ . '/../app/models/DailyClosing.php';
require_once __DIR__ . '/../app/models/Report.php';
require_once __DIR__ . '/../app/models/User.php';
require_once __DIR__ . '/../app/models/Settings.php';
require_once __DIR__ . '/../app/models/Organizer.php';
require_once __DIR__ . '/../app/models/WorkEmployee.php';
require_once __DIR__ . '/../app/models/WorkHour.php';
require_once __DIR__ . '/../app/models/ApiToken.php';
require_once __DIR__ . '/../app/models/ApiLog.php';
require_once __DIR__ . '/../app/models/EmailTemplate.php';
require_once __DIR__ . '/../app/services/EmailTemplateRenderer.php';
require_once __DIR__ . '/../app/core/Documents.php';
require_once __DIR__ . '/../app/core/MiniXlsx.php';
require_once __DIR__ . '/../app/core/Exporter.php';
require_once __DIR__ . '/../app/services/MailService.php';
require_once __DIR__ . '/../app/services/SearchService.php';
require_once __DIR__ . '/../app/services/SearchIndexer.php';
require_once __DIR__ . '/../app/services/ReportCache.php';
require_once __DIR__ . '/../app/services/WorkTimeExportService.php';
require_once __DIR__ . '/../app/services/LegacyImporter.php';
startSession();
// Query-Profiler vor der Database-Initialisierung scharfmachen — sonst bekommt
// die erste PDO-Connection das ATTR_STATEMENT_CLASS nicht gesetzt.
QueryProfiler::init();
try {
$db = new Database();
// Selbstheilung: legt fehlende Spalten/Tabellen aus Migrationen 047/048/049/051
// beim ersten Request an. Risikofrei: jede Operation idempotent + try/catch.
SchemaSelfHeal::run($db->getConnection());
} catch (Throwable $e) {
http_response_code(500);
$installedMarker = __DIR__ . '/../app/config/.installed';
echo '<!doctype html><html><body style="font-family: system-ui; padding: 2rem;">';
echo '<h1>Datenbankverbindung fehlgeschlagen</h1>';
echo '<p>' . safeText($e->getMessage()) . '</p>';
if (!file_exists($installedMarker)) {
echo '<p>Das System ist noch nicht installiert. Bitte den Installations-Wizard öffnen:</p>';
echo '<p><a href="/setup.php">/setup.php</a></p>';
} else {
echo '<p>Bitte die Datenbank-Zugangsdaten in <code>app/config/database.php</code> prüfen' .
' bzw. den MySQL-Server starten.</p>';
}
echo '</body></html>';
exit;
}
Auth::bind($db);
AuditLog::bind($db);
$customerRepo = new Customer($db);
$eventRepo = new Event($db);
$productRepo = new Product($db);
$bookingRepo = new Booking($db);
$paymentRepo = new Payment($db);
$paymentMethodRepo = new PaymentMethod($db);
$messageRepo = new Message($db);
$invoiceRepo = new Invoice($db);
$noteRepo = new Note($db);
$checkinRepo = new Checkin($db);
$closingRepo = new DailyClosing($db);
$reportRepo = new Report($db);
$userRepo = new User($db);
$settingsRepo = new Settings($db);
ProductCategories::bind($settingsRepo);
$mailService = new MailService($settingsRepo);
$mailService->setMessageRepo($messageRepo);
$searchSvc = new SearchService($db);
$searchIndexer = new SearchIndexer($db);
SearchIndexer::bind($searchIndexer);
$reportCache = new ReportCache($db);
ReportCache::bind($reportCache);
$organizerRepo = new Organizer($db);
$workEmployeeRepo = new WorkEmployee($db);
$workHourRepo = new WorkHour($db);
$apiTokenRepo = new ApiToken($db);
$apiLogRepo = new ApiLog($db);
$emailTemplateRepo = new EmailTemplate($db);
$emailRenderer = new EmailTemplateRenderer($db, $bookingRepo, $invoiceRepo, $settingsRepo);
$page = $_GET['page'] ?? 'dashboard';
// --- CORS für die REST-API v1 -------------------------------------------
// Mobile Clients (Cordova-WebView mit Origin `app://localhost`/`file://`),
// SPAs auf fremder Domain etc. brauchen explizite CORS-Header. Preflight
// (OPTIONS) wird mit 204 sofort beantwortet, sonst läuft die Auth-Middleware
// in den Login-Redirect.
$isApiRequest = str_starts_with($page, 'api/v1/');
if ($isApiRequest) {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With, Accept');
header('Access-Control-Expose-Headers: Content-Type, Content-Length');
header('Access-Control-Max-Age: 3600');
header('Vary: Origin');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
http_response_code(204);
header('Content-Length: 0');
exit;
}
}
// --- Auth-Middleware -----------------------------------------------------
$publicPages = ['login', 'logout'];
// REST-API authentifiziert sich per Bearer-Token im Authorization-Header,
// nicht über die Web-Session. Die Token-Prüfung erfolgt im jeweiligen Route-Handler.
// AJAX-Routen, die KEIN HTML zurück liefern dürfen — der Auth-/Permission-Block
// muss hier mit JSON 401/403 antworten statt mit einem Location-Redirect, sonst
// crasht das Frontend mit „Unexpected token '<' … is not valid JSON".
$isJsonRoute = str_starts_with($page, 'settings/import/')
|| str_starts_with($page, 'customers/ajax/')
|| str_starts_with($page, 'health/');
if (!$isApiRequest && !in_array($page, $publicPages, true)) {
if (!Auth::check()) {
if ($isJsonRoute) {
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Session abgelaufen oder nicht angemeldet — bitte neu einloggen.']);
exit;
}
// Original-Ziel merken, damit nach erfolgreichem Login dorthin
// zurück gesprungen werden kann. Bei POST/PUT/PATCH/DELETE den
// GET-Referer als Ziel verwenden, da ein POST-Replay nach Login
// nicht möglich ist.
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($method === 'GET') {
$qs = (string)($_SERVER['QUERY_STRING'] ?? '');
$returnTo = $qs !== '' ? '/?' . $qs : '/?page=dashboard';
} else {
$ref = (string)($_SERVER['HTTP_REFERER'] ?? '');
$refPath = $ref !== '' ? parse_url($ref, PHP_URL_PATH) : null;
$refQs = $ref !== '' ? parse_url($ref, PHP_URL_QUERY) : null;
$returnTo = ($refPath === '/' && is_string($refQs) && $refQs !== '')
? '/?' . $refQs
: '/?page=dashboard';
}
// Sicherheits-Whitelist: nur lokale ?page=…-URLs zulassen.
if (!preg_match('#^/\?page=[A-Za-z0-9_./-]#', $returnTo)) {
$returnTo = '/?page=dashboard';
}
$_SESSION['return_to'] = $returnTo;
redirect('/?page=login');
}
// Resource-Mapping: extrahiere den ersten Pfad-Teil als Resource
$resource = explode('/', $page)[0];
// Free für jeden eingeloggten User
$alwaysAllowed = ['search', 'api', 'profile', 'audit', 'health'];
if (!in_array($resource, $alwaysAllowed, true) && !Permission::can(Auth::role(), $resource)) {
if ($isJsonRoute) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Kein Zugriff auf diese Funktion.']);
exit;
}
http_response_code(403);
ob_start();
$activeNav = '';
$pageTitle = '403';
$pageSubtitle = 'Kein Zugriff';
require __DIR__ . '/../app/views/errors/403.php';
$content = ob_get_clean();
require __DIR__ . '/../app/views/layout.php';
exit;
}
}
$pageTitle = 'Dashboard';
$pageSubtitle = '';
$activeNav = 'dashboard';
ob_start();
try {
switch ($page) {
// ======================================================================
// REST-API v1 — Bearer-Token-Auth, JSON-Antworten.
// Routen:
// GET /?page=api/v1/status → Verbindungs-Status (PUBLIC, ohne Token)
// GET /?page=api/v1/auth/me → Token validieren + Token-Info (Token nötig)
// GET /?page=api/v1/events → Liste aller Events (Filter ?from=&to=&ship=)
// GET /?page=api/v1/events&id=<id> → Einzelner Event
// POST /?page=api/v1/events → Neuen Event anlegen (write-Scope)
// GET /?page=api/v1/bookings → Liste (Filter ?date=&event_id=)
// GET /?page=api/v1/bookings&id=<id> → Einzelne Buchung
// ======================================================================
// Status-Endpoint — KEINE Authentifizierung. Mobile App nutzt das vor dem
// QR-Onboarding, um zu prüfen, ob die Server-Adresse erreichbar ist und der
// PaCIM-Backend dort läuft. Liefert minimale, nicht-vertrauliche Info.
// ----------------------------------------------------------------------
// Health-Check — Selbst-Diagnose der Installation. Wird vom Layout-
// Hintergrund-Ping abgefragt und steuert den dismissbaren Banner.
// GET /?page=health/banner → JSON-Status (für eingeloggte User)
// POST /?page=health/install-missing → Migrations idempotent erneut fahren
// POST /?page=health/dismiss → State-Hash als "weggeklickt" merken
// ----------------------------------------------------------------------
case 'health/banner':
case 'health/install-missing':
case 'health/dismiss':
if (ob_get_level() > 0) { ob_clean(); }
header('Content-Type: application/json; charset=utf-8');
$healthOut = static function (int $status, array $payload): never {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
$hc = new HealthCheck($db, $settingsRepo);
$role = Auth::role();
$isAdmin = $role === 'admin';
if ($page === 'health/banner') {
$r = $hc->checkWithDismissState();
// Banner nur dem Admin zeigen — ein Counter-Mitarbeiter kann eh nichts dran ändern.
$r['visible_for_user'] = $isAdmin && !$r['ok'] && !$r['dismissed'];
$healthOut(200, $r);
}
if (!$isAdmin) {
$healthOut(403, ['ok' => false, 'message' => 'Nur Admins.']);
}
if (!isPost()) {
$healthOut(405, ['ok' => false, 'message' => 'POST erwartet.']);
}
if (!csrfCheck()) {
$healthOut(419, ['ok' => false, 'message' => 'Sicherheitsprüfung fehlgeschlagen.']);
}
if ($page === 'health/install-missing') {
$result = $hc->installMissing();
// Nach Re-Run nochmal prüfen, damit das UI direkt den neuen Zustand sieht.
$result['check'] = $hc->check();
AuditLog::write('settings', 0, 'health_install_missing',
'Health-Check: fehlende Migrationen nachgefahren',
null, ['ok' => $result['ok'], 'steps' => count($result['steps'])]);
$healthOut($result['ok'] ? 200 : 500, $result);
}
if ($page === 'health/dismiss') {
$hash = (string)($_POST['state_hash'] ?? '');
if ($hash === '') $healthOut(422, ['ok' => false, 'message' => 'State-Hash fehlt.']);
$hc->dismiss($hash);
$healthOut(200, ['ok' => true]);
}
$healthOut(404, ['ok' => false, 'message' => 'Unbekannte Aktion.']);
case 'api/v1/status':
if (ob_get_level() > 0) { ob_clean(); }
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$apiStartedAt = microtime(true);
$dbOk = false;
try {
$db->getConnection()->query('SELECT 1')->fetch();
$dbOk = true;
} catch (Throwable) { /* dbOk bleibt false */ }
$statusCode = $dbOk ? 200 : 503;
http_response_code($statusCode);
echo json_encode([
'ok' => $dbOk,
'service' => 'PaCIM SaaS',
'api' => 'v1',
'database' => $dbOk ? 'ok' : 'error',
'time' => date('c'),
'timezone' => date_default_timezone_get(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// Logging — Aufruf ist anonym (kein Token), wird aber in api_logs
// erfasst, damit auch unauthentifizierte Pings sichtbar sind.
try {
$apiLogRepo->record([
'token_id' => null,
'token_name' => null,
'method' => (string)($_SERVER['REQUEST_METHOD'] ?? 'GET'),
'endpoint' => '/?page=' . $page,
'query_string'=> (string)($_SERVER['QUERY_STRING'] ?? ''),
'status_code' => $statusCode,
'duration_ms' => (int)round((microtime(true) - $apiStartedAt) * 1000),
'ip_address' => (string)($_SERVER['REMOTE_ADDR'] ?? ''),
'user_agent' => (string)($_SERVER['HTTP_USER_AGENT'] ?? ''),
'error_code' => $dbOk ? null : 'database_unreachable',
'message' => null,
]);
} catch (Throwable) {}
exit;
case 'api/v1/events':
case 'api/v1/bookings':
case 'api/v1/organizers':
case 'api/v1/auth/me':
case 'api/v1/checkin/login':
// Saubere JSON-Antwort ohne führende Warnings / Ausgabe.
if (ob_get_level() > 0) { ob_clean(); }
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
// Start-Zeit + Request-Metadaten für das Logging.
$apiStartedAt = microtime(true);
$apiTokenForLog = null;
$apiOut = static function (int $status, array $payload) use (&$apiTokenForLog, $apiLogRepo, $apiStartedAt, $page): void {
// Vor dem exit: API-Log schreiben. Best-effort — Logging-Fehler sollen
// die Antwort nicht killen.
try {
$apiLogRepo->record([
'token_id' => $apiTokenForLog['id'] ?? null,
'token_name' => $apiTokenForLog['name'] ?? null,
'method' => (string)($_SERVER['REQUEST_METHOD'] ?? 'GET'),
'endpoint' => '/?page=' . $page,
'query_string'=> (string)($_SERVER['QUERY_STRING'] ?? ''),
'status_code' => $status,
'duration_ms' => (int)round((microtime(true) - $apiStartedAt) * 1000),
'ip_address' => (string)($_SERVER['REMOTE_ADDR'] ?? ''),
'user_agent' => (string)($_SERVER['HTTP_USER_AGENT'] ?? ''),
'error_code' => isset($payload['error']) ? (string)$payload['error'] : null,
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
]);
} catch (Throwable) {}
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
// Bearer-Token aus Authorization-Header lesen (alternativ ?api_token=...).
$authHeader = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
$apiPlain = '';
if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
$apiPlain = trim($m[1]);
} elseif (!empty($_GET['api_token']) || !empty($_POST['api_token'])) {
$apiPlain = (string)($_GET['api_token'] ?? $_POST['api_token']);
}
if ($apiPlain === '') {
$apiOut(401, ['error' => 'unauthorized', 'message' => 'Authorization-Header mit Bearer-Token erforderlich.']);
}
$tokenRow = $apiTokenRepo->findByPlainToken($apiPlain);
if (!$tokenRow) {
$apiOut(401, ['error' => 'invalid_token', 'message' => 'Token ungültig oder widerrufen.']);
}
// Token-Daten für das Logging im $apiOut-Closure verfügbar machen.
$apiTokenForLog = $tokenRow;
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
// ---- Routen --------------------------------------------------------
// Hilfs-Closure: Login-Versuch in pacim_checkin_employee_logins ablegen.
// Best-effort (Logging-Fehler killen den Login nicht).
$logEmployeeLogin = static function (?int $userId, bool $success, ?string $failureReason) use ($db, $tokenRow): void {
try {
$pdoLog = $db->getConnection();
// Tabelle nachziehen, falls Migration 041 noch nicht lief.
$pdoLog->exec(
"CREATE TABLE IF NOT EXISTS pacim_checkin_employee_logins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NULL,
api_token_id INT UNSIGNED NULL,
device_id VARCHAR(191) NULL,
app_version VARCHAR(50) NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
success TINYINT(1) NOT NULL DEFAULT 0,
failure_reason VARCHAR(64) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_celg_user (user_id, created_at),
INDEX idx_celg_token (api_token_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$bodyRaw = file_get_contents('php://input') ?: '';
$body = $bodyRaw !== '' ? (json_decode($bodyRaw, true) ?: []) : ($_POST ?? []);
$stmt = $pdoLog->prepare(
"INSERT INTO pacim_checkin_employee_logins
(user_id, api_token_id, device_id, app_version, ip_address, user_agent, success, failure_reason)
VALUES (:uid, :tid, :did, :av, :ip, :ua, :ok, :reason)"
);
$stmt->execute([
'uid' => $userId,
'tid' => (int)($tokenRow['id'] ?? 0) ?: null,
'did' => isset($body['device_id']) ? mb_substr((string)$body['device_id'], 0, 191) : null,
'av' => isset($body['app_version']) ? mb_substr((string)$body['app_version'], 0, 50) : null,
'ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64),
'ua' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
'ok' => $success ? 1 : 0,
'reason' => $failureReason !== null ? mb_substr($failureReason, 0, 64) : null,
]);
} catch (Throwable) { /* schlucken */ }
};
// Hilfs-Closure: Liste aller aktiven Check-in-Mitarbeiter — wird sowohl
// von /auth/me als auch indirekt für Validierung im Login genutzt.
$loadCheckinUsers = static function () use ($db): array {
$pdoU = $db->getConnection();
$stmt = $pdoU->query(
"SELECT id, first_name, last_name
FROM users
WHERE status = 'active' AND role = 'checkin'
ORDER BY first_name ASC, last_name ASC"
);
$out = [];
foreach ($stmt->fetchAll() as $u) {
$name = trim(($u['first_name'] ?? '') . ' ' . ($u['last_name'] ?? ''));
if ($name === '') $name = 'Mitarbeiter #' . (int)$u['id'];
$out[] = [
'id' => (int)$u['id'],
'name' => $name,
'display_name' => $name,
];
}
return $out;
};
// Auth-Check: bestätigt dass Verbindung steht und Token gültig ist.
// Liefert Token-Metadaten + Server-Zeit. Die Mitarbeiter-Liste
// (`checkin_users`) erscheint nur, wenn der Token den `checkin`-Scope hat.
if ($page === 'api/v1/auth/me') {
if ($method !== 'GET') {
$apiOut(405, ['error' => 'method_not_allowed', 'message' => 'Nur GET erlaubt.', 'allowed' => ['GET']]);
}
$scopes = array_values(array_filter(
array_map('trim', explode(',', (string)$tokenRow['scopes'])),
static fn ($s) => $s !== ''
));
$payload = [
'ok' => true,
'token' => [
'id' => (int)$tokenRow['id'],
'name' => (string)$tokenRow['name'],
'scopes' => $scopes,
'created_at' => (string)($tokenRow['created_at'] ?? ''),
'last_used_at' => $tokenRow['last_used_at'] !== null ? (string)$tokenRow['last_used_at'] : null,
'revoked' => !empty($tokenRow['revoked_at']),
],
'server' => [
'service' => 'PaCIM SaaS',
'api' => 'v1',
'time' => date('c'),
],
];
// Mitarbeiter-Dropdown nur ausliefern, wenn die Check-in-App-Funktionalität
// für diesen Token freigeschaltet ist (scope=checkin).
if (ApiToken::hasScope($tokenRow, 'checkin')) {
$payload['checkin_users'] = $loadCheckinUsers();
}
$apiOut(200, $payload);
}
// Mitarbeiter-Login in der Check-in-App.
// POST /?page=api/v1/checkin/login
// { user_id, password, device_id?, app_version? }
// API-Token ist bereits validiert. Hier prüfen wir das User-Passwort
// gegen `users.password` (bcrypt) und geben Mitarbeiter-Metadaten zurück.
if ($page === 'api/v1/checkin/login') {
if ($method !== 'POST') {
$apiOut(405, ['error' => 'method_not_allowed', 'message' => 'Nur POST erlaubt.', 'allowed' => ['POST']]);
}
// Erfordert den `checkin`-Scope — der Token muss in der Admin-UI
// explizit für die Check-in-App freigeschaltet werden.
if (!ApiToken::hasScope($tokenRow, 'checkin')) {
$apiOut(403, ['error' => 'forbidden', 'message' => 'Token hat keinen checkin-Scope. Check-in-App-Funktionalität nicht freigeschaltet.']);
}
// JSON-Body bevorzugt; Formular-Felder als Fallback.
$rawBody = file_get_contents('php://input') ?: '';
$body = $rawBody !== '' ? (json_decode($rawBody, true) ?: []) : $_POST;
if (!is_array($body)) {
$apiOut(400, ['error' => 'bad_request', 'message' => 'Ungültiger Request-Body.']);
}
$userId = (int)($body['user_id'] ?? 0);
$password = (string)($body['password'] ?? '');
if ($userId <= 0 || $password === '') {
$logEmployeeLogin(null, false, 'missing_fields');
$apiOut(422, ['error' => 'invalid_employee_login', 'message' => 'Mitarbeiter oder Passwort ungültig.']);
}
// Mitarbeiter holen — DIRECT, da User-Repo ggf. anders filtert.
$stmt = $db->getConnection()->prepare(
"SELECT id, first_name, last_name, role, status, password
FROM users WHERE id = :id LIMIT 1"
);
$stmt->execute(['id' => $userId]);
$user = $stmt->fetch() ?: null;
// Aus Sicherheitsgründen IMMER dieselbe Antwort, egal welcher Pfad
// scheitert — die App soll nicht erfahren, welcher Teil falsch war.
$failReason = null;
if (!$user) {
$failReason = 'unknown_user';
} elseif ($user['status'] !== 'active') {
$failReason = 'inactive';
} elseif ($user['role'] !== 'checkin') {
$failReason = 'wrong_role';
} elseif (!password_verify($password, (string)$user['password'])) {
$failReason = 'wrong_password';
}
if ($failReason !== null) {
$logEmployeeLogin($userId > 0 ? $userId : null, false, $failReason);
$apiOut(401, ['error' => 'invalid_employee_login', 'message' => 'Mitarbeiter oder Passwort ungültig.']);
}
// Erfolg: last_login_at am User aktualisieren (best-effort).
try {
$db->getConnection()
->prepare("UPDATE users SET last_login_at = NOW(), failed_login_count = 0 WHERE id = :id")
->execute(['id' => (int)$user['id']]);
} catch (Throwable) { /* last_login_at-Spalte fehlt evtl. — egal */ }
$logEmployeeLogin((int)$user['id'], true, null);
// Audit-Log (interne History) — sichtbar in /?page=audit-Listen.
try {
AuditLog::write('auth', (int)$user['id'], 'checkin_app_login',
'Mitarbeiter-Login Check-in-App: ' . trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')),
null,
[
'token_id' => (int)$tokenRow['id'],
'token_name' => (string)$tokenRow['name'],
'device_id' => $body['device_id'] ?? null,
'app_version' => $body['app_version'] ?? null,
]);
} catch (Throwable) { /* schlucken */ }
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
if ($name === '') $name = 'Mitarbeiter #' . (int)$user['id'];
$apiOut(200, [
'ok' => true,
'employee' => [
'id' => (int)$user['id'],
'name' => $name,
'display_name' => $name,
'role' => (string)$user['role'],
],
'session' => [
'started_at' => date('c'),
],
]);
}
if ($page === 'api/v1/events') {
if ($method === 'GET') {
if (!ApiToken::hasScope($tokenRow, 'read')) {
$apiOut(403, ['error' => 'forbidden', 'message' => 'Token hat keinen read-Scope.']);
}
$id = (int)($_GET['id'] ?? 0);
if ($id > 0) {
$e = $eventRepo->find($id);
if (!$e) $apiOut(404, ['error' => 'not_found', 'message' => 'Event nicht gefunden.']);
$apiOut(200, ['data' => $e]);
}
// Listen-Filter: from, to, ship
$from = (string)($_GET['from'] ?? '');
$to = (string)($_GET['to'] ?? '');
$ship = (string)($_GET['ship'] ?? '');
$rows = $eventRepo->all();
$rows = array_values(array_filter($rows, static function ($e) use ($from, $to, $ship) {
if ($ship !== '' && strcasecmp((string)($e['ship_name'] ?? ''), $ship) !== 0) return false;
if ($from !== '' && strtotime((string)($e['starts_at'] ?? '')) < strtotime($from . ' 00:00:00')) return false;
if ($to !== '' && strtotime((string)($e['starts_at'] ?? '')) > strtotime($to . ' 23:59:59')) return false;
return true;
}));
$apiOut(200, ['data' => $rows, 'count' => count($rows)]);
}
if ($method === 'POST') {
if (!ApiToken::hasScope($tokenRow, 'write')) {
$apiOut(403, ['error' => 'forbidden', 'message' => 'Token hat keinen write-Scope.']);
}
// JSON-Body bevorzugt; Formular-Felder als Fallback.
$rawBody = file_get_contents('php://input') ?: '';
$body = $rawBody !== '' ? (json_decode($rawBody, true) ?: []) : $_POST;
if (!is_array($body)) $apiOut(400, ['error' => 'bad_request', 'message' => 'Ungültiger Request-Body.']);
$data = [
'title' => (string)($body['title'] ?? ''),
'ship_name' => (string)($body['ship_name'] ?? ''),
'terminal' => (string)($body['terminal'] ?? ''),
'starts_at' => (string)($body['starts_at'] ?? ''),
'ends_at' => (string)($body['ends_at'] ?? ''),
'arrival_from' => (string)($body['arrival_from'] ?? ''),
'arrival_to' => (string)($body['arrival_to'] ?? ''),
'capacity_indoor' => (int) ($body['capacity_indoor'] ?? 0),
'capacity_outdoor' => (int) ($body['capacity_outdoor'] ?? 0),
'capacity_valet' => (int) ($body['capacity_valet'] ?? 0),
'status' => (string)($body['status'] ?? 'scheduled'),
];
// Normalisierung (Date-Only → DATETIME, Time-Only → DATETIME) — selbe Helferfunktion wie das Web-Formular.
$data = normalizeEventDateTimes($data);
$errors = Event::validate($data);
if (!empty($errors)) {
$apiOut(422, ['error' => 'validation_failed', 'fields' => $errors]);
}
try {
$newId = $eventRepo->create($data);
AuditLog::write('event', $newId, 'created', 'API: Event angelegt (Token "' . ($tokenRow['name'] ?? '') . '")',
null, ['title' => $data['title'], 'ship_name' => $data['ship_name']]);
$apiOut(201, ['data' => $eventRepo->find($newId)]);
} catch (Throwable $e) {
$apiOut(500, ['error' => 'server_error', 'message' => $e->getMessage()]);
}
}
$apiOut(405, ['error' => 'method_not_allowed', 'allowed' => ['GET', 'POST']]);
}
if ($page === 'api/v1/organizers') {
if ($method !== 'GET') {
$apiOut(405, ['error' => 'method_not_allowed', 'allowed' => ['GET']]);
}
if (!ApiToken::hasScope($tokenRow, 'read')) {
$apiOut(403, ['error' => 'forbidden', 'message' => 'Token hat keinen read-Scope.']);
}
// Basis-URL (Scheme + Host) für absolute Logo-Pfade in der Antwort.
$apiScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$apiBase = $apiScheme . '://' . (string)($_SERVER['HTTP_HOST'] ?? '');
$id = (int)($_GET['id'] ?? 0);
$mapOne = static function (array $o) use ($apiBase): array {
$o['logo'] = Organizer::logoUrls($o, $apiBase);
// Roh-Pfade entfernen, damit das JSON kompakt bleibt.
foreach (['logo_path', 'logo_path_32', 'logo_path_64', 'logo_path_128', 'logo_path_256'] as $k) {
unset($o[$k]);
}
return $o;
};
if ($id > 0) {
$row = $organizerRepo->find($id);
if (!$row) $apiOut(404, ['error' => 'not_found', 'message' => 'Veranstalter nicht gefunden.']);
$apiOut(200, ['data' => $mapOne($row)]);
}
$rows = array_map($mapOne, $organizerRepo->all());
$apiOut(200, ['data' => $rows, 'count' => count($rows)]);
}
if ($page === 'api/v1/bookings') {
if ($method !== 'GET') {
$apiOut(405, ['error' => 'method_not_allowed', 'allowed' => ['GET']]);
}
if (!ApiToken::hasScope($tokenRow, 'read')) {
$apiOut(403, ['error' => 'forbidden', 'message' => 'Token hat keinen read-Scope.']);
}
$id = (int)($_GET['id'] ?? 0);
if ($id > 0) {
$b = $bookingRepo->find($id);
if (!$b) $apiOut(404, ['error' => 'not_found', 'message' => 'Buchung nicht gefunden.']);
$b['products'] = $bookingRepo->products($id);
$apiOut(200, ['data' => $b]);
}
// Liste — Filter: date (= Anreisedatum travel_from), event_id, limit, offset.
$date = trim((string)($_GET['date'] ?? ''));
$eventId = (int)($_GET['event_id'] ?? 0);
$limit = max(1, min(500, (int)($_GET['limit'] ?? 200)));
$offset = max(0, (int)($_GET['offset'] ?? 0));
$sql = "SELECT b.id, b.booking_no, b.customer_id, b.event_id, b.travel_from, b.travel_to,
b.arrival_time, b.license_plate, b.status, b.payment_status, b.checkin_status,
b.total_net, b.total_vat, b.total_gross, b.source, b.created_at,
c.first_name AS c_first_name, c.last_name AS c_last_name, c.company AS c_company,
e.title AS event_title, e.ship_name AS event_ship
FROM bookings b
INNER JOIN customers c ON c.id = b.customer_id
INNER JOIN events e ON e.id = b.event_id
WHERE 1=1";
$params = [];
if ($date !== '') {
// Indexbasiert (Migration 049): travel_from_date statt DATE(travel_from).
$sql .= " AND b.travel_from_date = :d";
$params['d'] = $date;
}
if ($eventId > 0) {
$sql .= " AND b.event_id = :e";
$params['e'] = $eventId;
}
$sql .= " ORDER BY b.travel_from ASC, b.id ASC LIMIT $limit OFFSET $offset";
$stmt = $db->getConnection()->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
$apiOut(200, [
'data' => $rows,
'count' => count($rows),
'limit' => $limit,
'offset' => $offset,
'filters'=> ['date' => $date ?: null, 'event_id' => $eventId ?: null],
]);
}
break;
case 'dashboard/ajax/monthly-bookings':
header('Content-Type: application/json; charset=utf-8');
$months = max(1, min(36, (int)($_GET['months'] ?? 6)));
try {
$rows = $reportRepo->monthlyBookings($months);
$out = array_map(static fn ($m) => [
'label' => $m['label'],
'ym' => $m['ym'],
'bookings' => (int)$m['bookings'],
'revenue' => round((float)$m['revenue'], 2),
], $rows);
echo json_encode(['ok' => true, 'data' => $out], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
case 'dashboard':
$pageTitle = 'Dashboard';
$pageSubtitle = 'Live-Übersicht: Anreisen heute, Zahlungen, Check-ins.';
$activeNav = 'dashboard';
$today = date('Y-m-d');
// Tages-Snapshot bleibt live (heute-Daten ändern sich fortlaufend).
$todaySnapshot = $closingRepo->computeSnapshot($today, null);
// 6-Monats-Aggregation: 30-min-Cache reicht — Daten kommen aus abgeschlossenen
// Monaten, ändern sich nur durch neue Buchungen im laufenden Monat.
$monthly = ReportCache::remember('dashboard.monthly_bookings_6', 1800,
fn() => $reportRepo->monthlyBookings(6));
// Auslastung: täglich ±14 Tage um heute (= 29 Tage Default).
$occFrom = date('Y-m-d', strtotime('-14 days'));
$occTo = date('Y-m-d', strtotime('+14 days'));
$dailyOcc = ReportCache::remember("dashboard.occupancy.{$occFrom}.{$occTo}", 900,
fn() => $eventRepo->occupancyTrend($occFrom, $occTo));
// KPIs / Aggregations-Reports: 5-min-Cache. Bei intensiver Nutzung (mehrere Mitarbeiter
// aufm Dashboard) reduziert das die Volltable-Scans auf ~1 alle 5 Minuten.
$kpis = ReportCache::remember('dashboard.kpis', 300, fn() => $reportRepo->kpis());
$checkinRate = ReportCache::remember('dashboard.checkin_rate', 300, fn() => $reportRepo->checkinRate());
$openInvoices = ReportCache::remember('dashboard.open_invoices', 300, fn() => $reportRepo->openInvoicesAmount());
$inventory = ReportCache::remember('dashboard.inventory', 300, fn() => $bookingRepo->vehicleInventory());
$topEvents = ReportCache::remember('dashboard.top_events.5', 600, fn() => $reportRepo->topEvents(5));
$stmt = $db->getConnection()->query("SELECT * FROM events
WHERE starts_at >= NOW() AND status <> 'cancelled'
ORDER BY starts_at ASC LIMIT 1");
$nextEvent = $stmt->fetch() ?: null;
$nextEvents = $eventRepo->upcoming(5);
// Dashboard: nur die 6 neuesten ziehen — mit LIMIT direkt in SQL,
// sonst lädt PHP bei großen Beständen (z.B. nach Massen-Import)
// alle Buchungen ins Memory und kippt.
$latestBookings = $bookingRepo->all([
'hide_sources' => hiddenSourcesFor($settingsRepo, 'bookings'),
'limit' => 6,
]);
$latestPayments = $paymentRepo->all(6, hiddenSourcesFor($settingsRepo, 'payments'));
$activity = attachAuditLinks(AuditLog::recent(10), $db->getConnection());
require __DIR__ . '/../app/views/dashboard.php';
break;
case 'customers':
$pageTitle = 'Kunden';
$pageSubtitle = 'Verwalte alle Kundenstammdaten.';
$activeNav = 'customers';
$search = trim((string)($_GET['q'] ?? ''));
['perPage' => $pp, 'page' => $pn] = paginationParams();
// Server-seitige Pagination — lädt nur die aktuelle Seite, nicht die gesamte Tabelle.
$pagination = $customerRepo->paginated($search, hiddenSourcesFor($settingsRepo, 'customers'), $pp, $pn);
$customers = $pagination['items'];
require __DIR__ . '/../app/views/customers/index.php';
break;
case 'customers/create':
$pageTitle = 'Neuer Kunde';
$pageSubtitle = 'Lege einen neuen Kunden an.';
$activeNav = 'customers';
$errors = [];
$data = [
'company' => '', 'first_name' => '', 'last_name' => '',
'street' => '', 'zip' => '', 'city' => '',
'phone' => '', 'email' => '', 'status' => 'active', 'source' => '',
];
if (isPost()) {
$data = array_merge($data, $_POST);
$errors = Customer::validate($data);
// Email-Uniqueness nur prüfen, wenn überhaupt eine E-Mail eingegeben wurde
// (AIDA-Kunden ohne Mail sollen speicherbar sein).
if (empty($errors) && !empty($data['email']) && $customerRepo->emailExists($data['email'])) {
$errors['email'] = 'Diese E-Mail-Adresse ist bereits vergeben.';
}
if (empty($errors)) {
$customerRepo->create($data);
setFlash('success', 'Kunde wurde erfolgreich angelegt.');
redirect('/?page=customers');
}
}
require __DIR__ . '/../app/views/customers/form.php';
break;
case 'customers/edit':
// Bearbeiten ist auf der Kundenakte (customers/view) per Inline-/AJAX-Edit
// integriert. Alte /edit-Links zeigen direkt auf die zentrale Detailseite.
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
redirect('/?page=customers');
}
redirect('/?page=customers/view&id=' . $id);
break;
case 'customers/view':
$id = (int)($_GET['id'] ?? 0);
$customer = $customerRepo->find($id);
if (!$customer) {
http_response_code(404);
require __DIR__ . '/../app/views/errors/404.php';
break;
}
$pageTitle = 'Kunde: ' . safeText($customer['first_name'] . ' ' . $customer['last_name']);
$pageSubtitle = $customer['company'] ?: 'Privatkunde';
$activeNav = 'customers';
$customerBookings = $bookingRepo->byCustomer($id);
$customerInvoices = $invoiceRepo->byCustomer($id);
$customerMessages = $messageRepo->byCustomer($id);
$customerPayments = $paymentRepo->byCustomer($id);
// Hinweise oben im Header: NUR direkt am Kunden hinterlegte Hinweise.
// Buchungs-Hinweise gehören zur jeweiligen Buchung und sollen den
// Kundenheader nicht überfluten — sie tauchen aber im Notizen-Tab auf.
$customerHints = $customerRepo->listHints($id);
// Notizen-Tab: Notizen + Hinweise direkt am Kunden UND aus allen
// Buchungen dieses Kunden (chronologisch, neueste zuerst).
$customerNotes = $noteRepo->listForCustomerWithBookings($id);
// Feature-Gate: ohne pacim_notes-Tabelle sind Notizen + Hinweise
// nicht verfügbar — UI degradiert sauber.
$notesAvailable = $noteRepo->isAvailable();
// Aggregationen — Stornos zählen für Umsatz/Zahlstatus mit (negative
// Stornos sind selten und werden separat im UI markiert), aber für
// "Erste/Letzte Reise" ignoriert (siehe unten).
$cTotalRevenue = 0.0;
$cPaidRevenue = 0.0;
$cOpenRevenue = 0.0;
$cCntPaid = 0; $cCntOpen = 0;
$cCntCancelled = 0;
$firstTravel = null; $lastTravel = null;
foreach ($customerBookings as $b) {
$isCancelled = ($b['status'] ?? '') === 'cancelled';
if ($isCancelled) {
$cCntCancelled++;
continue;
}
$cTotalRevenue += (float)$b['total_gross'];
if ($b['payment_status'] === 'paid') {
$cCntPaid++;
$cPaidRevenue += (float)$b['total_gross'];
} else {
$cCntOpen++;
$cOpenRevenue += max(0, (float)$b['total_gross'] - (float)$b['paid_amount_sum']);
}
$tFrom = $b['travel_from'] ?? null;
if ($tFrom) {
if ($firstTravel === null || strcmp((string)$tFrom, (string)$firstTravel) < 0) $firstTravel = $tFrom;
if ($lastTravel === null || strcmp((string)$tFrom, (string)$lastTravel) > 0) $lastTravel = $tFrom;
}
}
$cActiveBookingCount = count($customerBookings) - $cCntCancelled;
require __DIR__ . '/../app/views/customers/view.php';
break;
case 'customers/delete':
$id = (int)($_GET['id'] ?? 0);
if ($id && isPost()) {
try {
$customerRepo->delete($id);
setFlash('success', 'Kunde wurde gelöscht.');
} catch (Throwable $e) {
setFlash('danger', 'Löschen nicht möglich: Kunde hat noch Buchungen.');
}
}
redirect('/?page=customers');
break;
// ----------------------------------------------------------------------
// AJAX-Endpunkte für die Kundenakte (?page=customers/view).
// Inline-Edit der Stammdaten + chronologische Notizen ohne Reload.
// Alle Antworten JSON, CSRF-geprüft, ohne sensible Felder.
// ----------------------------------------------------------------------
case 'customers/ajax/update':
case 'customers/ajax/notes/list':
case 'customers/ajax/notes/add':
case 'customers/ajax/notes/update':
case 'customers/ajax/notes/delete':
if (ob_get_level() > 0) { ob_clean(); }
header('Content-Type: application/json; charset=utf-8');
$jsonOut = static function (int $status, array $payload): never {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
$cid = (int)($_POST['customer_id'] ?? $_GET['customer_id'] ?? 0);
if ($cid <= 0) $jsonOut(422, ['ok' => false, 'message' => 'Kunden-ID fehlt.']);
$cust = $customerRepo->find($cid);
if (!$cust) $jsonOut(404, ['ok' => false, 'message' => 'Kunde nicht gefunden.']);
// Lesen ist seiteneffektfrei — nur Schreib-Endpunkte verlangen CSRF + POST.
$needsCsrf = !in_array($page, ['customers/ajax/notes/list'], true);
if ($needsCsrf) {
if (!isPost()) $jsonOut(405, ['ok' => false, 'message' => 'POST erwartet.']);
if (!csrfCheck()) $jsonOut(419, ['ok' => false, 'message' => 'Sicherheitsprüfung fehlgeschlagen — Seite neu laden.']);
}
$author = Auth::user()['email'] ?? null;
if ($page === 'customers/ajax/update') {
// Nur die im UI editierbaren Felder durchlassen — vermeidet z.B.
// ein versehentliches Überschreiben von legacy_source_id etc.
$allowed = ['company', 'first_name', 'last_name', 'street', 'zip', 'city', 'phone', 'email', 'status', 'source'];
$input = [];
foreach ($allowed as $f) {
if (array_key_exists($f, $_POST)) $input[$f] = $_POST[$f];
}
$validated = Customer::validatePartial($cust, $input);
if (!empty($validated['errors'])) {
$jsonOut(422, ['ok' => false, 'message' => 'Bitte Eingaben prüfen.', 'errors' => $validated['errors']]);
}
$data = $validated['data'];
if (!empty($data['email']) && $customerRepo->emailExists($data['email'], $cid)) {
$jsonOut(422, ['ok' => false, 'message' => 'Bitte Eingaben prüfen.', 'errors' => ['email' => 'Diese E-Mail-Adresse ist bereits vergeben.']]);
}
$oldVals = auditPick('customer', $cust);
$customerRepo->update($cid, $data);
$updated = $customerRepo->find($cid) ?? [];
$newVals = auditPick('customer', $updated);
AuditLog::write('customer', $cid, 'updated',
'Kunde aktualisiert: ' . trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')),
$oldVals, $newVals);
$jsonOut(200, [
'ok' => true,
'customer' => [
'id' => (int)$updated['id'],
'company' => (string)($updated['company'] ?? ''),
'first_name' => (string)($updated['first_name'] ?? ''),
'last_name' => (string)($updated['last_name'] ?? ''),
'street' => (string)($updated['street'] ?? ''),
'zip' => (string)($updated['zip'] ?? ''),
'city' => (string)($updated['city'] ?? ''),
'phone' => (string)($updated['phone'] ?? ''),
'email' => (string)($updated['email'] ?? ''),
'status' => (string)($updated['status'] ?? 'active'),
'source' => (string)($updated['source'] ?? ''),
],
]);
}
if ($page === 'customers/ajax/notes/list') {
$jsonOut(200, ['ok' => true, 'notes' => $customerRepo->listNotes($cid)]);
}
if ($page === 'customers/ajax/notes/add') {
$body = trim((string)($_POST['body'] ?? ''));
if ($body === '') $jsonOut(422, ['ok' => false, 'message' => 'Notiz darf nicht leer sein.']);
$kind = in_array(($_POST['kind'] ?? 'info'), Note::KINDS, true) ? (string)$_POST['kind'] : 'info';
// Optional: Notiz an eine Buchung des Kunden hängen statt direkt am Kunden.
$bookingId = (int)($_POST['booking_id'] ?? 0);
try {
if ($bookingId > 0) {
$b = $bookingRepo->find($bookingId);
if (!$b || (int)$b['customer_id'] !== $cid) {
$jsonOut(404, ['ok' => false, 'message' => 'Buchung gehört nicht zu diesem Kunden.']);
}
$newId = $bookingRepo->addNote($bookingId, $body, $author, $kind);
AuditLog::write('booking', $bookingId, 'note_added',
($kind === 'hint' ? 'Hinweis' : 'Notiz') . ' hinzugefügt (aus Kundenakte)', null,
['note_id' => $newId, 'kind' => $kind, 'preview' => mb_substr($body, 0, 80)]);
} else {
$newId = $customerRepo->addNote($cid, $body, $author, $kind);
AuditLog::write('customer', $cid, 'note_added',
($kind === 'hint' ? 'Hinweis' : 'Notiz') . ' hinzugefügt', null,
['note_id' => $newId, 'kind' => $kind, 'preview' => mb_substr($body, 0, 80)]);
}
} catch (RuntimeException $rex) {
$jsonOut(503, ['ok' => false, 'message' => $rex->getMessage()]);
}
$jsonOut(200, [
'ok' => true,
'customer_notes' => $noteRepo->listForCustomerWithBookings($cid),
'customer_hints' => $customerRepo->listHints($cid),
]);
}
if ($page === 'customers/ajax/notes/update') {
$noteId = (int)($_POST['note_id'] ?? 0);
$body = trim((string)($_POST['body'] ?? ''));
$kind = isset($_POST['kind']) && in_array($_POST['kind'], Note::KINDS, true) ? (string)$_POST['kind'] : null;
if ($noteId <= 0) $jsonOut(422, ['ok' => false, 'message' => 'Notiz-ID fehlt.']);
if ($body === '') $jsonOut(422, ['ok' => false, 'message' => 'Notiz darf nicht leer sein.']);
$customerRepo->updateNote($cid, $noteId, $body, $kind);
$jsonOut(200, [
'ok' => true,
'customer_notes' => $noteRepo->listForCustomerWithBookings($cid),
'customer_hints' => $customerRepo->listHints($cid),
]);
}
if ($page === 'customers/ajax/notes/delete') {
$noteId = (int)($_POST['note_id'] ?? 0);
$entityType = (string)($_POST['entity_type'] ?? 'customer');
$entityIdRaw = (int)($_POST['entity_id'] ?? $cid);
if ($noteId <= 0) $jsonOut(422, ['ok' => false, 'message' => 'Notiz-ID fehlt.']);
if (!in_array($entityType, Note::TYPES, true)) {
$jsonOut(422, ['ok' => false, 'message' => 'Ungültiger Notiz-Typ.']);
}
// Autorisierung: Buchungsnotizen nur löschen, wenn die Buchung dem
// aktuellen Kunden gehört. Kunden-Notizen nur, wenn die ID gleich ist.
if ($entityType === 'booking') {
$b = $bookingRepo->find($entityIdRaw);
if (!$b || (int)$b['customer_id'] !== $cid) {
$jsonOut(404, ['ok' => false, 'message' => 'Buchung gehört nicht zu diesem Kunden.']);
}
$bookingRepo->deleteNote($entityIdRaw, $noteId);
AuditLog::write('booking', $entityIdRaw, 'note_deleted',
'Notiz gelöscht (aus Kundenakte)', ['note_id' => $noteId], null);
} else {
if ($entityIdRaw !== $cid) {
$jsonOut(403, ['ok' => false, 'message' => 'Notiz gehört nicht zu diesem Kunden.']);
}
$customerRepo->deleteNote($cid, $noteId);
AuditLog::write('customer', $cid, 'note_deleted', 'Notiz gelöscht',
['note_id' => $noteId], null);
}
$jsonOut(200, [
'ok' => true,
'customer_notes' => $noteRepo->listForCustomerWithBookings($cid),
'customer_hints' => $customerRepo->listHints($cid),
]);
}
$jsonOut(404, ['ok' => false, 'message' => 'Unbekannte Aktion.']);
// unreachable
case 'events/favorites':
$pageTitle = 'Favoriten';
$pageSubtitle = 'Vorgemerkte Buchungen — gefiltert nach Anreisedatum und Schiff.';
$activeNav = 'events-favorites';
$userId = (int)Auth::id();
if (!$userId) {
redirect('/?page=login');
}
$favDate = (string)($_GET['date'] ?? '');
$favShip = (string)($_GET['ship'] ?? '');
// AJAX-Fragment: nur Listenmarkup zurückgeben, Filter laden die Liste neu.
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$favorites = $bookingRepo->listFavorites($userId, $favDate !== '' ? $favDate : null, $favShip !== '' ? $favShip : null);
$favDates = $bookingRepo->favoriteDates($userId);
$favShips = $favDate !== '' ? $bookingRepo->favoriteShipsForDate($userId, $favDate) : [];
if ($isAjax) {
header('Content-Type: text/html; charset=utf-8');
require __DIR__ . '/../app/views/events/favorites-list.php';
exit;
}
require __DIR__ . '/../app/views/events/favorites.php';
break;
case 'events':
case 'events/ajax/list':
case 'events/ajax/stats':
case 'events/ajax/booking-trend':
case 'events/ajax/occupancy-trend':
case 'events/ajax/prediction-history':
case 'events/ajax/guests-trend':
$pageTitle = 'Events';
$pageSubtitle = 'Übersicht und Statistiken aller kommenden Anreisen';
$activeNav = 'events';
// ---- Filter-Parameter parsen ----------------------------------------
// range: upcoming|today|tomorrow|week|month|custom
// from/to: YYYY-MM-DD (nur bei range=custom verbindlich)
// q: Volltext über title/ship/cruise
// flags: aida_only, meinschiff_only, early_checkin_only
$rangeParam = (string)($_GET['range'] ?? $_POST['range'] ?? 'upcoming');
$q = trim((string)($_GET['q'] ?? $_POST['q'] ?? ''));
$aidaOnly = !empty($_GET['aida_only']) || !empty($_POST['aida_only']);
$meinSchiff = !empty($_GET['meinschiff_only']) || !empty($_POST['meinschiff_only']);
$earlyOnly = !empty($_GET['early_checkin_only']) || !empty($_POST['early_checkin_only']);
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day'));
switch ($rangeParam) {
case 'today':
$rangeFrom = $today; $rangeTo = $today;
break;
case 'tomorrow':
$rangeFrom = $tomorrow; $rangeTo = $tomorrow;
break;
case 'week':
$rangeFrom = $today;
$rangeTo = date('Y-m-d', strtotime('+6 day'));
break;
case 'month':
$rangeFrom = $today;
$rangeTo = date('Y-m-d', strtotime(date('Y-m-t')));
break;
case 'custom':
$rangeFrom = (string)($_GET['from'] ?? $_POST['from'] ?? $today);
$rangeTo = (string)($_GET['to'] ?? $_POST['to'] ?? $today);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $rangeFrom)) $rangeFrom = $today;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $rangeTo)) $rangeTo = $rangeFrom;
if ($rangeTo < $rangeFrom) { $rangeTo = $rangeFrom; }
break;
case 'upcoming':
default:
$rangeParam = 'upcoming';
$rangeFrom = $today;
$rangeTo = date('Y-m-d', strtotime('+90 day'));
break;
}
$eventFilters = [
'q' => $q,
'aida_only' => $aidaOnly,
'meinschiff_only' => $meinSchiff,
'early_checkin_only' => $earlyOnly,
];
// ---- AJAX: Vorbucher-Prognose-Verlauf pro Cruise (Vorwochen) ---------
if ($page === 'events/ajax/prediction-history') {
header('Content-Type: application/json; charset=utf-8');
$cruises = $_GET['cruises'] ?? [];
if (!is_array($cruises)) {
$cruises = array_filter(array_map('trim', explode(',', (string)$cruises)));
}
$weeks = max(1, min(52, (int)($_GET['weeks'] ?? 4)));
try {
$rows = $eventRepo->predictionHistoryForCruises(array_values(array_filter($cruises)), $weeks);
echo json_encode(['ok' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ---- AJAX: Gäste-Trend (angekündigte Gäste je Event-Title-Gruppe) ---
if ($page === 'events/ajax/guests-trend') {
header('Content-Type: application/json; charset=utf-8');
$today = date('Y-m-d');
$from = (string)($_GET['from'] ?? '');
$to = (string)($_GET['to'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) $from = date('Y-m-d', strtotime($today . ' -14 days'));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) $to = date('Y-m-d', strtotime($today . ' +14 days'));
if ($to < $from) { [$from, $to] = [$to, $from]; }
$eventIds = $_GET['event_ids'] ?? [];
if (!is_array($eventIds)) {
$eventIds = array_filter(array_map('trim', explode(',', (string)$eventIds)));
}
$eventIds = array_values(array_filter(array_map('intval', $eventIds), static fn($v) => $v > 0));
try {
$rows = $eventRepo->aggregateByArrivalDate($from, $to, [
'event_ids' => $eventIds,
]);
// Schlankes Payload: nur was die Liniencharts brauchen.
$out = array_map(static fn($r) => [
'event_id' => (int)$r['id'],
'title' => $r['title'],
'ship_name' => $r['ship_name'],
'starts_at' => $r['starts_at'],
'duration' => max(0, (int)round((strtotime((string)$r['ends_at']) - strtotime((string)$r['starts_at'])) / 86400)),
'guests' => (int)$r['guests'],
'source_kind'=> $r['source_kind'] ?? 'bookings',
], $rows);
echo json_encode(['ok' => true, 'data' => $out, 'from' => $from, 'to' => $to], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ---- AJAX: Belegungs-Verlauf (Aktuell + Prognose, Halle/Außen) ------
// Erwartet from/to (YYYY-MM-DD). Ohne Angabe: ±14 Tage um heute.
if ($page === 'events/ajax/occupancy-trend') {
header('Content-Type: application/json; charset=utf-8');
$today = date('Y-m-d');
$from = (string)($_GET['from'] ?? '');
$to = (string)($_GET['to'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
$from = date('Y-m-d', strtotime($today . ' -14 days'));
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
$to = date('Y-m-d', strtotime($today . ' +14 days'));
}
// Hard-Limit gegen Missbrauch: max. 1 Jahr Fenster
$maxRange = 366;
$days = max(1, (int)round((strtotime($to) - strtotime($from)) / 86400) + 1);
if ($days > $maxRange) {
$to = date('Y-m-d', strtotime($from . " +{$maxRange} days"));
}
$eventIds = $_GET['event_ids'] ?? [];
if (!is_array($eventIds)) {
$eventIds = array_filter(array_map('trim', explode(',', (string)$eventIds)));
}
$eventIds = array_values(array_filter(array_map('intval', $eventIds), static fn($v) => $v > 0));
try {
$rows = $eventRepo->occupancyTrend($from, $to, $eventIds);
echo json_encode([
'ok' => true, 'data' => $rows,
'from' => $from, 'to' => $to,
'event_ids' => $eventIds,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ---- AJAX: Monats-Trend Buchungseingänge (für „Vergleich & Verlauf") -
if ($page === 'events/ajax/booking-trend') {
header('Content-Type: application/json; charset=utf-8');
$months = (int)($_GET['months'] ?? 6);
// event_ids[] aus dem Modal-Filter: nur Buchungen dieser Events einbeziehen.
$eventIds = $_GET['event_ids'] ?? [];
if (!is_array($eventIds)) {
// Komma-separierte Form (Fallback): "12,34,56"
$eventIds = array_filter(array_map('trim', explode(',', (string)$eventIds)));
}
$eventIds = array_values(array_filter(array_map('intval', $eventIds), static fn($v) => $v > 0));
try {
$rows = $eventRepo->bookingIncomingTrend($months, $eventIds);
echo json_encode(['ok' => true, 'data' => $rows, 'event_ids' => $eventIds], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ---- AJAX: Statistik-Detail (Modal + events/view inline-Tab) --------
if ($page === 'events/ajax/stats') {
header('Content-Type: application/json; charset=utf-8');
$statDate = (string)($_GET['date'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $statDate)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Ungültiges Datum.']);
exit;
}
// Optionaler Event-Scope: für events/view (Single-Event) — wenn leer,
// werden alle Events des Tages einbezogen (Modal-Verhalten).
$statEventIds = $_GET['event_ids'] ?? [];
if (!is_array($statEventIds)) {
$statEventIds = array_filter(array_map('trim', explode(',', (string)$statEventIds)));
}
$statEventIds = array_values(array_filter(array_map('intval', $statEventIds), static fn($v) => $v > 0));
try {
$detail = $eventRepo->detailedStatsForDate($statDate, $statEventIds);
echo json_encode(['ok' => true, 'data' => $detail], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ---- AJAX: Listen-/Card-HTML reloaden -------------------------------
// Für die AJAX-Routen darf hier KEINE Exception als HTML rausfallen —
// sonst kriegt der Client „Unexpected token '<'..." statt einer
// verwertbaren Fehlermeldung. Daher Wrapper um die Aggregation.
if ($page === 'events/ajax/list') {
header('Content-Type: application/json; charset=utf-8');
try {
$rows = $eventRepo->aggregateByArrivalDate($rangeFrom, $rangeTo, $eventFilters);
$byDate = [];
foreach ($rows as $r) {
$d = (string)$r['arrival_date'];
$byDate[$d][] = $r;
}
ksort($byDate);
$layout = (string)($_GET['layout'] ?? 'list');
$layout = in_array($layout, ['list', 'cards'], true) ? $layout : 'list';
ob_start();
require __DIR__ . '/../app/views/events/_day_rows.php';
$html = ob_get_clean();
echo json_encode([
'ok' => true,
'html' => $html,
'count' => count($byDate),
'dates' => array_keys($byDate), // für Modal-Pagination
'from' => $rangeFrom,
'to' => $rangeTo,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
if (ob_get_level() > 0) { ob_end_clean(); }
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
}
exit;
}
// Default (Server-Render): hier darf eine Exception sichtbar werden.
$rows = $eventRepo->aggregateByArrivalDate($rangeFrom, $rangeTo, $eventFilters);
$byDate = [];
foreach ($rows as $r) {
$d = (string)$r['arrival_date'];
$byDate[$d][] = $r;
}
ksort($byDate);
// ---- Normale Server-Render ------------------------------------------
$organizers = $organizerRepo->all();
$ships = $eventRepo->distinctShips();
$layout = 'list'; // Default; JS schaltet auf localStorage-Wert
require __DIR__ . '/../app/views/events/index.php';
break;
case 'events/create':
$pageTitle = 'Neues Event';
$pageSubtitle = 'Plane eine neue Kreuzfahrt.';
$activeNav = 'events';
$errors = [];
$data = [
'title' => '', 'organizer_id' => '', 'terminal' => '', 'cruise' => '',
'starts_at' => '', 'ends_at' => '',
'arrival_from' => '', 'arrival_to' => '',
'capacity_indoor' => 0, 'capacity_outdoor' => 0, 'capacity_valet' => 0,
'status' => 'scheduled',
];
if (isPost()) {
$data = array_merge($data, $_POST);
$data = normalizeEventDateTimes($data);
$errors = Event::validate($data);
if (empty($errors)) {
$eventRepo->create($data);
setFlash('success', 'Event wurde erfolgreich angelegt.');
redirect('/?page=events');
}
}
$organizers = $organizerRepo->allActive();
require __DIR__ . '/../app/views/events/form.php';
break;
case 'events/view':
$id = (int)($_GET['id'] ?? 0);
$existing = $eventRepo->find($id);
if (!$existing) {
http_response_code(404);
require __DIR__ . '/../app/views/errors/404.php';
break;
}
$pageTitle = 'Event: ' . safeText($existing['title']);
$pageSubtitle = $existing['organizer_name'] ?? '';
$activeNav = 'events';
$event = $existing;
$eventBookings = $bookingRepo->byEvent($id);
$eventPayments = $paymentRepo->byEvent($id);
$eventInvoices = $invoiceRepo->byEvent($id);
$checkinDist = $bookingRepo->checkinDistributionByEvent($id); // rückwärtskompatibel
$checkinSplit = $bookingRepo->checkinDistributionByEventSplit($id); // tatsächlich (Ist)
$arrivalSplit = $bookingRepo->arrivalDistributionByEventSplit($id); // geplant (Soll)
// Aggregationen
$totalRevenue = 0.0; $paidRevenue = 0.0; $openRevenue = 0.0;
$cntPaid = 0; $cntOpen = 0; $cntCheckedIn = 0; $cntCheckedOut = 0; $cntCancelled = 0;
foreach ($eventBookings as $eb) {
$totalRevenue += (float)$eb['total_gross'];
if ($eb['payment_status'] === 'paid') { $cntPaid++; $paidRevenue += (float)$eb['total_gross']; }
else { $cntOpen++; $openRevenue += max(0, (float)$eb['total_gross'] - (float)$eb['paid_amount_sum']); }
if ($eb['checkin_status'] === 'checked_in') $cntCheckedIn++;
if ($eb['checkin_status'] === 'checked_out') $cntCheckedOut++;
if ($eb['status'] === 'cancelled') $cntCancelled++;
}
$invoiceTotal = 0.0; $invoicePaid = 0;
foreach ($eventInvoices as $i) {
$invoiceTotal += (float)$i['total_gross'];
if ($i['status'] === 'paid') $invoicePaid++;
}
// AIDA-Tab: nur sichtbar, wenn Veranstalter "AIDA Cruises" und Cruise-ID gesetzt.
$isAidaEvent = (trim((string)($event['organizer_name'] ?? '')) === 'AIDA Cruises')
&& trim((string)($event['cruise'] ?? '')) !== '';
$aidaImportBatches = [];
if ($isAidaEvent) {
$pdoE = $db->getConnection();
$cruise = trim((string)$event['cruise']);
// Buchungs-Import-Batches der letzten 7 Tage (Preview & Final).
// Eine Excel-Datei kann mehrere Cruises enthalten — `import_batches.cruise`
// hält aber nur die ERSTE (aus peekFileMetadata). Für die korrekte Zuordnung
// zum Event joinen wir über `import_files` → `import_runs.cruise`, sodass auch
// Files erscheinen, deren Batch-Header zu einer anderen Cruise gehört.
$bStmt = $pdoE->prepare(
"SELECT DISTINCT b.id, b.source, b.cruise, b.event_id, b.ship_name, b.departure_date,
b.arrival_date, b.file_date, b.list_type, b.status, b.started_at,
b.finished_at, b.summary
FROM import_batches b
JOIN import_files imf ON imf.import_batch_id = b.id
JOIN import_runs ir ON ir.filename = imf.filename
WHERE b.source = 'AIDA' AND ir.cruise = :c
AND b.started_at >= NOW() - INTERVAL 7 DAY
ORDER BY b.started_at DESC"
);
$bStmt->execute(['c' => $cruise]);
$aidaImportBatches = $bStmt->fetchAll();
if (!empty($aidaImportBatches)) {
$ids = array_map(static fn ($r) => (int)$r['id'], $aidaImportBatches);
$ph = implode(',', array_fill(0, count($ids), '?'));
$fStmt = $pdoE->prepare(
"SELECT import_batch_id, filename, product_code, list_type, status, summary, imported_at
FROM import_files
WHERE import_batch_id IN ($ph)
ORDER BY product_code ASC, filename ASC"
);
$fStmt->execute($ids);
$byBatch = [];
foreach ($fStmt->fetchAll() as $r) {
$byBatch[(int)$r['import_batch_id']][] = $r;
}
foreach ($aidaImportBatches as &$bRow) {
$bRow['_files'] = $byBatch[(int)$bRow['id']] ?? [];
}
unset($bRow);
}
// Prediction-Dateien dieser Cruise: alle aus den letzten 7 Tagen UND zusätzlich
// immer den allerletzten Snapshot (auch wenn älter), damit die Vorhersage
// im Event-Tab garantiert sichtbar ist.
$fStmt = $pdoE->prepare(
"SELECT DISTINCT f.file_name, MAX(f.file_datetime) AS file_datetime
FROM files f
JOIN aida_prebookings p ON p.source_hash = f.file_md5
WHERE f.file_category IN ('prediction', 'aida_prebooking')
AND f.file_datetime >= NOW() - INTERVAL 7 DAY
AND p.cruise = :c
GROUP BY f.file_name
ORDER BY file_datetime DESC"
);
$fStmt->execute(['c' => $cruise]);
$names = array_map(static fn ($r) => (string)$r['file_name'], $fStmt->fetchAll());
$latestStmt = $pdoE->prepare(
"SELECT f.file_name
FROM aida_prebookings p
JOIN files f ON f.file_md5 = p.source_hash
WHERE p.cruise = :c
ORDER BY p.created_at DESC
LIMIT 1"
);
$latestStmt->execute(['c' => $cruise]);
$latestName = $latestStmt->fetchColumn();
if ($latestName && !in_array($latestName, $names, true)) $names[] = (string)$latestName;
require_once __DIR__ . '/../app/views/partials/import-history.php';
if (!empty($names)) {
$aidaImportBatches = array_merge(
$aidaImportBatches,
_ih_load_prediction_batches($names, null, $cruise)
);
usort($aidaImportBatches, static function ($a, $b) {
return strtotime((string)($b['started_at'] ?? '0')) <=> strtotime((string)($a['started_at'] ?? '0'));
});
}
}
// Pagination prev/next + same-day events für Stats-Modal-Preset.
$eventNeighbors = $eventRepo->neighbors($id);
$eventSameDay = $eventRepo->sameDayEvents($id);
$organizers = $organizerRepo->allActive();
// Alle einzigartigen Anreisetage (für Modal-Pager auf events/view).
$statsAllDates = array_map(
static fn ($r) => (string)$r['d'],
$db->getConnection()->query("SELECT DISTINCT DATE(starts_at) AS d FROM events
WHERE (status IS NULL OR status <> 'cancelled')
ORDER BY d ASC")->fetchAll()
);
require __DIR__ . '/../app/views/events/view.php';
break;
case 'events/ajax/update':
// AJAX-Edit aus events/view: nimmt POST-Felder, validiert, speichert,
// gibt JSON zurück — kein Redirect, damit das Edit-Modal in-page bleibt.
header('Content-Type: application/json; charset=utf-8');
if (!isPost() || !csrfCheck()) {
http_response_code(419);
echo json_encode(['ok' => false, 'error' => 'CSRF/Method ungültig.']);
exit;
}
$id = (int)($_POST['id'] ?? 0);
$existing = $eventRepo->find($id);
if (!$existing) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Event nicht gefunden.']);
exit;
}
$data = array_merge($existing, $_POST);
$data = normalizeEventDateTimes($data);
$errors = Event::validate($data);
if (!empty($errors)) {
echo json_encode(['ok' => false, 'errors' => $errors], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$oldVals = auditPick('event', $existing);
$eventRepo->update($id, $data);
$newVals = auditPick('event', $eventRepo->find($id) ?? []);
AuditLog::write('event', $id, 'updated',
'Event aktualisiert (AJAX): ' . $data['title'],
$oldVals, $newVals);
echo json_encode(['ok' => true, 'data' => $eventRepo->find($id)], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
case 'events/edit':
// Legacy-Route — Bearbeitung erfolgt jetzt direkt auf events/view per
// AJAX-Modal (siehe case 'events/ajax/update'). Bestehende Links auf
// ?page=events/edit&id=X werden auf die View-Seite umgeleitet.
$id = (int)($_GET['id'] ?? 0);
redirect($id > 0 ? '/?page=events/view&id=' . $id : '/?page=events');
break;
case 'events/delete':
$id = (int)($_GET['id'] ?? 0);
if ($id && isPost()) {
try {
$eventRepo->delete($id);
setFlash('success', 'Event wurde gelöscht.');
} catch (Throwable $e) {
setFlash('danger', 'Löschen nicht möglich: Event hat noch Buchungen.');
}
}
redirect('/?page=events');
break;
// --- VERANSTALTER (organizers) --------------------------------------
case 'events/organizers':
$pageTitle = 'Veranstalter';
$pageSubtitle = 'Reederei / Veranstalter pflegen.';
$activeNav = 'events';
$organizers = $organizerRepo->all();
require __DIR__ . '/../app/views/organizers/index.php';
break;
case 'events/organizers/create':
$pageTitle = 'Neuer Veranstalter';
$activeNav = 'events';
$errors = [];
$data = ['name' => '', 'contact_email' => '', 'contact_phone' => '', 'website' => '', 'notes' => '', 'status' => 'active'];
if (isPost()) {
$data = array_merge($data, $_POST);
$errors = Organizer::validate($data);
if (empty($errors) && $organizerRepo->nameExists($data['name'])) {
$errors['name'] = 'Veranstalter mit diesem Namen existiert bereits.';
}
if (empty($errors)) {
$oid = $organizerRepo->create($data);
AuditLog::write('organizer', $oid, 'created', 'Veranstalter angelegt: ' . $data['name']);
// Logo direkt nach dem Anlegen verarbeiten (sofern hochgeladen).
if (!empty($_FILES['logo']['name']) && $_FILES['logo']['error'] !== UPLOAD_ERR_NO_FILE) {
$upload = processOrganizerLogoUpload($oid, $_FILES['logo'], $organizerRepo);
if (!empty($upload['error'])) {
setFlash('warning', 'Veranstalter angelegt, Logo aber abgewiesen: ' . $upload['error']);
}
}
setFlash('success', 'Veranstalter angelegt.');
redirect('/?page=events/organizers');
}
}
require __DIR__ . '/../app/views/organizers/form.php';
break;
case 'events/organizers/edit':
$id = (int)($_GET['id'] ?? 0);
$existing = $organizerRepo->find($id);
if (!$existing) {
http_response_code(404);
echo '<div class="alert alert-danger">Veranstalter nicht gefunden.</div>';
break;
}
$pageTitle = 'Veranstalter bearbeiten';
$activeNav = 'events';
$errors = [];
$data = $existing;
if (isPost()) {
$data = array_merge($existing, $_POST);
$errors = Organizer::validate($data);
if (empty($errors) && $organizerRepo->nameExists($data['name'], $id)) {
$errors['name'] = 'Veranstalter mit diesem Namen existiert bereits.';
}
// Optionales Logo entfernen — wird unabhängig von Stamm-Daten-Validierung verarbeitet.
if (!empty($_POST['remove_logo'])) {
deleteOrganizerLogoFiles($existing);
$organizerRepo->clearLogo($id);
AuditLog::write('organizer', $id, 'logo_removed', 'Veranstalter-Logo entfernt');
$existing = $organizerRepo->find($id);
$data = array_merge($existing, $_POST);
}
if (empty($errors)) {
$organizerRepo->update($id, $data);
AuditLog::write('organizer', $id, 'updated', 'Veranstalter aktualisiert: ' . $data['name']);
if (!empty($_FILES['logo']['name']) && $_FILES['logo']['error'] !== UPLOAD_ERR_NO_FILE) {
$upload = processOrganizerLogoUpload($id, $_FILES['logo'], $organizerRepo, $existing);
if (!empty($upload['error'])) {
setFlash('warning', 'Veranstalter aktualisiert, Logo aber abgewiesen: ' . $upload['error']);
} else {
AuditLog::write('organizer', $id, 'logo_uploaded',
'Veranstalter-Logo aktualisiert', null,
['heights' => array_keys($upload['heights'] ?? [])]);
}
}
setFlash('success', 'Veranstalter aktualisiert.');
redirect('/?page=events/organizers');
}
// Bei Fehler: aktuellen DB-Stand (inkl. ggf. entferntem Logo) ins Form spiegeln.
$existing = $organizerRepo->find($id);
$data = array_merge($existing, $_POST);
}
require __DIR__ . '/../app/views/organizers/form.php';
break;
case 'events/organizers/delete':
if (!isPost()) {
redirect('/?page=events/organizers');
}
$id = (int)($_GET['id'] ?? 0);
requireCsrfOrAbort('/?page=events/organizers');
if ($organizerRepo->eventCount($id) > 0) {
setFlash('danger', 'Veranstalter kann nicht gelöscht werden – noch Events verknüpft.');
} else {
$organizerRepo->delete($id);
AuditLog::write('organizer', $id, 'deleted', 'Veranstalter gelöscht');
setFlash('success', 'Veranstalter gelöscht.');
}
redirect('/?page=events/organizers');
break;
case 'products':
$pageTitle = 'Produkte';
$pageSubtitle = 'Pflege Parkplätze, Services und Tickets.';
$activeNav = 'products';
$allProducts = $productRepo->all();
['perPage' => $pp, 'page' => $pn] = paginationParams();
$pagination = paginate($allProducts, $pp, $pn);
$products = $pagination['items'];
require __DIR__ . '/../app/views/products/index.php';
break;
case 'products/create':
$pageTitle = 'Neues Produkt';
$pageSubtitle = 'Lege ein neues Produkt an.';
$activeNav = 'products';
$errors = [];
$data = [
'title' => '', 'description' => '', 'type' => 'parking',
'price_net' => '0.00', 'vat_rate' => '19.00', 'price_gross' => '0.00',
'is_active' => 1, 'use_as_filter' => 1,
];
if (isPost()) {
$data = array_merge($data, $_POST);
$data['use_as_filter'] = !empty($_POST['use_as_filter']) ? 1 : 0;
$errors = Product::validate($data);
if (empty($errors)) {
$productRepo->create($data);
setFlash('success', 'Produkt wurde erfolgreich angelegt.');
redirect('/?page=products');
}
}
require __DIR__ . '/../app/views/products/form.php';
break;
case 'products/edit':
$id = (int)($_GET['id'] ?? 0);
$existing = $productRepo->find($id);
if (!$existing) {
http_response_code(404);
echo '<div class="alert alert-danger">Produkt nicht gefunden.</div>';
break;
}
$pageTitle = 'Produkt bearbeiten';
$pageSubtitle = safeText($existing['title']);
$activeNav = 'products';
$errors = [];
$data = $existing;
if (isPost()) {
$data = array_merge($existing, $_POST);
$data['use_as_filter'] = !empty($_POST['use_as_filter']) ? 1 : 0;
$errors = Product::validate($data);
if (empty($errors)) {
$oldVals = auditPick('product', $existing);
$productRepo->update($id, $data);
$newVals = auditPick('product', $productRepo->find($id) ?? []);
AuditLog::write('product', $id, 'updated',
'Produkt aktualisiert: ' . $data['title'],
$oldVals, $newVals);
setFlash('success', 'Produkt wurde aktualisiert.');
redirect('/?page=products');
}
}
$recordHistory = AuditLog::byEntity('product', $id, 30);
require __DIR__ . '/../app/views/products/form.php';
break;
case 'products/tiers':
$id = (int)($_GET['id'] ?? 0);
$product = $productRepo->find($id);
if (!$product) {
http_response_code(404);
require __DIR__ . '/../app/views/errors/404.php';
break;
}
if (($product['pricing_mode'] ?? 'fixed') !== 'tiered') {
setFlash('warning', 'Produkt nutzt aktuell Festpreis. Wechsle in den Bearbeiten-Dialog auf „Preisstaffel".');
redirect('/?page=products/edit&id=' . $id);
}
$pageTitle = 'Preisstaffel · ' . safeText($product['title']);
$pageSubtitle = 'Tarife pro Jahr und Reisedauer.';
$activeNav = 'products';
$action = (string)($_POST['action'] ?? $_GET['action'] ?? '');
if (isPost()) {
requireCsrfOrAbort('/?page=products/tiers&id=' . $id);
try {
if ($action === 'add') {
$perDay = !empty($_POST['per_day']);
$maxDays = trim((string)($_POST['max_days'] ?? ''));
$tierId = $productRepo->addTier(
$id,
(int)$_POST['valid_year'],
max(1, (int)$_POST['min_days']),
$maxDays === '' ? null : max(1, (int)$maxDays),
round((float)$_POST['price_gross'], 2),
$perDay
);
AuditLog::write('product_tier', $tierId, 'created',
'Preisstaffel hinzugefügt für ' . $product['title'] . ' (' . (int)$_POST['valid_year'] . ')');
setFlash('success', 'Tarif-Stufe angelegt.');
} elseif ($action === 'update') {
$perDay = !empty($_POST['per_day']);
$maxDays = trim((string)($_POST['max_days'] ?? ''));
$tierId = (int)$_POST['tier_id'];
$productRepo->updateTier(
$tierId,
(int)$_POST['valid_year'],
max(1, (int)$_POST['min_days']),
$maxDays === '' ? null : max(1, (int)$maxDays),
round((float)$_POST['price_gross'], 2),
$perDay
);
AuditLog::write('product_tier', $tierId, 'updated',
'Preisstaffel aktualisiert für ' . $product['title']);
setFlash('success', 'Tarif aktualisiert.');
} elseif ($action === 'delete') {
$tierId = (int)$_POST['tier_id'];
$productRepo->deleteTier($tierId);
AuditLog::write('product_tier', $tierId, 'deleted',
'Preisstaffel-Eintrag gelöscht (' . $product['title'] . ')');
setFlash('success', 'Tarif gelöscht.');
} elseif ($action === 'copy') {
$from = (int)$_POST['copy_from'];
$to = (int)$_POST['copy_to'];
if ($from === $to) {
setFlash('warning', 'Quell- und Zieljahr müssen sich unterscheiden.');
} else {
$count = $productRepo->copyTiersFromYear($id, $from, $to);
AuditLog::write('product_tier', 0, 'copied',
$count . ' Stufen kopiert von ' . $from . ' → ' . $to . ' (' . $product['title'] . ')');
setFlash('success', $count . ' Tarif-Stufen von ' . $from . ' nach ' . $to . ' kopiert.');
}
}
} catch (Throwable $e) {
setFlash('danger', 'Aktion fehlgeschlagen: ' . $e->getMessage());
}
redirect('/?page=products/tiers&id=' . $id);
}
$tiers = $productRepo->tiers($id);
$years = $productRepo->tierYears($id);
require __DIR__ . '/../app/views/products/tiers.php';
break;
case 'products/delete':
$id = (int)($_GET['id'] ?? 0);
if ($id && isPost()) {
try {
$productRepo->delete($id);
setFlash('success', 'Produkt wurde gelöscht.');
} catch (Throwable $e) {
setFlash('danger', 'Löschen nicht möglich: Produkt ist in Buchungen verwendet.');
}
}
redirect('/?page=products');
break;
case 'bookings':
$pageTitle = 'Buchungen';
$pageSubtitle = 'Alle Buchungen, Zahlungen und Check-ins.';
$activeNav = 'bookings';
$productIdRaw = $_GET['product_id'] ?? '';
if (is_array($productIdRaw)) {
$productIds = array_values(array_filter(array_map('intval', $productIdRaw), fn($v) => $v > 0));
} else {
$productIds = $productIdRaw !== '' ? [(int)$productIdRaw] : [];
}
// Kombiniertes "f[]" — Codes:
// status:open/arrived/no_valet (Check-in & Valet-Ausschluss)
// pay:open/paid/failed (Zahlungsstatus)
// ship:<title> (Event-Titel)
$rawFilter = $_GET['f'] ?? [];
if (!is_array($rawFilter)) $rawFilter = [$rawFilter];
$selectedShipTitles = [];
$checkinStatuses = [];
$paymentStatuses = [];
$excludeValet = false;
foreach ($rawFilter as $f) {
if (!is_string($f) || $f === '') continue;
if ($f === 'status:open') { $checkinStatuses[] = 'pending'; }
elseif ($f === 'status:arrived') { $checkinStatuses[] = 'checked_in'; }
elseif ($f === 'status:no_valet') { $excludeValet = true; }
elseif ($f === 'pay:open') { $paymentStatuses[] = 'open'; }
elseif ($f === 'pay:paid') { $paymentStatuses[] = 'paid'; }
elseif ($f === 'pay:failed') { $paymentStatuses[] = 'failed'; }
elseif (str_starts_with($f, 'ship:')) {
$t = substr($f, 5);
if ($t !== '') $selectedShipTitles[] = $t;
}
}
$filters = [
'date_from' => trim((string)($_GET['date_from'] ?? '')),
'date_to' => trim((string)($_GET['date_to'] ?? '')),
'event_titles' => $selectedShipTitles,
'product_id' => $productIds,
'payment_status' => $paymentStatuses,
'checkin_status' => $checkinStatuses,
'exclude_valet' => $excludeValet,
'q' => trim((string)($_GET['q'] ?? '')),
'hide_sources' => hiddenSourcesFor($settingsRepo, 'bookings'),
];
// Serverseitige Pagination — auch bei 50k+ Buchungen darf PHP keine
// OOM-Exception werfen. Total per COUNT(*), Slice via LIMIT/OFFSET.
['perPage' => $pp, 'page' => $pn] = paginationParams();
$total = $bookingRepo->countAll($filters);
$pages = max(1, (int)ceil($total / max(1, $pp)));
$pn = max(1, min($pages, $pn));
$off = ($pn - 1) * $pp;
$bookings = $bookingRepo->all($filters + ['limit' => $pp, 'offset' => $off]);
$pagination = [
'items' => $bookings,
'total' => $total,
'page' => $pn,
'pages' => $pages,
'perPage' => $pp,
'from' => $total === 0 ? 0 : $off + 1,
'to' => min($total, $off + $pp),
];
$ships = $eventRepo->upcomingShipTitles();
$products = $productRepo->forFilter();
$selectedFilterCodes = array_values(array_unique($rawFilter));
require __DIR__ . '/../app/views/bookings/index.php';
break;
case 'bookings/create':
$pageTitle = 'Neue Buchung';
$pageSubtitle = 'Lege eine neue Buchung im SaaS Wizard an.';
$activeNav = 'bookings';
$errors = [];
// Preset-Parameter aus dem Aufruf (z. B. "Buchung anlegen" auf events/view oder
// customers/view). Sie starten den Wizard frisch und füllen das jeweilige Feld vor.
$presetEventId = isset($_GET['event_id']) ? max(0, (int)$_GET['event_id']) : 0;
$presetCustomerId = isset($_GET['customer_id']) ? max(0, (int)$_GET['customer_id']) : 0;
$hasPreset = $presetEventId > 0 || $presetCustomerId > 0;
// Bei Customer-Preset überspringen wir Schritt 1 (Kunde) — der ist bereits gewählt,
// direkt zur Event-Auswahl. Explizites ?step=… vom Aufrufer hat aber Vorrang.
$stepDefault = ($presetCustomerId > 0) ? 2 : 1;
$step = max(1, min(4, (int)($_POST['step'] ?? $_GET['step'] ?? $stepDefault)));
if (isset($_GET['reset']) || $hasPreset) {
unset($_SESSION['booking_wizard']);
}
if (!isset($_SESSION['booking_wizard'])) {
$_SESSION['booking_wizard'] = [
'customer_id' => $presetCustomerId > 0 ? $presetCustomerId : null,
'new_customer' => null,
'event_id' => $presetEventId > 0 ? $presetEventId : null,
'travel_from' => '',
'travel_to' => '',
'arrival_time' => '',
'license_plate' => '',
'guest_count' => 1,
'items' => [],
'notes' => '',
];
// Wenn der Kunde vorausgewählt ist, das zuletzt verwendete Kennzeichen als Vorschlag mitnehmen.
if ($presetCustomerId > 0) {
$lastPlate = $bookingRepo->lastLicensePlate($presetCustomerId);
if ($lastPlate) {
$_SESSION['booking_wizard']['license_plate'] = $lastPlate;
}
}
// Wenn das Event vorausgewählt ist, den Reisezeitraum mit den
// Event-Datumsgrenzen vorbelegen — typischer Cruise-Anlauf:
// starts_at → travel_from, ends_at → travel_to (jeweils YYYY-MM-DD).
if ($presetEventId > 0) {
$ev = $eventRepo->find($presetEventId);
if ($ev) {
$from = substr((string)($ev['starts_at'] ?? ''), 0, 10);
$to = substr((string)($ev['ends_at'] ?? ''), 0, 10);
if ($from && $from !== '0000-00-00') $_SESSION['booking_wizard']['travel_from'] = $from;
if ($to && $to !== '0000-00-00') $_SESSION['booking_wizard']['travel_to'] = $to;
}
}
}
$wiz = &$_SESSION['booking_wizard'];
if (isPost()) {
$action = $_POST['action'] ?? 'next';
if ($step === 1) {
if (!empty($_POST['existing_customer_id'])) {
$wiz['customer_id'] = (int)$_POST['existing_customer_id'];
$wiz['new_customer'] = null;
// Letztes Kennzeichen des Kunden als Vorschlag für Step 2 mitnehmen
if (empty($wiz['license_plate'])) {
$lastPlate = $bookingRepo->lastLicensePlate((int)$wiz['customer_id']);
if ($lastPlate) {
$wiz['license_plate'] = $lastPlate;
}
}
} elseif (!empty($_POST['create_new'])) {
$newData = [
'company' => $_POST['company'] ?? '',
'first_name' => $_POST['first_name'] ?? '',
'last_name' => $_POST['last_name'] ?? '',
'street' => $_POST['street'] ?? '',
'zip' => $_POST['zip'] ?? '',
'city' => $_POST['city'] ?? '',
'phone' => $_POST['phone'] ?? '',
'email' => $_POST['email'] ?? '',
'status' => 'active',
];
$errors = Customer::validate($newData);
if (empty($errors) && $customerRepo->emailExists($newData['email'])) {
$errors['email'] = 'Diese E-Mail-Adresse ist bereits vergeben.';
}
if (empty($errors)) {
$newId = $customerRepo->create($newData);
$wiz['customer_id'] = $newId;
$wiz['new_customer'] = null;
} else {
$wiz['new_customer'] = $newData;
}
} else {
$errors['customer'] = 'Bitte Kunden auswählen oder neu anlegen.';
}
if (empty($errors)) {
redirect('/?page=bookings/create&step=2');
}
} elseif ($step === 2) {
$wiz['event_id'] = (int)($_POST['event_id'] ?? 0);
$wiz['travel_from'] = trim((string)($_POST['travel_from'] ?? ''));
$wiz['travel_to'] = trim((string)($_POST['travel_to'] ?? ''));
// Anreisezeit ist Uhrzeit (HH:MM). Mit travel_from-Datum (Fallback: heute) zu DATETIME kombinieren.
$arrTime = trim((string)($_POST['arrival_time'] ?? ''));
if ($arrTime !== '' && preg_match('/^\d{2}:\d{2}$/', $arrTime)) {
$anchor = $wiz['travel_from'] ?: date('Y-m-d');
$wiz['arrival_time'] = $anchor . ' ' . $arrTime . ':00';
} else {
$wiz['arrival_time'] = '';
}
$wiz['license_plate'] = trim((string)($_POST['license_plate'] ?? ''));
$wiz['guest_count'] = max(1, min(10, (int)($_POST['guest_count'] ?? 1)));
if (!$wiz['event_id']) {
$errors['event_id'] = 'Bitte Event auswählen.';
}
if (empty($errors)) {
redirect('/?page=bookings/create&step=3');
}
} elseif ($step === 3) {
$items = [];
$productIds = $_POST['product_id'] ?? [];
$quantities = $_POST['quantity'] ?? [];
$priceGross = $_POST['price_gross'] ?? [];
$stayDays = Product::daysBetween($wiz['travel_from'] ?? null, $wiz['travel_to'] ?? null);
$stayYear = Product::yearFromDate($wiz['travel_from'] ?? null);
foreach ($productIds as $idx => $pid) {
$pid = (int)$pid;
if ($pid <= 0) { continue; }
$qty = max(0, (int)($quantities[$idx] ?? 0));
if ($qty <= 0) { continue; }
$p = $productRepo->find($pid);
if (!$p) { continue; }
// Brutto: explizit aus Formular, sonst gestaffelt nach Tagen+Jahr
$gross = isset($priceGross[$idx]) && $priceGross[$idx] !== ''
? round((float)$priceGross[$idx], 2)
: $productRepo->priceForDuration($p, $stayDays, $stayYear);
$vat = (float)$p['vat_rate'];
$items[] = [
'product_id' => $pid,
'title' => $p['title'],
'type' => $p['type'],
'quantity' => $qty,
'price_gross' => $gross,
'vat_rate' => $vat,
'price_net' => round($gross / (1 + $vat / 100), 2),
];
}
$wiz['items'] = $items;
$wiz['notes'] = trim((string)($_POST['notes'] ?? ''));
if (empty($items)) {
$errors['items'] = 'Mindestens ein Produkt mit Menge > 0 auswählen.';
}
if (empty($errors)) {
redirect('/?page=bookings/create&step=4');
}
} elseif ($step === 4) {
if ($action === 'save') {
$payload = [
'customer_id' => $wiz['customer_id'],
'event_id' => $wiz['event_id'],
'travel_from' => $wiz['travel_from'],
'travel_to' => $wiz['travel_to'],
'arrival_time' => $wiz['arrival_time'],
'license_plate' => $wiz['license_plate'],
'guest_count' => $wiz['guest_count'] ?? 1,
'status' => 'confirmed',
'payment_status' => 'open',
'checkin_status' => 'pending',
'notes' => $wiz['notes'],
];
$vErrors = Booking::validate($payload, $wiz['items']);
if (empty($vErrors)) {
$bookingId = $bookingRepo->create($payload, $wiz['items']);
try {
Documents::generateBookingConfirmation($bookingRepo, $paymentRepo, $bookingId, $settingsRepo);
} catch (Throwable $e) {
setFlash('warning', 'Buchung gespeichert, aber Bestätigung konnte nicht erzeugt werden: ' . $e->getMessage());
}
AuditLog::write('booking', $bookingId, 'created',
'Buchung angelegt', null,
['customer_id' => $payload['customer_id'], 'event_id' => $payload['event_id']]);
// Auto-Trigger: alle aktiven Templates mit trigger_event = booking_created.
try { $mailService->triggerTemplates('booking_created', $bookingId, $emailTemplateRepo, $emailRenderer); }
catch (Throwable $e) { /* still — Email-Fehler darf Booking-Flow nicht killen */ }
unset($_SESSION['booking_wizard']);
setFlash('success', 'Buchung wurde erfolgreich angelegt.');
redirect('/?page=bookings/show&id=' . $bookingId);
} else {
$errors = $vErrors;
}
}
}
}
$customers = $customerRepo->all();
$events = $eventRepo->all();
$products = $productRepo->allActive();
$shipMonths = [];
// Schiffe-Picker: heute + 14 Tage rückwirkend, damit nachträgliche Buchungen
// bzw. Event-Wechsel auf ein bereits gestartetes Event möglich sind.
$rows = $db->getConnection()
->query("SELECT ship_name,
YEAR(starts_at) AS y,
MONTH(starts_at) AS m,
GREATEST(1, DATEDIFF(ends_at, starts_at)) AS d
FROM events
WHERE ship_name IS NOT NULL AND ship_name <> ''
AND status <> 'cancelled'
AND DATE(starts_at) >= DATE_SUB(CURDATE(), INTERVAL 14 DAY)
ORDER BY ship_name ASC, y ASC, m ASC")
->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$shipMonths[$r['ship_name']][] = [
'y' => (int)$r['y'],
'm' => (int)$r['m'],
'd' => (int)$r['d'],
];
}
$duplicates = [];
if ($step === 4 && !empty($wiz['customer_id']) && !empty($wiz['event_id'])) {
$duplicates = $bookingRepo->findDuplicates(
(int)$wiz['customer_id'],
(int)$wiz['event_id'],
$wiz['license_plate'] ?: null,
$wiz['arrival_time'] ?: null
);
}
// Letzten Schritt für „Fortsetzen" merken
$_SESSION['booking_wizard']['last_step'] = $step;
require __DIR__ . '/../app/views/bookings/create.php';
break;
case 'bookings/create/discard':
if (!isPost()) {
redirect('/?page=dashboard');
}
$back = (string)($_POST['back'] ?? '/?page=dashboard');
if (!str_starts_with($back, '/?page=')) {
$back = '/?page=dashboard';
}
requireCsrfOrAbort($back);
unset($_SESSION['booking_wizard']);
setFlash('info', 'Buchungseingabe verworfen.');
redirect($back);
break;
case 'bookings/show':
// Alte Detail-Seite — alle Aufrufer landen jetzt auf der neuen
// zentralen Buchungsakte. Wir behalten die Route als Redirect, damit
// bestehende Links im Projekt ohne Rewrite weiter funktionieren.
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) redirect('/?page=bookings');
redirect('/?page=bookings/view&id=' . $id);
break;
case 'bookings/view':
$id = (int)($_GET['id'] ?? 0);
$booking = $bookingRepo->find($id);
if (!$booking) {
http_response_code(404);
require __DIR__ . '/../app/views/errors/404.php';
break;
}
$pageTitle = 'Buchung ' . safeText($booking['booking_no']);
$pageSubtitle = 'Operative Buchungsakte.';
$activeNav = 'bookings';
$items = $bookingRepo->products($id);
$payments = $paymentRepo->byBooking($id);
$paymentSummary = $paymentRepo->summary($id, (float)$booking['total_gross']);
$invoice = $invoiceRepo->findByBooking($id);
$checkin = $checkinRepo->findByBooking($id);
$recordHistory = AuditLog::byEntity('booking', $id, 50);
// Event-Details inkl. Veranstalter-Logo für die Hero-Reihe.
$event = $eventRepo->find((int)$booking['event_id']);
// AIDA-Excel-Listen, in denen dieser Datensatz vorkommt — kommt aus
// zwei Quellen:
//
// 1) FINAL/UPDATE/MANIFEST-Listen → leben in import_files (mit
// Metadaten cruise/list_type) und sind über bookings.import_uuid
// bzw. cruise/event_id verknüpft.
// 2) PREDICTION/VORBUCHER-Listen → leben NUR in `files` (kein
// import_files-Eintrag, siehe PrebookingImporter:148).
// file_category ∈ ('prediction','aida_prebooking'). Verknüpfung
// über die import_uuid der zugehörigen aida_prebookings-Zeilen
// desselben Cruise.
$importFiles = [];
$eventCruise = (string)($event['cruise'] ?? '');
// Customer-Import-UUID lesen — der Kunde wurde u.U. aus einer anderen
// (früheren) Datei importiert als die Buchung. Beide Dateien sind relevant.
$customerImportUuid = '';
try {
$stmt = $db->getConnection()->prepare("SELECT import_uuid FROM customers WHERE id = :id");
$stmt->execute(['id' => (int)$booking['customer_id']]);
$customerImportUuid = (string)($stmt->fetchColumn() ?: '');
} catch (Throwable) { /* customers.import_uuid existiert nicht → leerer Wert */ }
try {
// ---- Quelle 0: import_file_bookings (N:M-Mapping, exakt) ----
// Migration 045: für jede Buchung pro Import-Datei eine Zeile. Liefert
// die GENAU passende Datei-Liste (Preview / Update / Final), wenn der
// Importer/Backfill gelaufen ist. Schlägt sauber zurück auf Quelle 1-3,
// falls die Tabelle nicht existiert oder leer ist.
try {
$stmt = $db->getConnection()->prepare("
SELECT imf.id AS import_file_id,
imf.source, imf.filename, imf.file_date, imf.list_type,
imf.cruise, imf.imported_at, imf.status,
f.id AS file_id, f.file_path, f.file_size, f.file_name,
ifb.line_no
FROM import_file_bookings ifb
LEFT JOIN import_files imf ON imf.import_uuid = ifb.import_uuid
LEFT JOIN files f ON f.import_uuid = ifb.import_uuid
WHERE ifb.booking_id = :bid
AND (
LOWER(SUBSTRING_INDEX(imf.filename, '.', -1)) IN ('xlsx', 'xls')
OR LOWER(SUBSTRING_INDEX(f.file_name, '.', -1)) IN ('xlsx', 'xls')
)
ORDER BY imf.imported_at DESC, f.file_datetime DESC, imf.id DESC");
$stmt->execute(['bid' => (int)$booking['id']]);
foreach ($stmt->fetchAll() as $r) {
$r['source_kind'] = 'mapping';
$importFiles[] = $r;
}
} catch (Throwable) { /* Tabelle fehlt → fallen zurück auf Quelle 1-3 */ }
// Wenn das N:M-Mapping mindestens einen Treffer geliefert hat, ist
// die Datei-Liste exakt — die breiteren Cruise-/Schiff-Fallbacks
// unten würden nur Cruise-Geschwister-Dateien dazumischen, in denen
// diese Buchung gar nicht vorkommt. Also überspringen.
$hasExactMapping = !empty($importFiles);
// ---- Quelle 1: import_files (final + update + manifest) -----
if (!$hasExactMapping):
$sqlParts = [];
$sqlParams = [];
if (!empty($booking['import_uuid'])) {
$sqlParts[] = 'f.import_uuid = :uuid';
$sqlParams['uuid'] = (string)$booking['import_uuid'];
$sqlParts[] = 'imf.import_uuid = :uuid2';
$sqlParams['uuid2'] = (string)$booking['import_uuid'];
}
if ($eventCruise !== '') {
$sqlParts[] = 'imf.cruise = :cruise';
$sqlParams['cruise'] = $eventCruise;
}
if (!empty($booking['event_id'])) {
$sqlParts[] = 'imf.event_id = :eid';
$sqlParams['eid'] = (int)$booking['event_id'];
}
if (!empty($sqlParts)) {
$where = '(' . implode(' OR ', $sqlParts) . ')';
$stmt = $db->getConnection()->prepare("
SELECT imf.id AS import_file_id,
imf.source, imf.filename, imf.file_date, imf.list_type,
imf.cruise, imf.imported_at, imf.status,
f.id AS file_id, f.file_path, f.file_size, f.file_name
FROM import_files imf
LEFT JOIN files f ON f.import_uuid = imf.import_uuid
WHERE $where
AND (
LOWER(SUBSTRING_INDEX(imf.filename, '.', -1)) IN ('xlsx', 'xls')
OR LOWER(SUBSTRING_INDEX(f.file_name, '.', -1)) IN ('xlsx', 'xls')
)
ORDER BY imf.imported_at DESC, imf.id DESC");
$stmt->execute($sqlParams);
foreach ($stmt->fetchAll() as $r) {
$isDup = false;
foreach ($importFiles as $existing) {
if (!empty($existing['file_id']) && (int)$existing['file_id'] === (int)($r['file_id'] ?? 0)) {
$isDup = true; break;
}
if (!empty($existing['import_file_id']) && (int)$existing['import_file_id'] === (int)($r['import_file_id'] ?? 0)) {
$isDup = true; break;
}
}
if (!$isDup) {
$r['source_kind'] = 'import_files';
$importFiles[] = $r;
}
}
}
// ---- Quelle 2: files (prediction + aida_prebooking) ---------
// aida_prebookings.cruise = events.cruise. Wir lesen die import_uuid
// aus aida_prebookings und joinen `files` mit derselben uuid.
if ($eventCruise !== '') {
$stmt = $db->getConnection()->prepare("
SELECT DISTINCT
NULL AS import_file_id,
'AIDA' AS source,
f.file_name AS filename,
NULL AS file_date,
CASE WHEN f.file_category = 'prediction' THEN 'prediction'
ELSE 'pre' END AS list_type,
ap.cruise AS cruise,
f.file_datetime AS imported_at,
'imported' AS status,
f.id AS file_id,
f.file_path,
f.file_size,
f.file_name
FROM aida_prebookings ap
INNER JOIN files f ON f.import_uuid = ap.import_uuid
WHERE ap.cruise = :cruise
AND f.file_category IN ('prediction', 'aida_prebooking')
AND LOWER(SUBSTRING_INDEX(f.file_name, '.', -1)) IN ('xlsx', 'xls')
ORDER BY f.file_datetime DESC, f.id DESC");
$stmt->execute(['cruise' => $eventCruise]);
foreach ($stmt->fetchAll() as $r) {
// Dedup: wenn Datei schon über import_files reinkam, nicht doppelt zeigen.
$isDup = false;
foreach ($importFiles as $existing) {
if (!empty($existing['file_id']) && (int)$existing['file_id'] === (int)$r['file_id']) {
$isDup = true; break;
}
}
if (!$isDup) {
$r['source_kind'] = 'files_prediction';
$importFiles[] = $r;
}
}
}
// ---- Quelle 3: Fallback — files DIREKT (breit gefasst) ------
// Suche alle Excel-Dateien aus `files`, die zu diesem Datensatz
// gehören könnten. Verzichtet bewusst auf file_category-Filter,
// weil die Kategorien je nach Import-Pfad uneinheitlich sind.
//
// Match-Kriterien (OR-verknüpft):
// a) f.import_uuid = bookings.import_uuid → exakte Buchungs-Quelle
// b) f.import_uuid = customers.import_uuid → Kunden-Import-Datei
// c) f.file_path LIKE '%<cruise>%' → Cruise-Code im Pfad
// d) f.file_path LIKE '%<ship_name>%' → Schiff-Name im Pfad
$fallbackParts = [];
$fallbackParams = [];
if (!empty($booking['import_uuid'])) {
$fallbackParts[] = 'f.import_uuid = :fb_uuid';
$fallbackParams['fb_uuid'] = (string)$booking['import_uuid'];
}
if ($customerImportUuid !== '') {
$fallbackParts[] = 'f.import_uuid = :fb_cust_uuid';
$fallbackParams['fb_cust_uuid'] = $customerImportUuid;
}
if ($eventCruise !== '') {
$fallbackParts[] = 'f.file_path LIKE :fb_cruise';
$fallbackParams['fb_cruise'] = '%' . $eventCruise . '%';
}
$shipName = (string)($event['ship_name'] ?? $booking['event_ship'] ?? '');
if ($shipName !== '') {
$fallbackParts[] = 'f.file_path LIKE :fb_ship';
$fallbackParams['fb_ship'] = '%' . $shipName . '%';
}
if (!empty($fallbackParts)) {
$fbWhere = '(' . implode(' OR ', $fallbackParts) . ')';
$stmt = $db->getConnection()->prepare("
SELECT NULL AS import_file_id,
'AIDA' AS source,
f.file_name AS filename,
NULL AS file_date,
f.file_category AS list_type,
NULL AS cruise,
f.file_datetime AS imported_at,
'imported' AS status,
f.id AS file_id,
f.file_path,
f.file_size,
f.file_name
FROM files f
WHERE $fbWhere
AND LOWER(SUBSTRING_INDEX(f.file_name, '.', -1)) IN ('xlsx', 'xls')
ORDER BY f.file_datetime DESC, f.id DESC");
$stmt->execute($fallbackParams);
foreach ($stmt->fetchAll() as $r) {
$isDup = false;
foreach ($importFiles as $existing) {
if (!empty($existing['file_id']) && (int)$existing['file_id'] === (int)$r['file_id']) {
$isDup = true; break;
}
}
if (!$isDup) {
$r['source_kind'] = 'files_direct';
$importFiles[] = $r;
}
}
}
endif; // !$hasExactMapping
// Prediction-Listen aus der Dokumente-Ansicht ausblenden — die zeigen
// wir nur in den Statistiken/Reports, nicht auf der Buchungsakte.
$importFiles = array_values(array_filter($importFiles, static function ($r): bool {
$lt = strtolower((string)($r['list_type'] ?? ''));
if ($lt === 'prediction' || $lt === 'pred' || $lt === 'predict'
|| $lt === 'aida_prebooking' || ($r['source_kind'] ?? '') === 'files_prediction') {
return false;
}
return true;
}));
// Nach Datum absteigend sortieren (jüngste zuerst).
usort($importFiles, static function ($a, $b) {
return strcmp((string)($b['imported_at'] ?? ''), (string)($a['imported_at'] ?? ''));
});
} catch (Throwable) { /* Tabelle fehlt → leere Liste, UI degradiert sauber */ }
// Diagnose-Modus: ?page=bookings/view&id=X&tab=documents&debug-imports=1
// Gibt JSON aus, welche Match-Kriterien greifen und welche Dateien gefunden wurden.
if (Auth::role() === 'admin' && !empty($_GET['debug-imports'])) {
if (ob_get_level() > 0) { ob_clean(); }
header('Content-Type: application/json; charset=utf-8');
$debug = [
'booking_id' => (int)$booking['id'],
'booking_import_uuid' => (string)($booking['import_uuid'] ?? ''),
'booking_external_uid' => (string)($booking['external_uid'] ?? ''),
'customer_import_uuid' => $customerImportUuid,
'event_id' => (int)$booking['event_id'],
'event_cruise' => $eventCruise,
'event_ship' => (string)($event['ship_name'] ?? ''),
'mapping_rows' => (function () use ($db, $booking) {
try {
$s = $db->getConnection()->prepare("
SELECT ifb.import_uuid, ifb.external_uid, ifb.booking_id, ifb.line_no, ifb.seen_at,
imf.filename AS import_files_filename,
imf.list_type, imf.cruise AS if_cruise,
f.file_name, f.file_path, f.file_category
FROM import_file_bookings ifb
LEFT JOIN import_files imf ON imf.import_uuid = ifb.import_uuid
LEFT JOIN files f ON f.import_uuid = ifb.import_uuid
WHERE ifb.booking_id = :bid
ORDER BY ifb.seen_at DESC");
$s->execute(['bid' => (int)$booking['id']]);
return $s->fetchAll();
} catch (Throwable $e) { return ['error' => $e->getMessage()]; }
})(),
'mapping_total_rows_table' => (function () use ($db) {
try { return (int)$db->getConnection()->query("SELECT COUNT(*) FROM import_file_bookings")->fetchColumn(); }
catch (Throwable $e) { return 'error: ' . $e->getMessage(); }
})(),
'found_files' => array_map(static fn($f) => [
'source_kind' => $f['source_kind'] ?? null,
'file_id' => $f['file_id'] ?? null,
'filename' => $f['filename'] ?? null,
'file_path' => $f['file_path'] ?? null,
'list_type' => $f['list_type'] ?? null,
'cruise' => $f['cruise'] ?? null,
'imported_at' => $f['imported_at'] ?? null,
], $importFiles),
'all_aida_xlsx_files_in_db' => (function () use ($db) {
try {
$stmt = $db->getConnection()->query("
SELECT id, file_name, file_path, file_category, import_uuid, file_datetime
FROM files
WHERE LOWER(SUBSTRING_INDEX(file_name, '.', -1)) IN ('xlsx', 'xls')
ORDER BY file_datetime DESC LIMIT 50");
return $stmt->fetchAll();
} catch (Throwable $e) { return ['error' => $e->getMessage()]; }
})(),
];
echo json_encode($debug, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
// Stellplatz-Wechsel: aktive Produkte vom Typ des aktuellen Stellplatzes
// (= erstes Item) mit live berechnetem Preis für den Reisezeitraum.
$parkingOptions = [];
$currentParking = null;
if (!empty($items)) {
$firstItem = $items[0];
$currentParking = [
'item_id' => (int)$firstItem['id'],
'product_id' => (int)$firstItem['product_id'],
];
$parkingType = (string)$firstItem['product_type'];
$bDays = Product::daysBetween($booking['travel_from'] ?? null, $booking['travel_to'] ?? null);
$bYear = Product::yearFromDate($booking['travel_from'] ?? null);
foreach ($productRepo->allActive() as $p) {
if ((string)$p['type'] !== $parkingType) continue;
$price = (float)$productRepo->priceForDuration($p, $bDays, $bYear);
$parkingOptions[] = [
'id' => (int)$p['id'],
'title' => (string)$p['title'],
'description' => (string)($p['description'] ?? ''),
'price' => $price,
'is_current' => (int)$p['id'] === (int)$firstItem['product_id'],
];
}
}
// Notizen + Hinweise (vereinheitlichte pacim_notes-Tabelle, Migration 042).
$bookingNotes = $bookingRepo->listNotes($id); // [] wenn Tabelle fehlt
$bookingHints = $bookingRepo->listHints($id);
$customerHints = $customerRepo->listHints((int)$booking['customer_id']);
// Auch Kundennotizen für den Filter „Kunde" im Notizen-Tab.
$customerNotes = $customerRepo->listNotes((int)$booking['customer_id']);
$notesAvailable = $noteRepo->isAvailable();
// Methods-Liste für Zahlung-erfassen-Dropdown — Payment::METHODS + DB.
$paymentMethods = Payment::METHODS;
$activeTab = (string)($_GET['tab'] ?? 'overview');
$allowedTabs = ['overview', 'products', 'documents', 'notes', 'activity'];
if (!in_array($activeTab, $allowedTabs, true)) $activeTab = 'overview';
require __DIR__ . '/../app/views/bookings/view.php';
break;
case 'bookings/edit':
// Legacy-Route — Bearbeitung erfolgt jetzt direkt auf bookings/view.
$id = (int)($_GET['id'] ?? 0);
redirect($id > 0 ? '/?page=bookings/view&id=' . $id : '/?page=bookings');
break;
case 'bookings/delete':
$id = (int)($_GET['id'] ?? 0);
if ($id && isPost()) {
$bookingRepo->delete($id);
setFlash('success', 'Buchung wurde gelöscht.');
}
redirect('/?page=bookings');
break;
case 'bookings/cancel':
case 'bookings/uncancel':
// Booking-ID darf via GET ODER POST kommen, damit das Formular aus dem
// Arrival-Action-Modal die ID als hidden field mitschicken kann (kein eigener Action-URL pro Booking).
$id = (int)($_GET['id'] ?? $_POST['booking_id'] ?? 0);
if (!$id || !isPost()) {
redirect('/?page=bookings');
}
requireCsrfOrAbort('/?page=bookings/show&id=' . $id);
$existing = $bookingRepo->find($id);
if (!$existing) {
setFlash('danger', 'Buchung nicht gefunden.');
redirect('/?page=bookings');
}
$newStatus = ($page === 'bookings/cancel') ? 'cancelled' : 'confirmed';
$cancelReason = trim((string)($_POST['cancel_reason'] ?? ''));
$bookingRepo->updateStatus($id, $newStatus);
// Zugehörige Rechnung gleichzeitig stornieren / wieder aktivieren — Counter-Mitarbeiter
// erwarten beim Storno aus dem Check-in-Modal, dass Buchung UND Rechnung in einem Schritt
// erledigt werden. Beim Uncancel zurück auf 'issued' (Default-Status frisch erstellter Rechnungen).
$linkedInvoice = $invoiceRepo->findByBooking($id);
if ($linkedInvoice) {
$invoiceNewStatus = $newStatus === 'cancelled' ? 'cancelled' : 'issued';
if ((string)($linkedInvoice['status'] ?? '') !== $invoiceNewStatus) {
$invoiceRepo->updateStatus((int)$linkedInvoice['id'], $invoiceNewStatus);
AuditLog::write('invoice', (int)$linkedInvoice['id'],
$newStatus === 'cancelled' ? 'cancelled' : 'uncancelled',
($newStatus === 'cancelled' ? 'Rechnung mit Buchung storniert' : 'Storno der Rechnung aufgehoben')
. ($cancelReason !== '' ? ' — Grund: ' . $cancelReason : ''),
['status' => $linkedInvoice['status']], ['status' => $invoiceNewStatus]);
}
}
AuditLog::write('booking', $id,
$newStatus === 'cancelled' ? 'cancelled' : 'uncancelled',
($newStatus === 'cancelled' ? 'Buchung storniert' : 'Storno aufgehoben')
. ($cancelReason !== '' ? ' — Grund: ' . $cancelReason : ''),
['status' => $existing['status']], ['status' => $newStatus]);
$flashMsg = $newStatus === 'cancelled'
? ($linkedInvoice ? 'Buchung und Rechnung wurden storniert.' : 'Buchung wurde storniert.')
: ($linkedInvoice ? 'Storno von Buchung und Rechnung aufgehoben.' : 'Storno wurde aufgehoben.');
setFlash($newStatus === 'cancelled' ? 'warning' : 'success', $flashMsg);
// Wenn der Aufrufer (z.B. Arrival-Modal) ein redirect_to mitgibt, dorthin zurück;
// sonst die klassische Booking-Detailseite.
$redirectTo = trim((string)($_POST['redirect_to'] ?? ''));
if ($redirectTo === '' || !str_starts_with($redirectTo, '/')) {
$redirectTo = '/?page=bookings/show&id=' . $id;
}
redirect($redirectTo);
break;
case 'bookings/confirmation':
$id = (int)($_GET['id'] ?? 0);
$booking = $bookingRepo->find($id);
if (!$booking) {
http_response_code(404);
echo '<div class="alert alert-danger">Buchung nicht gefunden.</div>';
break;
}
$forceRefresh = !empty($_GET['refresh']);
$path = $booking['confirmation_path'] ?? null;
$abs = $path ? PdfRenderer::absolutePath($path) : null;
$fileExists = $abs && is_file($abs);
// Stornierte Buchungen: KEIN neues Dokument erzeugen. Vorhandenes nur lesen.
$bookingCancelled = ($booking['status'] ?? '') === 'cancelled';
// Alte HTML-Bestätigung neu rendern, sobald PDF möglich ist (oder bei Force-Refresh).
$isStaleHtml = $fileExists && str_ends_with($abs, '.html') && PdfRenderer::canRenderPdf();
if (!$bookingCancelled && (!$fileExists || $forceRefresh || $isStaleHtml)) {
if ($fileExists) @unlink($abs);
try {
$path = Documents::generateBookingConfirmation($bookingRepo, $paymentRepo, $id, $settingsRepo);
$abs = $path ? PdfRenderer::absolutePath($path) : null;
} catch (Throwable $e) {
setFlash('danger', 'Bestätigung konnte nicht erzeugt werden: ' . $e->getMessage());
redirect('/?page=bookings/show&id=' . $id);
}
} elseif ($bookingCancelled && !$fileExists) {
setFlash('danger', 'Bestätigung nicht verfügbar — Buchung ist storniert, keine Erzeugung möglich.');
redirect('/?page=bookings/show&id=' . $id);
}
if (!$abs || !is_file($abs)) {
setFlash('danger', 'Bestätigungsdokument nicht verfügbar.');
redirect('/?page=bookings/show&id=' . $id);
}
$isPdf = str_ends_with($abs, '.pdf');
header('Content-Type: ' . ($isPdf ? 'application/pdf' : 'text/html; charset=utf-8'));
if (isset($_GET['download'])) {
header('Content-Disposition: attachment; filename="' . basename($abs) . '"');
} else {
header('Content-Disposition: inline; filename="' . basename($abs) . '"');
}
header('Content-Length: ' . filesize($abs));
readfile($abs);
exit;
// --- PAYMENTS -------------------------------------------------------
case 'payments':
$pageTitle = 'Zahlungen';
$pageSubtitle = 'Alle erfassten Zahlungseingänge.';
$activeNav = 'payments';
['perPage' => $pp, 'page' => $pn] = paginationParams();
// Server-seitige Pagination — kein 10.000er Hard-Limit mehr.
$pagination = $paymentRepo->paginated(hiddenSourcesFor($settingsRepo, 'payments'), $pp, $pn);
$payments = $pagination['items'];
require __DIR__ . '/../app/views/payments/index.php';
break;
case 'payments/create':
$bookingId = (int)($_GET['booking_id'] ?? 0);
$booking = $bookingId ? $bookingRepo->find($bookingId) : null;
if (!$booking) {
setFlash('danger', 'Buchung nicht gefunden.');
redirect('/?page=bookings');
}
$pageTitle = 'Zahlung erfassen';
$pageSubtitle = 'Buchung ' . safeText($booking['booking_no']);
$activeNav = 'payments';
$summary = $paymentRepo->summary($bookingId, (float)$booking['total_gross']);
$errors = [];
$data = [
'booking_id' => $bookingId,
'amount' => number_format($summary['remaining'], 2, '.', ''),
'payment_method' => 'bank_transfer',
'paid_at' => date('Y-m-d\TH:i'),
'note' => '',
];
require __DIR__ . '/../app/views/payments/create.php';
break;
case 'payments/store':
if (!isPost()) {
redirect('/?page=bookings');
}
$bookingId = (int)($_POST['booking_id'] ?? 0);
$booking = $bookingId ? $bookingRepo->find($bookingId) : null;
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$payRespondJson = static function (int $status, array $payload): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
if (!$booking) {
if ($isAjax) $payRespondJson(404, ['ok' => false, 'message' => 'Buchung nicht gefunden.']);
setFlash('danger', 'Buchung nicht gefunden.');
redirect('/?page=bookings');
}
if (($booking['status'] ?? '') === 'cancelled') {
$msg = 'Zahlungserfassung nicht möglich: Buchung ist storniert.';
if ($isAjax) $payRespondJson(409, ['ok' => false, 'message' => $msg]);
setFlash('danger', $msg);
$redirectTo = (string)($_POST['redirect_to'] ?? '/?page=bookings/show&id=' . $bookingId);
redirect($redirectTo);
}
// Rabatt vor der Zahlung anwenden — reduziert total_gross der Buchung.
$discountCode = (string)($_POST['discount_code'] ?? '');
$discountCatalog = [
'early10' => ['label' => '10% Frühbucherrabatt', 'type' => 'percent', 'value' => 10.0],
'loyalty5' => ['label' => '5 EUR Bestandskunde', 'type' => 'fixed', 'value' => 5.0],
];
if (isset($discountCatalog[$discountCode])) {
$d = $discountCatalog[$discountCode];
$result = $bookingRepo->applyDiscount($bookingId, $d['label'], $d['type'], $d['value']);
if ($result['ok']) {
AuditLog::write('booking', $bookingId, 'discount_applied',
'Rabatt angewendet: ' . $d['label'] . ' (−' . formatCurrency($result['amount']) . ')',
null, ['label' => $d['label'], 'amount' => $result['amount']]);
// Buchung neu laden für aktuelle Summen
$booking = $bookingRepo->find($bookingId);
}
}
// datetime-local -> "Y-m-d H:i:s"
$paidAt = trim((string)($_POST['paid_at'] ?? ''));
$paidAt = str_replace('T', ' ', $paidAt);
if ($paidAt && strlen($paidAt) === 16) {
$paidAt .= ':00';
}
$data = [
'booking_id' => $bookingId,
'amount' => $_POST['amount'] ?? '',
'payment_method' => $_POST['payment_method'] ?? '',
'paid_at' => $paidAt,
'note' => $_POST['note'] ?? '',
];
$errors = Payment::validate($data);
// Wenn Validierung fehlschlägt und der Aufruf aus dem Anreiselisten-Modal kam,
// gleichbleibend dort hin zurück mit Flash.
$modalRedirect = (string)($_POST['redirect_to'] ?? '');
if (!empty($errors)) {
$msg = 'Zahlung konnte nicht erfasst werden: ' . implode(' · ', $errors);
if ($isAjax) $payRespondJson(422, ['ok' => false, 'message' => $msg, 'errors' => $errors]);
if ($modalRedirect !== '' && str_starts_with($modalRedirect, '/?page=arrivals')) {
setFlash('danger', $msg);
redirect($modalRedirect);
}
}
if (empty($errors)) {
// Wenn ein Entwurf (z. B. AIDA-Zusatzposten) vorhanden ist, Zahlung
// direkt dort verbuchen — sonst bleibt die Standard-Verknüpfung der
// Payments-Erfassung (booking-bezogen) erhalten.
$draft = $invoiceRepo->findDraftForBooking($bookingId);
if ($draft) {
$data['invoice_id'] = (int)$draft['id'];
}
$paymentId = $paymentRepo->create($data);
AuditLog::write('payment', $paymentId, 'created',
'Zahlung erfasst: ' . formatCurrency((float)$data['amount']) . ' für Buchung ' . $booking['booking_no'], null,
['booking_id' => $bookingId, 'amount' => $data['amount'], 'method' => $data['payment_method'],
'invoice_id' => $draft ? (int)$draft['id'] : null]);
// Rechnungsdatum = Tag der Zahlungserfassung.
$invForDate = $draft ?: $invoiceRepo->findByBooking($bookingId);
if ($invForDate) {
$invoiceRepo->touchInvoiceDate((int)$invForDate['id']);
// Entwurf voll bezahlt? → Rechnungsnummer automatisch vergeben.
$invoiceRepo->assignNumberIfDraftFullyPaid((int)$invForDate['id']);
}
$summary = $paymentRepo->updateBookingPaymentStatus($bookingId);
// Auto-Trigger: Templates mit trigger_event = payment_received.
try { $mailService->triggerTemplates('payment_received', $bookingId, $emailTemplateRepo, $emailRenderer); }
catch (Throwable $e) { /* still */ }
$flashType = 'success';
$flashMsg = '';
if ($summary['status'] === 'paid') {
$existingInvoice = $invoiceRepo->findByBooking($bookingId);
if (!$existingInvoice) {
try {
$invoiceId = $invoiceRepo->createFromBooking($bookingId);
Documents::generateInvoiceFile($invoiceRepo, $paymentRepo, $invoiceId, $settingsRepo);
AuditLog::write('invoice', $invoiceId, 'created',
'Rechnung automatisch erzeugt für Buchung ' . $booking['booking_no']);
// Auto-Trigger: Templates mit trigger_event = invoice_created.
try { $mailService->triggerTemplates('invoice_created', $bookingId, $emailTemplateRepo, $emailRenderer); }
catch (Throwable $e) { /* still */ }
$flashMsg = 'Zahlung erfasst. Buchung ist vollständig bezahlt. Rechnung wurde automatisch erzeugt.';
} catch (Throwable $e) {
$flashType = 'warning';
$flashMsg = 'Zahlung erfasst, Rechnung konnte nicht erzeugt werden: ' . $e->getMessage();
}
} else {
$invoiceRepo->markPaid((int)$existingInvoice['id']);
$flashMsg = 'Zahlung erfasst. Buchung ist vollständig bezahlt.';
}
} else {
$flashMsg = 'Zahlung erfasst (Restbetrag: ' . formatCurrency($summary['remaining']) . ').';
}
if ($isAjax) {
// Aktuelle Beträge nach Rabatt + Zahlung für das Live-Update der Anreiselisten-Zeile.
$finalBooking = $bookingRepo->find($bookingId) ?: $booking;
$finalSummary = $paymentRepo->summary($bookingId, (float)$finalBooking['total_gross']);
$payRespondJson(200, [
'ok' => true,
'message' => $flashMsg,
'flash_type' => $flashType,
'booking_id' => $bookingId,
'payment_status' => $summary['status'],
'remaining_raw' => number_format((float)$finalSummary['remaining'], 2, '.', ''),
'remaining_fmt' => formatCurrency((float)$finalSummary['remaining']),
'total_gross_raw' => (float)$finalBooking['total_gross'],
'total_gross_fmt' => formatCurrency((float)$finalBooking['total_gross']),
]);
}
setFlash($flashType, $flashMsg);
$redirectTo = (string)($_POST['redirect_to'] ?? '');
if ($redirectTo === '' || !str_starts_with($redirectTo, '/?page=')) {
$redirectTo = '/?page=bookings/show&id=' . $bookingId;
}
redirect($redirectTo);
}
// Re-render form with errors
$pageTitle = 'Zahlung erfassen';
$pageSubtitle = 'Buchung ' . safeText($booking['booking_no']);
$activeNav = 'payments';
$summary = $paymentRepo->summary($bookingId, (float)$booking['total_gross']);
require __DIR__ . '/../app/views/payments/create.php';
break;
case 'payments/history':
$bookingId = (int)($_GET['booking_id'] ?? 0);
$booking = $bookingId ? $bookingRepo->find($bookingId) : null;
if (!$booking) {
setFlash('danger', 'Buchung nicht gefunden.');
redirect('/?page=bookings');
}
$pageTitle = 'Zahlungsverlauf';
$pageSubtitle = 'Buchung ' . safeText($booking['booking_no']);
$activeNav = 'payments';
$payments = $paymentRepo->byBooking($bookingId);
$summary = $paymentRepo->summary($bookingId, (float)$booking['total_gross']);
require __DIR__ . '/../app/views/payments/history.php';
break;
case 'payments/edit':
$id = (int)($_GET['id'] ?? 0);
$existing = $paymentRepo->find($id);
if (!$existing) {
setFlash('danger', 'Zahlung nicht gefunden.');
redirect('/?page=payments');
}
$booking = $bookingRepo->find((int)$existing['booking_id']);
$pageTitle = 'Zahlung bearbeiten';
$pageSubtitle = $booking ? 'Buchung ' . safeText($booking['booking_no']) : '';
$activeNav = 'payments';
$errors = [];
$data = $existing;
if (isPost()) {
// Optionale Rabatt-Anpassung — vor der Zahlungs-Update-Logik.
$discountAction = (string)($_POST['discount_action'] ?? '');
$discountCode = (string)($_POST['discount_code'] ?? '');
$discountCatalog = [
'early10' => ['label' => '10% Frühbucherrabatt', 'type' => 'percent', 'value' => 10.0],
'loyalty5' => ['label' => '5 EUR Bestandskunde', 'type' => 'fixed', 'value' => 5.0],
];
if ($discountAction === 'remove' || ($discountAction === 'apply' && isset($discountCatalog[$discountCode]))) {
$bookingRepo->removeDiscount((int)$existing['booking_id']);
if ($discountAction === 'apply') {
$d = $discountCatalog[$discountCode];
$bookingRepo->applyDiscount((int)$existing['booking_id'], $d['label'], $d['type'], $d['value']);
}
$invoiceRepo->rebuildForBooking((int)$existing['booking_id']);
}
$paidAt = trim((string)($_POST['paid_at'] ?? ''));
$paidAt = str_replace('T', ' ', $paidAt);
if ($paidAt && strlen($paidAt) === 16) {
$paidAt .= ':00';
}
$data = array_merge($existing, $_POST, ['paid_at' => $paidAt]);
$errors = Payment::validate($data + ['booking_id' => $existing['booking_id']]);
if (empty($errors)) {
$oldVals = [
'amount' => $existing['amount'],
'payment_method' => $existing['payment_method'],
'paid_at' => $existing['paid_at'],
'note' => $existing['note'],
];
$paymentRepo->update($id, $data);
AuditLog::write('payment', $id, 'updated',
'Zahlung aktualisiert für Buchung ' . ($booking['booking_no'] ?? '–'),
$oldVals,
['amount' => $data['amount'], 'payment_method' => $data['payment_method'],
'paid_at' => $data['paid_at'], 'note' => $data['note']]);
$paymentRepo->updateBookingPaymentStatus((int)$existing['booking_id']);
setFlash('success', 'Zahlung wurde aktualisiert.');
redirect('/?page=bookings/show&id=' . (int)$existing['booking_id']);
}
}
// Booking nach möglichem Discount-Update neu laden für aktuelle Werte im View.
$booking = $bookingRepo->find((int)$existing['booking_id']);
require __DIR__ . '/../app/views/payments/edit.php';
break;
case 'bookings/discount/remove':
if (!isPost()) {
redirect('/?page=bookings');
}
$bid = (int)($_GET['id'] ?? 0);
$redirectTo = '/?page=bookings/show&id=' . $bid;
requireCsrfOrAbort($redirectTo);
$res = $bookingRepo->removeDiscount($bid);
if ($res['ok']) {
$paymentRepo->updateBookingPaymentStatus($bid);
// Falls eine Rechnung existiert: items + totals an aktuelle Buchung syncen
// und PDF beim nächsten Aufruf neu generieren lassen.
$invoiceRepo->rebuildForBooking($bid);
AuditLog::write('booking', $bid, 'discount_removed',
'Rabatt entfernt (+' . formatCurrency($res['amount'] ?? 0) . ')');
setFlash('success', 'Rabatt wurde entfernt.');
} else {
setFlash('warning', $res['reason'] ?? 'Rabatt konnte nicht entfernt werden.');
}
redirect($redirectTo);
break;
case 'bookings/discount/apply':
if (!isPost()) {
redirect('/?page=bookings');
}
$bid = (int)($_GET['id'] ?? 0);
$redirectTo = '/?page=bookings/show&id=' . $bid;
requireCsrfOrAbort($redirectTo);
$discountCode = (string)($_POST['discount_code'] ?? '');
$discountCatalog = [
'early10' => ['label' => '10% Frühbucherrabatt', 'type' => 'percent', 'value' => 10.0],
'loyalty5' => ['label' => '5 EUR Bestandskunde', 'type' => 'fixed', 'value' => 5.0],
];
if (!isset($discountCatalog[$discountCode])) {
setFlash('warning', 'Bitte einen Rabatt auswählen.');
redirect($redirectTo);
}
// Vorhandenen Rabatt zuerst entfernen, falls überschrieben werden soll.
$bookingRepo->removeDiscount($bid);
$d = $discountCatalog[$discountCode];
$res = $bookingRepo->applyDiscount($bid, $d['label'], $d['type'], $d['value']);
if ($res['ok']) {
$paymentRepo->updateBookingPaymentStatus($bid);
$invoiceRepo->rebuildForBooking($bid);
AuditLog::write('booking', $bid, 'discount_applied',
'Rabatt angewendet: ' . $d['label'] . ' (−' . formatCurrency($res['amount']) . ')');
setFlash('success', 'Rabatt angewendet.');
} else {
setFlash('warning', $res['reason'] ?? 'Rabatt konnte nicht angewendet werden.');
}
redirect($redirectTo);
break;
case 'payments/delete':
if (!isPost()) {
redirect('/?page=payments');
}
$id = (int)($_GET['id'] ?? 0);
$existing = $paymentRepo->find($id);
if (!$existing) {
setFlash('danger', 'Zahlung nicht gefunden.');
redirect('/?page=payments');
}
$bookingId = (int)$existing['booking_id'];
$booking = $bookingRepo->find($bookingId);
$redirectTo = (string)($_POST['redirect_to'] ?? ('/?page=bookings/show&id=' . $bookingId));
if (!str_starts_with($redirectTo, '/?page=')) {
$redirectTo = '/?page=bookings/show&id=' . $bookingId;
}
requireCsrfOrAbort($redirectTo);
if (!Payment::isDeletable((string)$existing['paid_at'])) {
setFlash('danger', 'Löschen ist nur am Tag der Zahlung vor 21:00 Uhr möglich.');
redirect($redirectTo);
}
// Rechnung des Buchungs prüfen: falls Buchung nach dem Storno nicht mehr "paid" wäre
// und eine Rechnung existiert, die ebenfalls heute erzeugt wurde → recyclen.
$paidBefore = $paymentRepo->paidAmount($bookingId);
$amount = (float)$existing['amount'];
$newPaid = $paidBefore - $amount;
$bookingTotal = (float)($booking['total_gross'] ?? 0);
$wouldDropFromPaid = $paidBefore + 0.005 >= $bookingTotal && $newPaid + 0.005 < $bookingTotal;
$paymentRepo->delete($id);
AuditLog::write('payment', $id, 'deleted',
'Zahlung gelöscht: ' . formatCurrency($amount) . ' (Buchung ' . ($booking['booking_no'] ?? '–') . ')',
['amount' => $existing['amount'], 'paid_at' => $existing['paid_at'], 'method' => $existing['payment_method']],
null);
if ($wouldDropFromPaid) {
$existingInvoice = $invoiceRepo->findByBooking($bookingId);
if ($existingInvoice) {
$invoiceCreated = (string)$existingInvoice['created_at'];
$sameDay = date('Y-m-d', strtotime($invoiceCreated)) === date('Y-m-d');
$beforeCutoff = (int)date('H') < 21;
if ($sameDay && $beforeCutoff) {
$invoiceNo = (string)$existingInvoice['invoice_no'];
$invoiceRepo->delete((int)$existingInvoice['id']);
$invoiceRepo->recycleNumber($invoiceNo, Auth::user()['email'] ?? 'admin');
AuditLog::write('invoice', (int)$existingInvoice['id'], 'deleted',
'Rechnung ' . $invoiceNo . ' storniert; Nummer in Recycling-Pool aufgenommen.');
setFlash('warning', 'Zahlung gelöscht. Rechnung ' . safeText($invoiceNo) . ' wurde storniert und die Nummer für die nächste Rechnung freigegeben.');
} else {
setFlash('warning', 'Zahlung gelöscht. Rechnung ' . safeText($existingInvoice['invoice_no']) . ' bleibt bestehen (außerhalb des Storno-Fensters).');
}
} else {
setFlash('success', 'Zahlung gelöscht.');
}
} else {
setFlash('success', 'Zahlung gelöscht.');
}
$paymentRepo->updateBookingPaymentStatus($bookingId);
redirect($redirectTo);
break;
// --- INVOICES -------------------------------------------------------
case 'invoices':
$pageTitle = 'Rechnungen';
$pageSubtitle = 'Alle erzeugten Rechnungen.';
$activeNav = 'invoices';
['perPage' => $pp, 'page' => $pn] = paginationParams();
// Server-seitige Pagination — lädt nur die aktuelle Seite (war: ganze Tabelle).
$pagination = $invoiceRepo->paginated(hiddenSourcesFor($settingsRepo, 'invoices'), $pp, $pn);
$invoices = $pagination['items'];
require __DIR__ . '/../app/views/invoices/index.php';
break;
case 'invoices/show':
$id = (int)($_GET['id'] ?? 0);
$invoice = $invoiceRepo->find($id);
if (!$invoice) {
http_response_code(404);
echo '<div class="alert alert-danger">Rechnung nicht gefunden.</div>';
break;
}
$pageTitle = 'Rechnung ' . safeText($invoice['invoice_no']);
$pageSubtitle = 'Buchung ' . safeText($invoice['booking_no']);
$activeNav = 'invoices';
$items = $invoiceRepo->items($id);
$payments = $paymentRepo->byBooking((int)$invoice['booking_id']);
$paymentSummary = $paymentRepo->summary((int)$invoice['booking_id'], (float)$invoice['total_gross']);
require __DIR__ . '/../app/views/invoices/show.php';
break;
case 'invoices/create-from-booking':
$bookingId = (int)($_GET['booking_id'] ?? 0);
$booking = $bookingId ? $bookingRepo->find($bookingId) : null;
if (!$booking) {
setFlash('danger', 'Buchung nicht gefunden.');
redirect('/?page=bookings');
}
if (($booking['status'] ?? '') === 'cancelled') {
setFlash('danger', 'Rechnung kann nicht erstellt werden: Buchung ist storniert.');
redirect('/?page=bookings/show&id=' . $bookingId);
}
$summary = $paymentRepo->summary($bookingId, (float)$booking['total_gross']);
if ($summary['status'] !== 'paid') {
setFlash('danger', 'Rechnung kann erst erstellt werden, wenn die Buchung vollständig bezahlt ist.');
redirect('/?page=bookings/show&id=' . $bookingId);
}
$existing = $invoiceRepo->findByBooking($bookingId);
if ($existing) {
setFlash('warning', 'Für diese Buchung existiert bereits Rechnung ' . $existing['invoice_no'] . '.');
redirect('/?page=invoices/show&id=' . (int)$existing['id']);
}
try {
$invoiceId = $invoiceRepo->createFromBooking($bookingId);
Documents::generateInvoiceFile($invoiceRepo, $paymentRepo, $invoiceId, $settingsRepo);
// Auto-Trigger: Templates mit trigger_event = invoice_created.
try { $mailService->triggerTemplates('invoice_created', $bookingId, $emailTemplateRepo, $emailRenderer); }
catch (Throwable $e) { /* still */ }
setFlash('success', 'Rechnung wurde erzeugt.');
redirect('/?page=invoices/show&id=' . $invoiceId);
} catch (Throwable $e) {
setFlash('danger', 'Rechnung konnte nicht erstellt werden: ' . $e->getMessage());
redirect('/?page=bookings/show&id=' . $bookingId);
}
break;
case 'invoices/regenerate':
$id = (int)($_GET['id'] ?? 0);
if (!$id || !isPost()) {
redirect('/?page=invoices');
}
$invForRegen = $invoiceRepo->find($id);
if ($invForRegen && (($invForRegen['booking_status'] ?? '') === 'cancelled' || ($invForRegen['status'] ?? '') === 'cancelled')) {
setFlash('danger', 'Neuerzeugung blockiert: zugehörige Buchung ist storniert.');
redirect('/?page=invoices/show&id=' . $id);
}
try {
Documents::generateInvoiceFile($invoiceRepo, $paymentRepo, $id, $settingsRepo);
setFlash('success', 'Rechnungsdokument wurde neu erzeugt.');
} catch (Throwable $e) {
setFlash('danger', 'Dokument konnte nicht erzeugt werden: ' . $e->getMessage());
}
redirect('/?page=invoices/show&id=' . $id);
break;
// --- ARRIVALS / CHECKIN -------------------------------------------
case 'arrivals':
$pageTitle = 'Anreiseliste';
$pageSubtitle = 'Operative Anreisen und Check-in Übersicht.';
$activeNav = 'arrivals';
// Entwürfe, deren Anreise vorbei oder heute ≥ 22:00 ist, automatisch finalisieren.
$invoiceRepo->promoteDraftsAfterArrival();
// Datum kommt ausschließlich aus dem GET-Parameter — KEINE Session-Persistenz.
// Fehlt der Parameter oder ist er ungültig (leer, falsches Format, kein echter
// Kalendertag wie '2025-02-31'), wird konsequent auf "heute" zurückgefallen.
$dateParam = trim((string)($_GET['date'] ?? ''));
$date = date('Y-m-d');
if ($dateParam !== '' && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $dateParam, $dm)
&& checkdate((int)$dm[2], (int)$dm[3], (int)$dm[1])) {
$date = $dateParam;
}
// Defensive: alte Session-Keys, falls je gesetzt, hier wegräumen.
unset($_SESSION['arrivals_date'], $_SESSION['arrivals']['date']);
$sort = $_GET['sort'] ?? 'kennzeichen';
if (!in_array($sort, ['kennzeichen', 'time', 'product'], true)) {
$sort = 'kennzeichen';
}
// product_id darf als Array (Multi-Select) oder String (Legacy) reinkommen.
$productIdRaw = $_GET['product_id'] ?? '';
if (is_array($productIdRaw)) {
$productIds = array_values(array_filter(array_map('intval', $productIdRaw), fn($v) => $v > 0));
} else {
$productIds = $productIdRaw !== '' ? [(int)$productIdRaw] : [];
}
// Kombiniertes Multiselect "f[]": event:<id>, status:open, status:arrived,
// status:no_valet, status:early, source:AIDA, source:Eigene. Daraus die strukturierten Filter ableiten.
$rawFilter = $_GET['f'] ?? [];
if (!is_array($rawFilter)) $rawFilter = [$rawFilter];
$selectedEventIds = [];
$selectedSources = [];
$wantOpen = false;
$wantArrived = false;
$wantEarly = false;
$excludeValet = false;
foreach ($rawFilter as $f) {
if (!is_string($f) || $f === '') continue;
if (str_starts_with($f, 'event:')) {
$eid = (int)substr($f, 6);
if ($eid > 0) $selectedEventIds[] = $eid;
} elseif ($f === 'status:open') { $wantOpen = true; }
elseif ($f === 'status:arrived') { $wantArrived = true; }
elseif ($f === 'status:no_valet') { $excludeValet = true; }
elseif ($f === 'status:early') { $wantEarly = true; }
elseif (str_starts_with($f, 'source:')) {
$src = substr($f, 7);
if ($src !== '') $selectedSources[] = $src;
}
}
// "Offen" = noch nicht eingecheckt (pending); "Angereist" = checked_in.
// Bei Mehrfachauswahl wird das per IN(…) als OR im SQL aufgelöst.
$checkinStatuses = [];
if ($wantOpen) $checkinStatuses[] = 'pending';
if ($wantArrived) $checkinStatuses[] = 'checked_in';
$earlyCheckinUntil = (string)$settingsRepo->get('early_checkin_until', '10:30');
$filters = [
'date' => $date,
'event_id' => $selectedEventIds,
'product_id' => $productIds,
'checkin_status' => $checkinStatuses,
'exclude_valet' => $excludeValet,
'early_until' => $wantEarly ? $earlyCheckinUntil : null,
'customer_sources' => $selectedSources,
'license_plate' => trim((string)($_GET['license_plate'] ?? '')),
'customer' => trim((string)($_GET['customer'] ?? '')),
'hide_sources' => hiddenSourcesFor($settingsRepo, 'arrivals'),
];
$arrivals = $bookingRepo->arrivals($filters, $sort);
// Event-Dropdown: nur Events mit Anreisen am gewählten Tag.
$events = $eventRepo->byArrivalDate($date);
$products = $productRepo->forFilter();
// Quelle-Filter (AIDA, Eigene) nur anbieten, wenn AIDA-Kunden im Tages-Pool existieren.
$hasAidaToday = $bookingRepo->hasAidaArrivalsOnDate($date);
$selectedFilterCodes = array_values(array_unique($rawFilter));
// Gesamtzahl Anreisen am Tag (ohne Filter, nur hide_sources) für die "X / Y"-Anzeige.
$totalArrivalsOnDate = $bookingRepo->countArrivalsOnDate($date, hiddenSourcesFor($settingsRepo, 'arrivals'));
$kpis = [
'total' => count($arrivals),
'paid' => 0,
'open' => 0,
'checked_in' => 0,
'checked_out' => 0,
'revenue_paid' => 0.0,
'revenue_open' => 0.0,
];
foreach ($arrivals as $a) {
if ($a['payment_status'] === 'paid') {
$kpis['paid']++;
$kpis['revenue_paid'] += (float)$a['total_gross'];
} else {
$kpis['open']++;
$kpis['revenue_open'] += (float)$a['total_gross'];
}
if ($a['checkin_status'] === 'checked_in') $kpis['checked_in']++;
if ($a['checkin_status'] === 'checked_out') $kpis['checked_out']++;
}
require __DIR__ . '/../app/views/arrivals/index.php';
break;
case 'departures':
$pageTitle = 'Abreiseliste';
$pageSubtitle = 'Operative Abreisen am gewählten Tag.';
$activeNav = 'departures';
$date = $_GET['date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$sort = $_GET['sort'] ?? 'kennzeichen';
if (!in_array($sort, ['kennzeichen', 'time', 'product'], true)) {
$sort = 'kennzeichen';
}
$productIdRaw = $_GET['product_id'] ?? '';
if (is_array($productIdRaw)) {
$productIds = array_values(array_filter(array_map('intval', $productIdRaw), fn($v) => $v > 0));
} else {
$productIds = $productIdRaw !== '' ? [(int)$productIdRaw] : [];
}
// Kombiniertes Multiselect "f[]": event:<id> (Schiff) + status:no_valet (Ohne VALET) + status:early.
$rawFilter = $_GET['f'] ?? [];
if (!is_array($rawFilter)) $rawFilter = [$rawFilter];
$selectedEventIds = [];
$excludeValet = false;
$wantEarly = false;
foreach ($rawFilter as $f) {
if (!is_string($f) || $f === '') continue;
if (str_starts_with($f, 'event:')) {
$eid = (int)substr($f, 6);
if ($eid > 0) $selectedEventIds[] = $eid;
} elseif ($f === 'status:no_valet') {
$excludeValet = true;
} elseif ($f === 'status:early') {
$wantEarly = true;
}
}
$earlyCheckinUntil = (string)$settingsRepo->get('early_checkin_until', '10:30');
$filters = [
'date' => $date,
'event_id' => $selectedEventIds,
'product_id' => $productIds,
'exclude_valet' => $excludeValet,
'early_until' => $wantEarly ? $earlyCheckinUntil : null,
'license_plate' => trim((string)($_GET['license_plate'] ?? '')),
'customer' => trim((string)($_GET['customer'] ?? '')),
'hide_sources' => hiddenSourcesFor($settingsRepo, 'arrivals'),
];
$departures = $bookingRepo->departures($filters, $sort);
$events = $eventRepo->byDepartureDate($date);
$products = $productRepo->forFilter();
$totalDeparturesOnDate = $bookingRepo->countDeparturesOnDate($date, hiddenSourcesFor($settingsRepo, 'arrivals'));
$selectedFilterCodes = array_values(array_unique($rawFilter));
require __DIR__ . '/../app/views/departures/index.php';
break;
case 'departures/export-excel':
$date = $_GET['date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$sort = $_GET['sort'] ?? 'kennzeichen';
if (!in_array($sort, ['kennzeichen', 'time', 'product'], true)) {
$sort = 'kennzeichen';
}
$productIdRaw = $_GET['product_id'] ?? '';
if (is_array($productIdRaw)) {
$productIds = array_values(array_filter(array_map('intval', $productIdRaw), fn($v) => $v > 0));
} else {
$productIds = $productIdRaw !== '' ? [(int)$productIdRaw] : [];
}
$rawFilter = $_GET['f'] ?? [];
if (!is_array($rawFilter)) $rawFilter = [$rawFilter];
$selectedEventIds = [];
$excludeValet = false;
$wantEarly = false;
foreach ($rawFilter as $f) {
if (!is_string($f) || $f === '') continue;
if (str_starts_with($f, 'event:')) {
$eid = (int)substr($f, 6);
if ($eid > 0) $selectedEventIds[] = $eid;
} elseif ($f === 'status:no_valet') {
$excludeValet = true;
} elseif ($f === 'status:early') {
$wantEarly = true;
}
}
$earlyCheckinUntil = (string)$settingsRepo->get('early_checkin_until', '10:30');
$filters = [
'date' => $date,
'event_id' => $selectedEventIds,
'product_id' => $productIds,
'exclude_valet' => $excludeValet,
'early_until' => $wantEarly ? $earlyCheckinUntil : null,
'hide_sources' => hiddenSourcesFor($settingsRepo, 'excel'),
];
$rows = $bookingRepo->departures($filters, $sort);
$headers = [
'Kennzeichen', 'Nachname, Vorname', 'Stellplatz',
'Betrag', 'Zahlung', 'Schiff',
'Abreisedatum', 'Reisedauer (Tage)',
'Rechnungs-Nr.', 'Buchungs-Nr.',
];
$hasValet = static function (string $summary): bool {
return stripos($summary, 'valet') !== false;
};
$hasHalle = static function (string $summary): bool {
$s = mb_strtolower($summary);
return str_contains($s, 'halle') || str_contains($s, 'premium')
|| str_contains($s, 'indoor') || str_contains($s, 'innen')
|| str_contains($s, 'überdacht') || str_contains($s, 'ueberdacht');
};
$hasAussen = static function (string $summary): bool {
$s = mb_strtolower($summary);
return str_contains($s, 'außen') || str_contains($s, 'aussen')
|| str_contains($s, 'standard') || str_contains($s, 'outdoor');
};
$hasWohnmobil = static function (string $summary): bool {
foreach (explode(',', $summary) as $part) {
if (mb_stripos(trim($part), 'wohnmobil') !== false) return true;
}
return false;
};
$stellplatzLabel = static function (string $summary): string {
$s = mb_strtolower($summary);
$v = str_contains($s, 'valet');
$h = str_contains($s, 'halle') || str_contains($s, 'premium') || str_contains($s, 'innen');
$a = str_contains($s, 'außen') || str_contains($s, 'aussen') || str_contains($s, 'standard') || str_contains($s, 'outdoor');
if ($v && $h) return 'VALET – Halle';
if ($v && $a) return 'VALET – Außen';
if ($v) return 'VALET';
if ($h) return 'Halle';
if ($a) return 'Außen';
return '';
};
$daysBetween = static function (?string $from, ?string $to): int {
if (!$from || !$to) return 0;
$f = strtotime($from); $t = strtotime($to);
if (!$f || !$t || $t < $f) return 0;
return (int)floor(($t - $f) / 86400);
};
$zahlungLabel = static function (array $r): string {
if (($r['payment_status'] ?? '') !== 'paid') {
return '- OFFEN -';
}
$methods = trim((string)($r['payment_methods'] ?? ''));
if ($methods === '') return statusLabel($r['payment_status'] ?? '');
$labels = [];
foreach (explode(',', $methods) as $m) {
$m = trim($m);
if ($m !== '') $labels[] = Payment::methodLabel($m);
}
return implode(', ', $labels) ?: statusLabel($r['payment_status'] ?? '');
};
$toRow = static function (array $r) use ($stellplatzLabel, $daysBetween, $zahlungLabel): array {
$days = $daysBetween($r['travel_from'] ?? null, $r['travel_to'] ?? null);
return [
formatLicensePlateDE($r['license_plate'] ?? ''),
trim(($r['c_last_name'] ?? '') . ', ' . ($r['c_first_name'] ?? ''), ', '),
$stellplatzLabel((string)($r['products_summary'] ?? '')),
(float)($r['total_gross'] ?? 0),
$zahlungLabel($r),
$r['event_title'] ?? ($r['event_ship'] ?? ''),
$r['travel_to'] ? date('d.m.Y', strtotime($r['travel_to'])) : '',
$days > 0 ? $days : '',
$r['invoice_no'] ?? '',
$r['booking_no'] ?? '',
];
};
$groups = [
'Alle' => [],
'Eigene' => [],
'AIDA' => [],
'VALET' => [],
'Halle' => [],
'Außen' => [],
'Wohnmobil' => [],
'Early Check-In' => [],
'Stornos' => [],
];
foreach ($rows as $r) {
$summary = (string)($r['products_summary'] ?? '');
$isCancelled = ($r['status'] ?? '') === 'cancelled';
$row = $toRow($r);
if ($isCancelled) {
$groups['Stornos'][] = $row;
continue;
}
$groups['Alle'][] = $row;
$source = trim((string)($r['c_source'] ?? ''));
if (strcasecmp($source, 'AIDA') === 0) {
$groups['AIDA'][] = $row;
} else {
$groups['Eigene'][] = $row;
}
$valet = $hasValet($summary);
$halle = $hasHalle($summary);
$aussen = $hasAussen($summary);
if ($valet) $groups['VALET'][] = $row;
if ($halle && !$valet) $groups['Halle'][] = $row;
if ($aussen && !$valet) $groups['Außen'][] = $row;
if ($hasWohnmobil($summary)) $groups['Wohnmobil'][] = $row;
// Early Check-In: Boarding-Zeit (= Anreisezeit − 30 Min) ≤ konfigurierte Schwelle.
if (!empty($r['arrival_time'])) {
$ts = strtotime($r['arrival_time']);
if ($ts) {
$boardingMin = (int)date('H', $ts) * 60 + (int)date('i', $ts) - 30;
[$thH, $thM] = array_pad(explode(':', $earlyCheckinUntil), 2, '0');
$thresholdMin = (int)$thH * 60 + (int)$thM;
if ($boardingMin <= $thresholdMin) {
$groups['Early Check-In'][] = $row;
}
}
}
}
// Spaltenbreiten: Kennzeichen, Nachname Vorname, Stellplatz, Betrag, Zahlung, Schiff, Abreisedatum, Dauer, Rechnungs-Nr., Buchungs-Nr.
$colWidths = [14, 24, 11, 11, 11, 18, 13, 12, 14, 14];
$currencyCols = [3];
$sheets = [];
foreach ($groups as $title => $gRows) {
if (empty($gRows) && $title !== 'Alle') continue;
$sheets[] = [
'title' => $title,
'headers' => $headers,
'rows' => $gRows,
'columnWidths' => $colWidths,
'currencyColumns' => $currencyCols,
];
}
$filename = Exporter::suggestFilename('departures-' . $date);
Exporter::streamMulti($filename, $sheets);
exit;
// ----------------------------------------------------------------------
// AJAX-Endpunkte für das Check-in-Modal (?page=arrivals).
// Liefern alle JSON, prüfen CSRF und konsistent das booking_id im POST.
// ----------------------------------------------------------------------
case 'bookings/ajax/plate':
case 'bookings/ajax/notes':
case 'bookings/ajax/notes/list':
case 'bookings/ajax/notes/add':
case 'bookings/ajax/notes/update':
case 'bookings/ajax/notes/delete':
case 'bookings/ajax/items/add':
case 'bookings/ajax/items/add-custom':
case 'bookings/ajax/items/update':
case 'bookings/ajax/items/swap':
case 'bookings/ajax/items/delete':
case 'bookings/ajax/items':
case 'bookings/ajax/products':
case 'bookings/ajax/discount':
case 'bookings/ajax/favorite':
case 'bookings/ajax/payments/list':
case 'bookings/ajax/payments/add':
case 'bookings/ajax/payments/update':
case 'bookings/ajax/payments/delete':
case 'bookings/ajax/travel-period':
case 'bookings/ajax/guest-count':
case 'bookings/ajax/cancel':
case 'bookings/ajax/uncancel':
header('Content-Type: application/json; charset=utf-8');
$jsonOut = static function (int $status, array $payload): void {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
// Hilfs-Helfer für Discount-Mapping (Code <-> Label/Type/Value) —
// dieselbe Definition wie im payments/edit-Flow, damit Code-Werte konsistent bleiben.
$discountCatalog = [
'early10' => ['label' => '10% Frühbucherrabatt', 'type' => 'percent', 'value' => 10.0],
'loyalty5' => ['label' => '5 EUR Bestandskunde', 'type' => 'fixed', 'value' => 5.0],
];
// AIDA-Zusatz-Posten → eigener Entwurf:
// Wenn die Buchung
// a) customers.source = 'AIDA',
// b) bookings.payment_status = 'paid',
// c) bereits eine Rechnung hat, deren invoice_no 'AIDA' enthält (z. B. AIDA-42424242)
// hat, darf die bestehende (bezahlte) AIDA-Rechnung nicht mutiert werden.
// Stattdessen wird der neu hinzugefügte booking_product-Posten in einen
// Entwurf (status='draft', invoice_no=NULL) der Buchung gehängt — entweder
// in den vorhandenen Entwurf oder in einen neu erstellten.
// Liefert die ID des Entwurfs (oder null, wenn Bedingungen nicht erfüllt).
$maybeRouteToAidaDraft = static function (int $bookingId, int $bookingProductId) use ($db, $invoiceRepo): ?int {
$stmt = $db->getConnection()->prepare(
"SELECT b.payment_status, c.source AS c_src
FROM bookings b
INNER JOIN customers c ON c.id = b.customer_id
WHERE b.id = :id"
);
$stmt->execute(['id' => $bookingId]);
$row = $stmt->fetch();
if (!$row) return null;
if ((string)($row['c_src'] ?? '') !== 'AIDA') return null;
if ((string)($row['payment_status'] ?? '') !== 'paid') return null;
// Existiert eine bestehende Rechnung mit 'AIDA' im invoice_no?
$hasAidaInvoice = false;
foreach ($invoiceRepo->allByBooking($bookingId) as $inv) {
$no = (string)($inv['invoice_no'] ?? '');
if ($no !== '' && stripos($no, 'AIDA') !== false) {
$hasAidaInvoice = true; break;
}
}
if (!$hasAidaInvoice) return null;
try {
$draftId = $invoiceRepo->addItemToDraftForBooking($bookingId, $bookingProductId);
AuditLog::write('invoice', $draftId, 'updated',
'AIDA-Zusatzposten in Rechnungsentwurf aufgenommen.',
null, ['booking_id' => $bookingId, 'booking_product_id' => $bookingProductId]);
return $draftId;
} catch (Throwable $e) {
error_log('AIDA-Entwurf konnte nicht aktualisiert werden (Buchung ' . $bookingId . '): ' . $e->getMessage());
return null;
}
};
// GET-Endpunkte (Lese-API) ohne CSRF — Lesen ist seiteneffektfrei.
if ($page === 'bookings/ajax/items') {
$bookingId = (int)($_GET['booking_id'] ?? 0);
$booking = $bookingId > 0 ? $bookingRepo->find($bookingId) : null;
if (!$booking) $jsonOut(404, ['ok' => false, 'message' => 'Buchung nicht gefunden.']);
$items = $bookingRepo->products($bookingId);
$paid = (float)($paymentRepo->summary($bookingId, (float)$booking['total_gross'])['paid'] ?? 0);
// Aktuellen Rabatt auf einen unserer Codes zurückmappen (Label-Match).
$currentDiscountCode = '';
if (!empty($booking['discount_label']) && (float)($booking['discount_amount'] ?? 0) > 0) {
foreach ($discountCatalog as $code => $d) {
if (strcasecmp($d['label'], (string)$booking['discount_label']) === 0) {
$currentDiscountCode = $code;
break;
}
}
}
$jsonOut(200, [
'ok' => true,
'items' => array_map(static fn($r) => [
'id' => (int)$r['id'],
'product_id' => (int)$r['product_id'],
'title' => $r['product_title'],
'type' => $r['product_type'],
'quantity' => (int)$r['quantity'],
'price_net' => (float)$r['price_net'],
'price_gross' => (float)$r['price_gross'],
'vat_rate' => (float)$r['vat_rate'],
'total_gross' => (float)$r['total_gross'],
], $items),
'discount' => [
'code' => $currentDiscountCode,
'label' => (string)($booking['discount_label'] ?? ''),
'amount' => (float)($booking['discount_amount'] ?? 0),
],
'totals' => [
'net' => (float)$booking['total_net'],
'vat' => (float)$booking['total_vat'],
'gross' => (float)$booking['total_gross'],
'paid' => $paid,
'open' => max(0.0, (float)$booking['total_gross'] - $paid),
],
'license_plate' => $booking['license_plate'] ?? '',
'notes' => $booking['notes'] ?? '',
'favorite' => Auth::id() ? $bookingRepo->isFavorite($bookingId, (int)Auth::id()) : false,
]);
}
if ($page === 'bookings/ajax/products') {
// Produkt-Picker: aktive Produkte mit Preis-Hint pro Reisedauer/Jahr der Buchung
// (für Staffelpreise). booking_id ist optional — ohne fällt der Preis auf den Festpreis zurück.
$bookingId = (int)($_GET['booking_id'] ?? 0);
$days = 1; $year = (int)date('Y');
if ($bookingId > 0) {
$b = $bookingRepo->find($bookingId);
if ($b) {
$days = Product::daysBetween($b['travel_from'] ?? null, $b['travel_to'] ?? null);
$year = Product::yearFromDate($b['travel_from'] ?? null);
}
}
$out = [];
foreach ($productRepo->allActive() as $p) {
$price = $productRepo->priceForDuration($p, $days, $year);
$out[] = [
'id' => (int)$p['id'],
'title' => $p['title'],
'type' => $p['type'],
'pricing_mode' => $p['pricing_mode'] ?? 'fixed',
'price_gross' => round((float)$price, 2),
'vat_rate' => (float)$p['vat_rate'],
];
}
$jsonOut(200, ['ok' => true, 'products' => $out, 'days' => $days, 'year' => $year]);
}
if ($page === 'bookings/ajax/notes/list') {
$bookingId = (int)($_GET['booking_id'] ?? 0);
$booking = $bookingId > 0 ? $bookingRepo->find($bookingId) : null;
if (!$booking) $jsonOut(404, ['ok' => false, 'message' => 'Buchung nicht gefunden.']);
$jsonOut(200, ['ok' => true, 'notes' => array_map(static fn($r) => [
'id' => (int)$r['id'],
'body' => $r['body'],
'created_by' => $r['created_by'] ?? '',
'created_at' => $r['created_at'],
'updated_at' => $r['updated_at'],
], $bookingRepo->listNotes($bookingId))]);
}
if ($page === 'bookings/ajax/payments/list') {
$bookingId = (int)($_GET['booking_id'] ?? 0);
$booking = $bookingId > 0 ? $bookingRepo->find($bookingId) : null;
if (!$booking) $jsonOut(404, ['ok' => false, 'message' => 'Buchung nicht gefunden.']);
$rows = $paymentRepo->byBooking($bookingId);
// Methoden-Label-Lookup (Cache via Payment::methodLabel).
$out = [];
foreach ($rows as $r) {
$out[] = [
'id' => (int)$r['id'],
'amount' => (float)$r['amount'],
'payment_method' => $r['payment_method'],
'method_label' => Payment::methodLabel($r['payment_method']),
'paid_at' => $r['paid_at'],
'note' => $r['note'] ?? '',
];
}
$jsonOut(200, ['ok' => true, 'payments' => $out]);
}
// Ab hier nur POST + CSRF.
if (!isPost()) $jsonOut(405, ['ok' => false, 'message' => 'POST erforderlich.']);
// Header-Variante zulassen, damit fetch() den Token auch außerhalb des FormData liefern kann.
if (empty($_POST['csrf_token']) && !empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$_POST['csrf_token'] = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
}
if (!csrfCheck()) {
$jsonOut(403, ['ok' => false, 'message' => 'CSRF-Token ungültig.']);
}
$bookingId = (int)($_POST['booking_id'] ?? 0);
$booking = $bookingId > 0 ? $bookingRepo->find($bookingId) : null;
if (!$booking) $jsonOut(404, ['ok' => false, 'message' => 'Buchung nicht gefunden.']);
// Bei stornierten Buchungen blockieren wir Änderungen am Inhalt — Ausnahmen:
// Favoriten-Toggle (Cleanup) und uncancel (Storno wieder aufheben).
$allowedOnCancelled = ['bookings/ajax/favorite', 'bookings/ajax/uncancel'];
if (($booking['status'] ?? '') === 'cancelled' && !in_array($page, $allowedOnCancelled, true)) {
$jsonOut(409, ['ok' => false, 'message' => 'Buchung ist storniert — Änderungen sind blockiert.']);
}
// Einheitlicher Rückgabe-Builder mit aktuellen Totals + Items + Rabatt-State.
$respondWithBooking = static function () use ($bookingRepo, $paymentRepo, $bookingId, $jsonOut, $discountCatalog): void {
$b = $bookingRepo->find($bookingId);
$items = $bookingRepo->products($bookingId);
$paid = (float)($paymentRepo->summary($bookingId, (float)$b['total_gross'])['paid'] ?? 0);
$currentCode = '';
if (!empty($b['discount_label']) && (float)($b['discount_amount'] ?? 0) > 0) {
foreach ($discountCatalog as $code => $d) {
if (strcasecmp($d['label'], (string)$b['discount_label']) === 0) { $currentCode = $code; break; }
}
}
$jsonOut(200, [
'ok' => true,
'items' => array_map(static fn($r) => [
'id' => (int)$r['id'],
'product_id' => (int)$r['product_id'],
'title' => $r['product_title'],
'type' => $r['product_type'],
'quantity' => (int)$r['quantity'],
'price_net' => (float)$r['price_net'],
'price_gross' => (float)$r['price_gross'],
'vat_rate' => (float)$r['vat_rate'],
'total_gross' => (float)$r['total_gross'],
], $items),
'discount' => [
'code' => $currentCode,
'label' => (string)($b['discount_label'] ?? ''),
'amount' => (float)($b['discount_amount'] ?? 0),
],
'totals' => [
'net' => (float)$b['total_net'],
'vat' => (float)$b['total_vat'],
'gross' => (float)$b['total_gross'],
'paid' => $paid,
'open' => max(0.0, (float)$b['total_gross'] - $paid),
],
'license_plate' => $b['license_plate'] ?? '',
'notes' => $b['notes'] ?? '',
]);
};
try {
if ($page === 'bookings/ajax/plate') {
$plate = trim((string)($_POST['license_plate'] ?? ''));
$bookingRepo->updateLicensePlate($bookingId, $plate !== '' ? $plate : null);
// Schlüssel-abgegeben-Flag — nur speichern, wenn die Spalte
// in der DB existiert (Migration 043 angewendet). Sonst still
// ignorieren, damit ältere Installationen ohne Migration nicht
// beim Plate-Update mit SQLSTATE 42S22 abbrechen.
$keysVal = isset($_POST['keys_handed_over']) ? 1 : 0;
$keysSaved = false;
try {
$bookingRepo->updateFields($bookingId, ['keys_handed_over' => $keysVal]);
$keysSaved = true;
} catch (PDOException $e) {
// 1054 = ER_BAD_FIELD_ERROR (Unknown column). Andere Fehler
// werfen wir weiter — das wäre ein echtes Problem.
$info = $e->errorInfo ?? null;
if (!is_array($info) || (int)($info[1] ?? 0) !== 1054) {
throw $e;
}
}
AuditLog::write('booking', $bookingId, 'plate_updated',
'Kennzeichen aktualisiert' . ($keysSaved && $keysVal ? ' · Schlüssel abgegeben' : ''),
['license_plate' => $booking['license_plate'] ?? null, 'keys_handed_over' => $booking['keys_handed_over'] ?? 0],
['license_plate' => $plate, 'keys_handed_over' => $keysSaved ? $keysVal : 'column-missing']);
$respondWithBooking();
}
if ($page === 'bookings/ajax/notes') {
$notes = trim((string)($_POST['notes'] ?? ''));
$bookingRepo->updateNotes($bookingId, $notes);
$respondWithBooking();
}
if ($page === 'bookings/ajax/items/add') {
$productId = (int)($_POST['product_id'] ?? 0);
$quantity = max(1, (int)($_POST['quantity'] ?? 1));
$priceGross = isset($_POST['price_gross']) && $_POST['price_gross'] !== ''
? (float)$_POST['price_gross'] : null;
$res = $bookingRepo->addItem($bookingId, $productId, $productRepo, $quantity, $priceGross);
// AIDA-Spezialfall: bezahlte AIDA-Rechnung existiert → neuen Posten in Entwurf.
$routedDraftId = $maybeRouteToAidaDraft($bookingId, (int)$res['item_id']);
if (!$routedDraftId) {
// Standard-Fluss: bestehende (offene) Rechnung neu aufbauen.
$invoiceRepo->rebuildForBooking($bookingId);
}
$paymentRepo->updateBookingPaymentStatus($bookingId);
AuditLog::write('booking', $bookingId, 'item_added',
'Position hinzugefügt', null,
['product_id' => $productId, 'quantity' => $quantity,
'aida_draft_invoice_id' => $routedDraftId]);
$respondWithBooking();
}
if ($page === 'bookings/ajax/items/add-custom') {
$title = trim((string)($_POST['title'] ?? ''));
$quantity = max(1, (int)($_POST['quantity'] ?? 1));
$price = (float)str_replace(',', '.', (string)($_POST['price_gross'] ?? '0'));
$vatRate = isset($_POST['vat_rate']) && $_POST['vat_rate'] !== ''
? (float)str_replace(',', '.', (string)$_POST['vat_rate']) : 19.0;
if ($title === '') $jsonOut(422, ['ok' => false, 'message' => 'Bezeichnung darf nicht leer sein.']);
if ($price <= 0) $jsonOut(422, ['ok' => false, 'message' => 'Preis muss größer als 0 sein.']);
$res = $bookingRepo->addCustomItem($bookingId, $title, $quantity, $price, $vatRate);
$routedDraftId = $maybeRouteToAidaDraft($bookingId, (int)$res['item_id']);
if (!$routedDraftId) {
$invoiceRepo->rebuildForBooking($bookingId);
}
$paymentRepo->updateBookingPaymentStatus($bookingId);
AuditLog::write('booking', $bookingId, 'item_added',
'Custom-Position hinzugefügt: ' . $title, null,
['title' => $title, 'quantity' => $quantity, 'price_gross' => $price,
'aida_draft_invoice_id' => $routedDraftId]);
$respondWithBooking();
}
if ($page === 'bookings/ajax/items/update') {
$itemId = (int)($_POST['item_id'] ?? 0);
$qty = isset($_POST['quantity']) && $_POST['quantity'] !== '' ? (int)$_POST['quantity'] : null;
$price = isset($_POST['price_gross']) && $_POST['price_gross'] !== '' ? (float)str_replace(',', '.', (string)$_POST['price_gross']) : null;
// custom_title bewusst nur dann anfassen, wenn der Aufrufer es schickt.
$title = array_key_exists('custom_title', $_POST) ? (string)$_POST['custom_title'] : null;
$bookingRepo->updateItem($bookingId, $itemId, $qty, $price, $title);
$invoiceRepo->rebuildForBooking($bookingId);
$paymentRepo->updateBookingPaymentStatus($bookingId);
AuditLog::write('booking', $bookingId, 'item_updated',
'Position aktualisiert', null,
array_filter(['item_id' => $itemId, 'quantity' => $qty, 'price_gross' => $price, 'custom_title' => $title],
static fn($v) => $v !== null));
$respondWithBooking();
}
if ($page === 'bookings/ajax/favorite') {
$uid = Auth::id();
if (!$uid) $jsonOut(401, ['ok' => false, 'message' => 'Nicht eingeloggt.']);
$action = (string)($_POST['action'] ?? 'toggle');
$isFav = $bookingRepo->isFavorite($bookingId, $uid);
$newState = match ($action) {
'add' => true,
'remove' => false,
default => !$isFav,
};
if ($newState) $bookingRepo->addFavorite($bookingId, $uid);
else $bookingRepo->removeFavorite($bookingId, $uid);
$jsonOut(200, ['ok' => true, 'favorite' => $newState]);
}
if ($page === 'bookings/ajax/items/swap') {
// Bestehende Position auf ein anderes Produkt umstellen — z. B. Außen → Halle.
$itemId = (int)($_POST['item_id'] ?? 0);
$newProductId = (int)($_POST['new_product_id'] ?? 0);
$bookingRepo->swapItem($bookingId, $itemId, $newProductId, $productRepo);
// Rechnung an die neuen Positionen anpassen — sonst zeigt die
// ursprüngliche Rechnung noch das alte Produkt.
$invoiceRepo->rebuildForBooking($bookingId);
AuditLog::write('booking', $bookingId, 'item_swapped',
'Position umgestellt',
['item_id' => $itemId], ['new_product_id' => $newProductId]);
$respondWithBooking();
}
if ($page === 'bookings/ajax/items/delete') {
$itemId = (int)($_POST['item_id'] ?? 0);
$bookingRepo->deleteItem($bookingId, $itemId);
$invoiceRepo->rebuildForBooking($bookingId);
$paymentRepo->updateBookingPaymentStatus($bookingId);
AuditLog::write('booking', $bookingId, 'item_removed',
'Position entfernt', ['item_id' => $itemId], null);
$respondWithBooking();
}
if ($page === 'bookings/ajax/discount') {
// action = 'apply' (mit code) oder 'remove'. Wechsel = remove+apply (atomar im Code).
$action = (string)($_POST['action'] ?? '');
$code = (string)($_POST['code'] ?? '');
if (!in_array($action, ['apply', 'remove'], true)) {
$jsonOut(422, ['ok' => false, 'message' => 'Unbekannte Aktion.']);
}
if ($action === 'apply' && !isset($discountCatalog[$code])) {
$jsonOut(422, ['ok' => false, 'message' => 'Unbekannter Rabattcode.']);
}
// Bestehenden Rabatt zuerst entfernen, damit applyDiscount nicht aufgrund
// bestehendem Rabatt fehlschlägt.
$bookingRepo->removeDiscount($bookingId);
if ($action === 'apply') {
$d = $discountCatalog[$code];
$bookingRepo->applyDiscount($bookingId, $d['label'], $d['type'], $d['value']);
}
$invoiceRepo->rebuildForBooking($bookingId);
AuditLog::write('booking', $bookingId, 'discount_' . $action,
$action === 'apply' ? ('Rabatt geändert: ' . ($discountCatalog[$code]['label'] ?? $code))
: 'Rabatt entfernt',
['discount_label' => $booking['discount_label'] ?? null],
['action' => $action, 'code' => $code]);
$respondWithBooking();
}
if ($page === 'bookings/ajax/notes/add') {
$body = trim((string)($_POST['body'] ?? ''));
if ($body === '') $jsonOut(422, ['ok' => false, 'message' => 'Notiz darf nicht leer sein.']);
$author = Auth::user()['email'] ?? null;
$kind = in_array(($_POST['kind'] ?? 'info'), Note::KINDS, true) ? (string)$_POST['kind'] : 'info';
try {
$newId = $bookingRepo->addNote($bookingId, $body, $author, $kind);
} catch (RuntimeException $rex) {
$jsonOut(503, ['ok' => false, 'message' => $rex->getMessage()]);
}
AuditLog::write('booking', $bookingId, 'note_added',
($kind === 'hint' ? 'Hinweis' : 'Notiz') . ' hinzugefügt', null,
['note_id' => $newId, 'kind' => $kind, 'preview' => mb_substr($body, 0, 80)]);
$jsonOut(200, [
'ok' => true,
'notes' => $bookingRepo->listNotes($bookingId),
'hints' => $bookingRepo->listHints($bookingId),
]);
}
if ($page === 'bookings/ajax/notes/update') {
$noteId = (int)($_POST['note_id'] ?? 0);
$body = trim((string)($_POST['body'] ?? ''));
$kind = isset($_POST['kind']) && in_array($_POST['kind'], Note::KINDS, true) ? (string)$_POST['kind'] : null;
if ($noteId <= 0) $jsonOut(422, ['ok' => false, 'message' => 'Notiz-ID fehlt.']);
if ($body === '') $jsonOut(422, ['ok' => false, 'message' => 'Notiz darf nicht leer sein.']);
$bookingRepo->updateNote($bookingId, $noteId, $body, $kind);
$jsonOut(200, [
'ok' => true,
'notes' => $bookingRepo->listNotes($bookingId),
'hints' => $bookingRepo->listHints($bookingId),
]);
}
if ($page === 'bookings/ajax/notes/delete') {
$noteId = (int)($_POST['note_id'] ?? 0);
if ($noteId <= 0) $jsonOut(422, ['ok' => false, 'message' => 'Notiz-ID fehlt.']);
$bookingRepo->deleteNote($bookingId, $noteId);
AuditLog::write('booking', $bookingId, 'note_deleted', 'Notiz gelöscht',
['note_id' => $noteId], null);
$jsonOut(200, [
'ok' => true,
'notes' => $bookingRepo->listNotes($bookingId),
'hints' => $bookingRepo->listHints($bookingId),
]);
}
if ($page === 'bookings/ajax/payments/update') {
$paymentId = (int)($_POST['payment_id'] ?? 0);
$existing = $paymentId > 0 ? $paymentRepo->find($paymentId) : null;
if (!$existing || (int)$existing['booking_id'] !== $bookingId) {
$jsonOut(404, ['ok' => false, 'message' => 'Zahlung nicht gefunden.']);
}
$paidAt = trim((string)($_POST['paid_at'] ?? ''));
$paidAt = str_replace('T', ' ', $paidAt);
if ($paidAt && strlen($paidAt) === 16) $paidAt .= ':00';
$data = array_merge($existing, $_POST, ['paid_at' => $paidAt]);
$errors = Payment::validate($data + ['booking_id' => $bookingId]);
if ($errors) $jsonOut(422, ['ok' => false, 'message' => implode(' ', $errors)]);
$paymentRepo->update($paymentId, $data);
AuditLog::write('payment', $paymentId, 'updated',
'Zahlung aus Check-in-Modal aktualisiert',
['amount' => $existing['amount'], 'payment_method' => $existing['payment_method'], 'paid_at' => $existing['paid_at']],
['amount' => $data['amount'], 'payment_method' => $data['payment_method'], 'paid_at' => $data['paid_at']]);
$paymentRepo->updateBookingPaymentStatus($bookingId);
$respondWithBooking();
}
if ($page === 'bookings/ajax/payments/delete') {
$paymentId = (int)($_POST['payment_id'] ?? 0);
$existing = $paymentId > 0 ? $paymentRepo->find($paymentId) : null;
if (!$existing || (int)$existing['booking_id'] !== $bookingId) {
$jsonOut(404, ['ok' => false, 'message' => 'Zahlung nicht gefunden.']);
}
$paymentRepo->delete($paymentId);
AuditLog::write('payment', $paymentId, 'deleted',
'Zahlung aus Check-in-Modal gelöscht',
['amount' => $existing['amount'], 'payment_method' => $existing['payment_method']], null);
$paymentRepo->updateBookingPaymentStatus($bookingId);
$respondWithBooking();
}
if ($page === 'bookings/ajax/payments/add') {
$amount = (float)str_replace(',', '.', (string)($_POST['amount'] ?? ''));
$method = trim((string)($_POST['payment_method'] ?? ''));
$paidAt = trim((string)($_POST['paid_at'] ?? date('Y-m-d H:i:s')));
$paidAt = str_replace('T', ' ', $paidAt);
if ($paidAt && strlen($paidAt) === 16) $paidAt .= ':00';
$note = trim((string)($_POST['note'] ?? ''));
$data = [
'booking_id' => $bookingId,
'invoice_id' => null,
'amount' => $amount,
'payment_method' => $method,
'paid_at' => $paidAt,
'note' => $note,
];
$errors = Payment::validate($data);
if ($errors) $jsonOut(422, ['ok' => false, 'message' => implode(' ', $errors), 'errors' => $errors]);
// Vorrangig an einen offenen Entwurf hängen (AIDA-Zusatzposten),
// sonst an die regulär verknüpfte Rechnung der Buchung.
$draft = $invoiceRepo->findDraftForBooking($bookingId);
$inv = $draft ?: $invoiceRepo->findByBooking($bookingId);
if ($inv) $data['invoice_id'] = (int)$inv['id'];
$newId = $paymentRepo->create($data);
AuditLog::write('payment', $newId, 'created',
'Zahlung erfasst aus Buchungsakte',
null,
['booking_id' => $bookingId, 'amount' => $amount, 'method' => $method,
'invoice_id' => $inv ? (int)$inv['id'] : null]);
// Rechnungsdatum = Tag der Zahlungserfassung.
if ($inv) {
$invoiceRepo->touchInvoiceDate((int)$inv['id']);
// Entwurf vollständig bezahlt? → eigene Rechnungsnummer vergeben
// (status bleibt 'draft' bis zur Anreise + 22:00).
$invoiceRepo->assignNumberIfDraftFullyPaid((int)$inv['id']);
}
$paymentRepo->updateBookingPaymentStatus($bookingId);
$respondWithBooking();
}
if ($page === 'bookings/ajax/travel-period') {
// Reisezeitraum + Anreisezeit aktualisieren — Validierung wie im
// Wizard: from <= to, arrival_time zwischen 08:00 und 16:00.
$from = trim((string)($_POST['travel_from'] ?? ''));
$to = trim((string)($_POST['travel_to'] ?? ''));
$arr = trim((string)($_POST['arrival_time']?? ''));
if ($from === '' || $to === '') {
$jsonOut(422, ['ok' => false, 'message' => 'Reisezeitraum unvollständig.']);
}
if (strcmp($from, $to) > 0) {
$jsonOut(422, ['ok' => false, 'message' => 'Reisezeitraum von darf nicht nach Reisezeitraum bis liegen.']);
}
$arrFull = null;
if ($arr !== '') {
if (!preg_match('/^\d{2}:\d{2}$/', $arr)) {
$jsonOut(422, ['ok' => false, 'message' => 'Anreisezeit muss im Format HH:MM angegeben werden.']);
}
[$h, $m] = array_map('intval', explode(':', $arr));
if ($h < 8 || $h > 16 || ($h === 16 && $m > 0)) {
$jsonOut(422, ['ok' => false, 'message' => 'Anreisezeit muss zwischen 08:00 und 16:00 Uhr liegen.']);
}
$arrFull = substr($from, 0, 10) . ' ' . $arr . ':00';
}
$update = [
'travel_from' => $from,
'travel_to' => $to,
'arrival_time' => $arrFull,
];
$existing = $bookingRepo->find($bookingId);
$bookingRepo->updateFields($bookingId, $update);
// Staffelpreise: aktive Positionen mit Pricing-Mode 'tier'
// werden mit dem neuen Reisezeitraum neu durchgerechnet.
$days = Product::daysBetween($from, $to);
$year = Product::yearFromDate($from);
$recalculated = $bookingRepo->recalculateTierPrices($bookingId, $days, $year);
AuditLog::write('booking', $bookingId, 'travel_period_updated',
'Reisezeitraum geändert',
array_intersect_key($existing, ['travel_from'=>1,'travel_to'=>1,'arrival_time'=>1]),
$update + ['recalculated_items' => $recalculated]);
$respondWithBooking();
}
if ($page === 'bookings/ajax/guest-count') {
$count = (int)($_POST['guest_count'] ?? 0);
if ($count < 1) $jsonOut(422, ['ok' => false, 'message' => 'Anzahl Gäste muss mindestens 1 sein.']);
$existing = $bookingRepo->find($bookingId);
$bookingRepo->updateFields($bookingId, ['guest_count' => $count]);
AuditLog::write('booking', $bookingId, 'guest_count_updated',
'Anzahl Gäste geändert',
['guest_count' => $existing['guest_count'] ?? null],
['guest_count' => $count]);
$respondWithBooking();
}
if ($page === 'bookings/ajax/cancel') {
$reasonKey = trim((string)($_POST['reason'] ?? ''));
$reasonText = trim((string)($_POST['reason_text'] ?? ''));
$alsoInv = !empty($_POST['also_invoice']);
$existing = $bookingRepo->find($bookingId);
if (($existing['status'] ?? '') === 'cancelled') {
$jsonOut(409, ['ok' => false, 'message' => 'Buchung ist bereits storniert.']);
}
// Lesbares Reason-Label zusammenstellen — Audit + UI nutzen denselben Text.
$reasonLabels = [
'customer' => 'Kunde abgesagt',
'duplicate' => 'Doppelbuchung',
'wrong_event'=> 'Falsches Event',
'aida' => 'AIDA-Storno',
'no_show' => 'No-Show',
'other' => 'Sonstige',
];
$reasonLabel = $reasonLabels[$reasonKey] ?? 'Sonstige';
if ($reasonKey === 'other' && $reasonText !== '') $reasonLabel = $reasonText;
// Snapshot der Daten, die wir beim Uncancel wieder brauchen.
$snapshot = [
'reason_key' => $reasonKey,
'reason_text' => $reasonText,
'reason' => $reasonLabel,
'also_invoice'=> $alsoInv ? 1 : 0,
'prev_invoice_status' => null,
'recycled_invoice_no' => null,
'deleted_payments' => [],
'prev_checkin' => null,
'prev_checkin_status' => (string)($existing['checkin_status'] ?? 'pending'),
];
// Check-in komplett zurücksetzen — Stornierte Buchungen dürfen
// keinen aktiven Check-in haben. Daten werden für Uncancel
// im Snapshot gesichert.
$existingCheckin = $checkinRepo->findByBooking($bookingId);
if ($existingCheckin) {
$snapshot['prev_checkin'] = [
'checked_in_at' => (string)($existingCheckin['checked_in_at'] ?? ''),
'checked_out_at' => (string)($existingCheckin['checked_out_at'] ?? ''),
'checked_in_by' => (string)($existingCheckin['checked_in_by'] ?? ''),
'checked_out_by' => (string)($existingCheckin['checked_out_by'] ?? ''),
'status' => (string)($existingCheckin['status'] ?? 'pending'),
];
try {
$db->getConnection()->prepare("DELETE FROM checkins WHERE booking_id = :id")
->execute(['id' => $bookingId]);
} catch (Throwable) {}
}
// booking.checkin_status zurücksetzen.
$bookingRepo->updateFields($bookingId, ['checkin_status' => 'pending']);
$linkedInvoice = $invoiceRepo->findByBooking($bookingId);
if ($alsoInv && $linkedInvoice) {
$snapshot['prev_invoice_status'] = (string)$linkedInvoice['status'];
$invoiceRepo->updateStatus((int)$linkedInvoice['id'], 'cancelled');
// Rechnungsnummer freigeben (wird in recycled_invoice_numbers gemerkt).
$invoiceRepo->recycleNumber((string)$linkedInvoice['invoice_no'], Auth::user()['email'] ?? null);
$snapshot['recycled_invoice_no'] = (string)$linkedInvoice['invoice_no'];
// Zahlungen snapshotten + löschen.
foreach ($paymentRepo->byBooking($bookingId) as $p) {
$snapshot['deleted_payments'][] = [
'amount' => (float)$p['amount'],
'payment_method' => (string)$p['payment_method'],
'paid_at' => (string)$p['paid_at'],
'note' => (string)($p['note'] ?? ''),
'invoice_id' => isset($p['invoice_id']) ? (int)$p['invoice_id'] : null,
];
$paymentRepo->delete((int)$p['id']);
}
$paymentRepo->updateBookingPaymentStatus($bookingId);
}
$bookingRepo->updateStatus($bookingId, 'cancelled');
AuditLog::write('booking', $bookingId, 'cancelled',
'Buchung storniert — Grund: ' . $reasonLabel
. ($alsoInv && $linkedInvoice ? ' · Rechnung + Zahlungen storniert' : ''),
['status' => $existing['status'] ?? null],
array_merge(['status' => 'cancelled'], $snapshot));
$respondWithBooking();
}
if ($page === 'bookings/ajax/uncancel') {
$existing = $bookingRepo->find($bookingId);
if (($existing['status'] ?? '') !== 'cancelled') {
$jsonOut(409, ['ok' => false, 'message' => 'Buchung ist nicht storniert.']);
}
// Letzten cancelled-Audit-Eintrag für diese Buchung lesen.
$cancelLog = null;
foreach (AuditLog::byEntity('booking', $bookingId, 20) as $h) {
if (($h['action'] ?? '') === 'cancelled') { $cancelLog = $h; break; }
}
$snapshot = [];
if ($cancelLog && !empty($cancelLog['new_values'])) {
$decoded = is_array($cancelLog['new_values'])
? $cancelLog['new_values']
: json_decode((string)$cancelLog['new_values'], true);
if (is_array($decoded)) $snapshot = $decoded;
}
// Rechnung wiederherstellen (Status zurück + Recycling-Eintrag entfernen).
if (!empty($snapshot['also_invoice'])) {
$linkedInvoice = $invoiceRepo->findByBooking($bookingId);
if ($linkedInvoice && ($linkedInvoice['status'] ?? '') === 'cancelled') {
$invoiceRepo->updateStatus(
(int)$linkedInvoice['id'],
(string)($snapshot['prev_invoice_status'] ?? 'issued')
);
}
// Recycling-Eintrag löschen, damit die Nummer nicht doppelt vergeben wird.
if (!empty($snapshot['recycled_invoice_no'])) {
try {
$stmt = $db->getConnection()->prepare(
"DELETE FROM recycled_invoice_numbers WHERE invoice_no = :no AND used_at IS NULL");
$stmt->execute(['no' => (string)$snapshot['recycled_invoice_no']]);
} catch (Throwable) {}
}
// Zahlungen aus Snapshot wiederherstellen.
foreach (($snapshot['deleted_payments'] ?? []) as $p) {
$paymentRepo->create([
'booking_id' => $bookingId,
'invoice_id' => $p['invoice_id'] ?? null,
'amount' => (float)($p['amount'] ?? 0),
'payment_method' => (string)($p['payment_method'] ?? 'other'),
'paid_at' => (string)($p['paid_at'] ?? date('Y-m-d H:i:s')),
'note' => (string)($p['note'] ?? ''),
]);
}
$paymentRepo->updateBookingPaymentStatus($bookingId);
}
// Check-in aus dem Snapshot wiederherstellen.
$restoredCheckin = false;
if (!empty($snapshot['prev_checkin']) && is_array($snapshot['prev_checkin'])) {
$pc = $snapshot['prev_checkin'];
try {
$stmt = $db->getConnection()->prepare("INSERT INTO checkins
(booking_id, checked_in_at, checked_out_at, checked_in_by, checked_out_by, status)
VALUES (:bid, :cin, :cout, :cinby, :coutby, :st)");
$stmt->execute([
'bid' => $bookingId,
'cin' => ($pc['checked_in_at'] ?? '') !== '' ? $pc['checked_in_at'] : null,
'cout' => ($pc['checked_out_at'] ?? '') !== '' ? $pc['checked_out_at'] : null,
'cinby' => ($pc['checked_in_by'] ?? '') !== '' ? $pc['checked_in_by'] : null,
'coutby'=> ($pc['checked_out_by'] ?? '') !== '' ? $pc['checked_out_by'] : null,
'st' => (string)($pc['status'] ?? 'pending'),
]);
$restoredCheckin = true;
} catch (Throwable) {}
}
// booking.checkin_status wieder auf den vorigen Wert.
$bookingRepo->updateFields($bookingId, [
'checkin_status' => (string)($snapshot['prev_checkin_status'] ?? 'pending'),
]);
$bookingRepo->updateStatus($bookingId, 'confirmed');
AuditLog::write('booking', $bookingId, 'uncancelled',
'Storno rückgängig gemacht'
. (!empty($snapshot['also_invoice']) ? ' — Rechnung + Zahlungen wiederhergestellt' : '')
. ($restoredCheckin ? ' — Check-in wiederhergestellt' : ''),
['status' => 'cancelled'],
['status' => 'confirmed', 'restored_payments' => count($snapshot['deleted_payments'] ?? []), 'restored_checkin' => $restoredCheckin]);
$respondWithBooking();
}
} catch (Throwable $e) {
$jsonOut(500, ['ok' => false, 'message' => $e->getMessage()]);
}
break;
case 'checkin/store':
case 'checkout/store':
if (!isPost()) {
redirect('/?page=arrivals');
}
$bookingId = (int)($_POST['booking_id'] ?? 0);
$redirectTo = (string)($_POST['redirect_to'] ?? '/?page=arrivals');
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$action = $page === 'checkin/store' ? 'checkin' : 'checkout';
// JSON-Response-Helper für AJAX-Aufrufe — verhindert das volle Page-Reload und damit
// den Scroll-Sprung in der Anreiseliste.
$jsonRespond = static function (int $status, array $payload): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
requireCsrfOrAbort($redirectTo);
$errorLabel = $action === 'checkin' ? 'Check-in' : 'Check-out';
if (isBookingCancelled($bookingRepo, $bookingId)) {
$msg = $errorLabel . ' nicht möglich: Buchung ist storniert.';
if ($isAjax) $jsonRespond(409, ['ok' => false, 'message' => $msg]);
setFlash('danger', $msg);
redirect($redirectTo);
}
try {
$result = $action === 'checkin'
? $checkinRepo->checkin($bookingId, Auth::user()['email'] ?? 'admin')
: $checkinRepo->checkout($bookingId, Auth::user()['email'] ?? 'admin');
$ok = $result['status'] === Checkin::OK;
$type = $ok ? 'success' : 'danger';
if ($isAjax) {
$b = $bookingRepo->find($bookingId);
$jsonRespond($ok ? 200 : 422, [
'ok' => $ok,
'message' => $result['message'],
'action' => $action,
'booking_id' => $bookingId,
'checkin_status' => $b['checkin_status'] ?? null,
'time' => date('H:i'),
]);
}
setFlash($type, $result['message']);
} catch (Throwable $e) {
$msg = $errorLabel . ' fehlgeschlagen: ' . $e->getMessage();
if ($isAjax) $jsonRespond(500, ['ok' => false, 'message' => $msg]);
setFlash('danger', $msg);
}
redirect($redirectTo);
break;
// ----------------------------------------------------------------------
// files/preview — Inline-Vorschau für Dateien aus der `files`-Tabelle.
// PDF/HTML/Text/CSV werden direkt mit korrektem Content-Type gestreamt;
// XLSX wird serverseitig zu einer HTML-Tabelle gerendert (erste Sheet,
// max. 200 Zeilen). Browser kann das im <iframe> nativ darstellen.
// ----------------------------------------------------------------------
case 'files/preview':
$fid = (int)($_GET['id'] ?? 0);
if ($fid <= 0) { http_response_code(400); echo 'Datei-ID fehlt.'; exit; }
$stmt = $db->getConnection()->prepare("SELECT * FROM files WHERE id = :id");
$stmt->execute(['id' => $fid]);
$f = $stmt->fetch();
if (!$f) { http_response_code(404); echo 'Datei nicht gefunden.'; exit; }
$abs = __DIR__ . '/..' . $f['file_path'];
if (!is_file($abs)) { http_response_code(404); echo 'Datei fehlt im Storage.'; exit; }
$ext = strtolower(pathinfo((string)$f['file_name'], PATHINFO_EXTENSION));
// Download-Shortcut: ?download=1 streamt die Original-Datei als attachment
// und überspringt die XLSX/CSV-HTML-Preview-Pfade weiter unten.
if (!empty($_GET['download'])) {
$rawMime = function_exists('mime_content_type') ? (mime_content_type($abs) ?: 'application/octet-stream') : 'application/octet-stream';
header('Content-Type: ' . $rawMime);
header('Content-Disposition: attachment; filename="' . rawurlencode((string)$f['file_name']) . '"');
header('Content-Length: ' . filesize($abs));
header('Cache-Control: no-store');
readfile($abs);
exit;
}
// XLSX → HTML-Tabelle. Identische Render-Logik wie ?page=reports/files/view —
// nutzt XlsxGrid::read() inkl. Date-Format-Erkennung über styles.xml und
// XlsxGrid::normalize() zur Header-Row-Erkennung.
if ($ext === 'xlsx') {
$grid = XlsxGrid::read($abs);
$tableGrid = $grid ? XlsxGrid::normalize($grid) : null;
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><title>'
. safeText($f['file_name']) . '</title>'
. '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">'
. '<style>body{font-family:system-ui,-apple-system,sans-serif;padding:1rem;font-size:.85rem;background:#fff}'
. 'table{border-collapse:collapse;}'
. '.table.table-sm td,.table.table-sm th{padding:.3rem .55rem;font-size:.82rem;vertical-align:top}'
. '.table thead th{background:#f9fafb;color:#475569;font-weight:600;position:sticky;top:0;border-bottom:2px solid #e5e7eb;white-space:nowrap}'
. '.table tbody tr:hover{background:#f8fafc}'
. '.muted{color:#94a3b8;font-style:italic}</style></head><body>'
. '<div class="d-flex justify-content-between align-items-center mb-2">'
. '<strong>' . safeText($f['file_name']) . '</strong>'
. '<span class="text-muted small">'
. (is_array($tableGrid) ? count($tableGrid['rows']) . ' Zeilen · ' : '')
. number_format((int)$f['file_size'] / 1024, 0, ',', '.') . ' KB</span>'
. '</div>';
if (!is_array($tableGrid) || empty($tableGrid['rows'])) {
echo '<p class="muted">Datei konnte nicht gelesen werden oder ist leer.</p>';
} else {
echo '<div style="max-width:100%; overflow-x:auto;">';
echo '<table class="table table-sm table-striped align-middle mb-0" style="width:auto; white-space:nowrap;">';
echo '<thead><tr><th class="text-muted small">#</th>';
foreach ($tableGrid['cols'] as $col) {
$label = $tableGrid['headers'][$col] ?? $col;
echo '<th class="small">' . safeText((string)$label) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($tableGrid['rows'] as $i => $row) {
echo '<tr><td class="text-muted small">' . ($i + 1) . '</td>';
foreach ($tableGrid['cols'] as $col) {
echo '<td class="small">' . safeText((string)($row[$col] ?? '')) . '</td>';
}
echo '</tr>';
}
echo '</tbody></table></div>';
echo '<div class="text-muted small mt-2">' . count($tableGrid['rows']) . ' Zeilen</div>';
}
echo '</body></html>';
exit;
}
// CSV → einfacher HTML-Table-Rendering.
if ($ext === 'csv') {
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><title>'
. safeText($f['file_name']) . '</title>'
. '<style>body{font-family:system-ui,sans-serif;padding:1rem;font-size:.85rem;background:#fff}'
. 'table{border-collapse:collapse;width:100%}td,th{border:1px solid #e5e7eb;padding:.35rem .55rem}</style>'
. '</head><body><table>';
$fh = @fopen($abs, 'r');
if ($fh) {
$i = 0;
while (($row = fgetcsv($fh, 0, ';')) !== false && $i < 500) {
echo $i === 0 ? '<thead><tr>' : '<tr>';
foreach ($row as $cell) {
echo $i === 0 ? '<th>' . safeText((string)$cell) . '</th>' : '<td>' . safeText((string)$cell) . '</td>';
}
echo $i === 0 ? '</tr></thead><tbody>' : '</tr>';
$i++;
}
echo '</tbody>';
fclose($fh);
}
echo '</table></body></html>';
exit;
}
// Standardfall: PDF/HTML/Bilder/Text → direkt streamen.
$mime = match ($ext) {
'pdf' => 'application/pdf',
'html', 'htm'=> 'text/html; charset=utf-8',
'txt', 'log' => 'text/plain; charset=utf-8',
'jpg', 'jpeg'=> 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
default => 'application/octet-stream',
};
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . rawurlencode((string)$f['file_name']) . '"');
header('Cache-Control: no-store');
readfile($abs);
exit;
case 'arrivals/export-excel':
$date = $_GET['date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$productIdRaw = $_GET['product_id'] ?? '';
if (is_array($productIdRaw)) {
$productIds = array_values(array_filter(array_map('intval', $productIdRaw), fn($v) => $v > 0));
} else {
$productIds = $productIdRaw !== '' ? [(int)$productIdRaw] : [];
}
// f[] aus der URL parsen wie auf der Anreiseliste — damit der Export 1:1 die
// Filter-Bar widerspiegelt (Offen/Angereist/Ohne VALET/Early Check-in, Source-Filter).
$rawFilter = $_GET['f'] ?? [];
if (!is_array($rawFilter)) $rawFilter = [$rawFilter];
$exSelectedEventIds = [];
$exSelectedSources = [];
$exWantOpen = false;
$exWantArrived = false;
$exWantEarly = false;
$exExcludeValet = false;
foreach ($rawFilter as $f) {
if (!is_string($f) || $f === '') continue;
if (str_starts_with($f, 'event:')) {
$eid = (int)substr($f, 6);
if ($eid > 0) $exSelectedEventIds[] = $eid;
} elseif ($f === 'status:open') { $exWantOpen = true; }
elseif ($f === 'status:arrived') { $exWantArrived = true; }
elseif ($f === 'status:no_valet') { $exExcludeValet = true; }
elseif ($f === 'status:early') { $exWantEarly = true; }
elseif (str_starts_with($f, 'source:')) {
$src = substr($f, 7);
if ($src !== '') $exSelectedSources[] = $src;
}
}
$exCheckinStatuses = [];
if ($exWantOpen) $exCheckinStatuses[] = 'pending';
if ($exWantArrived) $exCheckinStatuses[] = 'checked_in';
$earlyCheckinUntil = (string)$settingsRepo->get('early_checkin_until', '10:30');
$filters = [
'date' => $date,
'event_id' => !empty($exSelectedEventIds) ? $exSelectedEventIds : (string)($_GET['event_id'] ?? ''),
'product_id' => $productIds,
'payment_status' => (string)($_GET['payment_status'] ?? ''),
'checkin_status' => !empty($exCheckinStatuses) ? $exCheckinStatuses : (string)($_GET['checkin_status'] ?? ''),
'exclude_valet' => $exExcludeValet,
'early_until' => $exWantEarly ? $earlyCheckinUntil : null,
'customer_sources' => $exSelectedSources,
'license_plate' => trim((string)($_GET['license_plate'] ?? $_GET['q'] ?? '')),
'customer' => trim((string)($_GET['customer'] ?? '')),
'hide_sources' => hiddenSourcesFor($settingsRepo, 'excel'),
];
$sort = $_GET['sort'] ?? 'kennzeichen';
if (!in_array($sort, ['kennzeichen', 'time', 'product'], true)) {
$sort = 'kennzeichen';
}
$rows = $bookingRepo->arrivals($filters, $sort);
$headers = [
'Kennzeichen', 'Nachname, Vorname', 'Stellplatz',
'Betrag', 'Zahlung',
'Schiff', 'Anreisezeit', 'Dauer', 'Platz',
'Rechnungs-Nr.', 'Buchungs-Nr.',
];
// Hilfs-Closures für Klassifizierung
$hasValet = static function (string $summary): bool {
return stripos($summary, 'valet') !== false;
};
$hasHalle = static function (string $summary): bool {
$s = mb_strtolower($summary);
return str_contains($s, 'halle') || str_contains($s, 'premium')
|| str_contains($s, 'indoor') || str_contains($s, 'innen')
|| str_contains($s, 'überdacht') || str_contains($s, 'ueberdacht');
};
$hasAussen = static function (string $summary): bool {
$s = mb_strtolower($summary);
return str_contains($s, 'außen') || str_contains($s, 'aussen')
|| str_contains($s, 'standard') || str_contains($s, 'outdoor');
};
// Nur exakt "Wohnmobil" matchen — 'rv' würde sonst u.a. "Service" treffen.
$hasWohnmobil = static function (string $summary): bool {
foreach (explode(',', $summary) as $part) {
if (mb_stripos(trim($part), 'wohnmobil') !== false) return true;
}
return false;
};
$stellplatzLabel = static function (string $summary): string {
$s = mb_strtolower($summary);
$v = str_contains($s, 'valet');
$h = str_contains($s, 'halle') || str_contains($s, 'premium') || str_contains($s, 'innen');
$a = str_contains($s, 'außen') || str_contains($s, 'aussen') || str_contains($s, 'standard') || str_contains($s, 'outdoor');
if ($v && $h) return 'VALET – Halle';
if ($v && $a) return 'VALET – Außen';
if ($v) return 'VALET';
if ($h) return 'Halle';
if ($a) return 'Außen';
return '';
};
$daysBetween = static function (?string $from, ?string $to): int {
if (!$from || !$to) return 0;
$f = strtotime($from); $t = strtotime($to);
if (!$f || !$t || $t < $f) return 0;
return (int)floor(($t - $f) / 86400);
};
// Zahlung: bei offener Buchung "- OFFEN -", sonst Zahlungsmethoden.
$zahlungLabel = static function (array $r): string {
if (($r['payment_status'] ?? '') !== 'paid') {
return '- OFFEN -';
}
$methods = trim((string)($r['payment_methods'] ?? ''));
if ($methods === '') return statusLabel($r['payment_status'] ?? '');
$labels = [];
foreach (explode(',', $methods) as $m) {
$m = trim($m);
if ($m !== '') $labels[] = Payment::methodLabel($m);
}
return implode(', ', $labels) ?: statusLabel($r['payment_status'] ?? '');
};
// Platz: VALET → fester Übergabeort "Warnemünde Cruise Center"; sonst Firmenadresse.
$companyStreet = $settingsRepo->get('company_street', 'An der Werft 3') ?: 'An der Werft 3';
$platzLabel = static function (array $r) use ($hasValet, $companyStreet): string {
$summary = (string)($r['products_summary'] ?? '');
if ($hasValet($summary)) {
return 'Warnemünde Cruise Center';
}
return $companyStreet;
};
// Zeile im neuen Spaltenformat — Betrag als Zahl, Kennzeichen formatiert.
$toRow = static function (array $r) use ($stellplatzLabel, $daysBetween, $zahlungLabel, $platzLabel): array {
$days = $daysBetween($r['travel_from'] ?? null, $r['travel_to'] ?? null);
$arrivalTime = '';
if (!empty($r['arrival_time'])) {
$ts = strtotime($r['arrival_time']);
if ($ts) $arrivalTime = date('H:i', $ts);
}
return [
formatLicensePlateDE($r['license_plate'] ?? ''),
trim(($r['c_last_name'] ?? '') . ', ' . ($r['c_first_name'] ?? ''), ', '),
$stellplatzLabel((string)($r['products_summary'] ?? '')),
(float)($r['total_gross'] ?? 0), // Betrag als Zahl → Currency-Format in Excel
$zahlungLabel($r),
$r['event_title'] ?? ($r['event_ship'] ?? ''),
$arrivalTime,
$days > 0 ? $days : '',
$platzLabel($r),
$r['invoice_no'] ?? '',
$r['booking_no'] ?? '',
];
};
// Klassifizierung: jede Buchung in alle passenden Gruppen einsortieren
$groups = [
'Alle' => [],
'Eigene' => [],
'AIDA' => [],
'VALET' => [],
'Halle' => [],
'Außen' => [],
'Wohnmobil' => [],
'Early Check-In' => [],
'Stornos' => [],
];
foreach ($rows as $r) {
$summary = (string)($r['products_summary'] ?? '');
$isCancelled = ($r['status'] ?? '') === 'cancelled';
$row = $toRow($r);
if ($isCancelled) {
$groups['Stornos'][] = $row;
continue; // Stornos NICHT in den anderen Tabs
}
$groups['Alle'][] = $row;
$source = trim((string)($r['c_source'] ?? ''));
if (strcasecmp($source, 'AIDA') === 0) {
$groups['AIDA'][] = $row;
} else {
$groups['Eigene'][] = $row;
}
$valet = $hasValet($summary);
$halle = $hasHalle($summary);
$aussen = $hasAussen($summary);
if ($valet) $groups['VALET'][] = $row;
if ($halle && !$valet) $groups['Halle'][] = $row;
if ($aussen && !$valet) $groups['Außen'][] = $row;
if ($hasWohnmobil($summary)) $groups['Wohnmobil'][] = $row;
// Early Check-In: Anreisezeit ≤ konfigurierte Schwelle.
if (!empty($r['arrival_time'])) {
$ts = strtotime($r['arrival_time']);
if ($ts) {
$arrivalMin = (int)date('H', $ts) * 60 + (int)date('i', $ts);
[$thH, $thM] = array_pad(explode(':', $earlyCheckinUntil), 2, '0');
$thresholdMin = (int)$thH * 60 + (int)$thM;
if ($arrivalMin <= $thresholdMin) {
$groups['Early Check-In'][] = $row;
}
}
}
}
// Spaltenbreiten pro Index (0-basiert) — schmale Spalten halbiert, Dauer ein Viertel.
// Reihenfolge: Kennzeichen, Nachname Vorname, Stellplatz, Betrag, Zahlung, Schiff, Anreisezeit, Dauer, Platz, Rechnungs-Nr., Buchungs-Nr.
$colWidths = [14, 24, 11, 11, 11, 18, 11, 6, 22, 14, 14];
$currencyCols = [3]; // Betrag
// Leere Gruppen weglassen — außer "Alle", das immer erscheinen soll.
$sheets = [];
foreach ($groups as $title => $gRows) {
if (empty($gRows) && $title !== 'Alle') continue;
$sheets[] = [
'title' => $title,
'headers' => $headers,
'rows' => $gRows,
'columnWidths' => $colWidths,
'currencyColumns' => $currencyCols,
];
}
$filename = Exporter::suggestFilename('arrivals-' . $date);
Exporter::streamMulti($filename, $sheets);
exit;
case 'departures/license-plates-pdf':
case 'arrivals/license-plates-pdf':
// Schlüsselschilder-PDF — für Anreise (Ausgabe an Kunden) UND Abreise (Rückgabe-
// Übersicht). Beide Pfade nutzen dieselbe PDF-Logik, der Modus bestimmt nur, ob
// die Liste aus arrivals() oder departures() kommt.
$lpMode = ($page === 'departures/license-plates-pdf') ? 'departures' : 'arrivals';
$date = $_GET['date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$productIdRaw = $_GET['product_id'] ?? '';
if (is_array($productIdRaw)) {
$productIds = array_values(array_filter(array_map('intval', $productIdRaw), fn($v) => $v > 0));
} else {
$productIds = $productIdRaw !== '' ? [(int)$productIdRaw] : [];
}
$filters = [
'date' => $date,
'event_id' => (string)($_GET['event_id'] ?? ''),
'product_id' => $productIds,
'payment_status' => (string)($_GET['payment_status'] ?? ''),
'checkin_status' => (string)($_GET['checkin_status'] ?? ''),
];
$arrivals = $lpMode === 'departures'
? $bookingRepo->departures($filters, 'kennzeichen')
: $bookingRepo->arrivals($filters, 'kennzeichen');
// Optional: Gruppierung nach Produkt-Kategorie (Halle / Außen / VALET / Alle).
$group = strtolower((string)($_GET['group'] ?? 'all'));
if (!in_array($group, ['all', 'halle', 'aussen', 'valet'], true)) {
$group = 'all';
}
$groupOf = static function (?string $summary): string {
$s = mb_strtolower((string)$summary);
$hasValet = str_contains($s, 'valet');
$hasHalle = str_contains($s, 'premium') || str_contains($s, 'halle')
|| str_contains($s, 'indoor') || str_contains($s, 'innen')
|| str_contains($s, 'überdacht') || str_contains($s, 'ueberdacht');
$hasAussen = str_contains($s, 'standard') || str_contains($s, 'außen')
|| str_contains($s, 'aussen') || str_contains($s, 'outdoor')
|| (str_contains($s, 'parkplatz') && !$hasHalle)
|| (str_contains($s, 'parking') && !$hasHalle);
if ($hasValet) return 'valet';
if ($hasHalle) return 'halle';
if ($hasAussen) return 'aussen';
return 'other';
};
// Bei einzelnen Gruppen filtern, sonst nach Sektionen splitten (Halle / Außen / VALET).
$sections = null;
if ($group !== 'all') {
$arrivals = array_values(array_filter($arrivals, function ($a) use ($group, $groupOf) {
return $groupOf($a['products_summary'] ?? '') === $group;
}));
} else {
$sections = ['halle' => [], 'aussen' => [], 'valet' => []];
foreach ($arrivals as $a) {
$g = $groupOf($a['products_summary'] ?? '');
if (isset($sections[$g])) {
$sections[$g][] = $a;
}
}
}
// Schlüsselschild-Maße aus Settings (Fallback 70 × 30 mm).
$keytagW = max(20, min(210, (int)$settingsRepo->get('keytag_width_mm', '70')));
$keytagH = max(15, min(297, (int)$settingsRepo->get('keytag_height_mm', '30')));
// Aktives Event (für Dummy-Schilder im Restplatz auf A4) ermitteln, falls einheitlich.
$eventCtx = null;
$eventId = (int)($_GET['event_id'] ?? 0);
if (!$eventId && !empty($arrivals)) {
$ids = array_unique(array_map(fn($a) => (int)$a['event_id'], $arrivals));
if (count($ids) === 1) {
$eventId = (int)reset($ids);
}
}
if ($eventId > 0) {
$eventCtx = $eventRepo->find($eventId);
}
$baseName = 'license-plates-' . $date . ($group !== 'all' ? '-' . $group : '');
try {
$path = PdfRenderer::save(
__DIR__ . '/../app/views/pdf/license-plates.php',
[
'arrivals' => $arrivals,
'date' => $date,
'group' => $group,
'keytagW' => $keytagW,
'keytagH' => $keytagH,
'event' => $eventCtx,
'sections' => $sections,
],
'storage/license-plates',
$baseName
);
$abs = PdfRenderer::absolutePath($path);
$isPdf = str_ends_with($abs, '.pdf');
header('Content-Type: ' . ($isPdf ? 'application/pdf' : 'text/html; charset=utf-8'));
if (isset($_GET['download'])) {
header('Content-Disposition: attachment; filename="' . basename($abs) . '"');
} else {
header('Content-Disposition: inline; filename="' . basename($abs) . '"');
}
header('Content-Length: ' . filesize($abs));
readfile($abs);
exit;
} catch (Throwable $e) {
setFlash('danger', 'Kennzeichen-PDF konnte nicht erzeugt werden: ' . $e->getMessage());
redirect('/?page=' . $lpMode . '&date=' . $date);
}
break;
// --- DAILY CLOSING ------------------------------------------------
case 'daily-closing/ajax/save':
case 'daily-closing/ajax/finalize':
case 'daily-closing/ajax/cancel':
case 'daily-closing/ajax/delete':
case 'daily-closing/ajax/delete-customers':
header('Content-Type: application/json; charset=utf-8');
// CSRF + Method
if (!isPost()) {
http_response_code(405);
echo json_encode(['ok' => false, 'message' => 'POST erforderlich.']);
exit;
}
if (empty($_POST['csrf_token']) && !empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$_POST['csrf_token'] = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
}
if (!csrfCheck()) {
http_response_code(403);
echo json_encode(['ok' => false, 'message' => 'CSRF-Token ungültig.']);
exit;
}
// Edit-Window: das gleiche Fenster wie auf der UI-Seite. Server entscheidet
// verbindlich — Frontend-Hide ist nur kosmetisch.
$dcDateParam = (string)($_POST['date'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dcDateParam)) {
echo json_encode(['ok' => false, 'message' => 'Ungültiges Datum.']);
exit;
}
$today = date('Y-m-d');
$isEditable = ($dcDateParam >= $today)
&& !($dcDateParam === $today && (int)date('H') >= 22);
if (!$isEditable) {
echo json_encode(['ok' => false, 'message' => 'Bearbeitung nicht mehr möglich — Tag abgeschlossen.']);
exit;
}
$pdoDC = $db->getConnection();
$actorEmail = (string)((Auth::user() ?? [])['email'] ?? 'system');
// ====== /delete-customers : orphan-Kunden nach Delete bereinigen ====
if ($page === 'daily-closing/ajax/delete-customers') {
$custIds = $_POST['customer_ids'] ?? [];
if (!is_array($custIds)) $custIds = [$custIds];
$custIds = array_values(array_filter(array_map('intval', $custIds), static fn ($v) => $v > 0));
if (empty($custIds)) {
echo json_encode(['ok' => false, 'message' => 'Keine Kunden-IDs.']);
exit;
}
$pdoDC->beginTransaction();
try {
$deletedCustomers = [];
foreach ($custIds as $cid) {
// Re-Check: nur löschen, wenn weiterhin keine Buchungen/Rechnungen vorhanden
$b = (int)$pdoDC->query("SELECT COUNT(*) FROM bookings WHERE customer_id = " . $cid)->fetchColumn();
$i = (int)$pdoDC->query("SELECT COUNT(*) FROM invoices WHERE customer_id = " . $cid)->fetchColumn();
if ($b === 0 && $i === 0) {
$pdoDC->prepare("DELETE FROM customers WHERE id = :id")->execute(['id' => $cid]);
AuditLog::write('customer', $cid, 'deleted', 'Kunde gelöscht (keine weiteren Buchungen/Rechnungen, Tagesabschluss)', null, null);
$deletedCustomers[] = $cid;
}
}
$pdoDC->commit();
echo json_encode(['ok' => true, 'message' => count($deletedCustomers) . ' Kunde(n) gelöscht.', 'deleted_customers' => $deletedCustomers]);
} catch (Throwable $e) {
if ($pdoDC->inTransaction()) $pdoDC->rollBack();
echo json_encode(['ok' => false, 'message' => 'Fehler: ' . $e->getMessage()]);
}
exit;
}
$invoiceIds = $_POST['invoice_ids'] ?? [];
if (!is_array($invoiceIds)) $invoiceIds = [$invoiceIds];
$invoiceIds = array_values(array_filter(array_map('intval', $invoiceIds), static fn ($v) => $v > 0));
if (empty($invoiceIds)) {
echo json_encode(['ok' => false, 'message' => 'Keine Datensätze ausgewählt.']);
exit;
}
// ====== /save : Rechnungsnummer + Zahlungsart updaten ===============
if ($page === 'daily-closing/ajax/save') {
$editsByInv = $_POST['edits'] ?? [];
if (!is_array($editsByInv)) $editsByInv = [];
$pdoDC->beginTransaction();
try {
$updated = [];
$finalNumbers = [];
// 1) Erst alle leeren Rechnungsnummern in einem Rutsch fortlaufend
// aus dem Invoice-Generator vergeben — verhindert Doppel-Vergaben
// bei Parallel-Edits. Wir nehmen die Reihenfolge der ausgewählten
// invoice_ids (vom UI in stabiler Reihenfolge gesendet).
foreach ($invoiceIds as $iid) {
$newNo = isset($editsByInv[$iid]['invoice_no'])
? trim((string)$editsByInv[$iid]['invoice_no']) : null;
if ($newNo === '') {
// generateInvoiceNumber zieht aus recycled-Pool oder MAX+1 + Lock
$finalNumbers[$iid] = $invoiceRepo->generateInvoiceNumber();
} elseif ($newNo !== null) {
$finalNumbers[$iid] = $newNo;
}
}
// Duplikate innerhalb des Save-Batches abfangen
$seen = [];
foreach ($finalNumbers as $iid => $no) {
if (isset($seen[$no])) {
throw new RuntimeException("Doppelte Rechnungsnummer: $no");
}
$seen[$no] = true;
}
foreach ($invoiceIds as $iid) {
$inv = $invoiceRepo->find($iid);
if (!$inv) continue;
// Storniert/finalisierte Nummern nicht überschreiben
if (($inv['status'] ?? '') === 'cancelled') continue;
$edits = $editsByInv[$iid] ?? [];
$newNo = $finalNumbers[$iid] ?? ($inv['invoice_no'] ?? null);
// Rechnungsnummer ändern (nur wenn sich was ändert)
if ($newNo !== null && $newNo !== ($inv['invoice_no'] ?? null)) {
// Globale Eindeutigkeit prüfen (ausgenommen die aktuelle Zeile)
$st = $pdoDC->prepare(
"SELECT id FROM invoices WHERE invoice_no = :n AND id <> :id LIMIT 1"
);
$st->execute(['n' => $newNo, 'id' => $iid]);
if ($st->fetchColumn()) {
throw new RuntimeException("Rechnungsnummer $newNo bereits vergeben.");
}
// Rechnungsnummer aktualisieren; Rechnungsdatum kommt aus
// bookings.travel_from (touchInvoiceDate).
$st = $pdoDC->prepare("UPDATE invoices SET invoice_no = :n WHERE id = :id");
$st->execute(['n' => $newNo, 'id' => $iid]);
$invoiceRepo->touchInvoiceDate($iid);
AuditLog::write('invoice', $iid, 'updated',
'Rechnungsnummer geändert: ' . ($inv['invoice_no'] ?? '–') . ' → ' . $newNo,
['invoice_no' => $inv['invoice_no'] ?? null, 'invoice_date' => $inv['invoice_date'] ?? null],
['invoice_no' => $newNo]);
}
// Zahlungsart aller Zahlungen dieser Rechnung umstellen
if (!empty($edits['payment_method'])) {
$method = (string)$edits['payment_method'];
$st = $pdoDC->prepare(
"UPDATE payments SET payment_method = :m WHERE invoice_id = :id"
);
$st->execute(['m' => $method, 'id' => $iid]);
// Zahlung wurde (um-)erfasst → Rechnungsdatum auf heute.
$invoiceRepo->touchInvoiceDate($iid);
}
$updated[] = $iid;
}
$pdoDC->commit();
echo json_encode(['ok' => true, 'message' => count($updated) . ' Rechnung(en) gespeichert.', 'updated' => $updated]);
} catch (Throwable $e) {
if ($pdoDC->inTransaction()) $pdoDC->rollBack();
echo json_encode(['ok' => false, 'message' => 'Fehler beim Speichern: ' . $e->getMessage()]);
}
exit;
}
// ====== /finalize : Rechnung festschreiben ==========================
if ($page === 'daily-closing/ajax/finalize') {
$pdoDC->beginTransaction();
try {
$finalized = [];
foreach ($invoiceIds as $iid) {
$inv = $invoiceRepo->find($iid);
if (!$inv) continue;
if (($inv['status'] ?? '') === 'cancelled') continue;
// Wenn Rechnung noch keine vollwertige Nummer hat, jetzt eine vergeben.
// Rechnungsdatum kommt aus bookings.travel_from (touchInvoiceDate).
if (empty($inv['invoice_no'])) {
$newNo = $invoiceRepo->generateInvoiceNumber();
$st = $pdoDC->prepare("UPDATE invoices SET invoice_no = :n WHERE id = :id");
$st->execute(['n' => $newNo, 'id' => $iid]);
$invoiceRepo->touchInvoiceDate($iid);
}
// Status auf 'finalized' setzen — Nummer ist ab jetzt readonly
$st = $pdoDC->prepare(
"UPDATE invoices SET status = 'finalized', finalized_at = NOW() WHERE id = :id"
);
try { $st->execute(['id' => $iid]); }
catch (Throwable) {
// Spalte finalized_at evtl. nicht vorhanden — Status reicht
$st = $pdoDC->prepare("UPDATE invoices SET status = 'finalized' WHERE id = :id");
$st->execute(['id' => $iid]);
}
$invoiceRepo->syncSearchIndex($iid);
AuditLog::write('invoice', $iid, 'finalized',
'Rechnung festgeschrieben durch Tagesabschluss.',
['status' => $inv['status']], ['status' => 'finalized']);
$finalized[] = $iid;
}
$pdoDC->commit();
echo json_encode(['ok' => true, 'message' => count($finalized) . ' Rechnung(en) festgeschrieben.', 'finalized' => $finalized]);
} catch (Throwable $e) {
if ($pdoDC->inTransaction()) $pdoDC->rollBack();
echo json_encode(['ok' => false, 'message' => 'Fehler: ' . $e->getMessage()]);
}
exit;
}
// ====== /cancel : Buchung + Rechnung stornieren =====================
// Nutzt dieselbe Logik wie bookings/ajax/cancel, aber bündelt mehrere
// Rechnungen — pro Rechnung die zugehörige Buchung stornieren.
if ($page === 'daily-closing/ajax/cancel') {
$reason = trim((string)($_POST['reason'] ?? 'daily_closing'));
$reasonText = trim((string)($_POST['reason_text'] ?? 'Storniert über Tagesabschluss'));
$pdoDC->beginTransaction();
try {
$cancelled = [];
foreach ($invoiceIds as $iid) {
$inv = $invoiceRepo->find($iid);
if (!$inv || empty($inv['booking_id'])) continue;
if (($inv['status'] ?? '') === 'cancelled') continue;
$bid = (int)$inv['booking_id'];
// Storno: Buchung + Rechnung + Zahlungen (löschen) + Recycle.
// (Light-Version der Logik in bookings/ajax/cancel — ohne checkin
// snapshot/restore, da im Tagesabschluss meist nicht angereist.)
$stPay = $pdoDC->prepare("SELECT * FROM payments WHERE booking_id = :b");
$stPay->execute(['b' => $bid]);
$payments = $stPay->fetchAll();
foreach ($payments as $p) {
$pdoDC->prepare("DELETE FROM payments WHERE id = :id")->execute(['id' => (int)$p['id']]);
}
if (!empty($inv['invoice_no'])) {
$invoiceRepo->recycleNumber((string)$inv['invoice_no'], $actorEmail);
}
$pdoDC->prepare("UPDATE invoices SET status = 'cancelled' WHERE id = :id")
->execute(['id' => $iid]);
$invoiceRepo->syncSearchIndex($iid);
$pdoDC->prepare("UPDATE bookings SET status = 'cancelled', checkin_status = 'pending' WHERE id = :id")
->execute(['id' => $bid]);
$bookingRepo->syncSearchIndex($bid);
AuditLog::write('booking', $bid, 'cancelled',
'Storno über Tagesabschluss — Grund: ' . $reasonText,
['status' => 'confirmed'],
['status' => 'cancelled', 'reason_key' => $reason, 'reason_text' => $reasonText,
'deleted_payments' => array_map(static fn($p) => [
'amount' => $p['amount'], 'method' => $p['payment_method'],
'paid_at' => $p['paid_at'], 'invoice_id' => $p['invoice_id'],
], $payments)]);
$cancelled[] = $iid;
}
$pdoDC->commit();
echo json_encode(['ok' => true, 'message' => count($cancelled) . ' Rechnung(en) storniert.', 'cancelled' => $cancelled]);
} catch (Throwable $e) {
if ($pdoDC->inTransaction()) $pdoDC->rollBack();
echo json_encode(['ok' => false, 'message' => 'Fehler beim Stornieren: ' . $e->getMessage()]);
}
exit;
}
// ====== /delete : Hart löschen + Customer-Orphan-Check ==============
if ($page === 'daily-closing/ajax/delete') {
$alsoDeleteCustomer = !empty($_POST['delete_customer']);
$pdoDC->beginTransaction();
try {
$deletedIds = [];
$touchedCustomers = [];
foreach ($invoiceIds as $iid) {
$inv = $invoiceRepo->find($iid);
if (!$inv) continue;
$bid = (int)($inv['booking_id'] ?? 0);
$cid = (int)($inv['customer_id'] ?? 0);
if ($cid > 0) $touchedCustomers[$cid] = true;
// Zahlungen
$pdoDC->prepare("DELETE FROM payments WHERE booking_id = :b OR invoice_id = :i")
->execute(['b' => $bid, 'i' => $iid]);
// Rechnungs-Items + Rechnung selbst (CASCADE auf invoice_items)
$invoiceRepo->delete($iid);
// Buchung — bookings CASCADE auf booking_products, checkins, etc.
if ($bid > 0) {
$pdoDC->prepare("DELETE FROM bookings WHERE id = :id")->execute(['id' => $bid]);
}
AuditLog::write('invoice', $iid, 'deleted',
'Rechnung + Buchung gelöscht über Tagesabschluss',
['invoice_no' => $inv['invoice_no'] ?? null, 'booking_id' => $bid], null);
$deletedIds[] = $iid;
}
// Customer-Orphan-Check: ggf. Kunden mitlöschen
$orphanCustomerIds = [];
foreach (array_keys($touchedCustomers) as $cid) {
$b = (int)$pdoDC->query("SELECT COUNT(*) FROM bookings WHERE customer_id = " . (int)$cid)->fetchColumn();
$i = (int)$pdoDC->query("SELECT COUNT(*) FROM invoices WHERE customer_id = " . (int)$cid)->fetchColumn();
if ($b === 0 && $i === 0) {
$orphanCustomerIds[] = (int)$cid;
if ($alsoDeleteCustomer) {
$pdoDC->prepare("DELETE FROM customers WHERE id = :id")->execute(['id' => $cid]);
AuditLog::write('customer', $cid, 'deleted', 'Kunde gelöscht (keine weiteren Buchungen)', null, null);
}
}
}
$pdoDC->commit();
echo json_encode([
'ok' => true,
'message' => count($deletedIds) . ' Rechnung(en) gelöscht.',
'deleted' => $deletedIds,
'orphan_customers' => $alsoDeleteCustomer ? [] : $orphanCustomerIds,
]);
} catch (Throwable $e) {
if ($pdoDC->inTransaction()) $pdoDC->rollBack();
echo json_encode(['ok' => false, 'message' => 'Fehler beim Löschen: ' . $e->getMessage()]);
}
exit;
}
break;
case 'daily-closing':
$pageTitle = 'Tagesabrechnung';
$pageSubtitle = 'Tagessummen, Zahlungsarten und Tagesabschluss.';
$activeNav = 'daily-closing';
// Entwürfe, deren Anreise vorbei oder heute ≥ 22:00 ist, automatisch
// auf 'finalized' setzen — vorgeschlagene Rechnungsnummern werden so
// ohne manuellen Eingriff fest vergeben.
$invoiceRepo->promoteDraftsAfterArrival();
$date = $_GET['date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$eventId = !empty($_GET['event_id']) ? (int)$_GET['event_id'] : null;
$snapshot = $closingRepo->computeSnapshot($date, $eventId);
$existing = $closingRepo->findByDate($date, $eventId);
// AIDA-Buchungen/-Rechnungen werden auf der Tagesabrechnung NIE angezeigt.
// Greift sowohl auf bookings.source als auch customers.source, damit
// weder die Filteroptionen noch die Tabellenzeilen AIDA-Datensätze enthalten.
$dcInvoiceExcludeSources = ['AIDA'];
$invoicesOfDay = $invoiceRepo->byDate($date, $eventId, null, [
'exclude_sources' => $dcInvoiceExcludeSources,
]);
// Bearbeitungsfenster: heute oder Zukunft UND (heute → vor 22:00 Uhr).
// Liegt der Tag in der Vergangenheit oder ist nach 22:00 Uhr heute,
// wird die Rechnungsliste read-only gerendert. Wir leiten den Grund mit,
// damit das Frontend einen sinnvollen Hinweis anzeigen kann.
$dcEdit = ['allowed' => false, 'reason' => ''];
$todayIso = date('Y-m-d');
if (empty($invoicesOfDay)) {
$dcEdit['reason'] = 'Keine Rechnungen vorhanden';
} elseif ($date < $todayIso) {
$dcEdit['reason'] = 'Tag liegt in der Vergangenheit';
} elseif ($date === $todayIso && (int)date('H') >= 22) {
$dcEdit['reason'] = 'Bearbeitungsfrist 22:00 Uhr überschritten';
} else {
$dcEdit['allowed'] = true;
}
// Zahlungsarten + paid-Status pro Rechnung sammeln (für Inline-Edit-UI).
$invoicePaymentInfo = [];
if (!empty($invoicesOfDay)) {
$invIds = array_map(static fn ($i) => (int)$i['id'], $invoicesOfDay);
$ph = implode(',', array_fill(0, count($invIds), '?'));
$stmt = $db->getConnection()->prepare(
"SELECT invoice_id, payment_method, SUM(amount) AS sum_amt
FROM payments
WHERE invoice_id IN ($ph)
GROUP BY invoice_id, payment_method"
);
$stmt->execute($invIds);
foreach ($stmt->fetchAll() as $p) {
$iid = (int)$p['invoice_id'];
if (!isset($invoicePaymentInfo[$iid])) {
$invoicePaymentInfo[$iid] = ['methods' => [], 'paid' => 0.0];
}
$invoicePaymentInfo[$iid]['methods'][(string)$p['payment_method']] = (float)$p['sum_amt'];
$invoicePaymentInfo[$iid]['paid'] += (float)$p['sum_amt'];
}
}
$paymentMethodsActive = $paymentMethodRepo->allActive();
// Wenn unter „Darstellung → Zahlungen" Quellen deaktiviert sind, bekommen die
// einen eigenen Tab mit ihren Rechnungen (ohne Details-Button).
// AIDA wird auch hier hart ausgeschlossen.
$hiddenPaymentSources = array_values(array_diff(
hiddenSourcesFor($settingsRepo, 'payments'),
$dcInvoiceExcludeSources
));
$invoicesByHiddenSource = [];
foreach ($hiddenPaymentSources as $src) {
$invoicesByHiddenSource[$src] = $invoiceRepo->byDate($date, $eventId, $src, [
'exclude_sources' => $dcInvoiceExcludeSources,
]);
}
// Rechnungen-Tab: Multi-Select-Filter (?rf[]=event:<id>, method:<key>, source:<value>).
// AIDA wird hier nie als Filterwert angeboten und niemals als Treffer gezeigt.
$rawRechFilter = $_GET['rf'] ?? [];
if (!is_array($rawRechFilter)) $rawRechFilter = [$rawRechFilter];
$rechSelectedEventIds = [];
$rechSelectedMethods = [];
$rechSelectedSources = [];
foreach ($rawRechFilter as $f) {
if (!is_string($f) || $f === '') continue;
if (str_starts_with($f, 'event:')) {
$eid = (int)substr($f, 6);
if ($eid > 0) $rechSelectedEventIds[] = $eid;
} elseif (str_starts_with($f, 'method:')) {
$m = substr($f, 7);
if ($m !== '') $rechSelectedMethods[] = $m;
} elseif (str_starts_with($f, 'source:')) {
$s = substr($f, 7);
// AIDA niemals zulassen — egal wie der Filter aus der URL kommt.
if ($s !== '' && !in_array($s, $dcInvoiceExcludeSources, true)) {
$rechSelectedSources[] = $s;
}
}
}
$rechSelectedFilterCodes = array_values(array_unique($rawRechFilter));
// Verfügbare Filteroptionen (Chips) aus dem AIDA-bereinigten Tages-Set ableiten.
$rechAvailableEvents = [];
$rechAvailableSources = [];
foreach ($invoicesOfDay as $inv) {
$eid = (int)($inv['event_id'] ?? 0);
if ($eid > 0 && !isset($rechAvailableEvents[$eid])) {
$rechAvailableEvents[$eid] = (string)($inv['event_title'] ?? ('Event #' . $eid));
}
$bsrc = (string)($inv['booking_source'] ?? '');
if (!in_array($bsrc, $dcInvoiceExcludeSources, true) && !isset($rechAvailableSources[$bsrc])) {
$rechAvailableSources[$bsrc] = $bsrc !== '' ? $bsrc : 'Ohne Quelle';
}
}
ksort($rechAvailableEvents);
ksort($rechAvailableSources);
// Methoden, für die an diesem Tag tatsächlich Zahlungen verbucht sind (aus $invoicePaymentInfo).
$rechAvailableMethodsSet = [];
foreach ($invoicePaymentInfo as $info) {
foreach (array_keys($info['methods'] ?? []) as $mk) {
$rechAvailableMethodsSet[(string)$mk] = true;
}
}
$rechAvailableMethods = [];
foreach ($paymentMethodsActive as $pm) {
$k = (string)$pm['method_key'];
if (isset($rechAvailableMethodsSet[$k])) {
$rechAvailableMethods[$k] = (string)$pm['label'];
}
}
// Filter anwenden (PHP-seitig, weil die Chip-Optionen aus dem ungefilterten Tages-Set kommen).
$rechFilteredInvoices = $invoicesOfDay;
if (!empty($rechSelectedEventIds)) {
$set = array_flip($rechSelectedEventIds);
$rechFilteredInvoices = array_values(array_filter(
$rechFilteredInvoices,
fn ($inv) => isset($set[(int)($inv['event_id'] ?? 0)])
));
}
if (!empty($rechSelectedSources)) {
$set = array_flip($rechSelectedSources);
$rechFilteredInvoices = array_values(array_filter(
$rechFilteredInvoices,
fn ($inv) => isset($set[(string)($inv['booking_source'] ?? '')])
|| (in_array('__none__', $rechSelectedSources, true) && (string)($inv['booking_source'] ?? '') === '')
));
}
if (!empty($rechSelectedMethods)) {
$set = array_flip($rechSelectedMethods);
$rechFilteredInvoices = array_values(array_filter(
$rechFilteredInvoices,
function ($inv) use ($invoicePaymentInfo, $set) {
$iid = (int)$inv['id'];
$methods = $invoicePaymentInfo[$iid]['methods'] ?? [];
foreach (array_keys($methods) as $mk) {
if (isset($set[(string)$mk])) return true;
}
return false;
}
));
}
$rechHasActiveFilters = !empty($rechSelectedEventIds) || !empty($rechSelectedMethods) || !empty($rechSelectedSources);
// Anreisen-Tab im selben Stil wie ?page=arrivals — eigene Filter aus den GET-Parametern.
$dcProductIdRaw = $_GET['product_id'] ?? '';
if (is_array($dcProductIdRaw)) {
$dcProductIds = array_values(array_filter(array_map('intval', $dcProductIdRaw), fn($v) => $v > 0));
} else {
$dcProductIds = $dcProductIdRaw !== '' ? [(int)$dcProductIdRaw] : [];
}
// Kombiniertes "f[]" wie auf ?page=arrivals: event:<id>, status:open, status:arrived,
// status:no_valet, status:early, source:AIDA, source:Eigene.
$rawArrFilter = $_GET['f'] ?? [];
if (!is_array($rawArrFilter)) $rawArrFilter = [$rawArrFilter];
$arrSelectedEventIds = [];
$arrSelectedSources = [];
$arrWantOpen = false;
$arrWantArrived = false;
$arrWantEarly = false;
$arrExcludeValet = false;
foreach ($rawArrFilter as $f) {
if (!is_string($f) || $f === '') continue;
if (str_starts_with($f, 'event:')) {
$eid = (int)substr($f, 6);
if ($eid > 0) $arrSelectedEventIds[] = $eid;
} elseif ($f === 'status:open') { $arrWantOpen = true; }
elseif ($f === 'status:arrived') { $arrWantArrived = true; }
elseif ($f === 'status:no_valet') { $arrExcludeValet = true; }
elseif ($f === 'status:early') { $arrWantEarly = true; }
elseif (str_starts_with($f, 'source:')) {
$src = substr($f, 7);
if ($src !== '') $arrSelectedSources[] = $src;
}
}
$arrCheckinStatuses = [];
if ($arrWantOpen) $arrCheckinStatuses[] = 'pending';
if ($arrWantArrived) $arrCheckinStatuses[] = 'checked_in';
$arrEarlyUntil = (string)$settingsRepo->get('early_checkin_until', '10:30');
$arrivalFilters = [
'date' => $date,
'event_id' => $arrSelectedEventIds,
'product_id' => $dcProductIds,
'checkin_status' => $arrCheckinStatuses,
'exclude_valet' => $arrExcludeValet,
'early_until' => $arrWantEarly ? $arrEarlyUntil : null,
'customer_sources' => $arrSelectedSources,
'license_plate' => trim((string)($_GET['license_plate'] ?? '')),
'customer' => trim((string)($_GET['customer'] ?? '')),
'hide_sources' => hiddenSourcesFor($settingsRepo, 'arrivals'),
];
$arrivalSort = $_GET['sort'] ?? 'kennzeichen';
if (!in_array($arrivalSort, ['kennzeichen', 'time', 'product'], true)) {
$arrivalSort = 'kennzeichen';
}
$arrivalsRows = $bookingRepo->arrivals($arrivalFilters, $arrivalSort);
$arrivalEvents = $eventRepo->byArrivalDate($date);
$arrivalProducts = $productRepo->forFilter();
$arrHasAidaToday = $bookingRepo->hasAidaArrivalsOnDate($date);
$arrSelectedFilterCodes = array_values(array_unique($rawArrFilter));
// Event-Dropdown: nur Events, die am gewählten Tag starten.
$events = $eventRepo->byStartDate($date);
// Wenn ein Event-Filter aktiv ist, das aber nicht im Tages-Set liegt: trotzdem zeigen.
if ($eventId) {
$hasSelected = false;
foreach ($events as $e) { if ((int)$e['id'] === $eventId) { $hasSelected = true; break; } }
if (!$hasSelected) {
$selected = $eventRepo->find($eventId);
if ($selected) {
array_unshift($events, $selected);
}
}
}
require __DIR__ . '/../app/views/daily-closing/index.php';
break;
case 'daily-closing/close':
if (!isPost()) {
redirect('/?page=daily-closing');
}
$date = $_POST['date'] ?? date('Y-m-d');
$eventId = !empty($_POST['event_id']) ? (int)$_POST['event_id'] : null;
$note = (string)($_POST['note'] ?? '');
$redirectTo = '/?page=daily-closing&' . http_build_query(array_filter([
'date' => $date, 'event_id' => $eventId,
], 'strlen'));
requireCsrfOrAbort($redirectTo);
try {
$snapshot = $closingRepo->computeSnapshot($date, $eventId);
$user = Auth::user()['email'] ?? 'admin';
$id = $closingRepo->save($snapshot, $user, $note);
AuditLog::write('daily_closing', $id, 'closed',
'Tagesabschluss gespeichert für ' . $date . ($eventId ? ' (Event ' . $eventId . ')' : ''));
setFlash('success', 'Tagesabschluss wurde gespeichert.');
} catch (Throwable $e) {
setFlash('danger', 'Tagesabschluss konnte nicht gespeichert werden: ' . $e->getMessage());
}
redirect($redirectTo);
break;
case 'daily-closing/export-pdf':
$date = $_GET['date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$eventId = !empty($_GET['event_id']) ? (int)$_GET['event_id'] : null;
$snapshot = $closingRepo->computeSnapshot($date, $eventId);
$existing = $closingRepo->findByDate($date, $eventId);
$event = $eventId ? $eventRepo->find($eventId) : null;
$baseName = 'tagesabschluss-' . $date . ($eventId ? '-event' . $eventId : '');
try {
$path = PdfRenderer::save(
__DIR__ . '/../app/views/pdf/daily-closing.php',
[
'snapshot' => $snapshot,
'date' => $date,
'eventId' => $eventId,
'event' => $event,
'existing' => $existing,
],
'storage/daily-closings',
$baseName
);
$abs = PdfRenderer::absolutePath($path);
$isPdf = str_ends_with($abs, '.pdf');
header('Content-Type: ' . ($isPdf ? 'application/pdf' : 'text/html; charset=utf-8'));
if (isset($_GET['download'])) {
header('Content-Disposition: attachment; filename="' . basename($abs) . '"');
} else {
header('Content-Disposition: inline; filename="' . basename($abs) . '"');
}
header('Content-Length: ' . filesize($abs));
readfile($abs);
exit;
} catch (Throwable $e) {
setFlash('danger', 'Tagesabschluss-Dokument fehlgeschlagen: ' . $e->getMessage());
redirect('/?page=daily-closing&date=' . $date);
}
break;
case 'daily-closing/export-excel':
$date = $_GET['date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$eventId = !empty($_GET['event_id']) ? (int)$_GET['event_id'] : null;
// Anreisen am Tag = Datenbasis für die Tagesabrechnung (gleiche Logik wie Daily-Closing).
$closingActive = $bookingRepo->arrivals([
'date' => $date,
'event_id' => $eventId ? [$eventId] : [],
'hide_sources' => hiddenSourcesFor($settingsRepo, 'excel'),
]);
$closingCancelled = $bookingRepo->arrivals([
'date' => $date,
'event_id' => $eventId ? [$eventId] : [],
'checkin_status' => 'cancelled',
'hide_sources' => hiddenSourcesFor($settingsRepo, 'excel'),
]);
// Eigene Stornos (Parken-am-Schiff.de) komplett verwerfen — nur Fremd-Stornos
// (z.B. AIDA) erscheinen, und ausschließlich auf dem ersten "Alle"-Sheet.
$ownSourceLabel = 'Parken-am-Schiff.de';
$cancelledForeign = array_values(array_filter(
$closingCancelled,
static fn ($r) => strcasecmp((string)($r['c_source'] ?? ''), $ownSourceLabel) !== 0
));
if (empty($closingActive) && empty($cancelledForeign)) {
setFlash('warning', 'Für diesen Tag existieren keine Buchungen — keine Tagesabrechnung exportiert.');
redirect('/?page=daily-closing&date=' . $date . ($eventId ? '&event_id=' . $eventId : ''));
}
$companyStreet = (string)($settingsRepo->get('company_street', 'An der Werft 3') ?: 'An der Werft 3');
$dateLabel = date('d.m.Y', strtotime($date));
// Letztes Segment nach Bindestrich (für AIDA-Sheet: "AIDA-17162161" → "17162161", "K-7417" → "7417").
$lastSegment = static function (string $s): string {
if ($s === '') return '';
$parts = explode('-', $s);
return end($parts) ?: $s;
};
// Zahlungsmethode-Label (DB-konfigurierbar).
$methodLabel = static function (string $m): string {
return $m === '' ? '' : Payment::methodLabel($m);
};
// Eine Buchungs-Zeile → assoziatives Array mit allen benötigten Werten.
$extract = static function (array $r) use ($companyStreet, $methodLabel, $lastSegment): array {
$methods = trim((string)($r['payment_methods'] ?? ''));
$primary = '';
if ($methods !== '') {
$list = array_filter(array_map('trim', explode(',', $methods)));
$primary = $list ? $methodLabel($list[0]) : '';
}
$isPaid = ($r['payment_status'] ?? '') === 'paid';
$isCancelled = ($r['status'] ?? '') === 'cancelled';
$isAida = strcasecmp((string)($r['c_source'] ?? ''), 'AIDA') === 0;
$bookingNo = (string)($r['booking_no'] ?? '');
// AIDA-Buchungsnummern (z.B. "260509-AIDA-7417") werden in der Übersicht als
// "K-{Kabine}" geschrieben — die Kabine ist das letzte Segment nach dem Bindestrich.
$displayBookingNo = $isAida
? 'K-' . $lastSegment($bookingNo)
: $bookingNo;
// AIDA-Buchungen werden über das MyAIDA-Portal abgerechnet (Zahlungsart-Label).
// Bei storniertem AIDA-Datensatz wird stattdessen "STORNO" ausgegeben.
if ($isAida) {
$methodDisplay = $isCancelled ? 'STORNO' : 'MyAIDA';
} else {
$methodDisplay = $primary !== '' ? $primary : ($isPaid ? 'Bezahlt' : 'Offen');
}
return [
'invoice_no' => (string)($r['invoice_no'] ?? ''),
'booking_no' => $bookingNo,
'display_booking_no' => $displayBookingNo,
'name' => trim(($r['c_last_name'] ?? '') . ', ' . ($r['c_first_name'] ?? ''), ', '),
'plate' => formatLicensePlateDE((string)($r['license_plate'] ?? '')),
'standort' => $companyStreet,
'amount' => (float)($r['total_gross'] ?? 0),
'ship' => (string)($r['event_title'] ?? ($r['event_ship'] ?? '')),
'method' => $methodDisplay,
'method_raw' => $primary,
'info' => '',
'is_paid' => $isPaid,
'is_cancelled' => $isCancelled,
'is_aida' => $isAida,
];
};
// Alle-Sheet (Übersicht): alle aktiven Buchungen (eigene + fremde, bezahlt + offen)
// sowie Fremd-Stornos. Eigene Stornos werden weiterhin ausgeschlossen.
// Andere Sheets (AIDA/Methoden): nur aktive Buchungen ihrer Klasse.
$allRows = [];
$aidaRows = [];
$byMethod = [];
foreach ($closingActive as $r) {
$row = $extract($r);
$allRows[] = $row;
if ($row['is_aida']) {
$aidaRows[] = $row;
} elseif ($row['method_raw'] !== '') {
$byMethod[$row['method_raw']][] = $row;
}
}
foreach ($cancelledForeign as $r) {
$allRows[] = $extract($r);
}
// Sheet-Definitionen aufbauen (Headers + Datenzeilen + Summen-/Datums-Zeilen als Footer).
// Diese Form ist mit Exporter::streamMulti (MiniXlsx-Fallback) kompatibel.
// Spaltenbreiten in Excel-Einheiten — 1 Einheit ≈ 1 Zeichenbreite, ~4 Einheiten ≈ 1 cm.
// Erhöht um ~1 cm: Betrag, Schiff, Standort, Name, Kennzeichen/KFZ-Zeichen, Rechnung, Buchungs-Nr.
$alleHeaders = ['Rechnung', 'Buchungs-Nr.', 'Name', 'KFZ-Zeichen', 'Standort', 'Betrag', 'Schiff', 'Zahlungsart', 'Information'];
$alleWidths = [16, 13, 22, 13, 16, 13, 13, 9, 18];
$aidaHeaders = ['Buchungs-Nr.', 'Kabine', 'Nachname, Vorname', 'Kennzeichen', 'Standort', 'Betrag', 'Schiff', 'Information'];
$aidaWidths = [13, 9, 22, 13, 16, 13, 13, 18];
// BAR / EC-Karte: Zahlungsart-Spalte komplett entfernt.
$methodHeaders = ['Rechnung', 'Buchungs-Nr.', 'Name', 'KFZ-Zeichen', 'Standort', 'Betrag', 'Schiff', 'Information'];
$methodWidths = [13, 13, 22, 13, 16, 13, 13, 18];
$alleDataRaw = array_map(static fn ($x) => [
$x['invoice_no'], $x['display_booking_no'], $x['name'], $x['plate'], $x['standort'],
$x['amount'], $x['ship'], $x['method'], $x['info'],
], $allRows);
$alleMeta = array_map(static fn ($x) => [
'is_paid' => $x['is_paid'],
'is_cancelled' => $x['is_cancelled'],
], $allRows);
$aidaDataRaw = array_map(static fn ($x) => [
$lastSegment($x['invoice_no'] !== '' ? $x['invoice_no'] : $x['booking_no']),
$lastSegment($x['booking_no']),
$x['name'], $x['plate'], $x['standort'],
$x['amount'], $x['ship'], $x['info'],
], $aidaRows);
$methodSheets = [];
foreach ($byMethod as $mLabel => $mRows) {
$title = mb_substr(strtoupper((string)$mLabel) === 'BAR' ? 'BAR' : (string)$mLabel, 0, 31);
$methodSheets[] = [
'title' => $title,
'header' => $methodHeaders,
'widths' => $methodWidths,
'data' => array_map(static fn ($x) => [
$x['invoice_no'], $x['booking_no'], $x['name'], $x['plate'], $x['standort'],
$x['amount'], $x['ship'], $x['info'],
], $mRows),
'betragIdx' => 5,
];
}
// Hilfsfunktion: Hänge Summe-Zeile + Leerzeile + Datums-Zeile an die Daten.
// "Summe" steht in Spalte A, der Gesamtbetrag direkt in Spalte B (vorformatiert, damit
// MiniXlsx ohne Cell-Currency-Format trotzdem "EUR XX,XX" zeigt).
$appendFooter = static function (array $data, int $betragIdx, int $colCount, string $dateLabel): array {
$sum = 0.0;
foreach ($data as $row) {
if (isset($row[$betragIdx]) && is_numeric($row[$betragIdx])) $sum += (float)$row[$betragIdx];
}
$sumRow = array_fill(0, $colCount, '');
$sumRow[0] = 'Summe';
$sumRow[1] = 'EUR ' . number_format($sum, 2, ',', '.');
$emptyRow = array_fill(0, $colCount, '');
$dateRow = array_fill(0, $colCount, '');
$dateRow[0] = $dateLabel;
$data[] = $sumRow;
$data[] = $emptyRow;
$data[] = $dateRow;
return $data;
};
// Fallback-Pfad: kein PhpSpreadsheet → Exporter::streamMulti (nutzt MiniXlsx).
if (!class_exists('\\PhpOffice\\PhpSpreadsheet\\Spreadsheet')) {
$sheets = [[
'title' => 'Alle',
'headers' => $alleHeaders,
'rows' => $appendFooter($alleDataRaw, 5, count($alleHeaders), $dateLabel),
'columnWidths' => $alleWidths,
'currencyColumns' => [5],
]];
if (!empty($aidaDataRaw)) {
$sheets[] = [
'title' => 'AIDA',
'headers' => $aidaHeaders,
'rows' => $appendFooter($aidaDataRaw, 5, count($aidaHeaders), $dateLabel),
'columnWidths' => $aidaWidths,
'currencyColumns' => [5],
];
}
foreach ($methodSheets as $ms) {
$sheets[] = [
'title' => $ms['title'],
'headers' => $ms['header'],
'rows' => $appendFooter($ms['data'], 5, count($ms['header']), $dateLabel),
'columnWidths' => $ms['widths'],
'currencyColumns' => [5],
];
}
Exporter::streamMulti($date . '-Tagesabrechnung.xlsx', $sheets);
exit;
}
// PhpSpreadsheet-Pfad: Zeilenfärbung (grün/rot) + getrennte Summen-/Datums-Zeilen.
$ssCls = '\\PhpOffice\\PhpSpreadsheet\\Spreadsheet';
$writerCls = '\\PhpOffice\\PhpSpreadsheet\\Writer\\Xlsx';
$fillCls = '\\PhpOffice\\PhpSpreadsheet\\Style\\Fill';
$ss = new $ssCls();
$ss->removeSheetByIndex(0);
$currencyFmt = '#,##0.00\\ [$EUR];-#,##0.00\\ [$EUR]';
$alphaCol = static function (int $n): string {
$s = '';
while ($n > 0) {
$rem = ($n - 1) % 26;
$s = chr(65 + $rem) . $s;
$n = intdiv($n - 1, 26);
}
return $s;
};
$writeSheet = static function ($sheet, array $headers, array $rows, array $betragIdx, bool $colorize, string $dateLabel, array $rowMeta = [], array $widths = [])
use ($alphaCol, $currencyFmt, $fillCls) {
// Kopfzeile
foreach ($headers as $i => $h) {
$cell = $alphaCol($i + 1) . '1';
$sheet->setCellValue($cell, (string)$h);
$sheet->getStyle($cell)->getFont()->setBold(true);
}
// Datenzeilen
$sum = 0.0;
foreach ($rows as $rIdx => $row) {
$excelRow = $rIdx + 2;
foreach ($row as $cIdx => $val) {
$col = $alphaCol($cIdx + 1);
$cell = $col . $excelRow;
if (in_array($cIdx, $betragIdx, true) && is_numeric($val)) {
$sheet->setCellValueExplicit($cell, (float)$val, 'n');
$sheet->getStyle($cell)->getNumberFormat()->setFormatCode($currencyFmt);
$sum += (float)$val;
} else {
$sheet->setCellValueExplicit($cell, (string)$val, 's');
}
}
// Zeilen-Färbung (nur "Alle"-Sheet):
// - grün: Buchung bezahlt und nicht storniert (erfolgreiche Anreise)
// - leicht rot: Storno (helle Variante, Schrift bleibt lesbar)
if ($colorize) {
$isCancelled = !empty($rowMeta[$rIdx]['is_cancelled']);
$isPaid = !empty($rowMeta[$rIdx]['is_paid']);
$color = null;
if ($isCancelled) {
$color = 'FFFFD6D6'; // hellrotes Pastell
} elseif ($isPaid) {
$color = 'FF8FE388'; // grünes Pastell
}
if ($color !== null) {
$range = 'A' . $excelRow . ':' . $alphaCol(count($headers)) . $excelRow;
$sheet->getStyle($range)->getFill()
->setFillType($fillCls::FILL_SOLID)
->getStartColor()->setARGB($color);
}
}
}
// Summenzeile: A = "Summe", B = Total in Currency (direkt nebeneinander).
$sumRow = count($rows) + 2;
$sheet->setCellValue('A' . $sumRow, 'Summe');
$sheet->getStyle('A' . $sumRow)->getFont()->setBold(true);
$sheet->setCellValueExplicit('B' . $sumRow, $sum, 'n');
$sheet->getStyle('B' . $sumRow)->getNumberFormat()->setFormatCode($currencyFmt);
$sheet->getStyle('B' . $sumRow)->getFont()->setBold(true);
// Datumszeile zwei Zeilen drunter
$dateRow = $sumRow + 2;
$sheet->setCellValue('A' . $dateRow, $dateLabel);
// Spaltenbreiten (fix, ohne AutoSize) + Freeze
foreach ($headers as $i => $_) {
$w = isset($widths[$i]) ? (float)$widths[$i] : 18.0;
$sheet->getColumnDimension($alphaCol($i + 1))->setWidth($w);
}
$sheet->freezePane('A2');
};
// 1) "Alle"-Sheet — mit Zeilenfärbung (grün = bezahlt / rot = Storno).
$sheetAlle = $ss->createSheet();
$sheetAlle->setTitle('Alle');
$writeSheet($sheetAlle, $alleHeaders, $alleDataRaw, [5], true, $dateLabel, $alleMeta, $alleWidths);
// 2) "AIDA"-Sheet
if (!empty($aidaDataRaw)) {
$sheetA = $ss->createSheet();
$sheetA->setTitle('AIDA');
$writeSheet($sheetA, $aidaHeaders, $aidaDataRaw, [5], false, $dateLabel, [], $aidaWidths);
}
// 3) Pro Zahlungsmethode (außer AIDA)
foreach ($methodSheets as $ms) {
$sheetM = $ss->createSheet();
$sheetM->setTitle($ms['title']);
$writeSheet($sheetM, $ms['header'], $ms['data'], [5], false, $dateLabel, [], $ms['widths']);
}
$ss->setActiveSheetIndex(0);
$filename = $date . '-Tagesabrechnung.xlsx';
$tmp = tempnam(sys_get_temp_dir(), 'tag_') . '.xlsx';
(new $writerCls($ss))->save($tmp);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($tmp));
readfile($tmp);
@unlink($tmp);
exit;
// --- REPORTS ------------------------------------------------------
case 'reports':
$pageTitle = 'Reports';
$pageSubtitle = 'Auswertungen und KPI-Dashboard.';
$activeNav = 'reports';
$dateFrom = trim((string)($_GET['date_from'] ?? ''));
$dateTo = trim((string)($_GET['date_to'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) $dateFrom = '';
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) $dateTo = '';
$reportRepo->setHiddenSources(hiddenSourcesFor($settingsRepo, 'reports'));
$monthly = $reportRepo->monthlyBookings(12, $dateFrom ?: null, $dateTo ?: null);
$products = $reportRepo->productDistribution($dateFrom ?: null, $dateTo ?: null);
$paymentStatus = $reportRepo->paymentStatusDistribution($dateFrom ?: null, $dateTo ?: null);
$topEvents = $reportRepo->topEvents(5, $dateFrom ?: null, $dateTo ?: null);
$kpis = $reportRepo->kpis($dateFrom ?: null, $dateTo ?: null);
$checkinRate = $reportRepo->checkinRate($dateFrom ?: null, $dateTo ?: null);
$openInvoices = $reportRepo->openInvoicesAmount($dateFrom ?: null, $dateTo ?: null);
require __DIR__ . '/../app/views/reports/index.php';
break;
case 'reports/files/undo-import':
// Echtes Undo: Buchungen, die durch diese Import-Dateien NEU angelegt wurden, werden
// hart gelöscht (FK-Cascade kümmert sich um booking_products, invoices, invoice_items,
// payments, checkins). Buchungen, die nur AKTUALISIERT wurden, lassen sich ohne
// gespeicherten Vorzustand nicht zurückrollen — wir setzen ihre import_run_id auf NULL
// und melden ihre Anzahl im Flash, damit der User Bescheid weiß.
//
// Außerdem werden die zugehörigen Datei-Einträge (import_files, import_runs, files)
// und die archivierte Trash-Datei entfernt — der Import gilt damit als "nie passiert".
// Vorbucher-/Vorhersagelisten (list_type='prediction') sind ausgeschlossen.
if (!isPost()) {
redirect('/?page=reports/files');
}
requireCsrfOrAbort('/?page=reports/files');
$names = $_POST['files'] ?? [];
if (!is_array($names)) $names = [$names];
$names = array_values(array_unique(array_filter(array_map(
static fn ($n) => is_string($n) ? trim($n) : '', $names
))));
if (empty($names)) {
setFlash('warning', 'Keine Dateien für das Undo ausgewählt.');
redirect((string)($_POST['return_to'] ?? '/?page=reports/files'));
}
$pdoU = $db->getConnection();
$trashDir = realpath(__DIR__ . '/../data/trash') ?: '';
try {
// 1) Predictions ausfiltern — die wären über aida_prebookings zu bereinigen,
// nicht über import_files (separate Logik).
$ph = implode(',', array_fill(0, count($names), '?'));
$vStmt = $pdoU->prepare(
"SELECT filename FROM import_files
WHERE filename IN ($ph) AND list_type = 'prediction'"
);
$vStmt->execute($names);
$excluded = array_map(static fn ($r) => (string)$r['filename'], $vStmt->fetchAll());
$names = array_values(array_diff($names, $excluded));
if (empty($names)) {
setFlash('warning', 'Vorbucher-/Vorhersage-Listen können nicht rückgängig gemacht werden.');
redirect((string)($_POST['return_to'] ?? '/?page=reports/files'));
}
$ph2 = implode(',', array_fill(0, count($names), '?'));
$pdoU->beginTransaction();
// 2) Alle vom Import berührten Buchungen klassifizieren:
// a) Erstellt im Batch-Zeitfenster → echt anlegen, später hart löschen.
// b) Vorher schon existent → nur aktualisiert, kein Rollback möglich.
//
// Eine Excel-Datei kann Buchungen für MEHRERE Cruises enthalten — pro Cruise
// entsteht ein eigenes `import_runs`-Row mit derselben filename. Wir müssen
// ÜBER ALLE diese Runs joinen, damit auch Cruise-2/3/… mitgelöscht werden.
// Historisch wurde zusätzlich pro Cruise eine eigene import_uuid vergeben;
// import_files speichert per UNIQUE(filename) aber nur die ERSTE UUID, weshalb
// ein reiner UUID-Lookup unvollständig wäre. Lösung: Run-Lookup als Primär,
// UUID-Lookup additiv für Altdaten / Re-Import-Spuren.
$hits = [];
$seen = []; // booking_id → true, um Duplikate zwischen den Pfaden zu vermeiden
// PRIMÄR — über import_runs.filename: erfasst alle Cruises der Datei.
// CREATE vs UPDATE: created_at innerhalb des Batch-Fensters → in diesem Import angelegt.
$bStmt = $pdoU->prepare(
"SELECT b.id, b.booking_no, b.customer_id,
CASE WHEN b.created_at >= bt.started_at
AND b.created_at <= COALESCE(bt.finished_at, NOW())
THEN 1 ELSE 0 END AS created_in_batch
FROM bookings b
JOIN import_runs ir ON ir.id = b.import_run_id
JOIN import_files imf ON imf.filename = ir.filename
JOIN import_batches bt ON bt.id = imf.import_batch_id
WHERE imf.filename IN ($ph2)"
);
$bStmt->execute($names);
foreach ($bStmt->fetchAll() as $r) {
$bid = (int)$r['id'];
if (isset($seen[$bid])) continue;
$seen[$bid] = true;
$hits[] = $r;
}
// ADDITIV — über import_files.import_uuid: fängt Altbestand ab, dessen
// import_run_id / import_batches schon gepurged wurden, sowie Re-Importe,
// bei denen import_run_id evtl. überschrieben ist (UUID auf der Buchung
// bleibt beim UPDATE erhalten).
$uStmt = $pdoU->prepare("SELECT import_uuid FROM import_files WHERE filename IN ($ph2) AND import_uuid IS NOT NULL");
$uStmt->execute($names);
$importUuids = array_values(array_filter(array_map(static fn ($r) => (string)$r['import_uuid'], $uStmt->fetchAll())));
if (!empty($importUuids)) {
$iph = implode(',', array_fill(0, count($importUuids), '?'));
$bStmt = $pdoU->prepare(
"SELECT id, booking_no, customer_id, 1 AS created_in_batch
FROM bookings
WHERE import_uuid IN ($iph)"
);
$bStmt->execute($importUuids);
foreach ($bStmt->fetchAll() as $r) {
$bid = (int)$r['id'];
if (isset($seen[$bid])) continue;
$seen[$bid] = true;
$hits[] = $r;
}
}
// Fallback: wenn die Bookkeeping-Tabellen leer sind (z.B. nach truncate-data.sh
// oder Import ohne migrations 027/028), liefert der JOIN oben nichts. In diesem
// Fall lesen wir die Datei selbst und sammeln alle `booking_no`-Werte — Buchungen
// mit passendem `legacy_booking_no` + source='AIDA' werden hart gelöscht.
if (empty($hits)) {
require_once __DIR__ . '/../app/services/AidaBookingImporter.php';
$aidaParser = AidaBookingImporter::fromDatabase($db, __DIR__ . '/../data');
// Datei-Pfade aus `files`-Tabelle ziehen, sonst direkt in data/trash suchen.
$pathByName = [];
$fpStmt = $pdoU->prepare("SELECT file_name, file_path FROM files WHERE file_name IN ($ph2)");
$fpStmt->execute($names);
foreach ($fpStmt->fetchAll() as $fp) {
$pathByName[(string)$fp['file_name']] = (string)$fp['file_path'];
}
$fallbackLegacyNos = [];
$projectRoot = realpath(__DIR__ . '/..') ?: '';
foreach ($names as $n) {
$raw = $pathByName[$n] ?? '';
$candidates = [];
if ($raw !== '') {
$candidates[] = $raw; // Pfad evtl. absolut
if ($projectRoot !== '') {
$candidates[] = $projectRoot . $raw; // "/data/trash/..." relativ zum Root
$candidates[] = $projectRoot . '/' . ltrim($raw, '/');
}
}
$candidates[] = $trashDir . DIRECTORY_SEPARATOR . $n;
$path = '';
foreach ($candidates as $c) {
$real = realpath($c);
if ($real !== false && is_file($real)) { $path = $real; break; }
}
if ($path === '') continue;
$rows = $aidaParser->parseExcel($path);
if (!is_array($rows)) continue;
foreach ($rows as $r) {
$bn = trim((string)($r['booking_no'] ?? $r['booking_number'] ?? ''));
if ($bn !== '') $fallbackLegacyNos[$bn] = true;
}
}
if (!empty($fallbackLegacyNos)) {
$lbns = array_keys($fallbackLegacyNos);
$lph = implode(',', array_fill(0, count($lbns), '?'));
$fb = $pdoU->prepare(
"SELECT id, booking_no, customer_id
FROM bookings
WHERE source = 'AIDA'
AND legacy_booking_no IN ($lph)"
);
$fb->execute($lbns);
foreach ($fb->fetchAll() as $r) {
$hits[] = [
'id' => (int)$r['id'],
'booking_no' => $r['booking_no'],
'customer_id' => (int)$r['customer_id'],
// Ohne Bookkeeping können wir CREATED vs. UPDATED nicht unterscheiden —
// wir behandeln alle als "in diesem Import angelegt" und löschen sie hart.
'created_in_batch' => 1,
];
}
}
}
$createdIds = [];
$updatedOnlyIds = [];
$touchedCustIds = [];
foreach ($hits as $h) {
$bid = (int)$h['id'];
$cid = (int)$h['customer_id'];
if ((int)$h['created_in_batch'] === 1) {
$createdIds[] = $bid;
} else {
$updatedOnlyIds[] = $bid;
}
if ($cid > 0) $touchedCustIds[$cid] = true;
}
$deleted = 0;
if (!empty($createdIds)) {
$bph = implode(',', array_fill(0, count($createdIds), '?'));
$dStmt = $pdoU->prepare("DELETE FROM bookings WHERE id IN ($bph)");
$dStmt->execute($createdIds);
$deleted = $dStmt->rowCount();
foreach ($hits as $h) {
if ((int)$h['created_in_batch'] !== 1) continue;
AuditLog::write('booking', (int)$h['id'], 'import_undone',
'Import rückgängig: Buchung ' . $h['booking_no'] . ' inkl. Rechnung, Positionen und Zahlungen gelöscht.');
}
}
// 3) Updated-only Buchungen: aus dem in history_log abgelegten Pre-Import-Snapshot
// (action='import_snapshot') Buchung + Produkte + Rechnungen + Items + Zahlungen
// wiederherstellen. Findet sich kein Snapshot, wird nur die import_run_id
// abgelöst (Fallback wie vorher).
$updatedCount = 0;
$restoredCount = 0;
if (!empty($updatedOnlyIds)) {
// Welche import_run_ids waren betroffen? Für die Snapshot-Auswahl pro Buchung
// suchen wir den jüngsten Snapshot, der im Batch-Zeitfenster geschrieben wurde.
$snapStmt = $pdoU->prepare(
"SELECT h.id AS hid, h.entity_id, h.old_values
FROM history_log h
JOIN import_files imf ON imf.filename IN ($ph2)
JOIN import_batches bt ON bt.id = imf.import_batch_id
WHERE h.entity_type = 'booking'
AND h.action = 'import_snapshot'
AND h.entity_id = ?
AND h.created_at >= bt.started_at
AND h.created_at <= COALESCE(bt.finished_at, NOW())
ORDER BY h.id DESC
LIMIT 1"
);
foreach ($updatedOnlyIds as $bid) {
$snapStmt->execute(array_merge($names, [(int)$bid]));
$row = $snapStmt->fetch();
if (!$row || empty($row['old_values'])) {
// Kein Snapshot — nur Verweis kappen.
$pdoU->prepare("UPDATE bookings SET import_run_id = NULL WHERE id = :id")
->execute(['id' => (int)$bid]);
$updatedCount++;
continue;
}
$snap = json_decode((string)$row['old_values'], true);
if (!is_array($snap) || empty($snap['booking'])) {
$pdoU->prepare("UPDATE bookings SET import_run_id = NULL WHERE id = :id")
->execute(['id' => (int)$bid]);
$updatedCount++;
continue;
}
// 3a) bookings-Zeile zurücksetzen — alle Spalten aus dem Snapshot.
$b = $snap['booking'];
unset($b['id'], $b['created_at'], $b['updated_at']);
if (!empty($b)) {
$setSql = implode(', ', array_map(fn ($k) => "`$k` = :$k", array_keys($b)));
$up = $pdoU->prepare("UPDATE bookings SET $setSql WHERE id = :__id");
$up->execute(array_merge($b, ['__id' => (int)$bid]));
}
// 3b) booking_products komplett ersetzen.
$pdoU->prepare("DELETE FROM booking_products WHERE booking_id = :id")
->execute(['id' => (int)$bid]);
foreach (($snap['products'] ?? []) as $p) {
unset($p['id'], $p['created_at'], $p['updated_at']);
if (empty($p)) continue;
$cols = implode(', ', array_map(fn ($k) => "`$k`", array_keys($p)));
$vals = implode(', ', array_map(fn ($k) => ":$k", array_keys($p)));
$pdoU->prepare("INSERT INTO booking_products ($cols) VALUES ($vals)")->execute($p);
}
// 3c) Invoices + Items: aktuelle alle weg (Cascade säubert Items), dann
// aus dem Snapshot neu anlegen. invoice_no ist UNIQUE — falls ein
// INSERT scheitert (z.B. weil eine andere Rechnung dieselbe Nr. trägt),
// überspringen wir diese Zeile und schreiben einen Hinweis ins Audit.
// Wir merken uns altes_id → neues_id, damit payments.invoice_id korrekt
// auf die NEU eingefügten Rechnungs-IDs umgebogen werden kann.
$invIdMap = [];
$pdoU->prepare("DELETE FROM invoices WHERE booking_id = :id")
->execute(['id' => (int)$bid]);
foreach (($snap['invoices'] ?? []) as $inv) {
$oldInvId = (int)($inv['id'] ?? 0);
$items = $inv['_items'] ?? [];
unset($inv['id'], $inv['_items'], $inv['created_at'], $inv['updated_at']);
if (empty($inv)) continue;
try {
$cols = implode(', ', array_map(fn ($k) => "`$k`", array_keys($inv)));
$vals = implode(', ', array_map(fn ($k) => ":$k", array_keys($inv)));
$pdoU->prepare("INSERT INTO invoices ($cols) VALUES ($vals)")->execute($inv);
$newInvId = (int)$pdoU->lastInsertId();
if ($oldInvId > 0) $invIdMap[$oldInvId] = $newInvId;
foreach ($items as $it) {
unset($it['id'], $it['created_at']);
$it['invoice_id'] = $newInvId;
if (empty($it)) continue;
$iCols = implode(', ', array_map(fn ($k) => "`$k`", array_keys($it)));
$iVals = implode(', ', array_map(fn ($k) => ":$k", array_keys($it)));
$pdoU->prepare("INSERT INTO invoice_items ($iCols) VALUES ($iVals)")->execute($it);
}
} catch (PDOException $e) {
AuditLog::write('booking', (int)$bid, 'import_undo_invoice_skip',
'Snapshot-Rechnung konnte nicht restauriert werden: ' . $e->getMessage());
}
}
// 3d) Payments: alle löschen, dann aus Snapshot neu anlegen — invoice_id
// auf die neue ID umbiegen (Map oben), sonst auf NULL setzen, damit
// der FK nicht ins Leere zeigt.
$pdoU->prepare("DELETE FROM payments WHERE booking_id = :id")
->execute(['id' => (int)$bid]);
foreach (($snap['payments'] ?? []) as $pay) {
unset($pay['id'], $pay['created_at'], $pay['updated_at']);
if (empty($pay)) continue;
if (isset($pay['invoice_id'])) {
$oldRef = (int)$pay['invoice_id'];
$pay['invoice_id'] = $invIdMap[$oldRef] ?? null;
}
$cols = implode(', ', array_map(fn ($k) => "`$k`", array_keys($pay)));
$vals = implode(', ', array_map(fn ($k) => ":$k", array_keys($pay)));
$pdoU->prepare("INSERT INTO payments ($cols) VALUES ($vals)")->execute($pay);
}
// Snapshot-Eintrag aus history_log entfernen — verhindert Doppelanwendung.
$pdoU->prepare("DELETE FROM history_log WHERE id = :hid")->execute(['hid' => (int)$row['hid']]);
// Such-Index nach Vollrestore aktualisieren (Buchung + alle ihre Rechnungen).
try {
$bookingRepo->syncSearchIndex((int)$bid);
foreach ($invIdMap as $newInvId) {
$invoiceRepo->syncSearchIndex((int)$newInvId);
}
} catch (Throwable) { /* selbstheilend */ }
AuditLog::write('booking', (int)$bid, 'import_reverted',
'Buchung aus Pre-Import-Snapshot wiederhergestellt (run-Reset).');
$restoredCount++;
}
}
// 4) Verwaiste AIDA-Kunden entsorgen: Customer wurde im Import-Zeitfenster
// angelegt UND hat jetzt keine Buchungen mehr. Primär per import_uuid
// (sicherste Spur), sekundär per touched-IDs.
$orphanedCustomers = 0;
if (!empty($importUuids)) {
// Erst die zu löschenden Kunden-IDs einsammeln (für Search-Index-Purge),
// dann den eigentlichen Delete ausführen.
$iph2 = implode(',', array_fill(0, count($importUuids), '?'));
$idStmt = $pdoU->prepare(
"SELECT id FROM customers
WHERE import_uuid IN ($iph2)
AND NOT EXISTS (SELECT 1 FROM bookings b WHERE b.customer_id = customers.id)"
);
$idStmt->execute($importUuids);
$orphanIdsByUuid = array_column($idStmt->fetchAll(), 'id');
$iph = implode(',', array_fill(0, count($importUuids), '?'));
$delC = $pdoU->prepare(
"DELETE FROM customers
WHERE import_uuid IN ($iph)
AND NOT EXISTS (SELECT 1 FROM bookings b WHERE b.customer_id = customers.id)"
);
$delC->execute($importUuids);
$orphanedCustomers += $delC->rowCount();
foreach ($orphanIdsByUuid as $oid) {
SearchIndexer::purge('customer', (int)$oid);
}
}
if (!empty($touchedCustIds)) {
$cIds = array_keys($touchedCustIds);
$cph = implode(',', array_fill(0, count($cIds), '?'));
$cStmt = $pdoU->prepare(
"SELECT c.id
FROM customers c
WHERE c.id IN ($cph)
AND c.source = 'AIDA'
AND NOT EXISTS (SELECT 1 FROM bookings b WHERE b.customer_id = c.id)"
);
$cStmt->execute($cIds);
$orphanIds = array_map(static fn ($r) => (int)$r['id'], $cStmt->fetchAll());
if (!empty($orphanIds)) {
$oph = implode(',', array_fill(0, count($orphanIds), '?'));
$delC2 = $pdoU->prepare("DELETE FROM customers WHERE id IN ($oph)");
$delC2->execute($orphanIds);
$orphanedCustomers += $delC2->rowCount();
foreach ($orphanIds as $oid) {
SearchIndexer::purge('customer', (int)$oid);
}
}
}
// Prediction-/Prebooking-Datensätze: lösche aida_prebookings, die per
// import_uuid ODER source_hash (= file_md5) mit den ausgewählten Dateien verknüpft sind.
$deletedPrebookings = 0;
$fmdStmt = $pdoU->prepare("SELECT file_md5, import_uuid FROM files WHERE file_name IN ($ph2)");
$fmdStmt->execute($names);
$fileMd5s = [];
$fileUuids = [];
foreach ($fmdStmt->fetchAll() as $r) {
if (!empty($r['file_md5'])) $fileMd5s[] = (string)$r['file_md5'];
if (!empty($r['import_uuid'])) $fileUuids[] = (string)$r['import_uuid'];
}
$allPredictionUuids = array_values(array_unique(array_merge($importUuids, $fileUuids)));
if (!empty($allPredictionUuids)) {
$iph = implode(',', array_fill(0, count($allPredictionUuids), '?'));
$d = $pdoU->prepare("DELETE FROM aida_prebookings WHERE import_uuid IN ($iph)");
$d->execute($allPredictionUuids);
$deletedPrebookings += $d->rowCount();
}
if (!empty($fileMd5s)) {
$mph = implode(',', array_fill(0, count($fileMd5s), '?'));
$d = $pdoU->prepare("DELETE FROM aida_prebookings WHERE source_hash IN ($mph)");
$d->execute($fileMd5s);
$deletedPrebookings += $d->rowCount();
}
// 5) Archiv-Datei aus data/trash entsorgen — Pfad steht in `files`.file_path
// (kann absolut oder als "/data/trash/..."-Relativpfad gespeichert sein).
$deletedFiles = 0;
$projectRoot = realpath(__DIR__ . '/..') ?: '';
$fStmt = $pdoU->prepare("SELECT id, file_name, file_path FROM files WHERE file_name IN ($ph2)");
$fStmt->execute($names);
$fileRows = $fStmt->fetchAll();
foreach ($fileRows as $fr) {
$raw = (string)$fr['file_path'];
if ($raw === '' || $trashDir === '') continue;
$candidates = [$raw];
if ($projectRoot !== '') {
$candidates[] = $projectRoot . $raw;
$candidates[] = $projectRoot . '/' . ltrim($raw, '/');
}
$candidates[] = $trashDir . DIRECTORY_SEPARATOR . (string)$fr['file_name'];
foreach ($candidates as $cand) {
$real = realpath($cand);
if ($real === false || !is_file($real)) continue;
if (!str_starts_with($real, $trashDir . DIRECTORY_SEPARATOR)) continue;
if (@unlink($real)) { $deletedFiles++; }
break;
}
}
// `files`-Tabelle leeren
if (!empty($fileRows)) {
$delF = $pdoU->prepare("DELETE FROM files WHERE file_name IN ($ph2)");
$delF->execute($names);
}
// 6) Import-Bookkeeping bereinigen: import_runs, import_files und — soweit
// nach dem Löschen verwaist — auch import_batches. Damit verschwindet
// der Eintrag vollständig aus dem Import-Verlauf.
$batchIdsStmt = $pdoU->prepare("SELECT DISTINCT import_batch_id FROM import_files WHERE filename IN ($ph2) AND import_batch_id IS NOT NULL");
$batchIdsStmt->execute($names);
$candidateBatchIds = array_map(static fn ($r) => (int)$r['import_batch_id'], $batchIdsStmt->fetchAll());
$pdoU->prepare("DELETE FROM import_runs WHERE filename IN ($ph2)")->execute($names);
$pdoU->prepare("DELETE FROM import_files WHERE filename IN ($ph2)")->execute($names);
// Pro betroffenen Batch prüfen, ob noch Dateien daran hängen.
// Sind alle weg, ist der Batch verwaist → ebenfalls löschen.
$deletedBatches = 0;
foreach ($candidateBatchIds as $bid) {
if ($bid <= 0) continue;
$cnt = $pdoU->prepare("SELECT COUNT(*) FROM import_files WHERE import_batch_id = :b");
$cnt->execute(['b' => $bid]);
if ((int)$cnt->fetchColumn() === 0) {
$del = $pdoU->prepare("DELETE FROM import_batches WHERE id = :b");
$del->execute(['b' => $bid]);
$deletedBatches += $del->rowCount();
}
}
$pdoU->commit();
$msg = sprintf(
'Import rückgängig: %d Datei(en), %d Buchung(en) gelöscht.',
count($names), $deleted
);
if ($restoredCount > 0) {
$msg .= sprintf(' %d Buchung(en) aus Pre-Import-Snapshot wiederhergestellt (Buchung, Produkte, Rechnung, Zahlungen).', $restoredCount);
}
if ($updatedCount > 0) {
$msg .= sprintf(' %d Buchung(en) konnten nicht restauriert werden (kein Snapshot vorhanden) — Import-Verweis gekappt.', $updatedCount);
}
if ($orphanedCustomers > 0) {
$msg .= sprintf(' %d verwaiste AIDA-Kunden entfernt.', $orphanedCustomers);
}
if (!empty($deletedPrebookings) && $deletedPrebookings > 0) {
$msg .= sprintf(' %d Vorbucher-Datensätze gelöscht.', $deletedPrebookings);
}
if ($deletedFiles > 0) {
$msg .= sprintf(' %d Archiv-Datei(en) aus data/trash gelöscht.', $deletedFiles);
}
if ($deletedBatches > 0) {
$msg .= sprintf(' %d Import-Batch(es) aus dem Verlauf entfernt.', $deletedBatches);
}
if (!empty($excluded)) {
$msg .= ' (' . count($excluded) . ' Prediction-Datei(en) übersprungen.)';
}
setFlash('success', $msg);
} catch (Throwable $e) {
if ($pdoU->inTransaction()) $pdoU->rollBack();
setFlash('danger', 'Undo fehlgeschlagen: ' . $e->getMessage());
}
redirect((string)($_POST['return_to'] ?? '/?page=reports/files'));
break;
case 'reports/files/backfill-mapping':
// Rückwirkender Backfill für `import_file_bookings`: liest alle bekannten
// AIDA-Excel-Dateien neu ein und schreibt für jede Buchungs-Zeile einen
// Mapping-Eintrag (import_uuid ↔ external_uid ↔ booking_id). Idempotent.
if (!isPost()) {
redirect('/?page=reports/files');
}
requireCsrfOrAbort('/?page=reports/files');
try {
$importer = AidaBookingImporter::fromDatabase($db, __DIR__ . '/../data');
$res = $importer->backfillImportFileBookings();
setFlash('success', sprintf(
'Mapping rückgefüllt: %d Datei(en) verarbeitet, %d Buchungs-Zeilen verknüpft, %d übersprungen.',
$res['processed'], $res['mapped'], $res['skipped']
));
} catch (Throwable $e) {
setFlash('danger', 'Backfill fehlgeschlagen: ' . $e->getMessage());
}
redirect((string)($_POST['return_to'] ?? '/?page=reports/files'));
break;
case 'reports/files/upload':
// Upload-Endpoint: legt Dateien in data/aida ab, von wo aus AidaBookingImporter
// oder PrebookingImporter sie beim nächsten Lauf einliest. Akzeptiert XLSX-Dateien
// und ignoriert leere Slots eines Multi-File-Inputs.
if (!isPost()) {
redirect('/?page=reports/files');
}
requireCsrfOrAbort('/?page=reports/files');
$aidaInbox = realpath(__DIR__ . '/../data') . DIRECTORY_SEPARATOR . 'aida';
if (!is_dir($aidaInbox)) {
@mkdir($aidaInbox, 0775, true);
}
$files = $_FILES['files'] ?? null;
$okCount = 0;
$skipNote = [];
$errors = [];
if (!is_array($files) || !isset($files['name']) || !is_array($files['name'])) {
setFlash('warning', 'Es wurde keine Datei ausgewählt.');
redirect('/?page=reports/files');
}
foreach ($files['name'] as $i => $origName) {
$origName = is_string($origName) ? trim($origName) : '';
$errCode = $files['error'][$i] ?? UPLOAD_ERR_NO_FILE;
if ($errCode === UPLOAD_ERR_NO_FILE || $origName === '') continue;
if ($errCode !== UPLOAD_ERR_OK) {
$errors[] = $origName . ': Upload fehlgeschlagen (Code ' . (int)$errCode . ')';
continue;
}
$tmpPath = (string)($files['tmp_name'][$i] ?? '');
if ($tmpPath === '' || !is_uploaded_file($tmpPath)) {
$errors[] = $origName . ': interner Upload-Fehler';
continue;
}
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
if ($ext !== 'xlsx') {
$errors[] = $origName . ': nur XLSX-Dateien werden importiert';
continue;
}
// Dateiname sanitisieren — Pfad-Anteile und Sonderzeichen ausschließen.
$safe = preg_replace('/[^A-Za-z0-9 ._\-]/u', '', basename($origName));
if ($safe === '' || $safe === null) $safe = 'upload_' . date('Ymd_His') . '.xlsx';
$target = $aidaInbox . DIRECTORY_SEPARATOR . $safe;
if (file_exists($target)) {
// Konflikt → mit Zeitstempel-Suffix retten.
$base = pathinfo($safe, PATHINFO_FILENAME);
$target = $aidaInbox . DIRECTORY_SEPARATOR . $base . '_' . date('His') . '.xlsx';
$skipNote[] = $origName;
}
if (@move_uploaded_file($tmpPath, $target)) {
$okCount++;
} else {
$errors[] = $origName . ': konnte nicht in data/aida abgelegt werden';
}
}
if ($okCount > 0) {
$msg = $okCount . ' Datei' . ($okCount === 1 ? '' : 'en') . ' nach data/aida hochgeladen.';
if (!empty($skipNote)) {
$msg .= ' Hinweis: ' . count($skipNote) . ' Datei(en) wurden mit Zeitstempel-Suffix gespeichert (Name war bereits vergeben).';
}
$msg .= ' Der nächste Importer-Lauf nimmt sie automatisch auf.';
setFlash('success', $msg);
}
foreach ($errors as $e) setFlash('warning', $e);
if ($okCount === 0 && empty($errors)) setFlash('warning', 'Es wurde keine Datei hochgeladen.');
redirect('/?page=reports/files');
break;
case 'reports/files':
case 'reports/files/view':
case 'reports/files/download':
// Browser über data/trash. Pfade werden strikt validiert (kein Path Traversal,
// alles MUSS innerhalb von data/trash liegen). files-Tabelle wird zur Anreicherung
// benutzt — wenn eine Datei dort eingetragen ist, zeigen wir MD5 + Kategorie.
$pageTitle = 'Dateien';
$pageSubtitle = 'Archivierte Importe aus data/trash';
// Sidebar-Subnav: child-Key aus Permission::navGroupsFor() ist 'reports-files'.
$activeNav = 'reports-files';
$trashRoot = realpath(__DIR__ . '/../data/trash');
if ($trashRoot === false) {
setFlash('warning', 'Trash-Verzeichnis (data/trash) existiert nicht.');
redirect('/?page=reports');
}
// Helper: validiert einen relativen Pfad gegen $trashRoot und liefert den absoluten Pfad
// oder null, wenn der Pfad außerhalb liegt / nicht existiert.
$resolvePath = static function (string $rel) use ($trashRoot): ?string {
$rel = ltrim($rel, '/\\');
// Kein Path-Traversal
if ($rel === '' || str_contains($rel, "\0") || str_contains($rel, '..')) return null;
$candidate = $trashRoot . DIRECTORY_SEPARATOR . $rel;
$real = realpath($candidate);
if ($real === false) return null;
// Muss strikt unterhalb von trashRoot liegen
$rootNorm = rtrim($trashRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (!str_starts_with($real . DIRECTORY_SEPARATOR, $rootNorm)) return null;
return $real;
};
// Normalisierung für Such-Match: Kleinbuchstaben, Umlaute auflösen, alles außer
// [a-z0-9] entfernen. So zählt "HH-AB 1234" gleich wie "hhab1234" oder "HH AB1234".
$normalizeTerm = static function (string $s): string {
$s = mb_strtolower($s);
$s = strtr($s, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
return preg_replace('/[^a-z0-9]/', '', $s) ?? '';
};
// Findet einen Match in $text inkl. Buchstabendreher (Levenshtein ≤ 1) und gibt das
// Char-Intervall im *Original*-String zurück (für <mark>-Highlight).
$findMatchSpan = static function (string $text, string $needle) use ($normalizeTerm): ?array {
$needleNorm = $normalizeTerm($needle);
if ($needleNorm === '' || $text === '') return null;
// Original-Text in Chars splitten, parallel die normalisierte Form mit Position-Map aufbauen.
$chars = mb_str_split(mb_strtolower($text));
$norm = '';
$map = []; // norm-Position → originale Char-Position
foreach ($chars as $i => $ch) {
$repl = strtr($ch, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$clean = preg_replace('/[^a-z0-9]/', '', $repl) ?? '';
$len = strlen($clean);
for ($k = 0; $k < $len; $k++) {
$norm .= $clean[$k];
$map[] = $i;
}
}
if ($norm === '') return null;
$nl = strlen($needleNorm);
// 1) Exakter Substring (nach Normalisierung).
$pos = strpos($norm, $needleNorm);
if ($pos !== false) {
return ['start' => $map[$pos], 'end' => $map[$pos + $nl - 1] + 1];
}
// 2) Fuzzy: Levenshtein ≤ 1 im Sliding-Window (gleiche Länge + 1).
// Nur ab 3 Zeichen, sonst zu unscharf.
if ($nl >= 3) {
$hl = strlen($norm);
for ($w = $nl; $w <= $nl + 1 && $w <= $hl; $w++) {
for ($i = 0; $i + $w <= $hl; $i++) {
$sub = substr($norm, $i, $w);
if (levenshtein($needleNorm, $sub) <= 1) {
return ['start' => $map[$i], 'end' => $map[$i + $w - 1] + 1];
}
}
}
}
return null;
};
// Markiert den ersten Treffer in $text mit <mark>; restliche Zeichen werden HTML-escaped.
$highlightMatch = static function (string $text, string $needle) use ($findMatchSpan): string {
if ($needle === '' || $text === '') return safeText($text);
$m = $findMatchSpan($text, $needle);
if ($m === null) return safeText($text);
$before = mb_substr($text, 0, $m['start']);
$hit = mb_substr($text, $m['start'], $m['end'] - $m['start']);
$after = mb_substr($text, $m['end']);
return safeText($before) . '<mark>' . safeText($hit) . '</mark>' . safeText($after);
};
// Bool-Check: enthält der Text den Suchbegriff (normalisiert + Fuzzy)?
$textMatches = static function (string $text, string $needle) use ($findMatchSpan): bool {
return $findMatchSpan($text, $needle) !== null;
};
// Header-Detection: erste Zeile mit >= 3 belegten Zellen ist die Spalten-Header-Zeile.
// Title-Rows wie "Parking Operator parkenamschiff" (eine einzige Zelle) werden so
// automatisch übersprungen. Liefert ['cols','headers','rows','header_row_idx'].
$normalizeGrid = static function (array $grid): array {
$cols = $grid['cols'] ?? [];
$rows = $grid['rows'] ?? [];
$headerIdx = -1;
foreach ($rows as $i => $r) {
$nonEmpty = array_filter($r, static fn ($v) => $v !== '');
if (count($nonEmpty) >= 3) { $headerIdx = $i; break; }
}
$headers = [];
$dataRows = $rows;
if ($headerIdx >= 0) {
$hRow = $rows[$headerIdx];
foreach ($cols as $col) {
$label = trim((string)($hRow[$col] ?? ''));
$headers[$col] = $label !== '' ? $label : $col;
}
$dataRows = array_slice($rows, $headerIdx + 1);
} else {
foreach ($cols as $col) $headers[$col] = $col;
}
return [
'cols' => $cols,
'headers' => $headers,
'rows' => $dataRows,
'header_row_idx' => $headerIdx,
];
};
// Roher 2D-XLSX-Reader (mit Date-Format-Erkennung). Wird sowohl von der Datei-Vorschau
// als auch von der Inhalts-Suche genutzt.
$readXlsxGrid = static function (string $path): ?array {
$zip = new ZipArchive();
if ($zip->open($path) !== true) return null;
$ssXml = $zip->getFromName('xl/sharedStrings.xml');
$stylesXml = $zip->getFromName('xl/styles.xml');
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
$zip->close();
if ($sheetXml === false) return null;
$shared = [];
if ($ssXml !== false && ($ss = @simplexml_load_string($ssXml)) !== false) {
foreach ($ss->si as $si) {
$text = '';
if (isset($si->t)) $text = (string)$si->t;
elseif (isset($si->r)) foreach ($si->r as $r) $text .= (string)$r->t;
$shared[] = $text;
}
}
// Styles → Date-/Time-Format-Erkennung pro cellXf-Index.
$builtInDateFmts = [
14 => 'date', 15 => 'date', 16 => 'date', 17 => 'date',
18 => 'time', 19 => 'time', 20 => 'time', 21 => 'time',
22 => 'datetime',
45 => 'time', 46 => 'time', 47 => 'time',
];
$styleKind = [];
if ($stylesXml !== false && ($styles = @simplexml_load_string($stylesXml)) !== false) {
$customFmts = [];
if (isset($styles->numFmts->numFmt)) {
foreach ($styles->numFmts->numFmt as $f) {
$customFmts[(int)$f['numFmtId']] = (string)$f['formatCode'];
}
}
if (isset($styles->cellXfs->xf)) {
$i = 0;
foreach ($styles->cellXfs->xf as $xf) {
$numFmtId = (int)$xf['numFmtId'];
$kind = $builtInDateFmts[$numFmtId] ?? null;
if ($kind === null && isset($customFmts[$numFmtId])) {
$code = preg_replace('/\[[^\]]*\]/', '', $customFmts[$numFmtId]) ?? '';
$hasDate = (bool)preg_match('/[ymd]/i', $code);
$hasTime = (bool)preg_match('/h.*?m|am\/pm/i', $code);
if ($hasDate && $hasTime) $kind = 'datetime';
elseif ($hasDate) $kind = 'date';
elseif ($hasTime) $kind = 'time';
}
$styleKind[$i++] = $kind;
}
}
}
$excelDateToIso = static function (float $f, string $kind): string {
$days = (int)floor($f);
$base = strtotime('1899-12-30 +' . $days . ' days');
if ($base === false) return '';
if ($kind === 'date') return date('Y-m-d', $base);
if ($kind === 'time') {
$secs = (int)round($f * 86400);
return gmdate('H:i:s', $secs);
}
$secs = (int)round(($f - $days) * 86400);
return date('Y-m-d H:i:s', $base + $secs);
};
$sheet = @simplexml_load_string($sheetXml);
if ($sheet === false || !isset($sheet->sheetData)) return null;
$rows = [];
$cols = [];
foreach ($sheet->sheetData->row as $row) {
$cells = [];
foreach ($row->c as $c) {
$ref = (string)$c['r'];
$col = preg_replace('/\d+/', '', $ref);
$type = (string)$c['t'];
$styleIdx = isset($c['s']) ? (int)$c['s'] : -1;
$val = isset($c->v) ? (string)$c->v : '';
if ($type === 's' && $val !== '') {
$val = $shared[(int)$val] ?? '';
} elseif ($type === 'inlineStr' && isset($c->is->t)) {
$val = (string)$c->is->t;
} elseif ($val !== '' && is_numeric($val)
&& isset($styleKind[$styleIdx]) && $styleKind[$styleIdx] !== null) {
$val = $excelDateToIso((float)$val, $styleKind[$styleIdx]);
}
$cells[$col] = $val;
$cols[$col] = true;
}
if (empty(array_filter($cells, static fn ($v) => $v !== ''))) continue;
$rows[] = $cells;
}
$colList = array_keys($cols);
usort($colList, static fn ($a, $b) =>
strlen($a) === strlen($b) ? strcmp($a, $b) : strlen($a) - strlen($b)
);
return ['cols' => $colList, 'rows' => $rows];
};
// ---- Download ---------------------------------------------------
if ($page === 'reports/files/download') {
$rel = (string)($_GET['path'] ?? '');
$abs = $resolvePath($rel);
if ($abs === null || !is_file($abs)) {
http_response_code(404);
echo 'Datei nicht gefunden.';
exit;
}
$mime = function_exists('mime_content_type') ? (mime_content_type($abs) ?: 'application/octet-stream') : 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . basename($abs) . '"');
header('Content-Length: ' . filesize($abs));
readfile($abs);
exit;
}
// ---- View (XLSX/CSV als 2D-Tabelle, Header-frei) ---------------
if ($page === 'reports/files/view') {
$rel = (string)($_GET['path'] ?? '');
$abs = $resolvePath($rel);
if ($abs === null || !is_file($abs)) {
setFlash('warning', 'Datei nicht gefunden.');
redirect('/?page=reports/files');
}
$ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
// tableGrid = ['cols' => ['A','B',…], 'rows' => [['A'=>'val','B'=>'val',…], …]]
$tableGrid = null;
$errorMsg = null;
if ($ext === 'xlsx') {
$tableGrid = $readXlsxGrid($abs);
if ($tableGrid === null) {
$errorMsg = 'XLSX konnte nicht gelesen werden.';
} else {
// Header-Row (2. Zeile bei AIDA-Listen) erkennen und als Spalten-Labels nutzen.
$tableGrid = $normalizeGrid($tableGrid);
}
} elseif (in_array($ext, ['csv', 'txt', 'log'], true)) {
$rows = [];
$maxCols = 0;
if (($fh = fopen($abs, 'r')) !== false) {
while (($r = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
$rows[] = $r;
$maxCols = max($maxCols, count($r));
}
fclose($fh);
}
// CSV → numerische Indizes als „Pseudo-Spalten" mit Buchstaben kodieren.
$cols = [];
for ($i = 0; $i < $maxCols; $i++) {
$letter = '';
$n = $i;
do {
$letter = chr(65 + ($n % 26)) . $letter;
$n = intdiv($n, 26) - 1;
} while ($n >= 0);
$cols[] = $letter;
}
$assocRows = [];
foreach ($rows as $r) {
$row = [];
foreach ($cols as $i => $letter) $row[$letter] = $r[$i] ?? '';
$assocRows[] = $row;
}
$tableGrid = $normalizeGrid(['cols' => $cols, 'rows' => $assocRows]);
} else {
$errorMsg = 'Vorschau für Dateityp .' . $ext . ' nicht verfügbar — bitte Download nutzen.';
}
$filePath = $abs;
$fileRel = ltrim(str_replace($trashRoot, '', $abs), DIRECTORY_SEPARATOR);
$pdoFV = $db->getConnection();
$stmt = $pdoFV->prepare("SELECT * FROM files WHERE file_path = :p ORDER BY id DESC LIMIT 1");
$stmt->execute(['p' => $abs]);
$fileMeta = $stmt->fetch() ?: null;
// Import-Kontext: zur Datei gehöriger import_files-Eintrag + Batch + Event
// (Titel verlinkt zur Event-Detailseite). Per Filename matchen — UNIQUE(source,filename).
$importContext = null;
try {
$ctxStmt = $pdoFV->prepare(
"SELECT imf.filename, imf.product_code, imf.list_type AS file_list_type,
imf.status AS file_status, imf.summary AS file_summary, imf.imported_at,
b.id AS batch_id, b.source, b.cruise, b.ship_name AS batch_ship_name,
b.event_id, b.departure_date, b.arrival_date, b.file_date,
b.list_type AS batch_list_type, b.status AS batch_status,
b.started_at, b.finished_at, b.summary AS batch_summary,
e.title AS event_title, e.ship_name AS event_ship_name, e.terminal
FROM import_files imf
LEFT JOIN import_batches b ON b.id = imf.import_batch_id
LEFT JOIN events e ON e.id = b.event_id
WHERE imf.filename = :n
ORDER BY imf.id DESC LIMIT 1"
);
$ctxStmt->execute(['n' => basename($abs)]);
$importContext = $ctxStmt->fetch() ?: null;
} catch (Throwable) {
$importContext = null;
}
// Geschwister-Log (<datei>.log) ermitteln — wird als "Log"-Button neben
// Download / Undo in der Datei-Vorschau angeboten.
$logSiblingRel = null;
if ($abs !== $trashRoot) {
$logCandidate = $abs . '.log';
if (is_file($logCandidate)) {
$logSiblingRel = ltrim(str_replace($trashRoot, '', $logCandidate), DIRECTORY_SEPARATOR);
}
}
require __DIR__ . '/../app/views/reports/file-view.php';
break;
}
// ---- Listing (Top-Level Ordner + optional Drill-in) ------------
$currentRel = (string)($_GET['dir'] ?? '');
$currentAbs = $currentRel === '' ? $trashRoot : $resolvePath($currentRel);
if ($currentAbs === null || !is_dir($currentAbs)) {
$currentAbs = $trashRoot;
$currentRel = '';
}
$searchQuery = trim((string)($_GET['q'] ?? ''));
$folders = [];
$files = [];
// Projekt-Root → für die Umrechnung absoluter Pfade in die web-relative Form
// (wie sie in files.file_path gespeichert wird: "/data/trash/YYYY-MM-DD/datei.xlsx").
$projectRootForWeb = realpath(__DIR__ . '/..');
$toWebPath = static function (string $abs) use ($projectRootForWeb): string {
if ($projectRootForWeb !== false && str_starts_with($abs, $projectRootForWeb)) {
return substr($abs, strlen($projectRootForWeb));
}
return $abs;
};
// Datei-Eintrag-Bauer wiederverwendbar — sammelt $abs/$rel/$size/$mtime/$ext.
$makeFileEntry = static function (string $abs, string $rel) use ($toWebPath): array {
return [
'name' => basename($abs),
'rel' => $rel,
'abs' => $abs,
'web' => $toWebPath($abs), // Match-Key gegen files.file_path
'size' => filesize($abs) ?: 0,
'mtime' => filemtime($abs) ?: 0,
'ext' => strtolower(pathinfo($abs, PATHINFO_EXTENSION)),
];
};
if ($searchQuery !== '') {
// Schnellsuche: rekursiv ab dem aktuellen Verzeichnis durch alle Dateien.
// (Im Root → alle Tagesordner, im Unterordner → nur dessen Inhalt.)
// Match: zuerst gegen den Dateinamen (billig), dann gegen den Datei-INHALT.
//
// Für XLSX: SharedStrings + sheet1.xml als Text extrahieren (ohne Tags). Liefert
// alle textuellen UND numerischen Zellwerte. Für CSV/TXT: direkt lesen. Dateien
// > 10 MB werden aus Performance-Gründen nur per Name durchsucht.
// Der Match nutzt $textMatches → Normalisierung + Fuzzy (Buchstabendreher).
$matchContent = static function (string $path, string $rawNeedle) use ($textMatches): bool {
$size = filesize($path) ?: 0;
if ($size <= 0 || $size > 10 * 1024 * 1024) return false;
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($ext === 'xlsx') {
$zip = new ZipArchive();
if ($zip->open($path) !== true) return false;
$haystack = '';
foreach (['xl/sharedStrings.xml', 'xl/worksheets/sheet1.xml'] as $part) {
$xml = $zip->getFromName($part);
if ($xml === false) continue;
$haystack .= ' ' . strip_tags($xml);
}
$zip->close();
if ($haystack === '') return false;
$haystack = html_entity_decode($haystack, ENT_QUOTES | ENT_XML1, 'UTF-8');
return $textMatches($haystack, $rawNeedle);
}
if (in_array($ext, ['csv', 'txt', 'log'], true)) {
$content = @file_get_contents($path);
return $content !== false && $textMatches($content, $rawNeedle);
}
return false;
};
// Zeilen aus der Datei extrahieren, die das Pattern enthalten (max. 50 Treffer).
// Match je Zeile nutzt Fuzzy-Logik aus $textMatches.
$extractMatchingRows = static function (string $path, string $rawNeedle) use ($readXlsxGrid, $normalizeGrid, $textMatches): ?array {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($ext === 'xlsx') {
$grid = $readXlsxGrid($path);
if ($grid === null) return null;
$grid = $normalizeGrid($grid);
$matches = [];
foreach ($grid['rows'] as $r) {
$line = implode(' ', array_map(static fn ($v) => (string)$v, $r));
if ($textMatches($line, $rawNeedle)) {
$matches[] = $r;
if (count($matches) >= 50) break;
}
}
return ['cols' => $grid['cols'], 'headers' => $grid['headers'], 'rows' => $matches];
}
if (in_array($ext, ['csv', 'txt', 'log'], true)) {
$maxCols = 0;
$allRows = [];
if (($fh = fopen($path, 'r')) !== false) {
while (($r = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
$allRows[] = $r;
$maxCols = max($maxCols, count($r));
}
fclose($fh);
}
$cols = [];
for ($i = 0; $i < $maxCols; $i++) {
$letter = '';
$n = $i;
do {
$letter = chr(65 + ($n % 26)) . $letter;
$n = intdiv($n, 26) - 1;
} while ($n >= 0);
$cols[] = $letter;
}
$assocAll = [];
foreach ($allRows as $r) {
$assoc = [];
foreach ($cols as $i => $letter) $assoc[$letter] = $r[$i] ?? '';
$assocAll[] = $assoc;
}
$normalized = $normalizeGrid(['cols' => $cols, 'rows' => $assocAll]);
$matches = [];
foreach ($normalized['rows'] as $r) {
$line = implode(' ', array_map(static fn ($v) => (string)$v, $r));
if ($textMatches($line, $rawNeedle)) {
$matches[] = $r;
if (count($matches) >= 50) break;
}
}
return ['cols' => $normalized['cols'], 'headers' => $normalized['headers'], 'rows' => $matches];
}
return null;
};
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($currentAbs, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iter as $fileInfo) {
if (!$fileInfo->isFile()) continue;
$name = $fileInfo->getFilename();
if ($name[0] === '.') continue;
$nameMatch = $textMatches($name, $searchQuery);
$absPath = $fileInfo->getPathname();
$contentMatch = $nameMatch ? false : $matchContent($absPath, $searchQuery);
if (!$nameMatch && !$contentMatch) continue;
$relPath = ltrim(str_replace($trashRoot, '', $absPath), DIRECTORY_SEPARATOR);
$entry = $makeFileEntry($absPath, $relPath);
$entry['match_kind'] = $nameMatch ? 'name' : 'content';
if ($contentMatch) {
$entry['matches'] = $extractMatchingRows($absPath, $searchQuery);
}
$files[] = $entry;
}
} else {
$entries = scandir($currentAbs) ?: [];
foreach ($entries as $name) {
if ($name === '' || $name[0] === '.') continue;
$abs = $currentAbs . DIRECTORY_SEPARATOR . $name;
$rel = ltrim(($currentRel === '' ? '' : $currentRel . '/') . $name, '/');
if (is_dir($abs)) {
// Ordner-Count: nur Dateien, die in `files` registriert sind und NICHT
// 'import_log' (Logs werden über den Log-Button geöffnet, nicht direkt gezählt).
$dirWeb = $toWebPath($abs);
$cnt = $db->getConnection()->prepare("SELECT COUNT(*) FROM files
WHERE file_path LIKE :p
AND file_category <> 'import_log'");
$cnt->execute(['p' => $dirWeb . '/%']);
$folders[] = [
'name' => $name,
'rel' => $rel,
'count' => (int)$cnt->fetchColumn(),
];
} else {
$files[] = $makeFileEntry($abs, $rel);
}
}
}
usort($folders, fn ($a, $b) => strcmp($b['name'], $a['name']));
usort($files, fn ($a, $b) => $b['mtime'] <=> $a['mtime']);
// files-Tabelle in eine Map { web-Pfad => Meta } laden — files.file_path wird seit
// jetzt als "/data/trash/YYYY-MM-DD/datei.xlsx" gespeichert (web-relativ).
// WICHTIG: Listing zeigt NUR Dateien, die in `files` registriert sind. Log-Dateien
// (`import_log`) werden ausgeblendet und über den "Log"-Button erreicht.
$filesMeta = [];
if (!empty($files)) {
$webPaths = array_column($files, 'web');
$placeholders = implode(',', array_fill(0, count($webPaths), '?'));
$stmt = $db->getConnection()->prepare("SELECT file_path, file_md5, file_category, file_datetime
FROM files WHERE file_path IN ($placeholders)");
$stmt->execute($webPaths);
foreach ($stmt->fetchAll() as $row) {
$filesMeta[$row['file_path']] = $row;
}
}
$files = array_values(array_filter(
$files,
static fn ($f) => isset($filesMeta[$f['web']])
&& ($filesMeta[$f['web']]['file_category'] ?? '') !== 'import_log'
));
// View greift im Loop nur über $filesMeta[$f['abs']] — wir aliasen den Eintrag,
// damit beide Keys dasselbe liefern.
foreach ($files as $f) {
if (isset($filesMeta[$f['web']]) && !isset($filesMeta[$f['abs']])) {
$filesMeta[$f['abs']] = $filesMeta[$f['web']];
}
}
// Existiert ein Geschwister-Log (<datei>.log) — Map zum Anzeigen des Log-Buttons.
$logSibling = [];
foreach ($files as $f) {
$candidate = $f['abs'] . '.log';
if (is_file($candidate)) {
$logSibling[$f['abs']] = ltrim(str_replace($trashRoot, '', $candidate), DIRECTORY_SEPARATOR);
}
}
// Import-History.
// - Im Root-View: letzte 3 Importe global (Schnellüberblick).
// - In einem Tag-Ordner ($currentRel != ''): nur die Batches, deren Dateien
// in diesem Ordner liegen (über `files.file_path` aufgelöst).
// In beiden Fällen werden Prediction-Dateien (ParkingPredictionlist) als
// synthetische Pseudo-Batches mit reingemischt — der PrebookingImporter schreibt
// selbst nicht in import_batches, würde im Verlauf also unsichtbar bleiben.
require_once __DIR__ . '/../app/views/partials/import-history.php';
$importHistory = [];
$importHistoryTitle = 'Import-Verlauf';
$importHistorySubtitle = 'Die letzten 3 verarbeiteten Anläufe.';
try {
$pdoH = $db->getConnection();
$batches = [];
$predictions = [];
if ($currentRel !== '' && !empty($files)) {
// Dateinamen → Batch-IDs auflösen (Filename ist UNIQUE in import_files pro Quelle).
$names = array_values(array_unique(array_map(static fn ($f) => (string)$f['name'], $files)));
$ph = implode(',', array_fill(0, count($names), '?'));
$bidStmt = $pdoH->prepare(
"SELECT DISTINCT import_batch_id FROM import_files
WHERE filename IN ($ph) AND import_batch_id IS NOT NULL"
);
$bidStmt->execute($names);
$bIds = array_map(static fn ($r) => (int)$r['import_batch_id'], $bidStmt->fetchAll());
if (empty($bIds)) {
$batches = [];
} else {
$ph2 = implode(',', array_fill(0, count($bIds), '?'));
$batchStmt = $pdoH->prepare(
"SELECT id, source, cruise, event_id, ship_name, departure_date,
arrival_date, file_date, list_type, status, started_at,
finished_at, summary
FROM import_batches
WHERE id IN ($ph2)
ORDER BY started_at DESC"
);
$batchStmt->execute($bIds);
$batches = $batchStmt->fetchAll();
}
// Synthetische Prediction-Einträge — nur Dateien dieses Ordners.
$predictions = _ih_load_prediction_batches($names);
$importHistoryTitle = 'Import-Verlauf · ' . $currentRel;
$importHistorySubtitle = 'Anläufe, deren Dateien in diesem Ordner liegen.';
} else {
$batchStmt = $pdoH->query(
"SELECT b.id, b.source, b.cruise, b.event_id, b.ship_name, b.departure_date,
b.arrival_date, b.file_date, b.list_type, b.status, b.started_at,
b.finished_at, b.summary
FROM import_batches b
ORDER BY b.started_at DESC
LIMIT 3"
);
$batches = $batchStmt->fetchAll();
// Im Root-View die letzten 3 Predictions dazu — sie tauchen sonst nirgends auf.
$predictions = _ih_load_prediction_batches(null, 3);
}
// Mergen + neu sortieren nach Zeit DESC.
$batches = array_merge($batches, $predictions);
usort($batches, static function ($a, $b) {
return strtotime((string)($b['started_at'] ?? '0')) <=> strtotime((string)($a['started_at'] ?? '0'));
});
// Im Root-View harten Cap auf 3, nachdem Predictions gemerged sind.
if ($currentRel === '') {
$batches = array_slice($batches, 0, 3);
}
if (!empty($batches)) {
$batchIds = array_map(static fn ($r) => (int)$r['id'], $batches);
$ph = implode(',', array_fill(0, count($batchIds), '?'));
$fStmt = $pdoH->prepare(
"SELECT import_batch_id, filename, product_code, list_type, status, summary, imported_at
FROM import_files
WHERE import_batch_id IN ($ph)
ORDER BY product_code ASC, filename ASC"
);
$fStmt->execute($batchIds);
$filesByBatch = [];
foreach ($fStmt->fetchAll() as $row) {
$filesByBatch[(int)$row['import_batch_id']][] = $row;
}
foreach ($batches as $b) {
// Pseudo-Predictions haben '_files' schon vom Helper gesetzt — nicht überschreiben.
if (empty($b['_prediction'])) {
$b['_files'] = $filesByBatch[(int)$b['id']] ?? [];
}
$importHistory[] = $b;
}
}
} catch (Throwable) {
// Wenn die Tabellen noch nicht existieren (Migration 028 nicht gelaufen), History einfach leer lassen.
$importHistory = [];
}
require __DIR__ . '/../app/views/reports/files.php';
break;
case 'reports/worktime':
$pageTitle = 'Mitarbeiter-Zeiterfassung';
$pageSubtitle = 'Monatsübersicht, manuelle Erfassung und Excel-Export.';
$activeNav = 'reports-worktime';
// Default-Year/Month: wenn der Aufrufer nichts mitgibt, auf den jüngsten Monat
// mit Daten springen — sonst sieht der User beim ersten Besuch eine leere Seite.
// year=0 → alle Jahre, month=0 → alle Monate (Wildcard-Filter).
$hasYearParam = isset($_GET['year']);
$hasMonthParam = isset($_GET['month']);
$year = (int)($_GET['year'] ?? date('Y'));
$month = (int)($_GET['month'] ?? date('n'));
if ($year !== 0 && ($year < 2000 || $year > 2100)) $year = (int)date('Y');
if ($month < 0 || $month > 12) $month = (int)date('n');
if (!$hasYearParam && !$hasMonthParam) {
$latest = $workHourRepo->latestMonth();
if ($latest !== null) {
$year = $latest['year'];
$month = $latest['month'];
}
}
$selectedEmployeeId = (int)($_GET['employee_id'] ?? 0);
if ($selectedEmployeeId < 0) $selectedEmployeeId = 0;
$employees = $workEmployeeRepo->allActive();
$allEmployees = $workEmployeeRepo->all();
$entries = $workHourRepo->byMonth($year, $month, $selectedEmployeeId ?: null);
$summary = $workHourRepo->summaryByMonth($year, $month);
$yearsWithData = $workHourRepo->availableYears();
// Edit-Modus per ?edit=<id>.
$editEntry = null;
$editId = (int)($_GET['edit'] ?? 0);
if ($editId > 0) {
$editEntry = $workHourRepo->find($editId);
}
// Validierungs-Reste aus Flash übernehmen.
$errors = $_SESSION['worktime_errors'] ?? [];
$oldInput = $_SESSION['worktime_oldInput'] ?? [];
unset($_SESSION['worktime_errors'], $_SESSION['worktime_oldInput']);
require __DIR__ . '/../app/views/worktime/index.php';
break;
case 'reports/worktime/store':
$backY = (int)($_POST['year'] ?? date('Y'));
$backM = (int)($_POST['month'] ?? date('n'));
$backE = (int)($_POST['back_employee_id'] ?? 0);
$backQ = trim((string)($_POST['back_q'] ?? ''));
$backUrl = '/?page=reports/worktime&year=' . $backY . '&month=' . $backM
. ($backE ? '&employee_id=' . $backE : '')
. ($backQ !== '' ? '&q=' . urlencode($backQ) : '');
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$respondJson = static function (int $status, array $payload): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
if (!isPost()) {
if ($isAjax) $respondJson(405, ['ok' => false, 'message' => 'Methode nicht erlaubt.']);
redirect($backUrl);
}
requireCsrfOrAbort($backUrl);
$data = [
'employee_id' => (int)($_POST['employee_id'] ?? 0),
'date' => trim((string)($_POST['date'] ?? '')),
'start_time' => trim((string)($_POST['start_time'] ?? '')),
'end_time' => trim((string)($_POST['end_time'] ?? '')),
'break_minutes' => (int)($_POST['break_minutes'] ?? 0),
'note' => (string)($_POST['note'] ?? ''),
];
$errors = $workHourRepo->validate($data);
if (!empty($errors)) {
if ($isAjax) {
$respondJson(422, [
'ok' => false,
'message' => 'Eintrag konnte nicht gespeichert werden.',
'errors' => $errors,
]);
}
$_SESSION['worktime_errors'] = array_values($errors);
$_SESSION['worktime_oldInput'] = array_merge($data, ['id' => (int)($_POST['id'] ?? 0)]);
setFlash('danger', 'Eintrag konnte nicht gespeichert werden.');
redirect($backUrl);
}
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$workHourRepo->update($id, $data);
$msg = 'Eintrag aktualisiert.';
} else {
$workHourRepo->create($data);
$msg = 'Eintrag gespeichert.';
}
if ($isAjax) $respondJson(200, ['ok' => true, 'message' => $msg]);
setFlash('success', $msg);
redirect($backUrl);
break;
case 'reports/worktime/delete':
$backY = (int)($_POST['year'] ?? date('Y'));
$backM = (int)($_POST['month'] ?? date('n'));
$backE = (int)($_POST['employee_id'] ?? 0);
$backQ = trim((string)($_POST['back_q'] ?? ''));
$backUrl = '/?page=reports/worktime&year=' . $backY . '&month=' . $backM
. ($backE ? '&employee_id=' . $backE : '')
. ($backQ !== '' ? '&q=' . urlencode($backQ) : '');
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$respondJson = static function (int $status, array $payload): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
if (!isPost()) {
if ($isAjax) $respondJson(405, ['ok' => false, 'message' => 'Methode nicht erlaubt.']);
redirect($backUrl);
}
requireCsrfOrAbort($backUrl);
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$workHourRepo->delete($id);
}
if ($isAjax) $respondJson(200, ['ok' => true, 'message' => 'Eintrag gelöscht.']);
setFlash('success', 'Eintrag gelöscht.');
redirect($backUrl);
break;
case 'reports/worktime/employee/store':
// Mitarbeiter anlegen/aktualisieren — flach, ohne eigene Modal-Routen.
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$respondJson = static function (int $status, array $payload): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
if (!isPost()) {
if ($isAjax) $respondJson(405, ['ok' => false, 'message' => 'Methode nicht erlaubt.']);
redirect('/?page=reports/worktime');
}
$backUrl = '/?page=reports/worktime';
requireCsrfOrAbort($backUrl);
$id = (int)($_POST['id'] ?? 0);
$firstName = trim((string)($_POST['first_name'] ?? ''));
$lastName = trim((string)($_POST['last_name'] ?? ''));
$maxHours = (float)str_replace(',', '.', (string)($_POST['max_hours'] ?? '0'));
$isActive = !empty($_POST['is_active']);
$errors = [];
if ($firstName === '') $errors[] = 'Vorname ist erforderlich.';
if ($lastName === '') $errors[] = 'Nachname ist erforderlich.';
if ($maxHours < 0 || $maxHours > 999) $errors[] = 'Soll-Stunden müssen zwischen 0 und 999 liegen.';
if (!empty($errors)) {
if ($isAjax) {
$respondJson(422, ['ok' => false, 'message' => implode(' · ', $errors), 'errors' => $errors]);
}
setFlash('danger', implode(' · ', $errors));
redirect($backUrl);
}
$data = [
'first_name' => $firstName,
'last_name' => $lastName,
'max_hours' => $maxHours,
'is_active' => $isActive ? 1 : 0,
];
try {
if ($id > 0) {
$workEmployeeRepo->update($id, $data);
$msg = 'Mitarbeiter aktualisiert.';
$resId = $id;
} else {
$newId = $workEmployeeRepo->create($data);
$msg = 'Mitarbeiter „' . $firstName . ' ' . $lastName . '" angelegt.';
$resId = $newId;
}
} catch (Throwable $e) {
if ($isAjax) $respondJson(500, ['ok' => false, 'message' => 'Speichern fehlgeschlagen: ' . $e->getMessage()]);
setFlash('danger', 'Speichern fehlgeschlagen: ' . $e->getMessage());
redirect($backUrl . '#mitarbeiter');
}
if ($isAjax) {
$emp = $workEmployeeRepo->find((int)$resId);
$respondJson(200, ['ok' => true, 'message' => $msg, 'employee' => $emp]);
}
setFlash('success', $msg);
redirect($backUrl . '#mitarbeiter');
break;
case 'reports/worktime/employee/delete':
// "Löschen" = soft-delete via is_active=0. Erst auf Wunsch (delete_hard=1)
// wird die Datenbank-Zeile entfernt — was wegen FK cascade auch die work_hours
// dieses Mitarbeiters mitnimmt. Standard ist deaktivieren, damit Historie bleibt.
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$respondJson = static function (int $status, array $payload): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
};
if (!isPost()) {
if ($isAjax) $respondJson(405, ['ok' => false, 'message' => 'Methode nicht erlaubt.']);
redirect('/?page=reports/worktime');
}
$backUrl = '/?page=reports/worktime';
requireCsrfOrAbort($backUrl);
$id = (int)($_POST['id'] ?? 0);
$hard = !empty($_POST['delete_hard']);
$resultMsg = '';
$resultType = 'success';
try {
if ($id > 0) {
if ($hard) {
$workEmployeeRepo->delete($id);
$resultMsg = 'Mitarbeiter (inkl. aller erfassten Stunden) gelöscht.';
$resultType = 'warning';
} else {
$emp = $workEmployeeRepo->find($id);
if ($emp) {
$workEmployeeRepo->update($id, [
'first_name' => $emp['first_name'],
'last_name' => $emp['last_name'],
'max_hours' => $emp['max_hours'],
'is_active' => 0,
]);
$resultMsg = 'Mitarbeiter deaktiviert.';
}
}
}
} catch (Throwable $e) {
if ($isAjax) $respondJson(500, ['ok' => false, 'message' => 'Aktion fehlgeschlagen: ' . $e->getMessage()]);
setFlash('danger', 'Aktion fehlgeschlagen: ' . $e->getMessage());
redirect($backUrl . '#mitarbeiter');
}
if ($isAjax) {
$emp = $hard ? null : $workEmployeeRepo->find($id);
$respondJson(200, ['ok' => true, 'message' => $resultMsg, 'type' => $resultType, 'employee' => $emp, 'hard' => $hard]);
}
setFlash($resultType, $resultMsg);
redirect($backUrl . '#mitarbeiter');
break;
case 'reports/worktime/export':
// year=0 / month=0 = Wildcard — analog zum Filter in der Ansicht.
$year = (int)($_GET['year'] ?? date('Y'));
$month = (int)($_GET['month'] ?? date('n'));
if ($year !== 0 && ($year < 2000 || $year > 2100)) $year = (int)date('Y');
if ($month < 0 || $month > 12) $month = (int)date('n');
$perEmp = !empty($_GET['per_emp_sheets']);
$employees = $workEmployeeRepo->allActive();
$entries = $workHourRepo->byMonth($year, $month, null);
$summary = $workHourRepo->summaryByMonth($year, $month);
(new WorkTimeExportService())->streamMonthExport(
$year, $month, $employees, $entries, $summary, $perEmp
);
exit;
case 'reports/export-pdf':
$dateFrom = trim((string)($_GET['date_from'] ?? ''));
$dateTo = trim((string)($_GET['date_to'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) $dateFrom = '';
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) $dateTo = '';
$reportRepo->setHiddenSources(hiddenSourcesFor($settingsRepo, 'reports'));
$data = [
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'monthly' => $reportRepo->monthlyBookings(12, $dateFrom ?: null, $dateTo ?: null),
'products' => $reportRepo->productDistribution($dateFrom ?: null, $dateTo ?: null),
'paymentStatus' => $reportRepo->paymentStatusDistribution($dateFrom ?: null, $dateTo ?: null),
'topEvents' => $reportRepo->topEvents(10, $dateFrom ?: null, $dateTo ?: null),
'kpis' => $reportRepo->kpis($dateFrom ?: null, $dateTo ?: null),
'checkinRate' => $reportRepo->checkinRate($dateFrom ?: null, $dateTo ?: null),
'openInvoices' => $reportRepo->openInvoicesAmount($dateFrom ?: null, $dateTo ?: null),
'company' => $settingsRepo->company(),
];
$stamp = date('Y-m-d_H-i');
$baseName = 'report-' . $stamp . ($dateFrom || $dateTo ? '-' . ($dateFrom ?: 'open') . '_' . ($dateTo ?: date('Y-m-d')) : '');
try {
$path = PdfRenderer::save(
__DIR__ . '/../app/views/pdf/reports.php',
$data,
'storage/exports',
$baseName
);
$abs = PdfRenderer::absolutePath($path);
$isPdf = str_ends_with($abs, '.pdf');
header('Content-Type: ' . ($isPdf ? 'application/pdf' : 'text/html; charset=utf-8'));
if (isset($_GET['download'])) {
header('Content-Disposition: attachment; filename="' . basename($abs) . '"');
} else {
header('Content-Disposition: inline; filename="' . basename($abs) . '"');
}
header('Content-Length: ' . filesize($abs));
readfile($abs);
exit;
} catch (Throwable $e) {
setFlash('danger', 'Report-Export konnte nicht erzeugt werden: ' . $e->getMessage());
redirect('/?page=reports&' . http_build_query(array_filter(['date_from' => $dateFrom, 'date_to' => $dateTo])));
}
break;
case 'messages/templates':
if (!Permission::can(Auth::role(), 'messages') && Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'E-Mail-Templates';
$pageSubtitle = 'Vorlagen mit Platzhaltern, Anhängen und Auto-Trigger.';
$activeNav = 'messages-templates';
$templates = $emailTemplateRepo->all();
require __DIR__ . '/../app/views/messages/templates-index.php';
break;
case 'messages/templates/create':
case 'messages/templates/edit':
if (!Permission::can(Auth::role(), 'messages') && Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$isEdit = $page === 'messages/templates/edit';
$existing = null;
if ($isEdit) {
$existing = $emailTemplateRepo->find((int)($_GET['id'] ?? 0));
if (!$existing) {
http_response_code(404);
echo '<div class="alert alert-danger">Template nicht gefunden.</div>';
break;
}
}
$pageTitle = $isEdit ? 'Template bearbeiten' : 'Neues Template';
$pageSubtitle = 'Platzhalter siehe Sidebar — alle Felder werden beim Versand ersetzt.';
$activeNav = 'messages-templates';
$errors = [];
$data = $existing ?? [
'name' => '', 'description' => '', 'subject' => '',
'body_html' => "<p>Sehr geehrte/r {customer.first_name} {customer.last_name},</p>\n<p>vielen Dank für Ihre Buchung <strong>{booking.no}</strong>.</p>\n<p>{products_list}</p>",
'cc_addresses' => '', 'bcc_addresses' => '',
'attach_confirmation' => 0, 'attach_invoice' => 0,
'trigger_event' => 'manual', 'trigger_offset_days' => 0,
'is_active' => 1,
];
if (isPost()) {
$data = array_merge($data, $_POST);
$errors = EmailTemplate::validate($data);
if (empty($errors) && $emailTemplateRepo->nameExists((string)$data['name'], $isEdit ? (int)$existing['id'] : null)) {
$errors['name'] = 'Template mit diesem Namen existiert bereits.';
}
if (empty($errors)) {
if ($isEdit) {
$emailTemplateRepo->update((int)$existing['id'], $data);
AuditLog::write('settings', (int)$existing['id'], 'email_template_updated',
'Template aktualisiert: ' . $data['name']);
setFlash('success', 'Template gespeichert.');
} else {
$newId = $emailTemplateRepo->create($data);
AuditLog::write('settings', $newId, 'email_template_created',
'Template angelegt: ' . $data['name']);
setFlash('success', 'Template angelegt.');
}
redirect('/?page=messages/templates');
}
}
$placeholders = EmailTemplateRenderer::placeholderList();
$events = EmailTemplate::EVENTS;
require __DIR__ . '/../app/views/messages/template-form.php';
break;
case 'messages/templates/delete':
if (!isPost()) redirect('/?page=messages/templates');
if (!Permission::can(Auth::role(), 'messages') && Auth::role() !== 'admin') {
http_response_code(403); break;
}
requireCsrfOrAbort('/?page=messages/templates');
$tid = (int)($_POST['id'] ?? 0);
if ($tid > 0) {
$emailTemplateRepo->delete($tid);
AuditLog::write('settings', $tid, 'email_template_deleted', 'Template gelöscht');
setFlash('warning', 'Template gelöscht.');
}
redirect('/?page=messages/templates');
break;
case 'messages/templates/preview':
// AJAX-Endpoint: rendert ein Template gegen eine konkrete Buchung und
// gibt subject/body/recipient/attachments als JSON zurück.
// Akzeptiert POST mit Form-Overrides (subject, body_html, cc, bcc, attach_*),
// damit die Vorschau noch nicht gespeicherte Bearbeitungen anzeigt.
header('Content-Type: application/json; charset=utf-8');
$tid = (int)($_GET['id'] ?? 0);
$bid = (int)($_GET['booking_id'] ?? $_POST['booking_id'] ?? 0);
if ($tid <= 0 || $bid <= 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => 'id und booking_id erforderlich.']);
exit;
}
$tpl = $emailTemplateRepo->find($tid);
if (!$tpl) {
http_response_code(404);
echo json_encode(['ok' => false, 'message' => 'Template nicht gefunden.']);
exit;
}
// Form-Overrides anwenden — der Render-Step nutzt diese Werte direkt, ohne
// dass das Template gespeichert werden muss.
if (isset($_POST['subject'])) $tpl['subject'] = (string)$_POST['subject'];
if (isset($_POST['body_html'])) $tpl['body_html'] = (string)$_POST['body_html'];
if (isset($_POST['cc_addresses'])) $tpl['cc_addresses'] = (string)$_POST['cc_addresses'];
if (isset($_POST['bcc_addresses'])) $tpl['bcc_addresses'] = (string)$_POST['bcc_addresses'];
if (isset($_POST['attach_confirmation'])) $tpl['attach_confirmation'] = $_POST['attach_confirmation'] ? 1 : 0;
if (isset($_POST['attach_invoice'])) $tpl['attach_invoice'] = $_POST['attach_invoice'] ? 1 : 0;
try {
$rendered = $emailRenderer->renderForBooking($tpl, $bid);
echo json_encode([
'ok' => true,
'subject' => $rendered['subject'],
'body' => $rendered['body'],
'to' => $rendered['to'],
'cc' => $rendered['cc'],
'bcc' => $rendered['bcc'],
'attachments' => array_map(static fn($a) => ['name' => $a['name']], $rendered['attachments']),
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'message' => $e->getMessage()]);
}
exit;
case 'messages/templates/search-bookings':
// Live-Suche für die Preview-Auswahl. Kennzeichen, Booking-No, Name.
header('Content-Type: application/json; charset=utf-8');
$q = trim((string)($_GET['q'] ?? ''));
if (mb_strlen($q) < 2) {
echo json_encode(['ok' => true, 'results' => []]);
exit;
}
$like = '%' . $q . '%';
$stmt = $db->getConnection()->prepare("SELECT b.id, b.booking_no, b.license_plate, b.travel_from,
c.first_name, c.last_name, c.email,
e.title AS event_title, e.ship_name
FROM bookings b
INNER JOIN customers c ON c.id = b.customer_id
INNER JOIN events e ON e.id = b.event_id
WHERE b.booking_no LIKE :q1
OR b.license_plate LIKE :q2
OR c.first_name LIKE :q3
OR c.last_name LIKE :q4
OR c.email LIKE :q5
ORDER BY b.id DESC LIMIT 15");
$stmt->execute(['q1' => $like, 'q2' => $like, 'q3' => $like, 'q4' => $like, 'q5' => $like]);
$rows = $stmt->fetchAll();
$results = array_map(static fn($r) => [
'id' => (int)$r['id'],
'label' => trim(($r['first_name'] ?? '') . ' ' . ($r['last_name'] ?? '')),
'sub' => $r['booking_no']
. ($r['license_plate'] ? ' · ' . $r['license_plate'] : '')
. ($r['event_title'] ? ' · ' . $r['event_title'] : ''),
'email' => $r['email'],
], $rows);
echo json_encode(['ok' => true, 'results' => $results], JSON_UNESCAPED_UNICODE);
exit;
case 'messages':
if (!Permission::can(Auth::role(), 'messages')) {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'E-Mail';
$pageSubtitle = 'Versendete und vorbereitete Nachrichten.';
$activeNav = 'messages';
$search = trim((string)($_GET['q'] ?? ''));
$statusRaw = (string)($_GET['status'] ?? '');
$statusFilter = $statusRaw === '0' ? 0 : ($statusRaw === '1' ? 1 : null);
$allMessages = $messageRepo->recent(2000, $search, $statusFilter);
['perPage' => $pp, 'page' => $pn] = paginationParams();
$pagination = paginate($allMessages, $pp, $pn);
$messages = $pagination['items'];
require __DIR__ . '/../app/views/messages/index.php';
break;
case 'messages/show':
if (!Permission::can(Auth::role(), 'messages')) {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$mid = (int)($_GET['id'] ?? 0);
$message = $messageRepo->find($mid);
if (!$message) {
setFlash('danger', 'Nachricht nicht gefunden.');
redirect('/?page=messages');
}
$pageTitle = 'Nachricht ansehen';
$pageSubtitle = $message['message_subject'];
$activeNav = 'messages';
require __DIR__ . '/../app/views/messages/show.php';
break;
case 'messages/edit':
if (!Permission::can(Auth::role(), 'messages')) {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$mid = (int)($_GET['id'] ?? 0);
$message = $messageRepo->find($mid);
if (!$message) {
setFlash('danger', 'Nachricht nicht gefunden.');
redirect('/?page=messages');
}
$pageTitle = 'Nachricht bearbeiten';
$pageSubtitle = $message['message_subject'];
$activeNav = 'messages';
$errors = [];
if (isPost()) {
requireCsrfOrAbort('/?page=messages/edit&id=' . $mid);
$messageRepo->update($mid, [
'message_status' => 0, // Edit setzt auf "ungesendet"
'message_subject' => trim((string)($_POST['message_subject'] ?? '')),
'message_sender' => trim((string)($_POST['message_sender'] ?? '')),
'message_receiver' => trim((string)($_POST['message_receiver'] ?? '')),
'message_content' => (string)($_POST['message_content'] ?? ''),
]);
AuditLog::write('message', $mid, 'updated', 'Nachricht bearbeitet.');
setFlash('success', 'Nachricht gespeichert. Status zurück auf „offen".');
redirect('/?page=messages/show&id=' . $mid);
}
require __DIR__ . '/../app/views/messages/edit.php';
break;
case 'messages/resend':
if (!isPost()) {
redirect('/?page=messages');
}
if (!Permission::can(Auth::role(), 'messages')) {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$mid = (int)($_GET['id'] ?? 0);
$message = $messageRepo->find($mid);
if (!$message) {
setFlash('danger', 'Nachricht nicht gefunden.');
redirect('/?page=messages');
}
requireCsrfOrAbort('/?page=messages/show&id=' . $mid);
$messageRepo->markPending($mid);
$result = $mailService->sendRaw(
(string)$message['message_receiver'],
(string)$message['message_subject'],
(string)$message['message_content']
);
if (!empty($result['ok'])) {
$messageRepo->markSent($mid, $result['transport'] ?? null);
AuditLog::write('message', $mid, 'resent',
'E-Mail erneut versendet an ' . $message['message_receiver']);
setFlash('success', 'E-Mail erneut versendet (' . safeText($result['transport'] ?? '–') . ').');
} else {
$messageRepo->markFailed($mid, (string)($result['error'] ?? 'Unbekannter Fehler'), $result['transport'] ?? null);
setFlash('warning', 'Versand fehlgeschlagen: ' . safeText($result['error'] ?? 'Unbekannter Fehler'));
}
redirect('/?page=messages/show&id=' . $mid);
break;
case 'messages/settings':
if (!Permission::can(Auth::role(), 'messages')) {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'E-Mail · Einstellungen';
$pageSubtitle = 'SMTP-Server und Absender konfigurieren.';
$activeNav = 'messages-settings';
$errors = [];
if (isPost()) {
if (Auth::role() !== 'admin') {
setFlash('danger', 'Nur Administratoren dürfen Mail-Einstellungen ändern.');
redirect('/?page=messages/settings');
}
requireCsrfOrAbort('/?page=messages/settings');
$mailKeys = ['smtp_host','smtp_port','smtp_user','smtp_encryption','mail_from_email','mail_from_name'];
$changes = [];
foreach ($mailKeys as $k) {
$val = $_POST[$k] ?? '';
$changes[$k] = is_string($val) ? trim($val) : '';
}
if (!empty($_POST['smtp_password'])) {
$changes['smtp_password'] = (string)$_POST['smtp_password'];
}
$settingsRepo->setMany($changes);
AuditLog::write('settings', 0, 'updated', 'Mail-Einstellungen aktualisiert',
null, array_diff_key($changes, array_flip(['smtp_password'])));
setFlash('success', 'Mail-Einstellungen wurden gespeichert.');
redirect('/?page=messages/settings');
}
$values = $settingsRepo->all();
require __DIR__ . '/../app/views/messages/settings.php';
break;
case 'messages/compose':
if (!Permission::can(Auth::role(), 'messages')) {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
if (!isPost()) {
redirect('/?page=messages');
}
$redirectTo = (string)($_POST['redirect_to'] ?? '/?page=messages');
requireCsrfOrAbort($redirectTo);
$to = trim((string)($_POST['recipient'] ?? ''));
$subject = trim((string)($_POST['subject'] ?? ''));
$content = (string)($_POST['content'] ?? '');
$customerId = (int)($_POST['customer_id'] ?? 0) ?: null;
$bookingId = (int)($_POST['booking_id'] ?? 0) ?: null;
$invoiceId = (int)($_POST['invoice_id'] ?? 0) ?: null;
if ($to === '' || $subject === '' || $content === '') {
setFlash('danger', 'Empfänger, Betreff und Inhalt sind erforderlich.');
redirect($redirectTo);
}
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
setFlash('danger', 'Bitte eine gültige Empfänger-Adresse angeben.');
redirect($redirectTo);
}
$msgId = $messageRepo->create([
'message_template' => 'manual',
'message_subject' => $subject,
'message_receiver' => $to,
'message_sender' => $settingsRepo->get('mail_from_email'),
'message_content' => $content,
'message_customer' => $customerId,
'message_booking' => $bookingId,
'message_invoice' => $invoiceId,
]);
$result = $mailService->sendRaw($to, $subject, $content);
if (!empty($result['ok'])) {
$messageRepo->markSent($msgId, $result['transport'] ?? null);
AuditLog::write('message', $msgId, 'sent', 'E-Mail versendet an ' . $to);
setFlash('success', 'E-Mail versendet (' . safeText($result['transport'] ?? '–') . ').');
} else {
$messageRepo->markFailed($msgId, (string)($result['error'] ?? 'Versand fehlgeschlagen'), $result['transport'] ?? null);
setFlash('warning', 'Versand fehlgeschlagen — die Nachricht ist im Status „offen" gespeichert.');
}
redirect($redirectTo);
break;
case 'messages/delete':
if (!isPost() || !Permission::can(Auth::role(), 'messages')) {
redirect('/?page=messages');
}
$mid = (int)($_GET['id'] ?? 0);
requireCsrfOrAbort('/?page=messages');
$messageRepo->delete($mid);
AuditLog::write('message', $mid, 'deleted', 'Nachricht gelöscht.');
setFlash('success', 'Nachricht gelöscht.');
redirect('/?page=messages');
break;
case 'mobile-checkin':
$pageTitle = 'Mobile Check-in';
$activeNav = 'arrivals';
$query = trim((string)($_GET['q'] ?? ''));
$matches = $query !== '' ? $bookingRepo->findByLicensePlate($query) : [];
$useMobileLayout = true;
require __DIR__ . '/../app/views/mobile/checkin.php';
break;
case 'invoices/download':
$id = (int)($_GET['id'] ?? 0);
$invoice = $invoiceRepo->find($id);
if (!$invoice) {
http_response_code(404);
echo '<div class="alert alert-danger">Rechnung nicht gefunden.</div>';
break;
}
$path = $invoice['pdf_path'] ?: $invoice['html_path'];
$abs = $path ? PdfRenderer::absolutePath($path) : null;
if (!$abs || !is_file($abs)) {
try {
$path = Documents::generateInvoiceFile($invoiceRepo, $paymentRepo, $id, $settingsRepo);
$abs = $path ? PdfRenderer::absolutePath($path) : null;
} catch (Throwable $e) {
setFlash('danger', 'Rechnungsdokument nicht verfügbar: ' . $e->getMessage());
redirect('/?page=invoices/show&id=' . $id);
}
}
if (!$abs || !is_file($abs)) {
setFlash('danger', 'Rechnungsdokument nicht verfügbar.');
redirect('/?page=invoices/show&id=' . $id);
}
$isPdf = str_ends_with($abs, '.pdf');
header('Content-Type: ' . ($isPdf ? 'application/pdf' : 'text/html; charset=utf-8'));
if (isset($_GET['download'])) {
header('Content-Disposition: attachment; filename="' . basename($abs) . '"');
} else {
header('Content-Disposition: inline; filename="' . basename($abs) . '"');
}
header('Content-Length: ' . filesize($abs));
readfile($abs);
exit;
// --- AUTH ---------------------------------------------------------
case 'login':
if (Auth::check()) {
redirect('/?page=dashboard');
}
$errors = [];
$email = (string)($_POST['email'] ?? '');
if (isPost()) {
$password = (string)($_POST['password'] ?? '');
if ($email === '' || $password === '') {
$errors[] = 'Bitte E-Mail und Passwort eingeben.';
} elseif (!Auth::attempt($email, $password)) {
$errors[] = 'E-Mail oder Passwort ist falsch.';
AuditLog::write('auth', 0, 'login_failed', 'Fehlgeschlagener Login: ' . $email);
} else {
AuditLog::write('auth', Auth::id() ?? 0, 'login', 'Benutzer angemeldet');
// "Angemeldet bleiben" → Session-Cookie auf 30 Tage verlängern,
// damit Browser-Neustart / Laptop-Standby die Anmeldung nicht killt.
if (!empty($_POST['remember'])) {
rememberMeExtendCookie();
}
$returnTo = (string)($_SESSION['return_to'] ?? '/?page=dashboard');
unset($_SESSION['return_to']);
// Open-Redirect-Schutz: nur lokale ?page=…-URLs zulassen.
if (!preg_match('#^/\?page=[A-Za-z0-9_./-]#', $returnTo)) {
$returnTo = '/?page=dashboard';
}
setFlash('success', 'Willkommen zurück, ' . safeText(Auth::user()['first_name']) . '.');
redirect($returnTo);
}
}
$useBlankLayout = true;
$pageTitle = 'Anmelden';
require __DIR__ . '/../app/views/auth/login.php';
break;
case 'logout':
if (Auth::check()) {
AuditLog::write('auth', Auth::id() ?? 0, 'logout', 'Benutzer abgemeldet');
}
Auth::logout();
setFlash('success', 'Sie wurden abgemeldet.');
redirect('/?page=login');
break;
// --- USERS --------------------------------------------------------
case 'users':
if (Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'Benutzer';
$pageSubtitle = 'Benutzerverwaltung & Rollen.';
$activeNav = 'users';
$allUsers = $userRepo->all();
['perPage' => $pp, 'page' => $pn] = paginationParams();
$pagination = paginate($allUsers, $pp, $pn);
$users = $pagination['items'];
require __DIR__ . '/../app/views/users/index.php';
break;
case 'users/create':
if (Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'Neuer Benutzer';
$activeNav = 'users';
$errors = [];
$data = ['email' => '', 'first_name' => '', 'last_name' => '', 'role' => 'manager', 'status' => 'active', 'password' => ''];
if (isPost()) {
$data = array_merge($data, $_POST);
$errors = User::validate($data, true);
if (empty($errors) && $userRepo->emailExists($data['email'])) {
$errors['email'] = 'E-Mail ist bereits vergeben.';
}
if (empty($errors)) {
$id = $userRepo->create($data);
AuditLog::write('user', $id, 'created', 'Benutzer angelegt', null,
['email' => $data['email'], 'role' => $data['role']]);
setFlash('success', 'Benutzer angelegt.');
redirect('/?page=users');
}
}
require __DIR__ . '/../app/views/users/form.php';
break;
case 'users/edit':
if (Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$id = (int)($_GET['id'] ?? 0);
$existing = $userRepo->find($id);
if (!$existing) {
http_response_code(404);
echo '<div class="alert alert-danger">Benutzer nicht gefunden.</div>';
break;
}
$pageTitle = 'Benutzer bearbeiten';
$activeNav = 'users';
$errors = [];
$data = array_merge($existing, ['password' => '']);
if (isPost()) {
$data = array_merge($existing, $_POST);
$errors = User::validate($data, false);
if (empty($errors) && $userRepo->emailExists($data['email'], $id)) {
$errors['email'] = 'E-Mail ist bereits vergeben.';
}
if (empty($errors)) {
$oldVals = auditPick('user', $existing);
$userRepo->update($id, $data);
$newVals = auditPick('user', $userRepo->find($id) ?? []);
AuditLog::write('user', $id, 'updated', 'Benutzer aktualisiert: ' . $data['email'],
$oldVals, $newVals);
setFlash('success', 'Benutzer aktualisiert.');
redirect('/?page=users');
}
}
$recordHistory = AuditLog::byEntity('user', $id, 30);
require __DIR__ . '/../app/views/users/form.php';
break;
case 'users/delete':
if (Auth::role() !== 'admin' || !isPost()) {
redirect('/?page=users');
}
$id = (int)($_GET['id'] ?? 0);
requireCsrfOrAbort('/?page=users');
if ($id === Auth::id()) {
setFlash('danger', 'Eigener Account kann nicht gelöscht werden.');
} else {
$userRepo->delete($id);
AuditLog::write('user', $id, 'deleted', 'Benutzer gelöscht');
setFlash('success', 'Benutzer gelöscht.');
}
redirect('/?page=users');
break;
// --- SETTINGS -----------------------------------------------------
case 'invoices/settings':
if (!Permission::can(Auth::role(), 'invoices') && Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'Rechnungs-Einstellungen';
$pageSubtitle = 'Allgemein und Zahlungsdaten.';
$activeNav = 'invoices-settings';
$errors = [];
if (isPost()) {
if (Auth::role() !== 'admin') {
setFlash('danger', 'Nur Administratoren dürfen Einstellungen speichern.');
redirect('/?page=invoices/settings');
}
requireCsrfOrAbort('/?page=invoices/settings');
$keys = ['invoice_prefix', 'booking_prefix', 'default_vat'];
$changes = [];
foreach ($keys as $k) {
$val = $_POST[$k] ?? '';
$changes[$k] = is_string($val) ? trim($val) : '';
}
$settingsRepo->setMany($changes);
// Zahlungsarten: bestehende updaten, neue (id "new") anlegen.
if (isset($_POST['payment_methods']) && is_array($_POST['payment_methods'])) {
foreach ($_POST['payment_methods'] as $key => $row) {
if (!is_array($row)) continue;
$methodKey = trim((string)($row['method_key'] ?? ''));
$label = trim((string)($row['label'] ?? ''));
if ($key === 'new') {
if ($methodKey === '' || $label === '') continue;
try {
$paymentMethodRepo->create([
'method_key' => $methodKey,
'label' => $label,
'sort_order' => (int)($row['sort_order'] ?? 100),
'is_active' => !empty($row['is_active']),
]);
} catch (Throwable $e) {
setFlash('warning', 'Neue Zahlungsart konnte nicht angelegt werden: ' . $e->getMessage());
}
} else {
$id = (int)$key;
if ($id <= 0 || $methodKey === '') continue;
try {
$paymentMethodRepo->update($id, [
'method_key' => $methodKey,
'label' => $label !== '' ? $label : $methodKey,
'sort_order' => (int)($row['sort_order'] ?? 100),
'is_active' => !empty($row['is_active']),
]);
} catch (Throwable $e) {
setFlash('warning', 'Zahlungsart #' . $id . ' nicht aktualisiert: ' . $e->getMessage());
}
}
}
}
AuditLog::write('settings', 0, 'updated', 'Rechnungs-Einstellungen aktualisiert', null, $changes);
setFlash('success', 'Einstellungen wurden gespeichert.');
$tab = preg_replace('/[^a-z\-]/', '', (string)($_POST['active_tab'] ?? ''));
redirect('/?page=invoices/settings' . ($tab ? '&tab=' . $tab : ''));
}
$values = $settingsRepo->all();
$methods = $paymentMethodRepo->all();
require __DIR__ . '/../app/views/invoices/settings.php';
break;
case 'invoices/settings/payment-methods/delete':
$id = (int)($_GET['id'] ?? 0);
if (!$id || !isPost()) {
redirect('/?page=invoices/settings&tab=payment-methods');
}
requireCsrfOrAbort('/?page=invoices/settings&tab=payment-methods');
try {
$paymentMethodRepo->delete($id);
setFlash('success', 'Zahlungsart wurde gelöscht.');
} catch (Throwable $e) {
setFlash('danger', 'Zahlungsart konnte nicht gelöscht werden: ' . $e->getMessage());
}
redirect('/?page=invoices/settings&tab=payment-methods');
break;
case 'settings/api/docs':
if (Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'API-Dokumentation';
$pageSubtitle = 'Hilfe & Referenz für die REST-API.';
$activeNav = 'settings-api';
require __DIR__ . '/../app/views/settings/api-docs.php';
break;
case 'settings/api/logs':
if (Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'API-Log';
$pageSubtitle = 'Aufrufe gegen die REST-API — gefiltert und durchsuchbar.';
$activeNav = 'settings-api';
$logFilters = [
'token_id' => (int)($_GET['token_id'] ?? 0) ?: null,
'method' => trim((string)($_GET['method'] ?? '')),
'status_class' => trim((string)($_GET['status_class'] ?? '')),
'search' => trim((string)($_GET['q'] ?? '')),
'from' => trim((string)($_GET['from'] ?? '')),
'to' => trim((string)($_GET['to'] ?? '')),
];
$logSort = (string)($_GET['sort'] ?? 'created_desc');
$logPerPage = max(10, min(200, (int)($_GET['per_page'] ?? 25)));
// Pagination-Param heißt page_no, damit es nicht mit dem Route-Param "page" kollidiert.
$logPage = max(1, (int)($_GET['page_no'] ?? 1));
$logTotal = $apiLogRepo->countFiltered($logFilters);
$logEntries = $apiLogRepo->listFiltered($logFilters, $logPage, $logPerPage, $logSort);
$logTokens = $apiTokenRepo->all();
$logMethods = $apiLogRepo->distinctMethods();
// AJAX-Fragment: nur den Listen-Block ausliefern, damit Filter/Pagination
// ohne Full-Page-Reload aktualisiert werden können.
$isAjax = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
if ($isAjax) {
header('Content-Type: text/html; charset=utf-8');
require __DIR__ . '/../app/views/settings/api-logs-fragment.php';
exit;
}
require __DIR__ . '/../app/views/settings/api-logs.php';
break;
case 'settings/api':
case 'settings/api/create':
case 'settings/api/revoke':
case 'settings/api/delete':
if (Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'API';
$pageSubtitle = 'REST-API: Tokens verwalten und Dokumentation.';
$activeNav = 'settings-api';
$newPlainToken = null;
if (isPost()) {
requireCsrfOrAbort('/?page=settings/api');
try {
if ($page === 'settings/api/create') {
$name = trim((string)($_POST['name'] ?? ''));
// Scopes kommen jetzt als Checkbox-Array `scopes[]`.
// Whitelist gegen unbekannte Werte, Dedupe, mindestens „read".
$allowed = ['read', 'write', 'checkin'];
$raw = $_POST['scopes'] ?? [];
if (is_string($raw)) {
// Backwards compat: alter CSV-String aus Dropdown-Zeiten.
$raw = array_map('trim', explode(',', $raw));
}
if (!is_array($raw)) $raw = [];
$scopeList = array_values(array_unique(array_filter(
array_map('strval', $raw),
static fn ($s) => in_array($s, $allowed, true)
)));
if (!$scopeList) $scopeList = ['read'];
$scopes = implode(',', $scopeList);
$res = $apiTokenRepo->create($name, $scopes, Auth::user()['email'] ?? null);
AuditLog::write('settings', (int)$res['id'], 'api_token_created',
'API-Token angelegt: ' . $res['name'], null,
['scopes' => $res['scopes']]);
// Klartext-Token einmalig im Flash mitgeben (verschwindet nach erstem View).
$_SESSION['new_api_token'] = $res['plain_token'];
setFlash('success', 'API-Token "' . $res['name'] . '" angelegt — Schlüssel unten kopieren, er wird nicht erneut angezeigt.');
redirect('/?page=settings/api');
}
if ($page === 'settings/api/revoke') {
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$apiTokenRepo->revoke($id);
AuditLog::write('settings', $id, 'api_token_revoked',
'API-Token widerrufen (id ' . $id . ')', null, null);
setFlash('warning', 'Token widerrufen.');
}
redirect('/?page=settings/api');
}
if ($page === 'settings/api/delete') {
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$apiTokenRepo->delete($id);
AuditLog::write('settings', $id, 'api_token_deleted',
'API-Token gelöscht (id ' . $id . ')', null, null);
setFlash('warning', 'Token gelöscht.');
}
redirect('/?page=settings/api');
}
} catch (Throwable $e) {
setFlash('danger', 'Fehler: ' . $e->getMessage());
redirect('/?page=settings/api');
}
}
// Klartext-Token einmalig nach Create anzeigen (Flash-artig in Session).
if (!empty($_SESSION['new_api_token'])) {
$newPlainToken = (string)$_SESSION['new_api_token'];
unset($_SESSION['new_api_token']);
}
$apiTokens = $apiTokenRepo->all();
require __DIR__ . '/../app/views/settings/api.php';
break;
// --- Import-Quelle: AJAX-Probe (Verbindung testen + Anläufe abrufen) ---
// Liest live aus dem konfigurierten Altsystem (PaCIM-PAS) und gibt JSON
// zurück — schreibt NICHTS in die neue Datenbank.
//
// Robust gegen interne Fehler: kompletter Body in try/catch, Output-Buffer
// verwirft jeden HTML-Output (z.B. vom ErrorHandler), Antwort ist immer
// gültiges JSON.
case 'settings/import/probe':
// Verschachteltes catch-Konstrukt mit Closure → wir kommen sauber aus
// dem switch raus, egal was passiert.
// WICHTIG: `exit;` am Ende — sonst rendert nach dem Switch-Break die
// Layout-Pipeline noch zusätzlich HTML an die JSON-Antwort an.
$probeAnswer = static function (int $http, array $payload): never {
while (ob_get_level() > 0) ob_end_clean();
http_response_code($http);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
};
ob_start();
try {
if (Auth::role() !== 'admin') {
$probeAnswer(403, ['ok' => false, 'error' => 'Nur Administratoren']);
break;
}
if (!isPost() || !csrfCheck()) {
$probeAnswer(400, ['ok' => false, 'error' => 'Ungültiger Request (CSRF/Method)']);
break;
}
$action = (string)($_POST['action'] ?? '');
// Hilfs-Closure: PDO mit gegebenen Cfg-Werten öffnen.
$openPdoCfg = static function (array $cfg, int $timeout = 8): PDO {
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$cfg['host'], $cfg['port'] ?: '3306', $cfg['name']);
return new PDO($dsn, $cfg['user'], $cfg['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => $timeout,
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
]);
};
$stored = $settingsRepo->all();
// --- Anwendungs-Datenbank: Test + Speichern ----------------------
// Wird vom App-DB-Editor im MySQL-Tab aufgerufen; schreibt
// app/config/database.php nur, wenn die neuen Zugangsdaten erfolgreich
// verbinden konnten.
if ($action === 'test_app_db' || $action === 'save_app_db') {
$appCfgFile = __DIR__ . '/../app/config/database.php';
$existing = is_file($appCfgFile) ? (array)require $appCfgFile : [];
$appCfg = [
'host' => trim((string)($_POST['host'] ?? '')) ?: (string)($existing['host'] ?? ''),
'port' => trim((string)($_POST['port'] ?? '')) ?: (string)($existing['port'] ?? '3306'),
'user' => trim((string)($_POST['user'] ?? '')) ?: (string)($existing['username'] ?? ''),
// Leeres POST-Passwort → bestehendes verwenden (User hat nichts eingetippt).
'pass' => (string)($_POST['pass'] ?? '') !== '' ? (string)$_POST['pass'] : (string)($existing['password'] ?? ''),
'name' => trim((string)($_POST['name'] ?? '')) ?: (string)($existing['database'] ?? ''),
];
if ($appCfg['host'] === '' || $appCfg['user'] === '' || $appCfg['name'] === '') {
$probeAnswer(200, ['ok' => false, 'error' => 'Host, Benutzer und Datenbank müssen ausgefüllt sein.']);
break;
}
try {
$pdoTest = $openPdoCfg($appCfg);
$ver = (string)$pdoTest->query("SELECT VERSION()")->fetchColumn();
} catch (Throwable $e) {
$probeAnswer(200, ['ok' => false, 'error' => 'Verbindung fehlgeschlagen: ' . $e->getMessage()]);
break;
}
if ($action === 'test_app_db') {
$probeAnswer(200, ['ok' => true, 'serverVersion' => $ver]);
break;
}
// save_app_db: Datei schreiben
$php = "<?php\nreturn [\n"
. " 'host' => " . var_export($appCfg['host'], true) . ",\n"
. " 'port' => " . var_export((int)($appCfg['port'] ?: 3306), true) . ",\n"
. " 'database' => " . var_export($appCfg['name'], true) . ",\n"
. " 'username' => " . var_export($appCfg['user'], true) . ",\n"
. " 'password' => " . var_export($appCfg['pass'], true) . ",\n"
. " 'charset' => 'utf8mb4',\n"
. "];\n";
// Atomares Schreiben: erst Tempdatei, dann rename.
$tmp = $appCfgFile . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $php) === false) {
$probeAnswer(200, ['ok' => false, 'error' => 'Konnte Konfig-Datei nicht schreiben: ' . $appCfgFile]);
break;
}
@chmod($tmp, 0640);
if (!@rename($tmp, $appCfgFile)) {
@unlink($tmp);
$probeAnswer(200, ['ok' => false, 'error' => 'Konnte Konfig-Datei nicht ersetzen: ' . $appCfgFile]);
break;
}
// PHP-OPcache invalidieren, sonst nutzt der nächste Request noch die alte Datei.
if (function_exists('opcache_invalidate')) @opcache_invalidate($appCfgFile, true);
try {
AuditLog::write('settings', 0, 'app_db_changed',
'Anwendungs-Datenbank-Konfig geändert', null,
['host' => $appCfg['host'], 'database' => $appCfg['name'], 'username' => $appCfg['user']]);
} catch (Throwable) { /* Audit darf den Erfolg nicht verhindern */ }
$probeAnswer(200, ['ok' => true, 'serverVersion' => $ver]);
break;
}
// --- Import-Quelle (Altsystem) ----------------------------------
// „Reuse" → Host/Port/User/Pass aus der gespeicherten App-DB-Konfig
// (Datei) übernehmen; nur der Datenbankname bleibt POST/Form-getrieben.
$reuseApp = !empty($_POST['reuse_app']);
// Eingaben aus dem Formular gleich persistent merken — der User soll
// beim nächsten Besuch die Verbindung nicht erneut eintippen. Greift
// auch, wenn Folge-Calls wie run_import_event KEINE Form-Felder mehr
// mitschicken: die gespeicherten Werte werden weiter unten als
// Fallback verwendet.
$persistKv = [];
if ($reuseApp) {
$persistKv['import_db_reuse_app'] = '1';
if (trim((string)($_POST['name'] ?? '')) !== '') $persistKv['import_db_name'] = trim((string)$_POST['name']);
} else {
foreach ([
'host' => 'import_db_host',
'port' => 'import_db_port',
'user' => 'import_db_user',
'name' => 'import_db_name',
] as $postKey => $cfgKey) {
$v = trim((string)($_POST[$postKey] ?? ''));
if ($v !== '') $persistKv[$cfgKey] = $v;
}
// Passwort nur überschreiben, wenn der User wirklich was eingetippt hat.
if (($_POST['pass'] ?? '') !== '' && $_POST['pass'] !== null) {
$persistKv['import_db_pass'] = (string)$_POST['pass'];
}
// Reuse-Schalter explizit aus, wenn nicht aktiv.
if (array_key_exists('reuse_app', $_POST)) $persistKv['import_db_reuse_app'] = '';
}
if ($persistKv) {
try { $settingsRepo->setMany($persistKv); } catch (Throwable) {}
// Damit die Fallback-Logik gleich darunter den frischen Wert sieht.
$stored = array_merge($stored, $persistKv);
}
if ($reuseApp) {
$appCfgFile = __DIR__ . '/../app/config/database.php';
$appDb = is_file($appCfgFile) ? (array)require $appCfgFile : [];
$cfg = [
'host' => (string)($appDb['host'] ?? ''),
'port' => (string)($appDb['port'] ?? '3306'),
'user' => (string)($appDb['username'] ?? ''),
'pass' => (string)($appDb['password'] ?? ''),
'name' => trim((string)($_POST['name'] ?? '')) ?: (string)($stored['import_db_name'] ?? ''),
];
} else {
// Falls Form-Felder leer sind, auf gespeicherte Einstellungen zurückfallen
// (das Passwort-Feld zeigt im UI nur den Placeholder, nicht den echten Wert).
$cfg = [
'host' => trim((string)($_POST['host'] ?? '')) ?: (string)($stored['import_db_host'] ?? ''),
'port' => trim((string)($_POST['port'] ?? '')) ?: (string)($stored['import_db_port'] ?? '3306'),
'user' => trim((string)($_POST['user'] ?? '')) ?: (string)($stored['import_db_user'] ?? ''),
'pass' => (string)($_POST['pass'] ?? '') ?: (string)($stored['import_db_pass'] ?? ''),
'name' => trim((string)($_POST['name'] ?? '')) ?: (string)($stored['import_db_name'] ?? ''),
];
}
if ($cfg['host'] === '' || $cfg['user'] === '' || $cfg['name'] === '') {
$probeAnswer(200, ['ok' => false, 'error' => 'Host, Benutzer und Datenbank müssen ausgefüllt sein.']);
break;
}
try {
$srcPdo = $openPdoCfg($cfg);
} catch (Throwable $e) {
$probeAnswer(200, ['ok' => false, 'error' => 'Verbindung fehlgeschlagen: ' . $e->getMessage()]);
break;
}
if ($action === 'connection') {
$ver = (string)$srcPdo->query("SELECT VERSION()")->fetchColumn();
$probeAnswer(200, ['ok' => true, 'serverVersion' => $ver]);
break;
}
// --- Drill-in: Roh-Datensätze für einen einzelnen Anlauf -----------
// Liest die kompletten Buchungs+Kunde+Rechnung+Positionen-Pakete für
// ein altes event_calendar_id; rein lesend.
if ($action === 'event_bookings') {
$eventId = (int)($_POST['event_id'] ?? 0);
if ($eventId <= 0) {
$probeAnswer(200, ['ok' => false, 'error' => 'event_id fehlt.']);
break;
}
$rows = legacyEventBookings($srcPdo, $eventId);
$probeAnswer(200, ['ok' => true, 'event_id' => $eventId, 'count' => count($rows), 'bookings' => $rows]);
break;
}
// --- Import-Log: vergangene Importe auflisten + Detail ------------
if ($action === 'list_imports') {
$pdoLog = $db->getConnection();
ensureImportLogTable($pdoLog);
$rows = $pdoLog->query(
"SELECT id, kind, source_id, source_label, status,
started_at, finished_at,
bookings_total, bookings_imported, bookings_skipped,
created_by
FROM import_log
WHERE kind = 'cruise_event'
ORDER BY started_at DESC
LIMIT 200"
)->fetchAll(PDO::FETCH_ASSOC);
$probeAnswer(200, ['ok' => true, 'imports' => $rows]);
break;
}
if ($action === 'show_import') {
$logId = (int)($_POST['log_id'] ?? 0);
if ($logId <= 0) {
$probeAnswer(200, ['ok' => false, 'error' => 'log_id fehlt.']);
break;
}
ensureImportLogTable($db->getConnection());
$stmt = $db->getConnection()->prepare("SELECT * FROM import_log WHERE id = :id LIMIT 1");
$stmt->execute(['id' => $logId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
$probeAnswer(200, ['ok' => false, 'error' => 'Import-Log nicht gefunden.']);
break;
}
// payload/errors als echtes JSON-Objekt zurückgeben (nicht als String)
$row['payload'] = $row['payload_json'] !== null ? json_decode((string)$row['payload_json'], true) : null;
$row['errors'] = $row['errors_json'] !== null ? json_decode((string)$row['errors_json'], true) : null;
unset($row['payload_json'], $row['errors_json']);
$probeAnswer(200, ['ok' => true, 'import' => $row]);
break;
}
// --- Tatsächlicher Import eines Anlaufs ----------------------------
// Strikte Idempotenz: bricht ab, sobald irgendein legacy_source_id aus
// diesem Anlauf bereits in der Ziel-DB liegt (events/bookings/customers/invoices).
if ($action === 'run_import_event') {
$eventId = (int)($_POST['event_id'] ?? 0);
if ($eventId <= 0) {
$probeAnswer(200, ['ok' => false, 'error' => 'event_id fehlt.']);
break;
}
$importResult = runImportEvent($srcPdo, $db->getConnection(), $eventId, (string)(Auth::user()['email'] ?? ''));
$probeAnswer(200, $importResult);
break;
}
if ($action === 'cruises') {
// Anläufe = (Event-ID, Schiff-Titel) + aggregierte Buchungs- und Umsatz-Daten.
// Umsatz-Logik aus dem Altsystem nachgebaut: 10%-Coupon (coupon=2 oder pos-typ=2)
// → price * 0.9 * qty * 1.19; 5€-Coupon (coupon=1 oder pos-typ=1) → (price*qty*1.19)-5;
// sonst → price * qty * 1.19. Nur Parkprodukte (product_id <= 2) bekommen den Rabatt.
$sql = "
SELECT
e.event_calendar_id AS event_id,
e.event_calendar_title AS title,
ei.event_logo AS logo,
MIN(b.booking_begin) AS date_from,
MAX(b.booking_end) AS date_to,
COUNT(DISTINCT b.booking_id) AS bookings_count,
ROUND(SUM(
CASE
WHEN ip.product_id <= 2 AND (i.invoice_coupon = 2 OR ip.invoice_pos_discount_type = 2)
THEN ip.invoice_pos_price * 0.9 * ip.invoice_pos_quantity * 1.19
WHEN ip.product_id <= 2 AND (i.invoice_coupon = 1 OR ip.invoice_pos_discount_type = 1)
THEN (ip.invoice_pos_price * ip.invoice_pos_quantity * 1.19) - 5
ELSE ip.invoice_pos_price * ip.invoice_pos_quantity * 1.19
END
), 2) AS revenue_gross
FROM pacim_events e
LEFT JOIN pacim_events_info ei ON e.event_calendar_info_id = ei.event_id
INNER JOIN pacim_booking b ON b.booking_event_id = e.event_calendar_id
LEFT JOIN pacim_invoice i ON i.invoice_id = b.booking_invoice
LEFT JOIN pacim_invoice_pos ip ON ip.invoice_id = i.invoice_id
GROUP BY e.event_calendar_id, e.event_calendar_title, ei.event_logo
HAVING bookings_count > 0
ORDER BY date_from DESC, e.event_calendar_title ASC
";
try {
$rows = $srcPdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
$probeAnswer(200, ['ok' => false, 'error' => 'Abfrage fehlgeschlagen: ' . $e->getMessage()]);
break;
}
$totalBookings = array_sum(array_map(static fn ($r) => (int)$r['bookings_count'], $rows));
$probeAnswer(200, [
'ok' => true,
'timestamp' => date('Y-m-d H:i'),
'totalBookings' => $totalBookings,
'cruises' => array_map(static fn ($r) => [
'event_id' => (int)$r['event_id'],
'title' => (string)$r['title'],
'logo' => $r['logo'] !== null ? (string)$r['logo'] : null,
'date_from' => (string)$r['date_from'],
'date_to' => (string)$r['date_to'],
'bookings_count' => (int)$r['bookings_count'],
'revenue_gross' => $r['revenue_gross'] !== null ? (float)$r['revenue_gross'] : null,
], $rows),
]);
break;
}
$probeAnswer(200, ['ok' => false, 'error' => 'Unbekannte action: ' . $action]);
} catch (Throwable $e) {
// ErrorHandler hätte hier HTML ausgegeben — wir geben stattdessen JSON.
$probeAnswer(500, ['ok' => false, 'error' => 'Server-Fehler: ' . $e->getMessage()]);
}
break;
case 'settings':
if (!Permission::can(Auth::role(), 'settings.view') && Auth::role() !== 'admin') {
http_response_code(403);
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$pageTitle = 'Einstellungen';
$pageSubtitle = 'Firma, Rechnung, Mail und PDF.';
$activeNav = 'settings';
$errors = [];
if (isPost()) {
if (Auth::role() !== 'admin') {
setFlash('danger', 'Nur Administratoren dürfen Einstellungen speichern.');
redirect('/?page=settings');
}
requireCsrfOrAbort('/?page=settings');
$keys = [
'company_name','company_street','company_zip','company_city','company_phone',
'company_email','company_website','company_vat_id',
// Rechnungs-Felder (invoice_prefix/booking_prefix/default_vat) liegen jetzt unter
// /?page=invoices/settings. SMTP/Mail-Felder unter /?page=messages/settings.
'checkin_lock_unpaid',
'early_checkin_until',
'keytag_width_mm','keytag_height_mm',
// Darstellung — pro Kontext ausgeblendete customer.source-Werte (CSV).
'display_hide_sources_arrivals',
'display_hide_sources_excel',
'display_hide_sources_customers',
'display_hide_sources_invoices',
'display_hide_sources_bookings',
'display_hide_sources_payments',
'display_hide_sources_reports',
'display_hide_sources_search',
// Import-Quelle (Altsystem PaCIM-PAS) — Tab „MySQL / Import".
'import_db_host','import_db_port','import_db_name','import_db_user','import_db_pass',
];
$changes = [];
foreach ($keys as $k) {
$val = $_POST[$k] ?? '';
$changes[$k] = is_string($val) ? trim($val) : '';
}
// Boolean-Checkbox „App-DB-Verbindung wiederverwenden" — kommt nur bei
// aktiviertem Schalter im POST. Auf MySQL-Tab gespeicherten Wert
// explizit als '1' / '' persistieren.
if (($_POST['active_tab'] ?? '') === 'mysql') {
$changes['import_db_reuse_app'] = !empty($_POST['import_db_reuse_app']) ? '1' : '';
}
// Passwort-Feld: leerer POST-Wert behält den gespeicherten Wert.
// Sonst wird das Import-Passwort beim Speichern eines anderen Tabs versehentlich geleert.
if (array_key_exists('import_db_pass', $changes) && $changes['import_db_pass'] === '') {
unset($changes['import_db_pass']);
}
// Logo Upload
if (!empty($_FILES['logo']['name'])) {
$upload = handleLogoUpload($_FILES['logo']);
if (!empty($upload['error'])) {
$errors['logo'] = $upload['error'];
} else {
$changes['logo_path'] = $upload['path'];
}
}
// Logo entfernen
if (!empty($_POST['remove_logo'])) {
$changes['logo_path'] = '';
}
if (empty($errors)) {
// Early-Check-in: nur HH:MM akzeptieren, sonst Default 10:30.
if (isset($changes['early_checkin_until'])) {
$v = trim((string)$changes['early_checkin_until']);
$changes['early_checkin_until'] = preg_match('/^(\d{1,2}):(\d{2})$/', $v, $m)
&& (int)$m[1] < 24 && (int)$m[2] < 60
? sprintf('%02d:%02d', (int)$m[1], (int)$m[2])
: '10:30';
}
// Schlüsselschild-Maße auf sinnvolle Grenzen klemmen.
if (isset($changes['keytag_width_mm'])) {
$changes['keytag_width_mm'] = (string)max(20, min(210, (int)$changes['keytag_width_mm']));
}
if (isset($changes['keytag_height_mm'])) {
$changes['keytag_height_mm'] = (string)max(15, min(297, (int)$changes['keytag_height_mm']));
}
$settingsRepo->setMany($changes);
AuditLog::write('settings', 0, 'updated', 'Einstellungen aktualisiert',
null, array_diff_key($changes, array_flip(['smtp_password'])));
setFlash('success', 'Einstellungen wurden gespeichert.');
$tab = preg_replace('/[^a-z_]/', '', (string)($_POST['active_tab'] ?? ''));
redirect('/?page=settings' . ($tab ? '&tab=' . $tab : ''));
}
}
$values = $settingsRepo->all();
// Kanonische Quellen + in der DB tatsächlich verwendete (z.B. Legacy: "Costa", "PACIM Eingabe").
$canonicalSources = ['Parken-am-Schiff.de', 'AIDA', 'TUI', 'MSC', 'Booking', 'Sonstige'];
$dbSources = $db->getConnection()
->query("SELECT DISTINCT source FROM customers
WHERE source IS NOT NULL AND source <> ''
ORDER BY source")
->fetchAll(PDO::FETCH_COLUMN);
$availableSources = array_values(array_unique(array_merge($canonicalSources, $dbSources)));
sort($availableSources);
require __DIR__ . '/../app/views/settings/index.php';
break;
case 'settings/payment-methods/create':
if (Auth::role() !== 'admin' || !isPost()) {
redirect('/?page=invoices/settings&tab=payment-methods');
}
requireCsrfOrAbort('/?page=invoices/settings&tab=payment-methods');
try {
$paymentMethodRepo->create([
'method_key' => (string)($_POST['method_key'] ?? ''),
'label' => (string)($_POST['label'] ?? ''),
'sort_order' => (int)($_POST['sort_order'] ?? 100),
'is_active' => !empty($_POST['is_active']),
]);
setFlash('success', 'Zahlungsart angelegt.');
} catch (Throwable $e) {
setFlash('danger', 'Anlegen fehlgeschlagen: ' . $e->getMessage());
}
redirect('/?page=invoices/settings&tab=payment-methods');
break;
case 'settings/payment-methods/update':
if (Auth::role() !== 'admin' || !isPost()) {
redirect('/?page=invoices/settings&tab=payment-methods');
}
requireCsrfOrAbort('/?page=invoices/settings&tab=payment-methods');
$id = (int)($_GET['id'] ?? 0);
try {
$paymentMethodRepo->update($id, [
'method_key' => (string)($_POST['method_key'] ?? ''),
'label' => (string)($_POST['label'] ?? ''),
'sort_order' => (int)($_POST['sort_order'] ?? 100),
'is_active' => !empty($_POST['is_active']),
]);
setFlash('success', 'Zahlungsart aktualisiert.');
} catch (Throwable $e) {
setFlash('danger', 'Speichern fehlgeschlagen: ' . $e->getMessage());
}
redirect('/?page=invoices/settings&tab=payment-methods');
break;
case 'settings/payment-methods/delete':
if (Auth::role() !== 'admin' || !isPost()) {
redirect('/?page=invoices/settings&tab=payment-methods');
}
requireCsrfOrAbort('/?page=invoices/settings&tab=payment-methods');
$id = (int)($_GET['id'] ?? 0);
try {
$paymentMethodRepo->delete($id);
setFlash('success', 'Zahlungsart gelöscht.');
} catch (Throwable $e) {
setFlash('danger', 'Löschen fehlgeschlagen: ' . $e->getMessage());
}
redirect('/?page=invoices/settings&tab=payment-methods');
break;
case 'settings/test-mail':
if (Auth::role() !== 'admin' || !isPost()) {
redirect('/?page=settings');
}
requireCsrfOrAbort('/?page=settings');
$to = (string)($_POST['test_mail_to'] ?? (Auth::user()['email'] ?? ''));
$r = $mailService->sendTestMail($to);
AuditLog::write('settings', 0, 'test_mail',
'Testmail an ' . $to . ' via ' . ($r['transport'] ?? 'unknown'));
$type = $r['ok'] ? 'success' : 'warning';
$msg = 'Testmail an ' . safeText($to) . ' versendet (Transport: ' . safeText($r['transport'] ?? 'unbekannt') . ').';
if (!empty($r['preview'])) {
$msg .= ' Vorschau-Datei: ' . safeText(basename($r['preview']));
}
setFlash($type, $msg);
redirect('/?page=settings');
break;
case 'bookings/email-confirmation':
if (!isPost()) {
redirect('/?page=bookings');
}
$id = (int)($_POST['id'] ?? 0);
$redirectTo = '/?page=bookings/show&id=' . $id;
requireCsrfOrAbort($redirectTo);
if (isBookingCancelled($bookingRepo, $id)) {
setFlash('danger', 'Versand blockiert: Buchung ist storniert.');
redirect($redirectTo);
}
$r = $mailService->sendBookingConfirmation($bookingRepo, $paymentRepo, $id);
AuditLog::write('booking', $id, 'email_confirmation',
'Buchungsbestätigung versendet via ' . ($r['transport'] ?? 'unknown'));
setFlash($r['ok'] ? 'success' : 'warning',
'Buchungsbestätigung versendet (Transport: ' . safeText($r['transport'] ?? '–') . ').');
redirect($redirectTo);
break;
case 'invoices/email':
if (!isPost()) {
redirect('/?page=invoices');
}
$id = (int)($_POST['id'] ?? 0);
$redirectTo = '/?page=invoices/show&id=' . $id;
requireCsrfOrAbort($redirectTo);
$invForMail = $invoiceRepo->find($id);
if ($invForMail && (($invForMail['booking_status'] ?? '') === 'cancelled' || ($invForMail['status'] ?? '') === 'cancelled')) {
setFlash('danger', 'Versand blockiert: zugehörige Buchung ist storniert.');
redirect($redirectTo);
}
$r = $mailService->sendInvoice($invoiceRepo, $paymentRepo, $id);
AuditLog::write('invoice', $id, 'email',
'Rechnung versendet via ' . ($r['transport'] ?? 'unknown'));
setFlash($r['ok'] ? 'success' : 'warning',
'Rechnung versendet (Transport: ' . safeText($r['transport'] ?? '–') . ').');
redirect($redirectTo);
break;
// --- AUDIT / REVERT -----------------------------------------------
case 'audit/revert':
if (!isPost()) {
redirect('/?page=dashboard');
}
$auditId = (int)($_POST['audit_id'] ?? 0);
$entry = AuditLog::findById($auditId);
$fallback = $entry && $entry['entity_id']
? (auditEntityUrl($entry['entity_type'], (int)$entry['entity_id']) ?: '/?page=dashboard')
: '/?page=dashboard';
requireCsrfOrAbort($fallback);
if (!$entry) {
setFlash('danger', 'Audit-Eintrag nicht gefunden.');
redirect('/?page=dashboard');
}
if (empty($entry['old_values'])) {
setFlash('danger', 'Dieser Eintrag enthält keine alten Werte zum Wiederherstellen.');
redirect($fallback);
}
$entityType = (string)$entry['entity_type'];
$entityId = (int)$entry['entity_id'];
$allowed = ['customer', 'event', 'product', 'booking', 'user'];
if (!in_array($entityType, $allowed, true)) {
setFlash('danger', 'Rückgängig für diesen Datensatz-Typ nicht unterstützt.');
redirect($fallback);
}
// Rollen-Gate: user-Revert nur Admin
if ($entityType === 'user' && Auth::role() !== 'admin') {
setFlash('danger', 'Nur Administratoren dürfen Benutzer-Änderungen rückgängig machen.');
redirect($fallback);
}
$oldValues = json_decode($entry['old_values'], true);
if (!is_array($oldValues) || empty($oldValues)) {
setFlash('danger', 'Alte Werte konnten nicht gelesen werden.');
redirect($fallback);
}
try {
switch ($entityType) {
case 'customer':
$cur = $customerRepo->find($entityId);
if (!$cur) throw new RuntimeException('Kunde nicht mehr vorhanden.');
$merged = array_merge($cur, $oldValues);
$oldNow = auditPick('customer', $cur);
$customerRepo->update($entityId, $merged);
$newVals = auditPick('customer', $customerRepo->find($entityId) ?? []);
AuditLog::write('customer', $entityId, 'reverted',
'Rückgängig auf Stand vom ' . formatDateTime($entry['created_at']),
$oldNow, $newVals);
break;
case 'event':
$cur = $eventRepo->find($entityId);
if (!$cur) throw new RuntimeException('Event nicht mehr vorhanden.');
$merged = array_merge($cur, $oldValues);
$oldNow = auditPick('event', $cur);
$eventRepo->update($entityId, $merged);
$newVals = auditPick('event', $eventRepo->find($entityId) ?? []);
AuditLog::write('event', $entityId, 'reverted',
'Rückgängig auf Stand vom ' . formatDateTime($entry['created_at']),
$oldNow, $newVals);
break;
case 'product':
$cur = $productRepo->find($entityId);
if (!$cur) throw new RuntimeException('Produkt nicht mehr vorhanden.');
$merged = array_merge($cur, $oldValues);
$oldNow = auditPick('product', $cur);
$productRepo->update($entityId, $merged);
$newVals = auditPick('product', $productRepo->find($entityId) ?? []);
AuditLog::write('product', $entityId, 'reverted',
'Rückgängig auf Stand vom ' . formatDateTime($entry['created_at']),
$oldNow, $newVals);
break;
case 'booking':
$cur = $bookingRepo->find($entityId);
if (!$cur) throw new RuntimeException('Buchung nicht mehr vorhanden.');
$merged = array_merge($cur, $oldValues);
// Bestehende Produkte beibehalten (Revert nur auf Booking-Kopfdaten)
$items = array_map(function ($it) {
return [
'product_id' => (int)$it['product_id'],
'quantity' => (int)$it['quantity'],
'price_net' => (float)$it['price_net'],
'vat_rate' => (float)$it['vat_rate'],
];
}, $bookingRepo->products($entityId));
$oldNow = auditPick('booking', $cur);
$bookingRepo->update($entityId, $merged, $items);
$newVals = auditPick('booking', $bookingRepo->find($entityId) ?? []);
AuditLog::write('booking', $entityId, 'reverted',
'Rückgängig auf Stand vom ' . formatDateTime($entry['created_at']),
$oldNow, $newVals);
break;
case 'user':
$cur = $userRepo->find($entityId);
if (!$cur) throw new RuntimeException('Benutzer nicht mehr vorhanden.');
$merged = array_merge($cur, $oldValues, ['password' => '']);
$oldNow = auditPick('user', $cur);
$userRepo->update($entityId, $merged);
$newVals = auditPick('user', $userRepo->find($entityId) ?? []);
AuditLog::write('user', $entityId, 'reverted',
'Rückgängig auf Stand vom ' . formatDateTime($entry['created_at']),
$oldNow, $newVals);
break;
}
setFlash('success', 'Änderung wurde rückgängig gemacht.');
} catch (Throwable $e) {
setFlash('danger', 'Rückgängig fehlgeschlagen: ' . $e->getMessage());
}
redirect($fallback);
break;
// --- PROFILE ------------------------------------------------------
case 'profile':
$me = Auth::user();
if (!$me) {
redirect('/?page=login');
}
$requestedId = (int)($_GET['user_id'] ?? $me['id']);
$isOwn = ($requestedId === (int)$me['id']);
if (!$isOwn && Auth::role() !== 'admin') {
http_response_code(403);
$pageTitle = '403';
$pageSubtitle = 'Kein Zugriff';
require __DIR__ . '/../app/views/errors/403.php';
break;
}
$stmt = $db->getConnection()->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $requestedId]);
$profileUser = $stmt->fetch();
if (!$profileUser) {
http_response_code(404);
$pageTitle = '404';
require __DIR__ . '/../app/views/errors/404.php';
break;
}
$pageTitle = $isOwn ? 'Mein Profil' : 'Profil · ' . safeText($profileUser['first_name'] . ' ' . $profileUser['last_name']);
$pageSubtitle = $isOwn ? 'Deine Aktivitäten und Login-Historie.' : 'Aktivitäten und Login-Historie dieses Benutzers.';
$activeNav = '';
$summary = AuditLog::userSummary($requestedId);
$loginHistory = AuditLog::loginHistory($requestedId, 12);
$recentActions = attachAuditLinks(AuditLog::forUser($requestedId, 30), $db->getConnection());
$activityDay = AuditLog::activityByDay($requestedId, 14);
require __DIR__ . '/../app/views/profile/index.php';
break;
// --- SEARCH -------------------------------------------------------
case 'search':
$pageTitle = 'Suche';
$pageSubtitle = 'Globale Suche über Buchungen, Kunden, Rechnungen und Events.';
$activeNav = '';
$query = trim((string)($_GET['q'] ?? ''));
$searchSvc->setHiddenSources(hiddenSourcesFor($settingsRepo, 'search'));
$results = $query !== '' ? $searchSvc->search($query, 'global') : ['exact' => [], 'suggestions' => [], 'normalized' => ''];
require __DIR__ . '/../app/views/search/index.php';
break;
case 'api/customer-search':
header('Content-Type: application/json; charset=utf-8');
$q = trim((string)($_GET['q'] ?? ''));
$hits = $customerRepo->searchAll($q, 10);
$out = [];
foreach ($hits as $c) {
$plate = $bookingRepo->lastLicensePlate((int)$c['id']);
$out[] = [
'id' => (int)$c['id'],
'name' => trim($c['first_name'] . ' ' . $c['last_name']),
'company' => $c['company'] ?? '',
'email' => $c['email'] ?? '',
'phone' => $c['phone'] ?? '',
'street' => $c['street'] ?? '',
'zip' => $c['zip'] ?? '',
'city' => $c['city'] ?? '',
'last_plate' => $plate ?? '',
];
}
echo json_encode(['query' => $q, 'results' => $out], JSON_UNESCAPED_UNICODE);
exit;
case 'api/live-search/detail':
// Lazy-loaded Detail-Pane für die Mega-Suche. Wird gefeuert, sobald der
// Cursor/Hover über einer Trefferzeile landet — so müssen die Sub-Queries
// (customerBookings, payment-Aggregates, …) nicht für alle 100 Treffer
// vorab gezogen werden, sondern nur für den aktiv fokussierten.
header('Content-Type: application/json; charset=utf-8');
$type = (string)($_GET['type'] ?? '');
$id = (int)($_GET['id'] ?? 0);
if (!in_array($type, ['bookings', 'customers', 'invoices', 'events'], true) || $id <= 0) {
echo json_encode(['ok' => false, 'message' => 'Ungültige Parameter']);
exit;
}
// Caching-Header: 30 Sek. — gibt der Browser Window-Wechsel die Chance,
// Wiederholtes Hover auf gleiche Zeile aus dem Cache zu beantworten.
header('Cache-Control: private, max-age=30');
$meta = $searchSvc->getDetail($type, $id);
echo json_encode(['ok' => true, 'type' => $type, 'id' => $id, 'meta' => $meta], JSON_UNESCAPED_UNICODE);
exit;
case 'api/live-search':
header('Content-Type: application/json; charset=utf-8');
$query = trim((string)($_GET['q'] ?? ''));
$type = (string)($_GET['type'] ?? 'global');
if (!in_array($type, ['global', 'bookings', 'customers', 'invoices', 'events', 'arrivals', 'license_plate'], true)) {
$type = 'global';
}
// Bei type=events optional bis zu N Tage in die Vergangenheit zulassen
// (z. B. bookings/create: nachträgliche Buchung für gestriges Event).
$pastDays = max(0, min(60, (int)($_GET['past_days'] ?? 0)));
$debug = !empty($_GET['debug']);
$searchSvc->setHiddenSources(hiddenSourcesFor($settingsRepo, 'search'));
if ($debug) $searchSvc->enableDebug();
$totalT0 = microtime(true);
$result = $searchSvc->search($query, $type, $pastDays);
$totalMs = round((microtime(true) - $totalT0) * 1000, 2);
$payload = [
'query' => $query,
'normalized_query' => $result['normalized'],
'exact' => $result['exact'],
'suggestions' => $result['suggestions'],
];
if ($debug) {
$payload['debug'] = [
'total_ms' => $totalMs,
'queries' => $searchSvc->getDebugLog(),
];
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
default:
http_response_code(404);
$pageTitle = '404';
$pageSubtitle = 'Seite nicht gefunden';
require __DIR__ . '/../app/views/errors/404.php';
break;
}
} catch (Throwable $e) {
ob_end_clean();
http_response_code(500);
ob_start();
$pageTitle = '500';
$pageSubtitle = 'Interner Fehler';
$errorMessage = $e->getMessage();
require __DIR__ . '/../app/views/errors/500.php';
$content = ob_get_clean();
require __DIR__ . '/../app/views/layout.php';
exit;
}
$content = ob_get_clean();
if (!empty($useBlankLayout)) {
require __DIR__ . '/../app/views/auth/_layout.php';
} elseif (!empty($useMobileLayout)) {
require __DIR__ . '/../app/views/mobile/layout.php';
} else {
require __DIR__ . '/../app/views/layout.php';
}
/**
* Kombiniert separate Datums- und Zeitfelder aus dem Event-Formular
* zu DATETIME-Werten, wie sie die DB erwartet.
*
* starts_at: "2026-06-15" -> "2026-06-15 00:00:00"
* ends_at: "2026-06-15" -> "2026-06-15 23:59:00"
* arrival_from: "06:00" + starts_at -> "2026-06-15 06:00:00"
* arrival_to: "09:00" + starts_at -> "2026-06-15 09:00:00"
*
* Falls die Werte schon vollständige DATETIMEs sind, bleiben sie unangetastet.
*/
function normalizeEventDateTimes(array $data): array
{
$isDateOnly = fn ($v) => is_string($v) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $v);
$isTimeOnly = fn ($v) => is_string($v) && preg_match('/^\d{2}:\d{2}$/', $v);
if (!empty($data['starts_at']) && $isDateOnly($data['starts_at'])) {
$data['starts_at'] .= ' 00:00:00';
}
if (!empty($data['ends_at']) && $isDateOnly($data['ends_at'])) {
$data['ends_at'] .= ' 23:59:00';
}
$anchor = $isDateOnly($_POST['starts_at'] ?? '') ? $_POST['starts_at'] : substr((string)($data['starts_at'] ?? ''), 0, 10);
if (!empty($data['arrival_from']) && $isTimeOnly($data['arrival_from'])) {
$data['arrival_from'] = $anchor . ' ' . $data['arrival_from'] . ':00';
}
if (!empty($data['arrival_to']) && $isTimeOnly($data['arrival_to'])) {
$data['arrival_to'] = $anchor . ' ' . $data['arrival_to'] . ':00';
}
return $data;
}
/**
* Verarbeitet einen Organizer-Logo-Upload: validiert, legt Original ab und
* erzeugt skalierte PNG-Varianten in 32 / 64 / 128 / 256 px Höhe.
*
* @return array{error?:string, original?:string, heights?:array<int,string>}
*/
function processOrganizerLogoUpload(int $organizerId, array $file, Organizer $organizerRepo, ?array $existingRow = null): array
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'Upload fehlgeschlagen (Code ' . $file['error'] . ').'];
}
if ($file['size'] > 5 * 1024 * 1024) {
return ['error' => 'Datei zu groß (max. 5 MB).'];
}
$allowed = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/jpg' => 'jpg',
'image/pjpeg' => 'jpg',
'image/webp' => 'webp',
'image/svg+xml' => 'svg',
'image/svg' => 'svg',
'text/xml' => 'svg', // manche Browser melden SVG so
'application/xml'=> 'svg',
];
$mime = function_exists('mime_content_type') ? (string)mime_content_type($file['tmp_name']) : '';
// Manche Systeme erkennen SVG als text/plain — Fallback per Datei-Endung.
$fallbackExt = strtolower((string)pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
if (!isset($allowed[$mime]) && $fallbackExt === 'svg') {
$mime = 'image/svg+xml';
}
if (!isset($allowed[$mime])) {
return ['error' => 'Nur PNG / JPG / WebP / SVG erlaubt (erkannt: ' . safeText($mime) . ').'];
}
$ext = $allowed[$mime];
$isSvg = $ext === 'svg';
$publicDir = __DIR__ . '/uploads/organizers/' . $organizerId;
if (!is_dir($publicDir)) {
@mkdir($publicDir, 0775, true);
}
$basename = 'logo_' . date('Ymd_His') . '_' . substr(bin2hex(random_bytes(4)), 0, 8);
try {
if ($isSvg) {
// SVG-Inhalt sanitisieren (Scripts, Eventhandler etc. raus), dann ablegen.
$raw = file_get_contents($file['tmp_name']);
if ($raw === false) {
return ['error' => 'SVG nicht lesbar.'];
}
if (stripos($raw, '<svg') === false) {
return ['error' => 'Datei sieht nicht wie ein SVG aus.'];
}
$clean = ImageProcessor::sanitizeSvg($raw);
$originalFile = $basename . '.svg';
if (file_put_contents(rtrim($publicDir, '/') . '/' . $originalFile, $clean) === false) {
return ['error' => 'SVG konnte nicht gespeichert werden.'];
}
// PNG-Varianten via ImageMagick rastern. Fehlt das Binary, bekommen alle
// Höhen den SVG-Pfad — Browser skalieren das nativ; nur für Konsumenten
// ohne SVG-Support wäre das ungünstig.
if (ImageProcessor::hasImageMagick()) {
$heightFiles = ImageProcessor::rasterizeSvgToHeights(
rtrim($publicDir, '/') . '/' . $originalFile,
$publicDir, $basename, [32, 64, 128, 256]
);
} else {
$heightFiles = [32 => $originalFile, 64 => $originalFile, 128 => $originalFile, 256 => $originalFile];
}
} else {
$originalFile = ImageProcessor::storeOriginal($file['tmp_name'], $publicDir, $basename, $ext);
$heightFiles = ImageProcessor::resizeToHeights($file['tmp_name'], $publicDir, $basename, [32, 64, 128, 256]);
}
} catch (Throwable $e) {
return ['error' => $e->getMessage()];
}
$publicBase = '/uploads/organizers/' . $organizerId . '/';
$originalPath = $publicBase . $originalFile;
$heightPaths = [];
foreach ($heightFiles as $h => $fn) {
$heightPaths[$h] = $publicBase . $fn;
}
// Vorherige Dateien entfernen, sobald die neuen sicher liegen.
if ($existingRow) {
deleteOrganizerLogoFiles($existingRow, [$originalFile, ...array_values($heightFiles)]);
}
$organizerRepo->updateLogoPaths($organizerId, $originalPath, $heightPaths);
return ['original' => $originalPath, 'heights' => $heightPaths];
}
/**
* Entfernt alte Logo-Dateien einer Organizer-Zeile.
* @param string[] $keepBasenames Dateinamen (im selben Ordner), die nicht gelöscht werden sollen.
*/
function deleteOrganizerLogoFiles(array $row, array $keepBasenames = []): void
{
$paths = array_filter([
$row['logo_path'] ?? null,
$row['logo_path_32'] ?? null,
$row['logo_path_64'] ?? null,
$row['logo_path_128'] ?? null,
$row['logo_path_256'] ?? null,
]);
foreach ($paths as $rel) {
$full = __DIR__ . $rel; // public path -> public/uploads/...
if (!is_file($full)) continue;
if (in_array(basename($full), $keepBasenames, true)) continue;
@unlink($full);
}
}
function handleLogoUpload(array $file): array
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'Upload fehlgeschlagen (Code ' . $file['error'] . ').'];
}
if ($file['size'] > 5 * 1024 * 1024) {
return ['error' => 'Datei zu groß (max. 5 MB).'];
}
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/pjpeg' => 'jpg'];
$mime = function_exists('mime_content_type') ? (string)mime_content_type($file['tmp_name']) : '';
if (!isset($allowed[$mime])) {
return ['error' => 'Nur PNG oder JPG erlaubt (erkannt: ' . safeText($mime) . ').'];
}
$ext = $allowed[$mime];
$publicDir = __DIR__ . '/uploads/logos';
if (!is_dir($publicDir)) {
@mkdir($publicDir, 0775, true);
}
$name = 'logo_' . date('Ymd_His') . '_' . substr(bin2hex(random_bytes(4)), 0, 8) . '.' . $ext;
$target = $publicDir . '/' . $name;
if (!move_uploaded_file($file['tmp_name'], $target)) {
return ['error' => 'Datei konnte nicht gespeichert werden.'];
}
return ['path' => '/uploads/logos/' . $name];
}