TG BURG
  1. Aktuelle Seite:  
  2. Startseite

Home

Startseite

Details
Geschrieben von: Super User
Kategorie: Uncategorised
Veröffentlicht: 04. Februar 2026
Zugriffe: 43
<?php
// VaporCRM2026 – TV Leaderboard (Packstat + Pickstat)

declare(strict_types=1);

session_start();

// ====== Mode detection (Pack / Pick) ======
$MODE = isset($_GET['mode']) ? strtolower(trim((string)$_GET['mode'])) : 'pack';
if ($MODE !== 'pick') $MODE = 'pack';
$MODE_LABEL = ($MODE === 'pick') ? 'Pickstat' : 'Packstat';
$TITLE_LABEL = ($MODE === 'pick') ? 'Pickzahlen' : 'Packzahlen';

// PINs (one session unlocks both modes)
$TV_PIN_PICK = (string)(getenv('PICKSTAT_TV_PIN') ?: '');
$TV_PIN_PACK = (string)(getenv('PACKSTAT_TV_PIN') ?: '');
$TV_PIN_FALLBACK = '1905';

// Allow manual logout/reset: ?pin_reset=1
if (isset($_GET['pin_reset']) && (string)$_GET['pin_reset'] === '1') {
  unset($_SESSION['tv_pin_ok']);
}

$pinOk = isset($_SESSION['tv_pin_ok']) && $_SESSION['tv_pin_ok'] === true;

// Handle PIN submit
if (!$pinOk && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tv_pin'])) {
  $in = trim((string)$_POST['tv_pin']);
  $okPick = ($TV_PIN_PICK !== '') && hash_equals($TV_PIN_PICK, $in);
  $okPack = ($TV_PIN_PACK !== '') && hash_equals($TV_PIN_PACK, $in);
  $okFallback = hash_equals($TV_PIN_FALLBACK, $in);

  if ($okPick || $okPack || $okFallback) {
    $_SESSION['tv_pin_ok'] = true;
    // PRG pattern
    header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?') . (isset($_SERVER['QUERY_STRING']) && $_SERVER['QUERY_STRING'] !== '' ? ('?' . $_SERVER['QUERY_STRING']) : ''));
    exit;
  }
  $_SESSION['tv_pin_err'] = 'Falscher PIN.';
}

