| 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/seaside.pacim.de/web/ |
Upload File : |
<?php
/**
* day-list.php – Mobile Tages-Anreiseliste (aus der Pushover-Nachricht verlinkt).
*
* Zeigt dieselbe gefilterte Liste wie die Pushover-Nachricht als echte, ausgerichtete
* Tabelle. Jedes Kennzeichen ist fett und verlinkt auf die hash-gesicherte Check-in-/
* Zahlungsseite (checkin.php). Zugriff ohne Login, abgesichert über einen Hash, der
* individuell für diesen Tag erzeugt (pacim_sign('daylist:'.date)) und hier verglichen wird.
*/
require_once( __DIR__ . '/includes/config.inc.php' );
require_once( __DIR__ . '/includes/day-arrivals.php' );
require_once( __DIR__ . '/includes/mobileaccess.php' );
require_once( __DIR__ . '/includes/booking-images.php' );
@ini_set( 'display_errors', '0' );
error_reporting( 0 );
while ( ob_get_level() > 0 ) ob_end_clean();
$date = substr( (string) ( $_GET['date'] ?? '' ), 0, 10 );
$h = trim( (string) ( $_GET['h'] ?? '' ) );
$tok = trim( (string) ( $_GET['t'] ?? '' ) ); // Mobile-Zugang-Token (alternativ zu date+h)
$action = (string) ( $_GET['action'] ?? '' ); // ''=Seite, 'poll'=Status-JSON, 'rows'=Tabellen-HTML
// Terminal-Kontext (kam über ein Terminal): erlaubt „Zurück zur Terminal-Übersicht".
$termHash = preg_replace( '/[^a-f0-9]/', '', strtolower( (string) ( $_GET['term'] ?? '' ) ) );
$termBack = ''; // gültige Terminal-Sitzung? → „Terminal"-Button (wird nach DB-Init gesetzt)
$directLogout = ''; // Direktaufruf mit PIN (keine Terminal-Sitzung) → „Logout"-Button
function dl_css() {
return '
@font-face{font-family:"EuroPlate";src:url("' . SITE_URL . '/assets/fonts/EuroPlate.ttf") format("truetype");font-display:swap;}
:root{--navy:#0d2c44;--navy2:#11436b;--blue:#1b6ca8;--green:#2fb344;--red:#d63939;--gold:#f3b53f;--ink:#16384f;--muted:#5b7488;--line:#e6eef4;}
*{box-sizing:border-box;margin:0;padding:0;}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%;}
body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#eef3f7;color:var(--ink);padding:0 0 40px;touch-action:manipulation;}
.wrap{max-width:560px;margin:0 auto;}
.topbar{position:sticky;top:0;z-index:6;}
/* iOS: Inhalt nicht unter Dynamic Island / Statusleiste schieben (viewport-fit=cover + Safe-Area). */
.hd{position:relative;background:linear-gradient(135deg,var(--navy) 0%,var(--navy2) 65%,var(--blue) 130%);color:#fff;
padding:calc(20px + env(safe-area-inset-top,0px)) calc(18px + env(safe-area-inset-right,0px)) 18px calc(18px + env(safe-area-inset-left,0px));}
.term-back{position:absolute;top:calc(14px + env(safe-area-inset-top,0px));right:calc(14px + env(safe-area-inset-right,0px));
display:inline-flex;align-items:center;gap:5px;background:rgba(255,255,255,.16);color:#fff;text-decoration:none;
border-radius:9px;padding:7px 12px;font-size:13px;font-weight:700;-webkit-tap-highlight-color:transparent;}
.term-back:active{background:rgba(255,255,255,.3);}
.term-back svg{width:16px;height:16px;}
.hd .k{font-size:11px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;opacity:.8;}
.hd .t{font-size:21px;font-weight:800;margin-top:2px;}
.hd .sub{font-size:12.5px;opacity:.85;margin-top:3px;}
.stats{display:flex;gap:8px;margin-top:9px;}
.stat{background:rgba(255,255,255,.14);border-radius:10px;padding:6px 11px;font-size:12.5px;font-weight:600;}
.stat b{font-size:15px;} .stat.open b{color:#ffd0cf;}
.stat.filt{cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;transition:background .15s ease,box-shadow .15s ease;}
.stat.filt.active{background:rgba(255,255,255,.34);box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.6);}
.stat.live{display:inline-flex;align-items:center;gap:6px;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.stat.live:active{filter:brightness(.9);}
/* „Zuletzt geöffnet"-Panel (Bottom-Sheet) */
.rec{position:fixed;inset:0;z-index:55;background:rgba(13,44,68,.45);display:flex;align-items:flex-end;}
.rec-sheet{background:#fff;width:100%;max-width:560px;margin:0 auto;border-radius:18px 18px 0 0;max-height:80vh;display:flex;flex-direction:column;padding-bottom:env(safe-area-inset-bottom,0px);}
.rec-h{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;font-size:17px;font-weight:800;color:var(--navy);border-bottom:1px solid #e6eef4;}
.rec-h-act{display:flex;align-items:center;gap:6px;}
.rec-reset{background:#eef3f7;border:0;border-radius:8px;padding:7px 11px;font:inherit;font-size:13px;font-weight:700;color:#a23030;cursor:pointer;}
.rec-reset:active{background:#e2e9f0;}
.rec-x{background:none;border:0;font-size:28px;line-height:1;color:#5b7488;cursor:pointer;}
.rec-list{overflow-y:auto;-webkit-overflow-scrolling:touch;}
.rec-item{display:flex;align-items:center;gap:12px;width:100%;border:0;border-bottom:1px solid #eef3f7;background:none;text-align:left;padding:13px 18px;font:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.rec-item:active{background:#f4f8fb;}
.rec-plate{font-weight:800;font-size:15px;color:var(--navy);font-family:"EuroPlate",monospace;flex:0 0 auto;}
.rec-name{flex:1;min-width:0;color:#5b7488;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.rec-item svg{width:18px;height:18px;color:#9fb3c4;flex:0 0 auto;}
.rec-empty{padding:30px 18px;text-align:center;color:#5b7488;}
.live-dot{width:8px;height:8px;border-radius:50%;background:#34d058;box-shadow:0 0 0 0 rgba(52,208,88,.6);animation:dlpulse 2s infinite;}
.stat.live.stale .live-dot{background:#ffb84d;animation:none;}
@keyframes dlpulse{0%{box-shadow:0 0 0 0 rgba(52,208,88,.6);}70%{box-shadow:0 0 0 7px rgba(52,208,88,0);}100%{box-shadow:0 0 0 0 rgba(52,208,88,0);}}
@media (prefers-reduced-motion: reduce){ .live-dot{animation:none;} }
/* Check-in-Overlay (Iframe, kein Seitenreload) */
/* Bottom-Abstand für die eingebettete Einzelansicht (iframe liest env() selbst nicht):
iPhone-Home-Indikator (inset>0) freihalten; Android (inset≈0) übernimmt die Tableiste selbst. */
.ov{position:fixed;inset:0;z-index:50;background:#fff;display:flex;flex-direction:column;padding-bottom:env(safe-area-inset-bottom,0px);}
/* Safe-Area kann das eingebettete iframe (checkin.php) selbst NICHT lesen (env()=0 im iframe),
darum hier oben ein navyfarbener Streifen (= Header-Farbe) unter der Dynamic Island/Statusleiste. */
.ov-top{flex:0 0 auto;height:env(safe-area-inset-top,0px);background:var(--navy);}
.ov-frame{flex:1 1 auto;width:100%;border:0;background:#fff;min-height:0;}
.row-flash{animation:dlflash 1.1s ease-out;}
@keyframes dlflash{0%{background:#fff6cc;}100%{}}
/* Sitzungsende-Bildschirm */
.ended{position:fixed;inset:0;z-index:60;background:linear-gradient(160deg,var(--navy) 0%,var(--navy2) 70%,var(--blue) 140%);display:flex;align-items:center;justify-content:center;padding:24px;}
.ended-box{text-align:center;color:#fff;max-width:340px;}
.ended-ic{width:64px;height:64px;color:#cfe2f2;margin:0 auto 16px;}
.ended-t{font-size:24px;font-weight:800;}
.ended-x{font-size:14.5px;opacity:.85;margin-top:10px;line-height:1.5;}
.flt{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px;}
.chip{background:rgba(255,255,255,.16);border-radius:999px;padding:3px 9px;font-size:11.5px;font-weight:600;}
.search{background:#fff;padding:10px 14px;border-bottom:1px solid var(--line);}
.search-box{position:relative;}
.search input{width:100%;padding:11px 40px 11px 14px;border:1px solid var(--line);border-radius:10px;font-size:15px;background:#f6f9fc;}
.search input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;}
.search-clear{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:30px;height:30px;border:0;border-radius:50%;
background:#e6eef4;color:var(--navy);font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;}
.search-clear:active{filter:brightness(.94);}
.d-none{display:none!important;}
/* Meinten Sie? – Vorschläge bei ähnlichem Kennzeichen */
.suggest{background:#fff8e6;border-bottom:1px solid #f3e3b0;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
.suggest .sg-l{font-size:12.5px;font-weight:700;color:#8a6400;}
.suggest .sg-chip{background:#fff;border:1.5px solid #e8c766;border-radius:8px;padding:6px 12px;font-size:14px;font-weight:800;color:var(--navy);cursor:pointer;font-family:inherit;}
.suggest .sg-chip:active{filter:brightness(.95);}
.tblwrap{overflow-x:auto;-webkit-overflow-scrolling:touch;background:#fff;}
.tbl{width:100%;border-collapse:collapse;background:#fff;}
.tbl th{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;
padding:9px 10px;border-bottom:2px solid var(--line);background:#f6f9fc;}
.tbl td{padding:12px 10px;border-bottom:1px solid var(--line);font-size:14px;vertical-align:middle;}
.tbl tbody tr{cursor:pointer;}
/* Zeilen-Status: eingecheckt grün, Zahlung offen orange, bezahlt aber nicht eingecheckt leicht blau */
.tbl tbody tr.st-in{background:rgba(47,179,68,.20);box-shadow:inset 3px 0 0 #2fb344;}
.tbl tbody tr.st-open{background:rgba(245,159,0,.20);box-shadow:inset 3px 0 0 #f59f00;}
.tbl tbody tr.st-paid{background:rgba(27,108,168,.13);box-shadow:inset 3px 0 0 #1b6ca8;}
.tbl tbody tr.st-storno{background:rgba(214,57,57,.12);box-shadow:inset 3px 0 0 #d63939;}
.tbl tbody tr.st-storno .plate{text-decoration:line-through;color:#a23030;}
/* außerhalb des Filters – nur per Suche sichtbar, dezent + Markierung */
.tbl tbody tr.row-out{display:none;background:#f4f6f9;box-shadow:inset 3px 0 0 #b6c2cf;opacity:.92;}
.outflag{display:inline-block;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#6b7a89;background:#e4e9ef;border-radius:5px;padding:1px 6px;margin-left:6px;vertical-align:middle;}
.pcam{display:inline-flex;align-items:center;gap:2px;font-size:11px;font-weight:700;color:var(--blue);background:rgba(27,108,168,.10);border-radius:5px;padding:1px 5px;margin-left:6px;vertical-align:middle;white-space:nowrap;}
.pcam svg{width:12px;height:12px;}
.tbl tbody tr:active{filter:brightness(.96);}
/* Kennzeichen in der FE-Schrift (EuroPlate). Nur ein Schnitt -> „fett" per Kontur. */
.tbl td.plate{font-family:"EuroPlate",-apple-system,Segoe UI,Roboto,sans-serif;font-weight:800;font-size:20.4px;letter-spacing:.5px;color:var(--navy);white-space:nowrap;-webkit-text-stroke:.45px currentColor;}
.tbl td.typ-cell{white-space:nowrap;}
.tbl td.typ-cell .typ-name{display:block;font-weight:700;color:var(--navy);font-size:13.5px;line-height:1.2;}
.tbl td.typ-cell .typ-days{display:block;font-size:11.5px;color:var(--muted);margin-top:1px;line-height:1.2;}
.nm{font-weight:600;}
.nm .nm-name{display:block;font-size:15.5px;font-weight:600;color:var(--navy);line-height:1.2;}
.nm .nm-ship{display:block;font-size:11.5px;font-weight:500;color:var(--muted);margin-top:1px;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px;}
.mut{color:var(--muted);}
mark{background:#ffd54a;color:#3a2c00;border-radius:2px;padding:0 1px;}
.empty{padding:40px 20px;text-align:center;color:var(--muted);}
.foot{font-size:12px;color:var(--muted);text-align:center;padding:16px;}
.err{max-width:440px;margin:60px auto;background:#fff;border-radius:18px;padding:34px 26px;text-align:center;box-shadow:0 14px 40px rgba(13,44,68,.16);}
.err h1{font-size:40px;color:var(--navy);} .err p{color:var(--muted);margin-top:8px;}
/* PIN-Sperrbildschirm: gemeinsame Komponente (pacim_pin_css), siehe dl_pin_screen(). */
/* ---- ressourcenarme Animationen: nur opacity/transform (Compositor), keine Layout-Props ---- */
@keyframes dlFadeUp{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:none;}}
@keyframes dlFade{from{opacity:0;}to{opacity:1;}}
@keyframes dlSheet{from{opacity:.3;transform:translateY(16px);}to{opacity:1;transform:none;}}
@keyframes dlPop{from{opacity:0;transform:scale(.92);}to{opacity:1;transform:none;}}
.tblwrap{animation:dlFadeUp .28s ease both;}
.suggest:not(.d-none){animation:dlFadeUp .18s ease;}
.ov:not(.d-none){animation:dlFade .18s ease;}
.ov-frame{animation:dlSheet .24s cubic-bezier(.22,.61,.36,1);}
.ended:not(.d-none){animation:dlFade .25s ease;}
.ended-box{animation:dlPop .32s cubic-bezier(.22,.61,.36,1) both;}
.tbl tbody tr{transition:background .25s ease;}
@media (prefers-reduced-motion: reduce){
.tblwrap,.suggest,.ov,.ov-frame,.ended,.ended-box,.row-flash,.pin-card.shake{animation:none!important;}
.tbl tbody tr{transition:none;} .pin-key.lit,.pin-key:active{transform:none;}
}
';
}
function dl_html( $title, $body, $code = 200 ) {
http_response_code( $code );
header( 'Content-Type: text/html; charset=utf-8' );
echo '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover">'
. '<meta name="robots" content="noindex,nofollow">'
// Web-App-Modus: chrome-loses Vollbild beim Start vom Home-Bildschirm (iOS + Android)
. '<meta name="apple-mobile-web-app-capable" content="yes"><meta name="mobile-web-app-capable" content="yes">'
. '<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="theme-color" content="#0d2c44">'
// Web-App (PWA): Manifest + iOS-Home-Icon NUR in der mobilen Ansicht, sobald ein gültiger Token vorliegt.
. ( empty( $GLOBALS['dl_mtoken'] ) ? '' :
'<link rel="manifest" href="' . SITE_URL . '/manifest.php?t=' . rawurlencode( $GLOBALS['dl_mtoken'] ) . '">'
. '<link rel="apple-touch-icon" href="' . SITE_URL . '/assets/webapp/icon-180.png">'
. '<meta name="apple-mobile-web-app-title" content="Seaside Parking">' )
. '<title>' . htmlspecialchars( $title, ENT_QUOTES, 'UTF-8' ) . '</title>'
. '<style>' . dl_css() . '</style></head><body>' . $body
// Vollbild beim ersten Tippen anfordern (Android-Chrome unterstützt das; iOS-Safari ignoriert es still).
. '<script>(function(){function go(){var e=document.documentElement,r=e.requestFullscreen||e.webkitRequestFullscreen;if(r){try{r.call(e);}catch(_){}}}'
. 'document.addEventListener("pointerdown",function f(e){if(e.target&&e.target.closest&&e.target.closest("a,button,input,textarea,select,label"))return;document.removeEventListener("pointerdown",f);go();},{passive:true});})();</script>'
// „Zum Startbildschirm hinzufügen"-Anleitung, falls im Browser (nicht als installierte App) geöffnet.
. ( empty( $GLOBALS['dl_mtoken'] ) ? '' : pacim_webapp_install_html( 'Seaside Parking', SITE_URL . '/assets/webapp/icon-192.png' ) )
. pacim_webapp_loader_html()
. ( function_exists( 'pacim_webapp_offline_html' ) ? pacim_webapp_offline_html() : '' )
. ( function_exists( 'pacim_chat_widget_html' ) ? pacim_chat_widget_html( (string) ( $GLOBALS['dl_mtoken'] ?? '' ), '', 0 ) : '' )
. '</body></html>';
exit();
}
/** PIN-Sperrbildschirm mit eigener Tastatur (Tippen ODER Ziehen über die Ziffern). */
function dl_pin_screen( $tok ) {
// Gemeinsame PIN-Komponente (Tippen/Wischen mit Muster-Linien); Token steckt in der URL.
$body = '<style>' . pacim_pin_css() . '</style>'
. pacim_pin_markup( 'PIN eingeben', 'Dieser Zugang ist mit einer PIN geschützt.' )
. pacim_pin_js( "location.pathname+location.search+'&action=unlock'" );
dl_html( 'PIN', $body );
}
$db = new MysqliDb( MYSQL_HOST, MYSQL_USER, MYSQL_PASS, MYSQL_DB );
// Terminal-Kontext: gültige Terminal-Sitzung (Master ODER Login)? → „Terminal"-Button (zurück zur Übersicht) + Terminal-Scope.
$termLoginSuffix = '';
if ( $termHash !== '' && function_exists( 'pacim_terminal_load' ) ) {
$tRow = pacim_terminal_load( $db, $termHash );
if ( $tRow && pacim_terminal_session( $db, $tRow ) ) {
$termBack = SITE_URL . '/terminal.php?h=' . $termHash;
$cv = (string) ( $_COOKIE[ 'term_' . $termHash ] ?? '' );
if ( isset( $cv[0] ) && $cv[0] === 'L' ) { $p = strpos( $cv, ':' ); if ( $p ) $termLoginSuffix = ':' . substr( $cv, 1, $p - 1 ); }
}
}
$access = null; $canAct = true; $searchAll = true; $outItems = array(); $chips = array();
if ( $tok !== '' ) {
/* ---- Mobile-Zugang (Token): Filter + Rechte aus der DB */
$access = pacim_ma_load( $db, $tok );
if ( ! $access ) {
// Autorefresh (poll/rows): klares JSON-Signal statt HTML, damit der Client die Sitzung beendet.
if ( $action === 'poll' || $action === 'rows' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' );
echo '{"gone":1}'; exit();
}
dl_html( 'Ungültiger Link', '<div class="err"><h1>403</h1><p>Dieser Zugang ist ungültig, widerrufen oder abgelaufen.</p></div>', 403 );
}
// Web-App-Manifest nur bei gültigem Token einbinden (Token wandert in start_url -> installierte App öffnet diese Liste).
$GLOBALS['dl_mtoken'] = $tok;
/* ---- PIN-Gate (optional). Token bleibt das eigentliche Geheimnis; PIN = Zusatzschutz. */
$pinHash = (string) $access['ma_pin'];
$needPin = ( $pinHash !== '' );
$cookieNm = 'mau_' . $tok;
$unlocked = ! $needPin
|| ( isset( $_COOKIE[ $cookieNm ] ) && hash_equals( pacim_ma_unlock_token( $tok ), (string) $_COOKIE[ $cookieNm ] ) )
|| ( function_exists( 'pacim_terminal_grants' ) && pacim_terminal_grants( $db, $tok ) ); // Zugriff über Terminal-Sitzung
if ( $action === 'unlock' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' );
if ( ! $needPin ) { echo '{"ok":true}'; exit(); }
$pin = preg_replace( '/\D/', '', (string) ( $_POST['pin'] ?? '' ) );
if ( strlen( $pin ) >= 4 && strlen( $pin ) <= 10 && hash_equals( $pinHash, pacim_ma_pin_hash( $tok, $pin ) ) ) {
$exp = ! empty( $access['ma_expires'] ) ? strtotime( $access['ma_expires'] ) : ( time() + 12 * 3600 );
setcookie( $cookieNm, pacim_ma_unlock_token( $tok ), array( 'expires' => $exp, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax' ) );
echo '{"ok":true}'; exit();
}
usleep( 300000 ); // Brute-Force leicht bremsen
echo json_encode( array( 'ok' => false, 'error' => 'Falsche PIN.' ) ); exit();
}
// Abmelden (nur Direktaufruf mit eigener PIN): Freischalt-Cookie löschen → zurück zum PIN-Screen.
if ( $action === 'logout' ) {
setcookie( $cookieNm, '', array( 'expires' => time() - 3600, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax' ) );
header( 'Location: ' . SITE_URL . '/day-list.php?t=' . $tok ); exit();
}
if ( $needPin && ! $unlocked ) {
if ( $action === 'poll' || $action === 'rows' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' );
echo '{"locked":1}'; exit();
}
dl_pin_screen( $tok ); // Sperrbildschirm rendern (kein Listendaten-Leak) und beenden
}
// Zugriff über ein Terminal als Sitzung erfassen – auch OHNE neuen Login (bestehendes term_-Cookie).
// Bei neuem Gerät/IP oder nach Leerlauf entsteht eine neue Sitzung, sonst wird die aktive fortgeschrieben.
// (Nicht beim Status-Poll, um die Heartbeat-Frequenz nicht zu tracken.)
if ( $action !== 'poll' && function_exists( 'pacim_ts_touch_tok' ) ) pacim_ts_touch_tok( $db, $tok );
/* ===== Tag bestimmen: Standard = ma_date; optional &d=<Datum> innerhalb des Gültigkeitszeitraums (mehrtägig). ===== */
$maDate0 = substr( (string) $access['ma_date'], 0, 10 );
$maExp0 = ! empty( $access['ma_expires'] ) ? substr( (string) $access['ma_expires'], 0, 10 ) : '';
$date = $maDate0;
$reqD = (string) ( $_GET['d'] ?? '' );
if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $reqD ) && $reqD >= $maDate0 && ( $maExp0 === '' || $reqD <= $maExp0 ) ) $date = $reqD;
// „Logout"-Button NUR beim Direktaufruf mit eigener PIN (keine Terminal-Sitzung); über Terminal gilt der „Terminal"-Button.
if ( $termBack === '' && $needPin && isset( $_COOKIE[ $cookieNm ] ) && hash_equals( pacim_ma_unlock_token( $tok ), (string) $_COOKIE[ $cookieNm ] ) )
$directLogout = SITE_URL . '/day-list.php?t=' . $tok . '&action=logout';
/* ===== „Zuletzt geöffnet" (Recent Views) ===== */
// Scope: über Terminal-Sitzung -> Terminal (geteilt); sonst aktuelle (Browser-)Session des Direktaufrufs.
$rvScope = ( $termHash !== '' && $termBack !== '' ) ? ( 'term:' . $termHash . $termLoginSuffix ) : ( 'sess:' . session_id() );
if ( $action === 'recent_add' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' );
$rb = (int) ( $_POST['b'] ?? ( $_GET['b'] ?? 0 ) );
if ( $rb > 0 && function_exists( 'pacim_rv_add' ) ) pacim_rv_add( $db, $rvScope, $tok, $rb );
echo '{"ok":true}'; exit();
}
if ( $action === 'recent_clear' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' );
if ( function_exists( 'pacim_rv_clear' ) ) pacim_rv_clear( $db, $rvScope, $tok );
echo '{"ok":true}'; exit();
}
if ( $action === 'recent_list' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' );
$ids = function_exists( 'pacim_rv_list' ) ? pacim_rv_list( $db, $rvScope, $tok, 20 ) : array();
$out = array();
if ( $ids ) {
$place = implode( ',', array_fill( 0, count( $ids ), '?' ) );
$rows = (array) $db->rawQuery(
"SELECT b.booking_id AS id, license_plate(b.booking_serial) AS plate,
c.customer_lastname AS lastname, c.customer_name AS company, c.customer_firstname AS firstname
FROM pacim_booking b LEFT JOIN pacim_customer c ON c.customer_id = b.booking_customer
WHERE b.booking_id IN ($place)", $ids );
$map = array();
foreach ( $rows as $r ) $map[ (int) $r['id'] ] = $r;
foreach ( $ids as $id ) { // Reihenfolge = Recency
if ( ! isset( $map[ $id ] ) ) continue;
$r = $map[ $id ];
$nm = trim( (string) $r['lastname'] ); if ( $nm === '' ) $nm = trim( (string) $r['company'] ); if ( $nm === '' ) $nm = trim( (string) $r['firstname'] );
$pl = trim( (string) $r['plate'] );
$out[] = array( 'id' => (int) $id, 'plate' => $pl !== '' ? $pl : '—', 'name' => $nm !== '' ? $nm : '—',
'href' => SITE_URL . '/checkin.php?t=' . $tok . '&b=' . (int) $id );
}
}
echo json_encode( array( 'ok' => true, 'items' => $out ), JSON_UNESCAPED_UNICODE ); exit();
}
/* ===== Dashboard-Modus: Tageskennzahlen statt Anreiseliste ===== */
if ( pacim_ma_mode( $access ) === 'dashboard' ) {
// $date kommt von oben (ma_date bzw. &d innerhalb der Gültigkeit).
$eD = function ( $s ) { return htmlspecialchars( (string) $s, ENT_QUOTES, 'UTF-8' ); };
$wdD = array( 1 => 'Mo', 2 => 'Di', 3 => 'Mi', 4 => 'Do', 5 => 'Fr', 6 => 'Sa', 7 => 'So' );
$tsD = strtotime( $date );
$dLabelD = $wdD[ (int) date( 'N', $tsD ) ] . ', ' . date( 'd.m.Y', $tsD );
$dash = pacim_day_dashboard_cached( $db, $date, pacim_ma_filter_opts( $access ) ); // aus Cache (Cron alle 5 Min), sonst live + cachen
$chipsD = pacim_ma_summary( $access );
if ( trim( (string) $access['ma_label'] ) !== '' ) array_unshift( $chipsD, $access['ma_label'] );
$pk = $dash['parking'];
$showH = in_array( 'halle', $pk, true ); $showA = in_array( 'aussen', $pk, true ); $showV = in_array( 'valet', $pk, true );
// AJAX: nur das getabbte Widget (Terminal-Startseite lädt damit die Zahlen live nach).
if ( $action === 'dashwidget' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: text/html; charset=utf-8' ); header( 'Cache-Control: no-store' );
echo pacim_dash_widget_html( $dash, $showH, $showA, $showV );
exit();
}
// Schiffe nach Reiselänge gruppieren – je Reiselänge eine Footer-Tab.
$byTrip = array();
foreach ( $dash['ships'] as $s ) { $byTrip[ (int) $s['trip'] ][] = $s; }
ksort( $byTrip );
$tripKeys = array_keys( $byTrip );
// Daten für das Diagramme-/Filter-Modul (clientseitig): je Schiff Gesamt- + Offen-Zahlen.
$diagShips = array();
foreach ( $dash['ships'] as $s ) $diagShips[] = array(
'n' => $s['ship'], 't' => (int) $s['trip'],
'h' => (int) $s['total']['halle'], 'a' => (int) $s['total']['aussen'], 'v' => (int) $s['total']['valet'],
'oh' => (int) $s['open']['halle'], 'oa' => (int) $s['open']['aussen'], 'ov' => (int) $s['open']['valet'],
);
ob_start(); ?>
<div class="wrap">
<div class="hd">
<?php if ( $termBack ) { ?><a class="term-back" href="<?= $eD( $termBack ) ?>" title="Zurück zur Terminal-Übersicht"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 10v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9"/></svg> Terminal</a>
<?php } elseif ( $directLogout ) { ?><a class="term-back" href="<?= $eD( $directLogout ) ?>" title="Abmelden"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/></svg> Logout</a><?php } ?>
<div class="k">Dashboard · Anreisetag</div>
<div class="t"><?= $eD( $dLabelD ) ?></div>
<div class="stats"><span class="stat"><b><?= number_format( (int) $dash['guests'], 0, ',', '.' ) ?></b> Gäste<?php if ( $dash['shipCount'] > 1 ) echo ' · ' . (int) $dash['shipCount'] . ' Schiffe'; ?></span></div>
<?php if ( $chipsD ) { ?><div class="flt"><?php foreach ( $chipsD as $c ) { ?><span class="chip"><?= $eD( $c ) ?></span><?php } ?></div><?php } ?>
</div>
<?php if ( ! $dash['ships'] ) { ?>
<div class="empty">Keine Anreisen für diese Auswahl.</div>
<?php } else { ?>
<div class="dash-shell">
<?php foreach ( $tripKeys as $ti => $trip ) {
$ships = $byTrip[ $trip ];
$tlabel = $trip > 0 ? ( $trip . ' Tage' ) : 'Ohne Reiselänge'; ?>
<section class="dash-pane<?= $ti === 0 ? ' active' : '' ?>" data-pane="t<?= (int) $trip ?>">
<?php if ( count( $ships ) > 1 ) echo pacim_dash_sum_html( $ships, $tlabel, $showH, $showA, $showV );
foreach ( $ships as $s ) echo pacim_dash_card_html( $s, $showH, $showA, $showV );
?>
</section>
<?php } ?>
<section class="dash-pane" data-pane="charts">
<div class="diag-bar">
<button type="button" class="diag-fbtn" id="diagFilter">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
Filter<span class="diag-fcount d-none" id="diagFcount">0</span>
</button>
</div>
<div id="diagOut"></div>
</section>
</div>
<nav class="dash-tabs" id="dashTabs">
<?php foreach ( $tripKeys as $ti => $trip ) { ?>
<button type="button" class="dash-tab<?= $ti === 0 ? ' active' : '' ?>" data-tab="t<?= (int) $trip ?>"><span class="dt-n"><?= $trip > 0 ? (int) $trip : '—' ?></span><span class="dt-l"><?= $trip > 0 ? 'Tage' : 'Reise' ?></span></button>
<?php } ?>
<button type="button" class="dash-tab" data-tab="charts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:20px;height:20px;"><path d="M3 3v18h18"/><rect x="7" y="10" width="3" height="7"/><rect x="12" y="6" width="3" height="11"/><rect x="17" y="13" width="3" height="4"/></svg><span class="dt-l">Diagramme</span></button>
</nav>
<!-- Filter-Modal (Badges) -->
<div class="diag-modal d-none" id="diagModal">
<div class="diag-sheet">
<div class="diag-sheet-h"><span>Filter</span><button type="button" class="diag-x" id="diagClose">×</button></div>
<div class="diag-body">
<div class="diag-grp"><div class="diag-grp-t">Stellplatz</div><div class="diag-badges" id="diagPark"></div></div>
<div class="diag-grp"><div class="diag-grp-t">Reiselänge</div><div class="diag-badges" id="diagTrip"></div></div>
<div class="diag-grp"><div class="diag-grp-t">Schiff</div><div class="diag-badges" id="diagShip"></div></div>
</div>
<div class="diag-sheet-f"><button type="button" class="diag-reset" id="diagReset">Zurücksetzen</button><button type="button" class="diag-apply" id="diagApply">Anwenden</button></div>
</div>
</div>
<?php } ?>
</div>
<style>
<?= pacim_dash_css() ?>
.dash-shell{padding:0 0 calc(72px + env(safe-area-inset-bottom,0px));}
.dash-pane{display:none;} .dash-pane.active{display:block;}
/* Footer-Tabs */
.dash-tabs{position:fixed;left:0;right:0;bottom:0;max-width:560px;margin:0 auto;display:flex;background:#fff;border-top:1px solid #e6eef4;box-shadow:0 -2px 14px rgba(13,44,68,.1);padding-bottom:env(safe-area-inset-bottom,0px);z-index:20;}
.dash-tab{flex:1;border:0;background:none;cursor:pointer;-webkit-tap-highlight-color:transparent;font:inherit;display:flex;flex-direction:column;align-items:center;gap:2px;padding:9px 4px;position:relative;color:#5b7488;}
.dash-tab .dt-n{font-size:17px;font-weight:800;line-height:1;}
.dash-tab .dt-l{font-size:10.5px;font-weight:700;}
.dash-tab.active{color:#1b6ca8;}
.dash-tab.active::before{content:"";position:absolute;top:0;left:24%;right:24%;height:3px;border-radius:0 0 3px 3px;background:#1b6ca8;}
.dash-tab:active{background:rgba(27,108,168,.06);}
/* Diagramme: Filter + Statistik */
.diag-bar{padding:14px 16px;}
.diag-fbtn{display:inline-flex;align-items:center;gap:8px;background:#0d2c44;color:#fff;border:0;border-radius:11px;padding:11px 16px;font-size:15px;font-weight:700;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.diag-fbtn svg{width:18px;height:18px;}
.diag-fcount{background:#fff;color:#0d2c44;border-radius:999px;min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;padding:0 6px;}
.diag-sec{padding:14px 16px 4px;font-size:12px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#5b7488;}
.diag-hint{padding:0 16px 4px;font-size:12px;color:#5b7488;}
.dw-grid .dv-sub{display:block;font-size:10px;font-weight:600;color:#8aa0b3;margin-top:1px;}
.diag-modal{position:fixed;inset:0;z-index:60;background:rgba(13,44,68,.45);display:flex;align-items:flex-end;}
.diag-sheet{background:#fff;width:100%;max-width:560px;margin:0 auto;border-radius:18px 18px 0 0;max-height:85vh;display:flex;flex-direction:column;padding-bottom:env(safe-area-inset-bottom,0px);}
.diag-sheet-h{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;font-size:18px;font-weight:800;color:#16384f;border-bottom:1px solid #eef3f7;}
.diag-x{background:none;border:0;font-size:28px;line-height:1;color:#5b7488;cursor:pointer;}
.diag-body{overflow-y:auto;padding:8px 18px 4px;}
.diag-grp{margin-bottom:14px;}
.diag-grp-t{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#5b7488;margin-bottom:8px;}
.diag-badges{display:flex;flex-wrap:wrap;gap:8px;}
.diag-badge{border:1.5px solid #cdd9e4;background:#fff;color:#16384f;border-radius:999px;padding:8px 14px;font-size:14px;font-weight:700;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.diag-badge.on{background:#1b6ca8;border-color:#1b6ca8;color:#fff;}
.diag-sheet-f{display:flex;gap:10px;padding:14px 18px;border-top:1px solid #eef3f7;}
.diag-reset{flex:0 0 auto;background:#eef3f7;color:#16384f;border:0;border-radius:11px;padding:13px 18px;font-size:15px;font-weight:700;cursor:pointer;}
.diag-apply{flex:1;background:#1b6ca8;color:#fff;border:0;border-radius:11px;padding:13px;font-size:16px;font-weight:800;cursor:pointer;}
</style>
<script>
window.DASH = { ships: <?= json_encode( $diagShips, JSON_UNESCAPED_UNICODE ) ?>, parks: <?= json_encode( array_values( $pk ), JSON_UNESCAPED_UNICODE ) ?> };
(function(){
var tabs=document.getElementById('dashTabs'); if(!tabs) return;
var panes=document.querySelectorAll('.dash-pane');
tabs.addEventListener('click',function(e){ var b=e.target.closest('.dash-tab'); if(!b) return;
var t=b.getAttribute('data-tab');
for(var i=0;i<panes.length;i++) panes[i].classList.toggle('active', panes[i].getAttribute('data-pane')===t);
var bs=tabs.querySelectorAll('.dash-tab'); for(var j=0;j<bs.length;j++) bs[j].classList.toggle('active', bs[j]===b);
window.scrollTo(0,0);
});
/* ---- Diagramme: Filter-Badges + Einzel-/Gesamtstatistik ---- */
var D=window.DASH, out=document.getElementById('diagOut'); if(!out) return;
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
var PARK_LBL={halle:'Halle',aussen:'Außen',valet:'Valet'};
// verfügbare Reiselängen + Schiffe
var trips=[], ships=[];
D.ships.forEach(function(s){ if(trips.indexOf(s.t)<0) trips.push(s.t); if(ships.indexOf(s.n)<0) ships.push(s.n); });
trips.sort(function(a,b){return a-b;}); ships.sort();
var selPark=D.parks.slice(), selTrip=[], selShip=[];
function badge(txt,on){ return '<button type="button" class="diag-badge'+(on?' on':'')+'" data-v="'+esc(txt)+'">'+esc(txt)+'</button>'; }
function buildModal(){
document.getElementById('diagPark').innerHTML = D.parks.map(function(p){return badge(PARK_LBL[p]||p, selPark.indexOf(p)>=0);}).join('');
document.getElementById('diagTrip').innerHTML = trips.length? trips.map(function(t){return '<button type="button" class="diag-badge'+(selTrip.indexOf(t)>=0?' on':'')+'" data-t="'+t+'">'+t+' Tage</button>';}).join('') : '<span class="dw-muted">–</span>';
document.getElementById('diagShip').innerHTML = ships.length? ships.map(function(n){return '<button type="button" class="diag-badge'+(selShip.indexOf(n)>=0?' on':'')+'" data-s="'+esc(n)+'">'+esc(n)+'</button>';}).join('') : '<span class="dw-muted">–</span>';
}
// Karte „noch erwartet": große Zahl = offen (nicht eingecheckt), klein darunter „von <gesamt>".
function cell(lbl,cls,o,t){ return '<div'+(cls==='g'?' class="g"':'')+'><span class="dl">'+lbl+'</span><span class="dv '+(cls==='g'?'':cls)+'">'+(o||'–')+'</span><span class="dv-sub">von '+t+'</span></div>'; }
function statCard(title, oh,oa,ov, th,ta,tv){
var sh=selPark.indexOf('halle')>=0, sa=selPark.indexOf('aussen')>=0, sv=selPark.indexOf('valet')>=0;
var og=(sh?oh:0)+(sa?oa:0), tg=(sh?th:0)+(sa?ta:0);
var g='<div class="dw-card"><div class="dw-card-h">'+esc(title)+'</div><div class="dw-stat"><div class="dw-grid">';
if(sh)g+=cell('Halle','h',oh,th);
if(sa)g+=cell('Außen','a',oa,ta);
if(sv)g+=cell('Valet','v',ov,tv);
g+=cell('Gesamt','g',og,tg)+'</div></div></div>';
return g;
}
function render(){
var fc=selTrip.length+selShip.length, fcEl=document.getElementById('diagFcount');
if(fcEl){ fcEl.textContent=fc; fcEl.classList.toggle('d-none', fc===0); }
var match=D.ships.filter(function(s){ return (!selShip.length||selShip.indexOf(s.n)>=0) && (!selTrip.length||selTrip.indexOf(s.t)>=0); });
var h='<div class="diag-hint">Große Zahl = <b>noch erwartet</b> (nicht eingecheckt) · „von" = gesamt</div>';
if(!match.length){ out.innerHTML=h+'<div class="dw-card"><div class="dw-muted" style="padding:6px 0;">Keine Auswahl trifft zu.</div></div>'; return; }
h+='<div class="diag-sec">Einzeln</div>';
match.forEach(function(s){ h+=statCard(s.n+(s.t>0?' · '+s.t+'T':''), s.oh,s.oa,s.ov, s.h,s.a,s.v); });
var oh=0,oa=0,ov=0,th=0,ta=0,tv=0; match.forEach(function(s){oh+=s.oh;oa+=s.oa;ov+=s.ov;th+=s.h;ta+=s.a;tv+=s.v;});
h+='<div class="diag-sec">Gesamt der Auswahl</div>';
h+=statCard('Gesamt · '+match.length+' Schiff'+(match.length===1?'':'e'), oh,oa,ov, th,ta,tv);
out.innerHTML=h;
}
var modal=document.getElementById('diagModal');
document.getElementById('diagFilter').addEventListener('click', function(){ buildModal(); modal.classList.remove('d-none'); });
document.getElementById('diagClose').addEventListener('click', function(){ modal.classList.add('d-none'); });
modal.addEventListener('click', function(e){ if(e.target===modal) modal.classList.add('d-none'); });
document.getElementById('diagPark').addEventListener('click', function(e){ var b=e.target.closest('.diag-badge'); if(!b)return; var p=Object.keys(PARK_LBL).filter(function(k){return PARK_LBL[k]===b.getAttribute('data-v');})[0]; if(!p)return; var i=selPark.indexOf(p); if(i>=0)selPark.splice(i,1); else selPark.push(p); b.classList.toggle('on'); });
document.getElementById('diagTrip').addEventListener('click', function(e){ var b=e.target.closest('.diag-badge'); if(!b)return; var t=parseInt(b.getAttribute('data-t'),10); var i=selTrip.indexOf(t); if(i>=0)selTrip.splice(i,1); else selTrip.push(t); b.classList.toggle('on'); });
document.getElementById('diagShip').addEventListener('click', function(e){ var b=e.target.closest('.diag-badge'); if(!b)return; var n=b.getAttribute('data-s'); var i=selShip.indexOf(n); if(i>=0)selShip.splice(i,1); else selShip.push(n); b.classList.toggle('on'); });
document.getElementById('diagReset').addEventListener('click', function(){ selPark=D.parks.slice(); selTrip=[]; selShip=[]; buildModal(); });
document.getElementById('diagApply').addEventListener('click', function(){ modal.classList.add('d-none'); render(); });
render();
})();</script>
<?php
dl_html( 'Dashboard ' . $dLabelD, ob_get_clean() );
}
// $date kommt von oben (ma_date bzw. &d innerhalb der Gültigkeit).
$canAct = ( (int) $access['ma_perm_action'] === 1 );
$searchAll = ( (int) $access['ma_perm_search'] === 1 );
$cols = array_merge( array( 'name' ), pacim_ma_clean_cols( isset( $access['ma_cols'] ) ? $access['ma_cols'] : 'type,time,dur' ) );
$da = pacim_day_arrivals( $db, $date, pacim_ma_filter_opts( $access ) );
$items = $da['items'];
if ( $searchAll && $action !== 'poll' ) {
// Suche über den Filter hinaus: restliche Fahrzeuge des Tages laden und als „außerhalb" markieren.
$allDa = pacim_day_arrivals( $db, $date, array( 'mode' => pacim_ma_mode( $access ), 'parking' => array( 'halle', 'aussen', 'valet' ), 'statuses' => array( 'open', 'arrived', 'storno' ) ) );
$inIds = array(); foreach ( $items as $it ) $inIds[ (int) $it['id'] ] = true;
foreach ( $allDa['items'] as $it ) if ( empty( $inIds[ (int) $it['id'] ] ) ) $outItems[] = $it;
}
$chips = pacim_ma_summary( $access );
if ( ! $canAct ) $chips[] = 'nur Ansicht';
if ( trim( (string) $access['ma_label'] ) !== '' ) array_unshift( $chips, $access['ma_label'] );
} else {
/* ---- Legacy: date + Hash (Pushover-Tagesliste an den eigenen Account) */
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) || ! function_exists( 'pacim_sign_verify' ) || ! pacim_sign_verify( 'daylist:' . $date, $h ) ) {
dl_html( 'Ungültiger Link', '<div class="err"><h1>403</h1><p>Dieser Link ist ungültig oder abgelaufen.</p></div>', 403 );
}
$cols = array_values( array_intersect( array( 'name', 'type', 'time', 'dur' ),
array_map( 'trim', explode( ',', (string) ( $_GET['columns'] ?? 'name,type' ) ) ) ) );
$da = pacim_day_arrivals( $db, $date, array(
'parking' => explode( ',', (string) ( $_GET['parking'] ?? 'halle,aussen' ) ),
'status' => (string) ( $_GET['status'] ?? 'all' ),
'from' => (string) ( $_GET['from'] ?? '' ), 'to' => (string) ( $_GET['to'] ?? '' ),
'ship' => (string) ( $_GET['ship'] ?? '' ), 'trip' => (int) ( $_GET['trip'] ?? 0 ),
) );
$items = $da['items'];
if ( $da['status'] === 'open' ) $chips[] = 'nur offen';
if ( $da['status'] === 'arrived' ) $chips[] = 'nur angereist';
if ( $da['from'] !== '' || $da['to'] !== '' ) $chips[] = trim( ( $da['from'] !== '' ? 'ab ' . $da['from'] : '' ) . ( $da['to'] !== '' ? ' bis ' . $da['to'] : '' ) );
if ( $da['ship'] !== '' ) $chips[] = $da['ship'];
if ( $da['trip'] > 0 ) $chips[] = $da['trip'] . ' Tage';
$plabels = array( 'halle' => 'Halle', 'aussen' => 'Außen', 'valet' => 'Valet' );
if ( count( $da['parking'] ) < 3 ) $chips[] = implode( '+', array_map( function ( $p ) use ( $plabels ) { return $plabels[ $p ]; }, $da['parking'] ) );
}
// Modus dieser Liste (Anreisen/Abreisen) – steuert Beschriftungen (Check-in vs. Check-out).
$isDep = ( isset( $da['mode'] ) && $da['mode'] === 'abreise' );
// Kompakter Status-Code je Buchung + Fingerprint über das In-Filter-Set (für den Autorefresh).
$stateOf = function ( $it ) { return ! empty( $it['storno'] ) ? 'x' : ( $it['arrived'] ? 'i' : ( $it['paid'] ? 'p' : 'o' ) ); };
$fpParts = array();
foreach ( $items as $it ) $fpParts[] = $it['id'] . $stateOf( $it ) . $it['time']; // Zeit mit einbeziehen (z. B. geplant -> tatsächlich)
$fp = substr( md5( implode( ',', $fpParts ) ), 0, 12 );
/* ---- Autorefresh-Endpunkt: nur Status (winzige Antwort, kein HTML). Ressourcenschonend:
* Bei unverändertem Fingerprint kommt nur {"v":…} zurück (kein Datentransfer/Render). */
if ( $action === 'poll' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Cache-Control: no-store' );
if ( (string) ( $_GET['v'] ?? '' ) === $fp ) { echo '{"v":"' . $fp . '"}'; exit(); }
$out = array();
foreach ( $items as $it ) $out[] = array( (int) $it['id'], $stateOf( $it ), (string) $it['time'] );
echo json_encode( array( 'v' => $fp, 'items' => $out ) );
exit();
}
$e = function ( $s ) { return htmlspecialchars( (string) $s, ENT_QUOTES, 'UTF-8' ); };
// Check-in-Link je Buchung: im Token-Modus mit t=, sonst Legacy-Hash.
$ckUrl = function ( $id ) use ( $tok ) {
$id = (int) $id;
return $tok !== '' ? SITE_URL . '/checkin.php?t=' . $tok . '&b=' . $id
: SITE_URL . '/checkin.php?b=' . $id . '&h=' . pacim_sign( 'checkin:' . $id );
};
$wdays = array( 1 => 'Mo', 2 => 'Di', 3 => 'Mi', 4 => 'Do', 5 => 'Fr', 6 => 'Sa', 7 => 'So' );
$dLabel = $wdays[ (int) date( 'N', strtotime( $date ) ) ] . ', ' . date( 'd.m.Y', strtotime( $date ) );
// „Typ"-Spalte (H/A/VH/VA) nur sinnvoll, wenn mehr als ein Stellplatz oder Valet (=VH/VA) im Filter ist.
// Redundant ist sie nur bei genau „nur Halle" (alles H) bzw. „nur Außen" (alles A).
if ( count( $da['parking'] ) <= 1 && ! in_array( 'valet', $da['parking'], true ) )
$cols = array_values( array_diff( $cols, array( 'type' ) ) );
// Tabellenkopf. Typ + Tage in EINER Spalte (Typ fett, „x Tage" klein darunter) – keine eigene Tage-Spalte mehr.
$showTyp = ( in_array( 'type', $cols, true ) || in_array( 'dur', $cols, true ) );
$thead = '<th>Kennz.</th>';
if ( in_array( 'name', $cols, true ) ) $thead .= '<th>Name</th>';
if ( $showTyp ) $thead .= '<th>Typ</th>';
if ( in_array( 'time', $cols, true ) ) $thead .= '<th>Zeit</th>';
// Storno zählt NICHT bei „angereist"/„offen" (und erscheint nicht in diesen Filtern).
$arrivedCount = 0; $openCount = 0;
foreach ( $items as $it ) { if ( ! empty( $it['storno'] ) ) continue; if ( $it['arrived'] ) $arrivedCount++; else $openCount++; }
$activeCount = $arrivedCount + $openCount; // Gesamt ohne Storno
// Suchstring normalisieren (Leer-/Sonderzeichen raus) – muss zur JS-Normalisierung passen.
function dl_norm( $s ) { return preg_replace( '/[^0-9a-zäöüß]/u', '', mb_strtolower( (string) $s, 'UTF-8' ) ); }
// Bildanzahl je Buchung (nur beim Voll-Render/rows, NICHT im Poll – hält den Poll schlank).
$allBids = array();
foreach ( $items as $it ) $allBids[] = $it['id'];
foreach ( $outItems as $it ) $allBids[] = $it['id'];
$imgCounts = function_exists( 'pacim_bi_count_map' ) ? pacim_bi_count_map( $db, $allBids ) : array();
// Eine Zeile rendern. $out = außerhalb des Zugangsfilters (nur per Suche auffindbar, markiert).
$mkRow = function ( $it, $out ) use ( $e, $cols, $ckUrl, $imgCounts, $showTyp ) {
$state = ! empty( $it['storno'] ) ? 'st-storno' : ( $it['arrived'] ? 'st-in' : ( $it['paid'] ? 'st-paid' : 'st-open' ) );
if ( $out ) $state .= ' row-out';
// Durchsucht: Kennzeichen, Name, Buchungs- und Rechnungsnummer (Letztere nicht angezeigt).
$srch = dl_norm( $it['plate'] . ' ' . $it['name'] . ' ' . $it['booking_no'] . ' ' . $it['invoice_no'] );
// Sortierschlüssel der tatsächlichen Check-in-Zeit (HHMM) – nur für Angereiste; sonst leer.
$cin = ( ! empty( $it['arrived'] ) && $it['time'] !== '' ) ? preg_replace( '/\D/', '', $it['time'] ) : '';
$plateD = preg_replace( '/\s*-\s*/', '-', (string) $it['plate'] ); // „HEI - JC 7" -> „HEI-JC 7"
$r = '<tr class="' . $state . '" data-noloader="1" data-id="' . (int) $it['id'] . '" data-cin="' . $e( $cin ) . '" data-href="' . $e( $ckUrl( $it['id'] ) ) . '" data-s="' . $e( $srch ) . '" data-plate="' . $e( $plateD ) . '" data-out="' . ( $out ? '1' : '0' ) . '">';
$r .= '<td class="plate">' . $e( $plateD ) . '</td>';
$ic = isset( $imgCounts[ (int) $it['id'] ] ) ? (int) $imgCounts[ (int) $it['id'] ] : 0;
$cam = $ic > 0 ? ' <span class="pcam" title="' . $ic . ' Foto(s)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>' . $ic . '</span>' : '';
if ( in_array( 'name', $cols, true ) ) {
$shipTxt = trim( (string) ( $it['ship'] ?? '' ) );
$r .= '<td class="nm"><span class="nm-name">' . $e( $it['name'] ) . ( $out ? ' <span class="outflag">außerhalb</span>' : '' ) . $cam . '</span>'
. ( $shipTxt !== '' && $shipTxt !== '—' ? '<span class="nm-ship">' . $e( $shipTxt ) . '</span>' : '' ) . '</td>';
}
if ( $showTyp ) {
$tm = array( 'H' => 'Halle', 'A' => 'Außen', 'VH' => 'V-Halle', 'VA' => 'V-Außen' );
$c = '<td class="typ-cell">';
if ( in_array( 'type', $cols, true ) ) $c .= '<span class="typ-name">' . $e( isset( $tm[ $it['letter'] ] ) ? $tm[ $it['letter'] ] : $it['letter'] ) . '</span>';
if ( in_array( 'dur', $cols, true ) ) $c .= '<span class="typ-days">' . ( $it['dur'] > 0 ? (int) $it['dur'] . ' Tage' : '—' ) . '</span>';
$r .= $c . '</td>';
}
if ( in_array( 'time', $cols, true ) ) $r .= '<td class="mut tcell">' . ( $it['time'] !== '' ? $e( $it['time'] ) : '—' ) . '</td>';
$r .= '</tr>';
return $r;
};
// Stabile Reihenfolge nach Kennzeichen – Angereiste rutschen NICHT nach unten (bleiben an ihrem Platz).
$sorter = function ( $a, $b ) { return strnatcasecmp( $a['plate'], $b['plate'] ); };
usort( $items, $sorter );
usort( $outItems, $sorter );
$rowsHtml = '';
foreach ( $items as $it ) $rowsHtml .= $mkRow( $it, false );
foreach ( $outItems as $it ) $rowsHtml .= $mkRow( $it, true );
/* ---- Autorefresh: neue Tabellen-Zeilen (nur bei geänderter Zusammensetzung angefordert). */
if ( $action === 'rows' ) {
while ( ob_get_level() > 0 ) ob_end_clean();
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Cache-Control: no-store' );
echo json_encode( array( 'v' => $fp, 'arr' => $arrivedCount, 'open' => $openCount, 'total' => $activeCount, 'html' => $rowsHtml ) );
exit();
}
ob_start(); ?>
<div class="wrap">
<div class="topbar">
<div class="hd">
<?php if ( $termBack ) { ?><a class="term-back" href="<?= $e( $termBack ) ?>" title="Zurück zur Terminal-Übersicht"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 10v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9"/></svg> Terminal</a>
<?php } elseif ( $directLogout ) { ?><a class="term-back" href="<?= $e( $directLogout ) ?>" title="Abmelden"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/></svg> Logout</a><?php } ?>
<div class="k"><?= $isDep ? 'Abreisen · Check-out' : 'Anreisen · Check-in' ?></div>
<div class="t"><?= $e( $dLabel ) ?></div>
<div class="stats">
<span class="stat filt" id="filt-arr" role="button" tabindex="0"><b id="st-arr"><?= $arrivedCount ?></b> / <span id="st-tot"><?= $activeCount ?></span> <?= $isDep ? 'abgereist' : 'angereist' ?></span>
<span class="stat open filt" id="filt-open" role="button" tabindex="0"><b id="st-open"><?= $openCount ?></b> offen</span>
<span class="stat live" id="dl-live" role="button" tabindex="0" title="Zuletzt geöffnete Fahrzeuge"><span class="live-dot"></span> live</span>
</div>
<?php if ( $chips ) { ?><div class="flt"><?php foreach ( $chips as $c ) { ?><span class="chip"><?= $e( $c ) ?></span><?php } ?></div><?php } ?>
</div>
<div class="search">
<div class="search-box">
<input type="search" id="dl-search" placeholder="Suche: Kennzeichen, Name, Buchungs-/Rechnungsnr. …" autocomplete="off" autocapitalize="characters">
<button type="button" class="search-clear d-none" id="dl-clear" aria-label="Suche löschen">×</button>
</div>
</div>
</div><!-- /topbar -->
<div class="suggest d-none" id="dl-suggest"></div>
<?php if ( $items || $outItems ) { ?>
<div class="tblwrap"><table class="tbl"><thead><tr><?= $thead ?></tr></thead><tbody id="dl-body"><?= $rowsHtml ?></tbody></table></div>
<div class="empty d-none" id="dl-none">Kein Treffer.</div>
<?php } else { ?>
<div class="empty"><?= $isDep ? 'Keine Abreisen für diese Auswahl.' : 'Keine Anreisen für diese Auswahl.' ?></div>
<?php } ?>
</div>
<!-- Sitzungsende: Zugang nicht mehr verfügbar (vom Autorefresh erkannt) -->
<div class="ended d-none" id="dl-ended">
<div class="ended-box">
<svg class="ended-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>
<div class="ended-t">Zugang beendet</div>
<div class="ended-x">Dieser mobile Zugang ist nicht mehr verfügbar (widerrufen oder abgelaufen).<br>Bitte wende dich an das Büro.</div>
</div>
</div>
<!-- Check-in/Zahlung im Overlay (kein Seitenwechsel) -->
<div class="ov d-none" id="dl-ov">
<!-- Kein eigener Zurück-Balken mehr: die Buchungseinzelansicht (checkin.php) hat ihren eigenen Zurück-Button oben rechts im Header (postMessage {close:1}).
Nur ein navyfarbener Safe-Area-Streifen, damit der eingebettete Header nicht unter der iPhone-Dynamic-Island liegt. -->
<div class="ov-top"></div>
<iframe class="ov-frame" id="dl-ov-frame" title="Check-in" src="about:blank" allowfullscreen></iframe>
</div>
<!-- „Zuletzt geöffnet" (Klick auf „live") -->
<div class="rec d-none" id="dl-recent">
<div class="rec-sheet">
<div class="rec-h"><span>Zuletzt geöffnet</span><span class="rec-h-act"><button type="button" class="rec-reset" id="dl-recent-reset">Zurücksetzen</button><button type="button" class="rec-x" id="dl-recent-x" aria-label="Schließen">×</button></span></div>
<div class="rec-list" id="dl-recent-list"></div>
</div>
</div>
<script>
(function(){
// Browser-Zoom unterbinden (iOS Safari ignoriert user-scalable=no)
['gesturestart','gesturechange','gestureend'].forEach(function(ev){ document.addEventListener(ev, function(e){ e.preventDefault(); }, {passive:false}); });
document.addEventListener('touchmove', function(e){ if(e.touches && e.touches.length>1) e.preventDefault(); }, {passive:false});
var inp=document.getElementById('dl-search'), body=document.getElementById('dl-body'), none=document.getElementById('dl-none');
if(!body) return;
var DL_FP = <?= json_encode( $fp ) ?>;
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
function norm(s){ return (s||'').toLowerCase().replace(/[^0-9a-zäöüß]/g,''); }
function subseq(q,s){ var i=0; for(var j=0;j<s.length && i<q.length;j++) if(s[j]===q[i]) i++; return i===q.length; }
// Damerau-Levenshtein (OSA) – erlaubt Buchstaben-/Zahlendreher als 1 Edit.
function dlev(a,b){
var al=a.length, bl=b.length; if(!al) return bl; if(!bl) return al;
var d=[]; for(var i=0;i<=al;i++){ d[i]=[i]; } for(var j=0;j<=bl;j++){ d[0][j]=j; }
for(i=1;i<=al;i++) for(j=1;j<=bl;j++){
var c=a[i-1]===b[j-1]?0:1;
d[i][j]=Math.min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+c);
if(i>1&&j>1&&a[i-1]===b[j-2]&&a[i-2]===b[j-1]) d[i][j]=Math.min(d[i][j], d[i-2][j-2]+1);
}
return d[al][bl];
}
// Kennzeichen ohne Ortskürzel (erstes Segment), normalisiert – für „anderer Ort, Rest passt".
function plateCore(plate){ var p=(plate||'').split(/[^0-9a-zA-ZäöüÄÖÜß]+/).filter(Boolean); return norm(p.length>1?p.slice(1).join(''):plate); }
// Zeichen-Häufigkeit (Multiset) eines normalisierten Strings.
function freq(s){ var m={}; for(var i=0;i<s.length;i++){ m[s[i]]=(m[s[i]]||0)+1; } return m; }
// Enthält s alle Zeichen von q (mit Vielfachheit), egal in welcher Reihenfolge? (Gruppen-/Zeichendreher tolerant)
function multiSub(q,s){ var fs=freq(s); for(var k in q){ if((fs[k]||0)<q[k]) return false; } return true; }
/* Kennzeichen-Trefferstärke (kleiner = besser, -1 = kein Treffer). Beste Treffer = der Eingabe am ehesten:
0 = Kennzeichen BEGINNT mit der Eingabe (z. B. „TR" -> „TR-KW…")
1 = ein Segment beginnt mit der Eingabe (z. B. „KW" -> „TR-KW…")
2 = Eingabe als zusammenhängende Teilkette irgendwo 3 = Zeichen in Reihenfolge (verteilt)
4 = nur der Kern (ohne Ortskürzel) in Reihenfolge 5 = alle Zeichen vorhanden, Reihenfolge vertauscht. */
function plateScore(q, plate){
var np=norm(plate);
if(np.indexOf(q)===0) return 0;
var segs=(plate||'').split(/[^0-9a-zA-ZäöüÄÖÜß]+/).filter(Boolean);
for(var i=0;i<segs.length;i++) if(norm(segs[i]).indexOf(q)===0) return 1;
if(np.indexOf(q)>=0) return 2;
if(subseq(q,np)) return 3;
var core=plateCore(plate); if(core && subseq(q,core)) return 4;
if(q.length>=3 && multiSub(freq(q), np)) return 5;
return -1;
}
// Kleinste Editierdistanz von q zu IRGENDEINEM Teilstück von s (längen-tolerant) – fürs „Meinten Sie?".
function fuzzyDist(q, s){
if(!s) return 99; var best=99, ql=q.length, lo=Math.max(1,ql-3), hi=Math.min(s.length, ql+3);
for(var L=lo; L<=hi; L++){ for(var st=0; st+L<=s.length; st++){ var d=dlev(q, s.substr(st,L)); if(d<best){ best=d; if(best===0) return 0; } } }
return best;
}
// Kennzeichen mit Treffer-Hervorhebung: erst exakte Subsequenz, sonst Multiset (Reihenfolge egal).
function hl(plate,q){
if(!q) return esc(plate);
var qi=0,out=''; // 1) Subsequenz in Reihenfolge
for(var i=0;i<plate.length;i++){ var ch=plate[i], n=norm(ch);
if(qi<q.length && n && n===q[qi]){ out+='<mark>'+esc(ch)+'</mark>'; qi++; } else out+=esc(ch); }
if(qi===q.length) return out;
var need=freq(q), out2='', rem=q.length; // 2) Multiset (für vertauschte Reihenfolge)
for(var j=0;j<plate.length;j++){ var c=plate[j], m=norm(c);
if(m && need[m]>0){ out2+='<mark>'+esc(c)+'</mark>'; need[m]--; rem--; } else out2+=esc(c); }
return rem===0 ? out2 : esc(plate);
}
var orig=[].slice.call(body.querySelectorAll('tr')); // Ausgangsreihenfolge merken
var sgBox=document.getElementById('dl-suggest');
// „Meinten Sie?": ähnliche Kennzeichen (Tippfehler, Dreher, falsches Ortskürzel), die NICHT bereits Treffer sind.
function suggest(q){
if(q.length<2) return [];
var maxd = q.length<=3?1:(q.length<=6?2:3), res=[], seen={};
for(var i=0;i<orig.length;i++){ var r=orig[i], plate=r.getAttribute('data-plate')||'';
if(plate===''||plate==='—' || seen[plate]) continue;
if(plateScore(q, plate)>=0) continue; // bereits (auch unscharfer) Kennzeichen-Treffer → nicht vorschlagen
var dist=Math.min(fuzzyDist(q, norm(plate)), fuzzyDist(q, plateCore(plate)));
if(dist<=maxd){ seen[plate]=1; res.push({plate:plate, href:r.dataset.href, d:dist}); }
}
res.sort(function(a,b){return a.d-b.d;});
return res.slice(0,4);
}
function renderSuggest(q, plateHits){
if(!sgBox) return;
if(!q || q.length<2 || plateHits>0){ sgBox.classList.add('d-none'); sgBox.innerHTML=''; return; }
var s=suggest(q);
if(!s.length){ sgBox.classList.add('d-none'); sgBox.innerHTML=''; return; }
var html='<span class="sg-l">Meinten Sie?</span>';
for(var i=0;i<s.length;i++) html+='<button type="button" class="sg-chip" data-plate="'+esc(s[i].plate)+'">'+esc(s[i].plate)+'</button>';
sgBox.innerHTML=html; sgBox.classList.remove('d-none');
}
if(sgBox) sgBox.addEventListener('click', function(e){ var b=e.target.closest('.sg-chip'); if(!b) return; inp.value=b.getAttribute('data-plate'); runSearch(); inp.focus(); });
// ---- Check-in/Zahlung im Overlay öffnen (kein Seitenwechsel)
var ov=document.getElementById('dl-ov'), ovFrame=document.getElementById('dl-ov-frame');
function openCheckin(href){ if(!ov||!href) return; ovFrame.src = href + (href.indexOf('?')>=0?'&':'?') + 'embed=1'; ov.classList.remove('d-none'); document.documentElement.style.overflow='hidden'; }
function closeCheckin(){ if(!ov) return; ov.classList.add('d-none'); ovFrame.src='about:blank'; document.documentElement.style.overflow='';
if(inp) inp.value=''; // nach Öffnen+Zurück: Suche leeren
runSearch(); // Status-Filter neu anwenden -> Fahrzeug erscheint/verschwindet entsprechend
poll(); }
// ganze Zeile klickbar
body.addEventListener('click', function(e){ var tr=e.target.closest('tr'); if(tr && tr.dataset.href){ if(tr.dataset.id) recordRecent(tr.dataset.id); openCheckin(tr.dataset.href); } });
/* ---- „Zuletzt geöffnet": Öffnen vermerken + Panel über „live" ---- */
function recentUrl(extra){ var b=location.pathname+location.search; return b+(b.indexOf('?')>=0?'&':'?')+extra; }
function recordRecent(id){ if(!id) return; try{ fetch(recentUrl('action=recent_add'),{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'b='+encodeURIComponent(id),keepalive:true}); }catch(e){} }
var recPanel=document.getElementById('dl-recent'), recList=document.getElementById('dl-recent-list'), recX=document.getElementById('dl-recent-x');
function openRecent(){ if(!recPanel) return; recList.innerHTML='<div class="rec-empty">Wird geladen …</div>'; recPanel.classList.remove('d-none'); document.documentElement.style.overflow='hidden';
fetch(recentUrl('action=recent_list'),{headers:{'X-Requested-With':'XMLHttpRequest'}}).then(function(r){return r.json();}).then(function(res){
var items=(res&&res.items)||[];
if(!items.length){ recList.innerHTML='<div class="rec-empty">Noch nichts geöffnet.</div>'; return; }
var h=''; for(var i=0;i<items.length;i++){ var it=items[i];
h+='<button type="button" class="rec-item" data-noloader="1" data-href="'+esc(it.href)+'" data-id="'+it.id+'"><span class="rec-plate">'+esc(it.plate)+'</span><span class="rec-name">'+esc(it.name)+'</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></button>'; }
recList.innerHTML=h;
}).catch(function(){ recList.innerHTML='<div class="rec-empty">Konnte nicht geladen werden.</div>'; });
}
function closeRecent(){ if(recPanel) recPanel.classList.add('d-none'); document.documentElement.style.overflow=''; }
var liveBtn=document.getElementById('dl-live');
if(liveBtn) liveBtn.addEventListener('click', openRecent);
if(recX) recX.addEventListener('click', closeRecent);
var recReset=document.getElementById('dl-recent-reset');
if(recReset) recReset.addEventListener('click', function(){
if(!confirm('Liste „Zuletzt geöffnet" wirklich zurücksetzen?')) return;
recReset.disabled=true;
fetch(recentUrl('action=recent_clear'),{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}})
.then(function(r){return r.json();}).then(function(){ recReset.disabled=false; recList.innerHTML='<div class="rec-empty">Noch nichts geöffnet.</div>'; })
.catch(function(){ recReset.disabled=false; });
});
if(recPanel) recPanel.addEventListener('click', function(e){ if(e.target===recPanel){ closeRecent(); return; }
var it=e.target.closest('.rec-item'); if(it){ closeRecent(); if(it.dataset.id) recordRecent(it.dataset.id); openCheckin(it.dataset.href); } });
// Live-Suche: filtern, Kennzeichen-Treffer nach oben, Hervorhebung, Vorschläge.
function runSearch(){
var q=norm(inp?inp.value:''), shown=0, plateHits=0, hitsP=[], hitsO=[];
for(var i=0;i<orig.length;i++){ var r=orig[i];
var isOut = r.getAttribute('data-out')==='1';
var ps = q ? plateScore(q, r.getAttribute('data-plate')||'') : -1; // -1 = kein Kennzeichen-Treffer
var pl = ps>=0;
// ohne Suche: nur Fahrzeuge im Filter; mit Suche: Kennzeichen-Treffer (auch vertauscht) ODER Treffer in Name/Nr.
var full = q ? (pl || subseq(q, r.getAttribute('data-s')||'')) : (!isOut && passStatus(r));
r.style.display = full?'table-row':'none';
var cell=r.querySelector('td.plate'); if(cell){ var p=r.getAttribute('data-plate')||''; cell.innerHTML = full ? hl(p,q) : esc(p); }
if(full){ shown++; if(pl){ plateHits++; r._ps=ps; hitsP.push(r); } else hitsO.push(r); }
}
// Reihenfolge: bei Suche Kennzeichen-Treffer zuerst (exakte Reihenfolge vor vertauschten); im „angereist"-Filter
// nach tatsächlicher Check-in-Zeit absteigend (neueste zuerst); sonst Ausgangsreihenfolge (nach Kennzeichen).
var order;
if(q){ hitsP.sort(function(a,b){ return (a._ps-b._ps); }); order = hitsP.concat(hitsO); }
else if(statusFilter==='arrived'){
var vis=[], hid=[];
for(var z=0;z<orig.length;z++){ (orig[z].style.display!=='none'?vis:hid).push(orig[z]); }
vis.sort(function(a,b){ return (b.getAttribute('data-cin')||'').localeCompare(a.getAttribute('data-cin')||''); });
order = vis.concat(hid);
}
else { order = orig; }
for(var k=0;k<order.length;k++) body.appendChild(order[k]);
if(none) none.classList.toggle('d-none', shown!==0);
if(clearBtn) clearBtn.classList.toggle('d-none', !(inp && inp.value));
renderSuggest(q, plateHits);
if(q){ var fh=hitsP[0]||hitsO[0]; if(fh) scrollToFirst(fh); } // bei Suche zum ersten Treffer springen
}
function scrollToFirst(el){
var tb=document.querySelector('.topbar'); var off=(tb?tb.offsetHeight:0)+6;
var y=el.getBoundingClientRect().top + (window.pageYOffset||document.documentElement.scrollTop||0) - off;
window.scrollTo({top:(y<0?0:y), behavior:'smooth'});
}
var clearBtn=document.getElementById('dl-clear');
function clearSearch(){ if(inp) inp.value=''; runSearch(); }
if(clearBtn) clearBtn.addEventListener('click', function(){ clearSearch(); if(inp) inp.focus(); });
if(inp) inp.addEventListener('input', runSearch);
// Status-Schnellfilter über die Kopf-Statistik (angereist / offen). Beide schließen sich aus.
var statusFilter=''; // '' | 'arrived' | 'open'
function passStatus(r){ if(!statusFilter) return true; if(r.classList.contains('st-storno')) return false; var arr=r.classList.contains('st-in'); return statusFilter==='arrived'?arr:!arr; }
function updateFilt(){ var fa=document.getElementById('filt-arr'), fo=document.getElementById('filt-open'); if(fa) fa.classList.toggle('active',statusFilter==='arrived'); if(fo) fo.classList.toggle('active',statusFilter==='open'); }
(function(){
var fa=document.getElementById('filt-arr'), fo=document.getElementById('filt-open');
function tog(which){ statusFilter=(statusFilter===which)?'':which; updateFilt(); if(inp) inp.value=''; runSearch(); }
if(fa){ fa.addEventListener('click',function(){tog('arrived');}); fa.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();tog('arrived');}}); }
if(fo){ fo.addEventListener('click',function(){tog('open');}); fo.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();tog('open');}}); }
})();
// ================= Autorefresh (ressourcenschonend) =================
// Poll schickt den letzten Fingerprint; ist nichts passiert, kommt nur {"v":…} zurück
// (kein Datentransfer, kein Render). Pausiert bei verstecktem Tab und offenem Overlay.
var fp = DL_FP, INT = 20000, live=document.getElementById('dl-live'), ended=false, pollTimer=null;
// Zugang nicht mehr verfügbar -> App „beenden": Sitzungsende-Bildschirm, Polling stoppen.
function endSession(){
if(ended) return; ended=true;
if(pollTimer){ clearInterval(pollTimer); pollTimer=null; }
if(ov) ov.classList.add('d-none');
var el=document.getElementById('dl-ended'); if(el) el.classList.remove('d-none');
document.documentElement.style.overflow='hidden';
}
function base(){ return location.pathname + location.search; }
function pollUrl(extra){ var b=base(); return b + (b.indexOf('?')>=0?'&':'?') + extra; }
function setText(id,v){ var el=document.getElementById(id); if(el) el.textContent=v; }
function stOf(tr){ return tr.classList.contains('st-in')?'i':tr.classList.contains('st-paid')?'p':tr.classList.contains('st-storno')?'x':'o'; }
function applyState(tr, st){ tr.classList.remove('st-in','st-open','st-paid','st-storno'); tr.classList.add(st==='i'?'st-in':st==='p'?'st-paid':st==='x'?'st-storno':'st-open'); }
function flash(tr){ tr.classList.remove('row-flash'); void tr.offsetWidth; tr.classList.add('row-flash'); }
function sameSet(a,b){ if(a.length!==b.length) return false; var m={},i; for(i=0;i<a.length;i++) m[a[i]]=1; for(i=0;i<b.length;i++) if(!m[b[i]]) return false; return true; }
function applyPoll(items){
var map={}, tmap={}, ids=[], i;
for(i=0;i<items.length;i++){ map[items[i][0]]=items[i][1]; tmap[items[i][0]]=items[i][2]; ids.push(String(items[i][0])); }
var domRows=body.querySelectorAll('tr[data-out="0"]'), domIds=[];
for(i=0;i<domRows.length;i++) domIds.push(domRows[i].getAttribute('data-id'));
if(!sameSet(ids, domIds)){ fetchRows(); return; } // Zusammensetzung geändert -> einmalig Zeilen neu laden
var dirty=false;
for(i=0;i<domRows.length;i++){ var tr=domRows[i], id=tr.getAttribute('data-id'), st=map[id], tm=tmap[id], chg=false;
if(st && st!==stOf(tr)){ applyState(tr, st); chg=true; }
var tc=tr.querySelector('.tcell'); if(tc && tm!==undefined){ var nv=tm||'—'; if(tc.textContent!==nv){ tc.textContent=nv; chg=true; } }
if(st!==undefined) tr.setAttribute('data-cin', (st==='i' && tm) ? tm.replace(/\D/g,'') : ''); // Sortierzeit aktuell halten
if(chg){ flash(tr); dirty=true; }
}
var arr=0, act=0; for(i=0;i<items.length;i++){ if(items[i][1]==='x') continue; act++; if(items[i][1]==='i') arr++; }
setText('st-arr',arr); setText('st-tot',act); setText('st-open',act-arr);
if(dirty && (statusFilter || (inp&&inp.value))) runSearch(); // nur bei aktivem Filter/Suche neu anwenden (sonst Flash erhalten)
}
function fetchRows(){
fetch(pollUrl('action=rows'), {headers:{'X-Requested-With':'XMLHttpRequest'}}).then(function(r){return r.json();}).then(function(res){
if(res && res.gone){ endSession(); return; }
if(res && res.locked){ location.reload(); return; }
if(!res||typeof res.html!=='string') return;
fp=res.v; body.innerHTML=res.html; orig=[].slice.call(body.querySelectorAll('tr'));
setText('st-arr',res.arr); setText('st-open',res.open); setText('st-tot',res.total); runSearch();
}).catch(function(){});
}
function poll(){
if(ended || document.hidden) return;
if(ov && !ov.classList.contains('d-none')) return; // während Overlay offen nicht pollen
fetch(pollUrl('action=poll&v='+encodeURIComponent(fp)), {headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json();}).then(function(res){
if(res && res.gone){ endSession(); return; } // Zugang widerrufen/abgelaufen
if(res && res.locked){ location.reload(); return; } // PIN-Sperre erneut aktiv (Cookie abgelaufen)
if(live) live.classList.remove('stale');
if(res && res.v && res.v!==fp){ fp=res.v; if(res.items) applyPoll(res.items); }
}).catch(function(){ if(live) live.classList.add('stale'); });
}
pollTimer=setInterval(poll, INT);
document.addEventListener('visibilitychange', function(){ if(!document.hidden) poll(); });
// Sofort-Updates aus dem Check-in-Overlay (ohne auf den Poll zu warten)
window.addEventListener('message', function(ev){
var d=ev.data; if(!d || !d.pacimCi) return;
if(d.rows){ fetchRows(); }
else if(d.b && d.state){ var tr=body.querySelector('tr[data-id="'+d.b+'"]'); if(tr){ applyState(tr,d.state); flash(tr); if(statusFilter||(inp&&inp.value)) runSearch(); } }
if(d.close){ closeCheckin(); }
});
})();
</script>
<?php
dl_html( ( $isDep ? 'Abreisen ' : 'Anreisen ' ) . $dLabel, ob_get_clean() );