403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/seaside2.pacim.de/web/public/index.php
<?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];
}

Youez - 2016 - github.com/yon3zu
LinuXploit