// If AJAX call without PIN: return JSON 403 to avoid breaking fetch()
if (!$pinOk && isset($_GET['ajax']) && (string)$_GET['ajax'] === '1') {
  http_response_code(403);
  header('Content-Type: application/json; charset=UTF-8');
  header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
  echo json_encode(['ok' => false, 'error' => 'PIN required'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  exit;
}

// If normal page view without PIN: show PIN screen and stop
if (!$pinOk && (!isset($_GET['ajax']) || (string)($_GET['ajax'] ?? '') !== '1')) {
  $err = isset($_SESSION['tv_pin_err']) ? (string)$_SESSION['tv_pin_err'] : '';
  unset($_SESSION['tv_pin_err']);
  ?><!doctype html>
  <html lang="de">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>PIN erforderlich</title>
    <style>
      html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial}
      body{display:flex;align-items:center;justify-content:center;background:#0B1020;color:#fff}
      .card{width:min(420px,92vw);background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:18px;padding:22px;box-shadow:0 24px 80px rgba(0,0,0,.55)}
      h1{margin:0 0 6px;font-size:20px;letter-spacing:.02em}
      p{margin:0 0 14px;color:rgba(255,255,255,.7);font-size:13px;line-height:1.35}
      .row{display:flex;gap:10px}
      input{flex:1;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.22);color:#fff;font-size:16px;outline:none}
      input:focus{border-color:rgba(124,92,255,.55)}
      button{padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,.16);background:rgba(124,92,255,.95);color:#fff;font-weight:700;cursor:pointer}
      .err{margin-top:10px;padding:10px 12px;border-radius:12px;background:rgba(255,117,117,.12);border:1px solid rgba(255,117,117,.25);color:rgba(255,255,255,.9);font-size:13px;display:<?php echo $err ? 'block' : 'none'; ?>;}
      .hint{margin-top:10px;color:rgba(255,255,255,.55);font-size:12px}
    </style>
  </head>
  <body>
    <form class="card" method="post" autocomplete="off">
      <h1>PIN erforderlich</h1>
      <p>Bitte PIN eingeben, um das <?php echo htmlspecialchars($MODE_LABEL, ENT_QUOTES, 'UTF-8'); ?>-Leaderboard zu öffnen.</p>
      <div class="row">
        <input type="password" name="tv_pin" inputmode="numeric" pattern="[0-9]*" placeholder="PIN" autofocus />
        <button type="submit">Öffnen</button>
      </div>
      <div class="err"><?php echo htmlspecialchars($err, ENT_QUOTES, 'UTF-8'); ?></div>
      <div class="hint">Hinweis: PIN wird serverseitig als Session gespeichert. Reset: <code>?pin_reset=1</code></div>
    </form>
  </body>
  </html>
  <?php
  exit;
}

// ====== Helpers ======
function json_out($payload, int $code = 200): void {
  http_response_code($code);
  header('Content-Type: application/json; charset=UTF-8');
  header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
  echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  exit;
}

function get_pdo(): PDO {
  // Project DB include (as specified)
  require_once __DIR__ . '/../includes/db.php';

  // Common patterns: $pdo or $db (PDO)
  if (isset($pdo) && $pdo instanceof PDO) return $pdo;
  if (isset($db) && $db instanceof PDO) return $db;

  // If your includes expose a function
  if (function_exists('db')) {
    $x = db();
    if ($x instanceof PDO) return $x;
  }

  throw new RuntimeException('DB connection not found. Ensure /includes/db.php exposes $pdo (PDO).');
}

function stable_id(string $s): int {
  $h = crc32(mb_strtolower(trim($s)));
  if ($h < 0) $h = $h * -1;
  return (int)$h;
}

function column_exists(PDO $pdo, string $table, string $column): bool {
  $sql = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1";
  $st = $pdo->prepare($sql);
  $st->execute([$table, $column]);
  return (bool)$st->fetchColumn();
}

// ====== AJAX endpoint ======
if (isset($_GET['ajax']) && (string)$_GET['ajax'] === '1') {
  try {
    $pdo = get_pdo();

    $modeReq = isset($_GET['mode']) ? strtolower(trim((string)$_GET['mode'])) : $MODE;
    if ($modeReq !== 'pick') $modeReq = 'pack';

    $defaultTarget = ($modeReq === 'pick') ? 450 : 600;
    $target = isset($_GET['target']) ? max(1, (int)$_GET['target']) : $defaultTarget;

    if ($modeReq === 'pick') {
      // Picks today per employee (from pickstat_orders) with B2C/B2B split
      $statusCol = null;
      if (column_exists($pdo, 'pickstat_orders', 'status_id')) $statusCol = 'status_id';
      elseif (column_exists($pdo, 'pickstat_orders', 'statusId')) $statusCol = 'statusId';
      elseif (column_exists($pdo, 'pickstat_orders', 'status')) $statusCol = 'status';

      if ($statusCol) {
        $isB2B = "(CAST($statusCol AS DECIMAL(5,2)) = 6.50)";
        $isB2C = "(CAST($statusCol AS DECIMAL(5,2)) = 6.00)";
      } else {
        // No status column -> treat everything as B2C
        $isB2B = '0';
        $isB2C = '1';
      }

      $sqlToday = "
        SELECT
          picker_name AS employee,
          COUNT(*) AS packs_total,
          SUM(CASE WHEN $isB2C THEN 1 ELSE 0 END) AS packs_b2c,
          SUM(CASE WHEN $isB2B THEN 1 ELSE 0 END) AS packs_b2b
        FROM pickstat_orders
        WHERE DATE(updated_at) = CURDATE()
        GROUP BY picker_name
      ";

      $sqlDelta = "
        SELECT
          picker_name AS employee,
          COUNT(*) AS delta30m_total,
          SUM(CASE WHEN $isB2C THEN 1 ELSE 0 END) AS delta30m_b2c,
          SUM(CASE WHEN $isB2B THEN 1 ELSE 0 END) AS delta30m_b2b
        FROM pickstat_orders
        WHERE updated_at >= (NOW() - INTERVAL 30 MINUTE)
          AND DATE(updated_at) = CURDATE()
        GROUP BY picker_name
      ";
    } else {
      // Packs today per employee (from pakstat) with B2C/B2B split (segment comes from cron)
      $hasSeg = column_exists($pdo, 'pakstat', 'segment');
      $segCol = $hasSeg ? 'segment' : null;
      $isB2C = $segCol ? "($segCol = 'B2C')" : '1';
      $isB2B = $segCol ? "($segCol = 'B2B')" : '0';

      $sqlToday = "
        SELECT
          employee AS employee,
          COUNT(*) AS packs_total,
          SUM(CASE WHEN $isB2C THEN 1 ELSE 0 END) AS packs_b2c,
          SUM(CASE WHEN $isB2B THEN 1 ELSE 0 END) AS packs_b2b
        FROM pakstat
        WHERE packed_date = CURDATE()
        GROUP BY employee
      ";

      $sqlDelta = "
        SELECT
          employee AS employee,
          COUNT(*) AS delta30m_total,
          SUM(CASE WHEN $isB2C THEN 1 ELSE 0 END) AS delta30m_b2c,
          SUM(CASE WHEN $isB2B THEN 1 ELSE 0 END) AS delta30m_b2b
        FROM pakstat
        WHERE packed_at >= (NOW() - INTERVAL 30 MINUTE)
          AND packed_date = CURDATE()
        GROUP BY employee
      ";
    }

    $today = $pdo->query($sqlToday)->fetchAll(PDO::FETCH_ASSOC);
    $delta = $pdo->query($sqlDelta)->fetchAll(PDO::FETCH_ASSOC);

    $deltaMap = [];
    foreach ($delta as $r) {
      $name = trim((string)($r['employee'] ?? ''));
      if ($name === '') continue;
      $deltaMap[$name] = [
        'total' => (int)($r['delta30m_total'] ?? 0),
        'b2c'   => (int)($r['delta30m_b2c'] ?? 0),
        'b2b'   => (int)($r['delta30m_b2b'] ?? 0),
      ];
    }

    $out = [];
    foreach ($today as $r) {
      $name = trim((string)($r['employee'] ?? ''));
      if ($name === '') continue;

      // Filter out system/API rows
      if (stripos($name, 'shopware') !== false) continue; // e.g. "API Shopware"

      $packsTotal = (int)($r['packs_total'] ?? 0);
      $packsB2C   = (int)($r['packs_b2c'] ?? 0);
      $packsB2B   = (int)($r['packs_b2b'] ?? 0);

      // Noise filter
      if ($packsTotal < 1) continue;

      $d = $deltaMap[$name] ?? ['total'=>0,'b2c'=>0,'b2b'=>0];

      $out[] = [
        'id' => stable_id($name),
        'name' => $name,
        'zone' => '',
        'packs' => $packsTotal,
        'b2c' => $packsB2C,
        'b2b' => $packsB2B,
        'delta30m' => (int)$d['total'],
        'delta30m_b2c' => (int)$d['b2c'],
        'delta30m_b2b' => (int)$d['b2b'],
      ];
    }

    usort($out, fn($a, $b) => ($b['packs'] <=> $a['packs']));

    // Open-to-target totals (temporary B2C/B2B split; cron will later provide real split)
    $openTotal = 0;
    foreach ($out as $row) {
      $p = (int)($row['packs'] ?? 0);
      if ($p < $target) $openTotal += ($target - $p);
    }
    $openB2C = $openTotal; // TEMP: assume all open are B2C (status 6)
    $openB2B = 0;          // TEMP: cron will later fill B2B (status 6.5)

    json_out([
      'ok' => true,
      'mode' => $modeReq,
      'target' => $target,
      'serverTime' => date('H:i:s'),
      'open_total' => $openTotal,
      'open_b2c' => $openB2C,
      'open_b2b' => $openB2B,
      'data' => $out,
    ]);
  } catch (Throwable $e) {
    json_out(['ok' => false, 'error' => $e->getMessage()], 500);
  }
}

$defaultTarget = ($MODE === 'pick') ? 450 : 600;
$TARGET = isset($_GET['target']) ? max(1, (int)$_GET['target']) : $defaultTarget;
?>
<!doctype html>
<html lang="de">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title><?php echo htmlspecialchars($MODE_LABEL, ENT_QUOTES, 'UTF-8'); ?> – Live Leaderboard</title>
  <meta http-equiv="refresh" content="3600">
  <style>
    :root{
      --bg0:#070A12;
      --bg1:#0B1020;
      --card:rgba(255,255,255,.06);
      --card2:rgba(255,255,255,.10);
      --line:rgba(255,255,255,.10);
      --text:rgba(255,255,255,.92);
      --muted:rgba(255,255,255,.62);
      --good:rgba(78, 255, 196, .95);
      --warn:rgba(255, 217, 102, .95);
      --bad: rgba(255, 117, 117, .95);
      --accent: rgba(124, 92, 255, .95);
      --shadow: 0 24px 80px rgba(0,0,0,.55);
      --radius: 22px;
      --target: 600;
    }
    *{box-sizing:border-box}
    html,body{height:100%}
    body{
      margin:0;
      font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji","Segoe UI Emoji";
      color:var(--text);
      background:
        radial-gradient(1200px 900px at 20% 15%, rgba(124,92,255,.22), transparent 55%),
        radial-gradient(1000px 700px at 85% 25%, rgba(78,255,196,.16), transparent 55%),
        radial-gradient(1200px 900px at 65% 90%, rgba(255,217,102,.10), transparent 55%),
        linear-gradient(180deg, var(--bg0), var(--bg1));
      overflow:hidden;
    }
    .fx{
      position:fixed; inset:-40px;
      pointer-events:none;
      background:
        radial-gradient(900px 600px at 10% 30%, rgba(124,92,255,.18), transparent 60%),
        radial-gradient(900px 600px at 90% 20%, rgba(78,255,196,.12), transparent 60%),
        radial-gradient(1200px 800px at 55% 90%, rgba(255,217,102,.08), transparent 60%);
      filter: blur(18px);
      opacity:.9;
      animation: drift 14s ease-in-out infinite alternate;
    }
    @keyframes drift{
      from{ transform: translate3d(-10px,-8px,0) scale(1.02); }
      to  { transform: translate3d(18px,10px,0) scale(1.06); }
    }
    .grain{
      position:fixed; inset:0;
      pointer-events:none;
      background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.75' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E");
      mix-blend-mode: overlay;
      opacity:.10;
    }
    .wrap{height:100%;padding:44px 56px;display:flex;flex-direction:column;gap:20px;}
    header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;}
    .brand{display:flex;align-items:center;gap:16px;min-width:380px;}
    .logo{width:52px;height:52px;border-radius:16px;background:linear-gradient(135deg, rgba(124,92,255,.95), rgba(78,255,196,.80));box-shadow:0 18px 50px rgba(0,0,0,.45);position:relative;overflow:hidden;}
    .logo::after{content:"";position:absolute;inset:-40%;background:radial-gradient(circle at 30% 30%, rgba(255,255,255,.55), transparent 55%);transform:rotate(18deg);opacity:.55;}
    .titleblock .kicker{font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
    .titleblock h1{margin:6px 0 0;font-weight:820;font-size:44px;letter-spacing:.02em;line-height:1.06;}
    .meta{display:flex;gap:12px;align-items:center;justify-content:flex-end;flex-wrap:wrap;}
    .pill{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);box-shadow:0 14px 44px rgba(0,0,0,.25);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);font-size:14px;color:var(--muted);}
    .pill b{color:var(--text);font-weight:700}
    .dot{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px rgba(78,255,196,.10);animation:pulse 1.6s ease-in-out infinite;}
    @keyframes pulse{0%,100%{transform:scale(.95);opacity:.9;}50%{transform:scale(1.08);opacity:1;}}
    .grid{flex:1;display:grid;grid-template-columns:1.1fr .9fr;gap:18px;min-height:0;}
    .panel{border-radius:var(--radius);background:linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.10);box-shadow:0 24px 80px rgba(0,0,0,.55);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);overflow:hidden;min-height:0;}
    .panelhead{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid rgba(255,255,255,.10);}
    .panelhead h2{margin:0;font-size:16px;letter-spacing:.10em;text-transform:uppercase;color:rgba(255,255,255,.72);font-weight:760;}
    .hint{color:rgba(255,255,255,.55);font-size:13px;}
    .list{
      padding:14px;
      height:calc(100% - 56px);
      overflow:auto;
      display:flex;
      flex-direction:column;
      gap:14px;
      scrollbar-width:none;
    }
