<?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>