.list::-webkit-scrollbar{ display:none; }
    .row{position:relative;display:grid;grid-template-columns:92px 1.7fr 1.2fr 180px;align-items:center;gap:18px;padding:22px 24px;border-radius:24px;background:rgba(0,0,0,.16);border:1px solid rgba(255,255,255,.10);overflow:hidden;transform:translateZ(0);transition:transform .35s ease, background .35s ease, border-color .35s ease;
      flex: 0 0 auto;
    }
    .row.move{animation:pop .42s cubic-bezier(.2,.9,.2,1);}
    @keyframes pop{0%{transform:scale(.985);}55%{transform:scale(1.012);}100%{transform:scale(1);}}
    .rank{width:82px;height:82px;border-radius:24px;display:grid;place-items:center;font-weight:950;font-size:26px;letter-spacing:.02em;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.10);}
    .rank.top1{background:linear-gradient(135deg, rgba(255,217,102,.22), rgba(255,255,255,.06));border-color:rgba(255,217,102,.28);}
    .rank.top2{background:linear-gradient(135deg, rgba(124,92,255,.18), rgba(255,255,255,.06));border-color:rgba(124,92,255,.26);}
    .rank.top3{background:linear-gradient(135deg, rgba(78,255,196,.16), rgba(255,255,255,.06));border-color:rgba(78,255,196,.24);}
    .name{display:flex;flex-direction:column;gap:4px;min-width:0;}
    .name .main{font-size:28px;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
    .name .sub{font-size:16px;color:var(--muted);display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
    .chip{display:inline-flex;align-items:center;gap:10px;padding:8px 12px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);font-size:14px;color:rgba(255,255,255,.78);}
    .chip .miniDot{width:8px;height:8px;border-radius:50%;background:var(--accent);}
    .bar{height:18px;border-radius:999px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.10);overflow:hidden;position:relative;}
    .bar > i{display:block;height:100%;width:0%;border-radius:999px;background:linear-gradient(90deg, rgba(124,92,255,.90), rgba(78,255,196,.85));box-shadow:0 10px 28px rgba(124,92,255,.18);transition:width .65s cubic-bezier(.2,.9,.2,1);position:relative;overflow:hidden;}
    .bar > i::after{content:"";position:absolute;inset:-40% -20%;background:radial-gradient(circle at 25% 50%, rgba(255,255,255,.55), transparent 55%);opacity:.55;transform:translateX(-20%);animation:sheen 1.6s linear infinite;}
    @keyframes sheen{from{transform:translateX(-40%);}to{transform:translateX(140%);}}
    .count{text-align:right;font-weight:950;font-size:40px;letter-spacing:.02em;font-variant-numeric:tabular-nums;}
    .count small{display:block;color:var(--muted);font-size:14px;font-weight:750;margin-top:4px;letter-spacing:.10em;text-transform:uppercase;}
    .statusBadge{position:absolute;top:12px;right:14px;padding:8px 12px;border-radius:999px;font-size:13px;border:1px solid rgba(255,255,255,.12);background:rgba(0,0,0,.20);color:rgba(255,255,255,.80);display:none;}
    .row.fire .statusBadge{display:inline-flex;gap:8px;align-items:center;}
    .row.fire .statusBadge .flame{width:10px;height:10px;border-radius:3px;background:var(--warn);box-shadow:0 0 0 6px rgba(255,217,102,.12);}
    .side{display:flex;flex-direction:column;height:100%;min-height:0;}
    .kpis{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:14px;}
    .kpi{border-radius:18px;padding:14px;background:rgba(0,0,0,.16);border:1px solid rgba(255,255,255,.10);min-height:94px;display:flex;flex-direction:column;justify-content:space-between;}
    .kpi .label{color:rgba(255,255,255,.62);font-size:12px;letter-spacing:.12em;text-transform:uppercase;}
    .kpi .value{font-size:30px;font-weight:880;letter-spacing:.02em;font-variant-numeric:tabular-nums;}
    .kpi .delta{color:rgba(255,255,255,.62);font-size:13px;}
    .spotlight{flex:1;margin:0 14px 14px;border-radius:20px;padding:16px;
      background:radial-gradient(800px 400px at 20% 10%, rgba(124,92,255,.18), transparent 60%),
                 radial-gradient(800px 420px at 90% 30%, rgba(78,255,196,.14), transparent 60%),
                 rgba(0,0,0,.16);
      border:1px solid rgba(255,255,255,.10);
      overflow:hidden; /* IMPORTANT: scrolling happens in inner container */
      min-height:0;
      display:flex;
      flex-direction:column;
      gap:12px;
    }
    .spotScroll{
      flex:1;
      min-height:0;
      overflow:auto;
      display:flex;
      flex-direction:column;
      gap:12px;
      padding-right:2px;
      scrollbar-width:none;
    }
    .spotScroll::-webkit-scrollbar{ display:none; }
    .spotlight h3{margin:0;font-size:16px;letter-spacing:.10em;text-transform:uppercase;color:rgba(255,255,255,.72);font-weight:780;}
    .callout{border-radius:18px;padding:14px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);display:flex;gap:12px;align-items:center;}
    .medal{width:56px;height:56px;border-radius:18px;background:linear-gradient(135deg, rgba(255,217,102,.22), rgba(255,255,255,.06));border:1px solid rgba(255,217,102,.26);display:grid;place-items:center;font-weight:900;font-size:18px;}
    .callout .who{min-width:0;}
    .callout .who .big{font-weight:860;font-size:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
    .callout .who .small{color:rgba(255,255,255,.65);font-size:13px;margin-top:2px;}
    .ticker{
      margin-top:0;
      display:flex;
      gap:10px;
      align-items:center;
      color:rgba(255,255,255,.72);
      font-size:13px;
      padding:10px 12px;
      border-top:1px solid rgba(255,255,255,.10);
      border-radius:14px;
      background:linear-gradient(180deg, rgba(0,0,0,.18), rgba(0,0,0,.28));
      backdrop-filter:blur(10px);
      -webkit-backdrop-filter:blur(10px);
    }
    .ticker .spark{width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 6px rgba(124,92,255,.12);}
    .confetti{position:fixed;inset:0;pointer-events:none;overflow:hidden;display:none;}
    .confetti.on{display:block;}
    .confetti i{position:absolute;top:-10px;width:10px;height:14px;opacity:.9;transform:translateY(0) rotate(0deg);animation:fall 1.6s linear forwards;border-radius:3px;}
    @keyframes fall{to{transform:translateY(110vh) rotate(540deg);opacity:1;}}
    .err{position:fixed;left:18px;bottom:18px;right:18px;padding:14px 16px;border-radius:16px;background:rgba(255,117,117,.12);border:1px solid rgba(255,117,117,.25);color:rgba(255,255,255,.88);display:none;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);}
    .err.on{display:block;}
    /* Spotlight achievers pulse */
    .spot-achievers{display:flex;flex-direction:column;gap:10px;}
    .spot-achiever{
      display:flex;align-items:center;gap:10px;
      padding:10px 12px;border-radius:14px;
      background:rgba(78,255,196,.14);
      border:1px solid rgba(78,255,196,.35);
      box-shadow:0 0 0 0 rgba(78,255,196,.35);
      animation:spotPulse 2.2s ease-in-out infinite;
    }
    .spot-achiever .badge{
      width:34px;height:34px;border-radius:10px;
      display:grid;place-items:center;
      background:linear-gradient(135deg, rgba(78,255,196,.95), rgba(124,92,255,.85));
      font-weight:900;color:#08120f;
    }
    .spot-achiever .who{
      font-weight:800;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
    }
    @keyframes spotPulse{
      0%{ box-shadow:0 0 0 0 rgba(78,255,196,.35); }
      70%{ box-shadow:0 0 0 14px rgba(78,255,196,0); }
      100%{ box-shadow:0 0 0 0 rgba(78,255,196,0); }
    }

    /* Spotlight sections */
    .spot-section-title{margin:0;font-size:14px;letter-spacing:.12em;text-transform:uppercase;color:rgba(255,255,255,.72);font-weight:820;}
    .spot-under{display:flex;flex-direction:column;gap:10px;}
    .spot-under-item{
      display:flex;align-items:center;gap:10px;
      padding:10px 12px;border-radius:14px;
      background:rgba(255,117,117,.12);
      border:1px solid rgba(255,117,117,.30);
      box-shadow:0 0 0 0 rgba(255,117,117,.32);
      animation:negPulse 1.8s ease-in-out infinite;
    }
    .spot-under-item .badge{
      width:34px;height:34px;border-radius:10px;
      display:grid;place-items:center;
      background:linear-gradient(135deg, rgba(255,117,117,.95), rgba(255,217,102,.70));
      font-weight:900;color:#1a0a0a;
    }
    .spot-under-item .who{font-weight:850;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
    .spot-under-item .val{font-weight:950;font-variant-numeric:tabular-nums;color:rgba(255,255,255,.92);}
    @keyframes negPulse{
      0%{ box-shadow:0 0 0 0 rgba(255,117,117,.30); transform:scale(1); }
      60%{ box-shadow:0 0 0 16px rgba(255,117,117,0); transform:scale(1.01); }
      100%{ box-shadow:0 0 0 0 rgba(255,117,117,0); transform:scale(1); }
    }

    /* Config modal */
    .cfgModal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.55);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);z-index:9999;}
    .cfgModal.on{display:flex;}
    .cfgCard{width:min(860px,94vw);border-radius:22px;background:linear-gradient(180deg, rgba(255,255,255,.10), rgba(255,255,255,.06));border:1px solid rgba(255,255,255,.14);box-shadow:0 24px 80px rgba(0,0,0,.65);overflow:hidden;}
    .cfgHead{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 18px;border-bottom:1px solid rgba(255,255,255,.10);}
    .cfgKicker{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.62);}
    .cfgTitle{font-size:18px;font-weight:880;letter-spacing:.02em;color:rgba(255,255,255,.92);}
    .cfgX{border:1px solid rgba(255,255,255,.14);background:rgba(0,0,0,.22);color:rgba(255,255,255,.88);border-radius:12px;padding:8px 10px;cursor:pointer;}
    .cfgBody{padding:16px 18px;}
    .cfgGrid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
    .cfgField{display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:16px;background:rgba(0,0,0,.18);border:1px solid rgba(255,255,255,.10);}
    .cfgField label{font-size:12px;letter-spacing:.10em;text-transform:uppercase;color:rgba(255,255,255,.68);font-weight:760;}
    .cfgField input{padding:12px 12px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(0,0,0,.22);color:rgba(255,255,255,.92);font-size:18px;font-weight:850;outline:none;}
    .cfgField input:focus{border-color:rgba(124,92,255,.55);}
    .cfgHint{font-size:12px;color:rgba(255,255,255,.58);line-height:1.35;}
    .cfgFoot{display:flex;justify-content:flex-end;gap:10px;padding:14px 18px;border-top:1px solid rgba(255,255,255,.10);}
    .cfgBtn{border:1px solid rgba(255,255,255,.14);background:rgba(124,92,255,.95);color:#fff;border-radius:14px;padding:10px 14px;font-weight:850;cursor:pointer;}
    .cfgBtnGhost{background:rgba(0,0,0,.18);color:rgba(255,255,255,.88);}
    @media (max-width: 720px){ .cfgGrid{grid-template-columns:1fr;} }
    @media (min-width: 1600px){
      .wrap{padding:52px 68px;}
      .titleblock h1{font-size:40px;}
      .row{grid-template-columns:78px 1.4fr 1.2fr 140px;padding:16px 18px;}
      .count{font-size:26px;}
      .kpi .value{font-size:34px;}
    }

    /* Mobile / small screens */
    @media (max-width: 1100px) and (pointer: coarse){
      /* Disable TV fixed-height + hidden overflow for mobile */
      html, body { height: auto; }
      body { overflow: auto; }
      .wrap{
        padding: 18px 16px;
        gap: 14px;
        height: auto;
        min-height: 100vh;
      }
      header{
        flex-direction: column;
        align-items: flex-start;
        gap: 12px;
      }
      .brand{
        min-width: 0;
        width: 100%;
      }
      .titleblock h1{
        font-size: 24px;
        line-height: 1.15;
      }
      .meta{
        width: 100%;
        justify-content: flex-start;
      }
      .pill{
        padding: 10px 12px;
        font-size: 13px;
      }

      .grid{
        grid-template-columns: 1fr;
        gap: 14px;
      }

      .panel{
        min-height: auto;
        overflow: visible;
      }

      .list{
        height: auto;
        max-height: none;
        overflow: visible;
        overflow-x: hidden;
        padding-bottom: 14px;
      }

      .row{
        grid-template-columns: 56px 1fr;
        grid-template-areas:
          "rank name"
          "bar  count";
        gap: 10px 12px;
        padding: 12px 12px;
      }
      .rank{
        width: 46px;
        height: 46px;
        border-radius: 14px;
      }
      .name .main{
        font-size: 16px;
      }
      .name .sub{
        font-size: 12px;
      }
      .bar{
        grid-column: 1 / 2;
      }
      .count{
        grid-column: 2 / 3;
        font-size: 18px;
      }
      .statusBadge{
        top: 8px;
        right: 10px;
        font-size: 11px;
        padding: 6px 9px;
      }

      .kpis{
        grid-template-columns: 1fr;
        padding: 12px;
      }
      .kpi{
        min-height: 86px;
      }
      .kpi .value{
        font-size: 28px;
      }

      .spotlight{
        margin: 0 12px 12px;
        padding: 14px;
      }
      .callout{
        padding: 12px;
      }
      .medal{
        width: 50px;
        height: 50px;
        border-radius: 16px;
      }

      .err{
        left: 12px;
        right: 12px;
        bottom: 12px;
      }
    }

    /* Very small phones */
    @media (max-width: 420px){
      .logo{ width: 44px; height: 44px; border-radius: 14px; }
      .titleblock .kicker{ font-size: 12px; }
      .titleblock h1{ font-size: 22px; }
      .pill{ font-size: 12px; }
      .chip{ font-size: 11px; padding: 5px 9px; }
    }
  </style>
</head>
<body>
  <div class="fx"></div>
  <div class="grain"></div>
  <div class="confetti" id="confetti"></div>
  <div class="err" id="err"></div>

  <div class="wrap">
    <header>
      <div class="brand">
        <div class="logo" aria-hidden="true"></div>
        <div class="titleblock">
          <div class="kicker">Warehouse · Live Performance · <?php echo htmlspecialchars($MODE_LABEL, ENT_QUOTES, 'UTF-8'); ?></div>
          <h1><?php echo htmlspecialchars($TITLE_LABEL, ENT_QUOTES, 'UTF-8'); ?> – Leaderboard</h1>
        </div>
      </div>

      <div class="meta">
        <div class="pill"><span class="dot"></span> <span>Live</span></div>
        <button class="pill" id="btnMode" type="button" style="cursor:pointer; color:rgba(255,255,255,.88);">
          Modus: <b id="modeTxt"><?php echo htmlspecialchars($MODE_LABEL, ENT_QUOTES, 'UTF-8'); ?></b>
        </button>
        <button class="pill" id="btnCfg" type="button" style="cursor:pointer; color:rgba(255,255,255,.88);">Konfiguration</button>
        <div class="pill">Ziel: <b><span id="targetTxt"><?php echo (int)$TARGET; ?></span></b> <span id="unitPill">Pakete</span> / Mitarbeiter</div>
        <div class="pill">Letztes Update: <b id="lastUpdate">—</b></div>
        <div class="pill">Ranking: <b id="rankPage">1–5</b></div>
      </div>
    </header>

    <div class="grid">
      <section class="panel">
        <div class="panelhead">
          <h2>Ranking</h2>
          <div class="hint">Auto-Sort · Smooth Updates · TV Mode</div>
        </div>
        <div class="list" id="list"></div>
      </section>

      <aside class="panel side">
        <div class="panelhead">
          <h2>Shift KPIs</h2>
          <div class="hint">Heute · Aktueller Stand</div>
        </div>

        <div class="kpis">
          <div class="kpi">
            <div class="label" id="kpiTotalLabel">Gesamt Pakete</div>
            <div class="value" id="kpiTotal">0</div>
            <div class="delta" id="kpiTotalSub">—</div>
          </div>
          <div class="kpi">
            <div class="label">Ø pro MA</div>
            <div class="value" id="kpiAvg">0</div>
            <div class="delta">Ziel: <span id="kpiTarget"><?php echo (int)$TARGET; ?></span></div>
          </div>
          <div class="kpi">
            <div class="label">Top Performer</div>
            <div class="value" id="kpiTop">—</div>
            <div class="delta" id="kpiTopSub">—</div>
          </div>
          <div class="kpi">
            <div class="label">Ziel erreicht</div>
            <div class="value" id="kpiHit">0</div>
            <div class="delta">Mitarbeiter</div>
          </div>
        </div>

        <div class="spotlight">
          <h3>Spotlight</h3>

          <div class="spotScroll" id="spotScroll">
            <div class="spot-section-title">Tagessoll erfüllt</div>
            <div class="spot-achievers" id="spotAchievers"></div>

            <div class="spot-section-title" id="spotUnderTitle" style="margin-top:6px;">Deutlich unter Soll !!</div>
            <div class="spot-under" id="spotUnder"></div>
          </div>

          <div class="ticker">
            <span class="spark"></span>
            <span id="tickerText">Ziel: <?php echo (int)$TARGET; ?> · Push für die letzten Meter.</span>
          </div>
        </div>
      </aside>
    </div>
  </div>

  <!-- Config Modal (separate PIN protected client-side) -->
  <div class="cfgModal" id="cfgModal" aria-hidden="true">
    <div class="cfgCard" role="dialog" aria-modal="true" aria-labelledby="cfgTitle">
      <div class="cfgHead">
        <div>
          <div class="cfgKicker">TV Einstellungen</div>
          <div class="cfgTitle" id="cfgTitle">Konfiguration</div>
        </div>
        <button type="button" class="cfgX" id="cfgClose" aria-label="Schließen">✕</button>
      </div>

      <div class="cfgBody">
        <div class="cfgGrid">
          <div class="cfgField">
            <label for="cfgPackTarget">Pack-Ziel (pro MA / Tag)</label>
            <input id="cfgPackTarget" type="number" min="1" step="1" inputmode="numeric" />
          </div>
          <div class="cfgField">
            <label for="cfgPickTarget">Pick-Ziel (pro MA / Tag)</label>
            <input id="cfgPickTarget" type="number" min="1" step="1" inputmode="numeric" />
            <div class="cfgHint">Hinweis: Ziel ist frei konfigurierbar.</div>
          </div>
          <div class="cfgField">
            <label for="cfgUnderPick">Schwelle „Deutlich unter Soll“ (Pick)</label>
            <input id="cfgUnderPick" type="number" min="0" step="1" inputmode="numeric" />
            <div class="cfgHint">Rot pulsierend bei weniger als diesem Wert (Pick).</div>
          </div>
          <div class="cfgField">
            <label for="cfgUnderPack">Schwelle „Deutlich unter Soll“ (Pack)</label>
            <input id="cfgUnderPack" type="number" min="0" step="1" inputmode="numeric" />
            <div class="cfgHint">Rot pulsierend bei weniger als diesem Wert (Pack).</div>
          </div>
        </div>
      </div>

      <div class="cfgFoot">
        <button type="button" class="cfgBtn cfgBtnGhost" id="cfgReset">Reset</button>
        <button type="button" class="cfgBtn" id="cfgSave">Speichern</button>
      </div>
    </div>
  </div>
  <script>
    // Targets (Pack vs Pick)
    const TARGET_PACK_DEFAULT = 600;
    const TARGET_PICK_DEFAULT = 450;

    // URL override: ?target= (pack), optional ?target_pick= (pick)
    const urlParams = new URLSearchParams(window.location.search);
// ===== Config (separate PIN) =====
const CFG_PIN = '130313';
const CFG_KEY = 'pikst_tv_cfg_v1';

const DEFAULT_CFG = {
  packTarget: TARGET_PACK_DEFAULT,
  pickTarget: TARGET_PICK_DEFAULT,
  underPick: 300,
  underPack: 400
};

function loadCfg(){
  try{
    const raw = localStorage.getItem(CFG_KEY);
    if (!raw) return { ...DEFAULT_CFG };
    const j = JSON.parse(raw);
    return {
      packTarget: Number.isFinite(+j.packTarget) && +j.packTarget > 0 ? Math.round(+j.packTarget) : DEFAULT_CFG.packTarget,
      pickTarget: Number.isFinite(+j.pickTarget) && +j.pickTarget > 0 ? Math.round(+j.pickTarget) : DEFAULT_CFG.pickTarget,
      underPick: Number.isFinite(+j.underPick) && +j.underPick >= 0 ? Math.round(+j.underPick) : DEFAULT_CFG.underPick,
      underPack: Number.isFinite(+j.underPack) && +j.underPack >= 0 ? Math.round(+j.underPack) : DEFAULT_CFG.underPack,
    };
  }catch(e){
    return { ...DEFAULT_CFG };
  }
}

function saveCfg(cfg){
  localStorage.setItem(CFG_KEY, JSON.stringify(cfg));
}

let cfg = loadCfg();

    const targetPack = (() => {
      const v = parseInt(urlParams.get('target') || '', 10);
      return Number.isFinite(v) && v > 0 ? v : cfg.packTarget;
    })();
    const targetPick = (() => {
      const v = parseInt(urlParams.get('target_pick') || '', 10);
      const base = Number.isFinite(v) && v > 0 ? v : cfg.pickTarget;
      return Math.max(1, base);
    })();

    let currentTarget = 0; // set in applyModeLabels()

    const UPDATE_MS = 3500;
    const MODE_SWITCH_MS = 120000;

    // Ranking: Hybrid
    // - For small Pack lists (e.g. 6–12 rows) there is often no overflow => no scroll.
    //   In that case we page 1–5, 6–10, ...
    // - For larger lists we render all rows and auto-scroll.
    const PAGE_SIZE = 5;
    const PAGE_SWITCH_MS = 10000; // only used when paging is active
    let usePaging = false;
    let fullListCache = [];
    let pageIndex = 0;
    let lastListSig = '';
    let lastAutoScrollSig = '';
    let lastAutoScrollMode = '';

    // Pause page-rotation briefly after mode switch so first page stays visible
    let pageRotationPausedUntil = 0;

    const MODE_PACK = 'pack';
    const MODE_PICK = 'pick';
    let currentMode = <?php echo json_encode($MODE); ?>;
    const FIRE_THRESHOLD = 0.86;
    const CONFETTI_ON_HIT = true;

    let lastOrder = new Map();
    let celebrated = new Set();
    // Auto-scroll containers (ranking + spotlight) slowly up/down.
    // Goal: within ~MODE_SWITCH_MS you see everything.
    function startAutoScroll(el, cycleMs){
      if (!el) return null;

      // Robust bidirectional auto-scroll (down + up) that survives dynamic height changes.
      let dir = 1; // 1=down, -1=up
      const tickMs = 33; // ~30fps
      let pauseUntil = Date.now() + 1500; // initial pause so first view stays readable

      const tick = () => {
        if (!el) return;

        const max = Math.max(0, el.scrollHeight - el.clientHeight);
        if (max <= 2) {
          // Nothing to scroll
          el.scrollTop = 0;
          dir = 1;
          pauseUntil = Date.now() + 1500;
          return;
        }

        const now = Date.now();
        if (now < pauseUntil) return;

        // px per tick so a full down+up cycle roughly matches cycleMs
        const pxPerTick = (max * 2) / Math.max(1, (cycleMs / tickMs));

        let next = el.scrollTop + (pxPerTick * dir);

        // Clamp and flip direction at edges
        if (next >= max) {
          el.scrollTop = max;
          dir = -1;
          pauseUntil = now + 2200; // pause at bottom
          return;
        }

        if (next <= 0) {
          el.scrollTop = 0;
          dir = 1;
          pauseUntil = now + 1800; // pause at top
          return;
        }

        el.scrollTop = next;
      };

      return setInterval(tick, tickMs);
    }

    let scrollTimerRanking = null;
    let scrollTimerSpotlight = null;

    function resetAutoScrollTimers(){
      if (scrollTimerRanking) clearInterval(scrollTimerRanking);
      if (scrollTimerSpotlight) clearInterval(scrollTimerSpotlight);

      const listEl = document.getElementById('list');
      const spotEl = document.getElementById('spotScroll');

      if (listEl) listEl.scrollTop = 0;
      if (spotEl) spotEl.scrollTop = 0;

      // Wait one paint + a short delay so scrollHeight/clientHeight are correct
      requestAnimationFrame(() => {
        setTimeout(() => {
          if (listEl) listEl.scrollTop = 0;
          if (spotEl) spotEl.scrollTop = 0;
          scrollTimerRanking = startAutoScroll(listEl, MODE_SWITCH_MS);

          // IMPORTANT: Spotlight needs its own cycle and must be re-armed even if content height changes
          if (spotEl) {
            spotEl.scrollTop = 0;
            scrollTimerSpotlight = startAutoScroll(spotEl, MODE_SWITCH_MS);
          }
        }, 120);
      });
    }

    function nowTime(){
      const d = new Date();
      return d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
    }
    function clamp(n, a, b){ return Math.max(a, Math.min(b, n)); }

    function listSignature(list){
      // stable signature to reset paging when ranking changes
      return (list || []).map(x => `${x.id}:${x.packs}`).join('|');
    }

    function updateRankPageLabel(startIdx, endIdx, total){
      const el = document.getElementById('rankPage');
      if (!el){ return; }
      if (!total || total <= 0){
        el.textContent = '—';
        return;
      }
      const s = Math.max(1, (startIdx|0) + 1);
      const e = Math.max(s, (endIdx|0));
      el.textContent = `${s}–${e}`;
    }

    function pausePageRotation(ms){
      pageRotationPausedUntil = Date.now() + ms;
    }

    function renderPage(){
      const list = fullListCache || [];
      const total = list.length;
      if (total === 0){
        const host = document.getElementById('list');
        if (host) host.innerHTML = '';
        updateRankPageLabel(0, 0, 0);
        return;
      }

      const pageSize = usePaging ? PAGE_SIZE : total; // when not paging, render all
      const pages = usePaging ? Math.max(1, Math.ceil(total / pageSize)) : 1;
      if (pageIndex >= pages) pageIndex = 0;

      const startIdx = usePaging ? (pageIndex * pageSize) : 0;
      const pageSlice = list.slice(startIdx, startIdx + pageSize);

      // Render only the current page, but keep global rank numbers
      const host = document.getElementById('list');
      const keepScroll = host ? host.scrollTop : 0;
      host.innerHTML = '';

      pageSlice.forEach((item, localIdx)=>{
        const globalRank = startIdx + localIdx + 1;
        const row = buildRow(item, globalRank);
        host.appendChild(row);
      });

      const endIdx = startIdx + pageSlice.length;
      updateRankPageLabel(startIdx, endIdx, total);

      if (host) {
        const maxScroll = host.scrollHeight - host.clientHeight;
        host.scrollTop = clamp(keepScroll, 0, Math.max(0, maxScroll));
      }
    }

    function buildRow(item, rank){
      const pct = clamp((item.packs / currentTarget) * 100, 0, 120);
      const hit = item.packs >= currentTarget;
      const fire = (item.packs / currentTarget) >= FIRE_THRESHOLD && !hit;

      const row = document.createElement('div');
      row.className = 'row' + (fire ? ' fire' : '');
      row.dataset.id = String(item.id);

      const rankBox = document.createElement('div');
      rankBox.className = 'rank' + (rank===1?' top1':rank===2?' top2':rank===3?' top3':'');
      rankBox.textContent = "#" + rank;

      const name = document.createElement('div');
      name.className = 'name';
      const zone = (item.zone || '').trim();
      // Remove segChip logic entirely
      // Add B2C/B2B split helpers
      const b2c = Number(item.b2c || 0);
      const b2b = Number(item.b2b || 0);
      const dB2C = Number(item.delta30m_b2c || 0);
      const dB2B = Number(item.delta30m_b2b || 0);

      name.innerHTML = `
        <div class="main">${item.name}</div>
        <div class="sub">
          ${zone ? `<span class="chip"><span class="miniDot"></span>${zone}</span>` : ``}
          <span class="chip">30m: <b style="color:rgba(255,255,255,.92);font-weight:800">+${item.delta30m}</b></span>
          <span class="chip">B2C: <b style="color:rgba(255,255,255,.92);font-weight:800">${b2c}</b> <span style="opacity:.7">(+${dB2C})</span></span>
          <span class="chip" style="border-color:rgba(124,92,255,.28);background:rgba(124,92,255,.10)">B2B: <b style="color:rgba(255,255,255,.92);font-weight:800">${b2b}</b> <span style="opacity:.7">(+${dB2B})</span></span>
          ${hit ? `<span class="chip" style="border-color:rgba(78,255,196,.24);background:rgba(78,255,196,.10)">Ziel erreicht</span>` : ``}
        </div>
      `;

      const bar = document.createElement('div');
      bar.className = 'bar';
      const fill = document.createElement('i');
      fill.style.width = pct.toFixed(1) + '%';
      bar.appendChild(fill);

      const count = document.createElement('div');
      count.className = 'count';
      const unitSmall = (currentMode === MODE_PICK) ? 'Bestellungen' : 'Pakete';
      count.innerHTML = `${item.packs}<small>${unitSmall}</small>`;

      const badge = document.createElement('div');
      badge.className = 'statusBadge';
      badge.innerHTML = `<span class="flame"></span><span>On fire</span>`;

      row.append(rankBox, name, bar, count, badge);
      return row;
    }

    function applyModeLabels(){
      const isPick = currentMode === MODE_PICK;
      const modeLabel = isPick ? 'Pickstat' : 'Packstat';
      const titleLabel = isPick ? 'Pickzahlen' : 'Packzahlen';
      const unit = isPick ? 'Bestellungen' : 'Pakete';

      // target depends on mode
      currentTarget = isPick ? targetPick : targetPack;
      document.documentElement.style.setProperty('--target', String(currentTarget));

      const targetTxt = document.getElementById('targetTxt');
      if (targetTxt) targetTxt.textContent = String(currentTarget);
      const kpiTarget = document.getElementById('kpiTarget');
      if (kpiTarget) kpiTarget.textContent = String(currentTarget);

      // title + header
      document.title = modeLabel + ' – Live Leaderboard';
      const h1 = document.querySelector('.titleblock h1');
      if (h1) h1.textContent = titleLabel + ' – Leaderboard';
      const kicker = document.querySelector('.titleblock .kicker');
      if (kicker) kicker.textContent = 'Warehouse · Live Performance · ' + modeLabel;

      // pills + KPI label
      const unitPill = document.getElementById('unitPill');
      if (unitPill) unitPill.textContent = unit;
      const totalLbl = document.getElementById('kpiTotalLabel');
      if (totalLbl) totalLbl.textContent = 'Gesamt ' + unit;

      // mode button
      const modeTxt = document.getElementById('modeTxt');
      if (modeTxt) modeTxt.textContent = modeLabel;
      const btn = document.getElementById('btnMode');
      if (btn) btn.setAttribute('aria-label', 'Modus wechseln (aktuell ' + modeLabel + ')');
    }

    function render(list, serverTime, openTotal, openB2C, openB2B){
      list = [...(list || [])].sort((a,b)=> b.packs - a.packs);

      // Pack mode often has only a few rows; if there is no overflow, auto-scroll won't move.
      // We handle this in two steps:
      //  1) heuristic paging for small-ish pack lists
      //  2) after first render, force paging if the list has no overflow (typical on TVs/Flipboards)
      usePaging = (currentMode === MODE_PACK && list.length > PAGE_SIZE);
      if (!usePaging) pageIndex = 0;

      // reset paging if list changed
      const sig = listSignature(list);
      const listChanged = (sig !== lastListSig);
      if (listChanged){
        lastListSig = sig;
        pageIndex = 0;
      }

      // IMPORTANT: do NOT restart auto-scroll on every data refresh.
      // Otherwise the initial hold keeps re-triggering and nothing scrolls.
      const needScrollReset = (!scrollTimerRanking || !scrollTimerSpotlight || lastAutoScrollMode !== currentMode);
      lastAutoScrollMode = currentMode;
      lastAutoScrollSig = sig;
      if (needScrollReset) resetAutoScrollTimers();

      fullListCache = list;
      // keep scrolling smooth across refreshes
      renderPage();

      // Force paging when there is no overflow (e.g. Samsung Flipboard browser at 100% zoom)
      // because auto-scroll cannot move if scrollHeight == clientHeight.
      if (currentMode === MODE_PACK) {
        const host = document.getElementById('list');
        if (host) {
          const max = Math.max(0, host.scrollHeight - host.clientHeight);
          if (max <= 2 && (fullListCache || []).length > PAGE_SIZE) {
            if (!usePaging) {
              usePaging = true;
              pageIndex = 0;
              pausePageRotation(6000);
              renderPage();
            }
          }
        }
      }

      const total = list.reduce((s,x)=> s + x.packs, 0);
      const avg = list.length ? Math.round(total / list.length) : 0;
      const hitCount = list.filter(x => x.packs >= currentTarget).length;

      document.getElementById('kpiTotal').textContent = total.toLocaleString('de-DE');
      document.getElementById('kpiAvg').textContent = avg.toLocaleString('de-DE');
      document.getElementById('kpiHit').textContent = hitCount.toLocaleString('de-DE');

      // KPI subtext ("Gesamtoffene Pakete" split as 6(B2C) / 6.5(B2B))
      const sub = document.getElementById('kpiTotalSub');
      const fmt = (n) => (Number.isFinite(n) ? Number(n).toLocaleString('de-DE') : '—');
     

      const top = list[0];
      document.getElementById('kpiTop').textContent = top ? top.packs.toLocaleString('de-DE') : "—";
      document.getElementById('kpiTopSub').textContent = top ? `${top.name}${top.zone ? ' · ' + top.zone : ''}` : "—";

      // Spotlight: all achievers
      const spotHost = document.getElementById('spotAchievers');
      if (spotHost){
        const achievers = list.filter(x => x.packs >= currentTarget);
        spotHost.innerHTML = '';
        if (achievers.length === 0){
          spotHost.innerHTML = '<div class="hint">Noch kein Ziel erreicht</div>';
        } else {
          achievers.forEach((a, idx)=>{
            const el = document.createElement('div');
            el.className = 'spot-achiever';
            const b2c = Number(a.b2c || 0);
            const b2b = Number(a.b2b || 0);
            const segTxt = ` <span style="opacity:.75;font-weight:900">(B2C ${b2c} / B2B ${b2b})</span>`;
            el.innerHTML = `
              <div class="badge">#${idx+1}</div>
              <div class="who">${a.name}${segTxt}</div>
            `;
            spotHost.appendChild(el);
          });
        }
      }

      // Spotlight: far below daily target (negative pulse)
      const underHost = document.getElementById('spotUnder');
      const underTitle = document.getElementById('spotUnderTitle');
      if (underHost){
        const underLimit = (currentMode === MODE_PICK) ? cfg.underPick : cfg.underPack;
        const unit = (currentMode === MODE_PICK) ? 'Bestellungen' : 'Pakete';

        const under = list
          .filter(x => x.packs < underLimit)
          .sort((a,b) => a.packs - b.packs);

        underHost.innerHTML = '';

        if (underTitle) {
          underTitle.textContent = `Deutlich unter Soll !! (< ${underLimit} ${unit})`;
        }

        if (under.length === 0){
          underHost.innerHTML = '<div class="hint">Keine Mitarbeiter unter dem Schwellenwert</div>';
        } else {
          under.forEach((u, idx)=>{
            const el = document.createElement('div');
            el.className = 'spot-under-item';
            const b2c = Number(u.b2c || 0);
            const b2b = Number(u.b2b || 0);
            const segTxt = ` <span style="opacity:.75;font-weight:900">(B2C ${b2c} / B2B ${b2b})</span>`;
            el.innerHTML = `
              <div class="badge">!${idx+1}</div>
              <div class="who">${u.name}${segTxt}</div>
              <div class="val">${u.packs}</div>
            `;
            underHost.appendChild(el);
          });
        }
      }

      /* Re-arm spotlight auto-scroll after DOM changes */
      if (scrollTimerSpotlight) {
        clearInterval(scrollTimerSpotlight);
        scrollTimerSpotlight = null;
      }
      const spotEl = document.getElementById('spotScroll');
      if (spotEl) {
        requestAnimationFrame(() => {
          spotEl.scrollTop = 0;
          scrollTimerSpotlight = startAutoScroll(spotEl, MODE_SWITCH_MS);
        });
      }

      document.getElementById('lastUpdate').textContent = serverTime || nowTime();

      if (CONFETTI_ON_HIT){
        list.forEach(item=>{
          if (item.packs >= currentTarget && !celebrated.has(item.id)){
            celebrated.add(item.id);
            confettiBurst();
          }
        });
      }

      const remainingTotal = list.reduce((s,x)=> s + Math.max(0, currentTarget - x.packs), 0);
      document.getElementById('tickerText').textContent =
        remainingTotal === 0
          ? "Alle Ziele erreicht. Fokus: Qualität halten, Fehler vermeiden."
          : `Offen bis Ziel (sum): ${remainingTotal.toLocaleString('de-DE')} · Push bis zur Vorgabe.`;
    }

    function confettiBurst(){
      const c = document.getElementById('confetti');
      c.classList.add('on');
      const n = 120;
      const colors = ["rgba(124,92,255,.95)","rgba(78,255,196,.95)","rgba(255,217,102,.95)","rgba(255,255,255,.85)"];
      for (let i=0;i<n;i++){
        const p = document.createElement('i');
        p.style.left = (Math.random()*100) + "vw";
        p.style.animationDelay = (Math.random()*0.25) + "s";
        p.style.animationDuration = (1.2 + Math.random()*0.9) + "s";
        p.style.width = (7 + Math.random()*10) + "px";
        p.style.height = (8 + Math.random()*16) + "px";
        p.style.background = colors[(Math.random()*colors.length)|0];
        c.appendChild(p);
        p.addEventListener('animationend', ()=> p.remove());
      }
      setTimeout(()=>{ c.classList.remove('on'); }, 1800);
    }

    function setError(msg){
      const el = document.getElementById('err');
      if (!msg){ el.classList.remove('on'); el.textContent=''; return; }
      el.textContent = msg;
      el.classList.add('on');
    }

    async function fetchLive(){
      try{
        const url = new URL(window.location.href);
        url.searchParams.set('ajax','1');
        url.searchParams.set('target', String(currentTarget));
        url.searchParams.set('mode', currentMode);
        const res = await fetch(url.toString(), { cache: 'no-store' });
        const j = await res.json();
        if (!j || !j.ok){
          setError((j && j.error) ? ('DB/Backend: ' + j.error) : 'Backend error');
          return;
        }
        if (j.mode && (j.mode === MODE_PICK || j.mode === MODE_PACK)) {
          if (j.mode !== currentMode){
            currentMode = j.mode;
            pageIndex = 0;
            pausePageRotation(5000);
          } else {
            currentMode = j.mode;
          }
        }
        applyModeLabels();
        setError('');
        render(
          j.data || [],
          j.serverTime || null,
          j.open_total ?? null,
          j.open_b2c ?? null,
          j.open_b2b ?? null
        );
        // resetAutoScrollTimers(); // removed: handled in render() only on changes
      }catch(e){
        setError('Fetch failed: ' + (e && e.message ? e.message : String(e)));
      }
    }

    applyModeLabels();
    fetchLive();
    pausePageRotation(2500);

    const btnMode = document.getElementById('btnMode');
    if (btnMode) {
      btnMode.addEventListener('click', () => {
        currentMode = (currentMode === MODE_PICK) ? MODE_PACK : MODE_PICK;
        pageIndex = 0;
        pausePageRotation(12000);
        applyModeLabels();
        fetchLive();
      });
    }

    // Auto switch Pack/Pick
    setInterval(() => {
      currentMode = (currentMode === MODE_PICK) ? MODE_PACK : MODE_PICK;
      pageIndex = 0;
      pausePageRotation(12000);
      applyModeLabels();
      fetchLive();
    }, MODE_SWITCH_MS);

    // Page rotation (only when paging is active)
    setInterval(() => {
      if (!usePaging) return;
      if (Date.now() < pageRotationPausedUntil) return;
      const total = (fullListCache || []).length;
      const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
      pageIndex = (pageIndex + 1) % pages;
      renderPage();
    }, PAGE_SWITCH_MS);

    setInterval(fetchLive, UPDATE_MS);

// ===== Config modal wiring =====
const btnCfg = document.getElementById('btnCfg');
const cfgModal = document.getElementById('cfgModal');
const cfgClose = document.getElementById('cfgClose');
const cfgSaveBtn = document.getElementById('cfgSave');
const cfgResetBtn = document.getElementById('cfgReset');

function openCfg(){
  if (!cfgModal) return;
  document.getElementById('cfgPackTarget').value = String(cfg.packTarget);
  document.getElementById('cfgPickTarget').value = String(cfg.pickTarget);
  document.getElementById('cfgUnderPick').value = String(cfg.underPick);
  document.getElementById('cfgUnderPack').value = String(cfg.underPack);
  cfgModal.classList.add('on');
  cfgModal.setAttribute('aria-hidden','false');
}

function closeCfg(){
  if (!cfgModal) return;
  cfgModal.classList.remove('on');
  cfgModal.setAttribute('aria-hidden','true');
}

function askCfgPin(){
  const p = window.prompt('Konfigurations-PIN eingeben:');
  if (p === null) return false;
  if (String(p).trim() !== CFG_PIN){ alert('Falscher PIN'); return false; }
  return true;
}

if (btnCfg){
  btnCfg.addEventListener('click', ()=>{ if (askCfgPin()) openCfg(); });
}
if (cfgClose){ cfgClose.addEventListener('click', closeCfg); }
if (cfgModal){ cfgModal.addEventListener('click', e=>{ if (e.target === cfgModal) closeCfg(); }); }

if (cfgResetBtn){
  cfgResetBtn.addEventListener('click', ()=>{
    if (!confirm('Einstellungen wirklich zurücksetzen?')) return;
    cfg = { ...DEFAULT_CFG };
    saveCfg(cfg);
    location.reload();
  });
}

if (cfgSaveBtn){
  cfgSaveBtn.addEventListener('click', ()=>{
    const packT = Math.max(1, parseInt(document.getElementById('cfgPackTarget').value || '0', 10) || 0);
    const pickT = Math.max(1, parseInt(document.getElementById('cfgPickTarget').value || '0', 10) || 0);
    const uPick = Math.max(0, parseInt(document.getElementById('cfgUnderPick').value || '0', 10) || 0);
    const uPack = Math.max(0, parseInt(document.getElementById('cfgUnderPack').value || '0', 10) || 0);
    cfg = { packTarget: packT, pickTarget: pickT, underPick: uPick, underPack: uPack };
    saveCfg(cfg);
    location.reload();
  });
}
  </script>
</body>
</html>

 

Main Menu

  • Home

Login Form

  • Passwort vergessen?
  • Benutzername vergessen?