Files
zibo-dashboard/public/userarea/kiosk.php
T
2026-10-21 10:03:59 +02:00

1486 lines
48 KiB
PHP

<?php
/**
* kiosk.php - schermo di reparto per la sala pausa (TV 16:9, senza mouse né tastiera).
*
* Si apre senza login con il link che contiene il codice segreto: kiosk.php?k=CODICE
* (il codice si copia o si rigenera da kiosk_admin.php). Mostra solo dati di produzione e comunicazioni.
* Parametro facoltativo &solo=linee per fissare una sola slide (es. schermo vicino alle linee).
*/
// --- vendor e .env come gli script CLI: niente headscript (lo schermo non fa login) ---
$dirs = [__DIR__, dirname(__DIR__), dirname(__DIR__, 2), dirname(__DIR__, 3)];
foreach ($dirs as $d) {
if (file_exists($d . '/vendor/autoload.php')) {
require_once $d . '/vendor/autoload.php';
break;
}
}
if (class_exists('Dotenv\Dotenv')) {
foreach ($dirs as $d) {
if (file_exists($d . '/.env')) {
Dotenv\Dotenv::createImmutable($d)->safeLoad();
break;
}
}
}
require_once __DIR__ . '/class/db-functions.php';
require_once __DIR__ . '/class/Kiosk.php';
$pdo = DBHandlerSelect::getInstance()->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$k = (string)($_GET['k'] ?? '');
$token = Kiosk::settings($pdo)['token'];
if ($token === '' || !hash_equals($token, $k)) {
http_response_code(403);
echo '<!doctype html><meta charset="utf-8"><body style="background:#0a1120;color:#cbd5e1;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><h1>Schermo non autorizzato</h1><p>Apri il link completo dalla pagina di gestione dello schermo.</p></div>';
exit;
}
if (($_GET['a'] ?? '') === 'data') {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
try {
echo json_encode(Kiosk::data($pdo));
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
exit;
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Bacheca di reparto</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.49.1"></script>
<style>
:root {
--bg: #0a1120;
--panel: #131d33;
--panel2: #1a2742;
--line: #2a3a5c;
--ink: #f1f5ff;
--muted: #b4c2dd;
--label: #c6d2ea;
--run: #22c55e;
--runink: #4ade80;
--pause: #f59e0b;
--pauseink: #fbbf24;
--bad: #ef4444;
--badink: #f87171;
--accent: #7dd3fc;
}
* {
box-sizing: border-box;
}
html {
font-size: clamp(12px, 1.04vw, 44px);
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: radial-gradient(1400px 700px at 10% -10%, #15284d 0%, transparent 60%), var(--bg);
color: var(--ink);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
cursor: none;
}
.k {
height: 100%;
display: flex;
flex-direction: column;
}
/* ---------- testata ---------- */
header {
display: flex;
align-items: center;
gap: 2rem;
padding: 1.2rem 2.2rem .9rem;
}
.brand {
display: flex;
align-items: center;
gap: 1rem;
min-width: 18rem;
}
.logo {
width: 3.6rem;
height: 3.6rem;
border-radius: 1rem;
background: linear-gradient(135deg, #2563eb, #38bdf8);
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 1.9rem;
box-shadow: 0 .5rem 1.5rem rgba(37, 99, 235, .4);
}
.brand b {
display: block;
font-size: 1.6rem;
font-weight: 900;
line-height: 1.1;
}
.brand small {
color: var(--muted);
font-size: 1rem;
font-weight: 600;
}
.stitle {
flex: 1;
text-align: center;
font-size: 2.2rem;
font-weight: 900;
letter-spacing: .01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stitle i {
color: var(--accent);
margin-right: .8rem;
}
.right {
display: flex;
align-items: center;
gap: 2rem;
min-width: 18rem;
justify-content: flex-end;
}
.wx {
display: flex;
align-items: center;
gap: .6rem;
font-size: 2rem;
font-weight: 800;
}
.wx i {
font-size: 2.6rem;
color: #fcd34d;
}
.wx small {
display: block;
font-size: .9rem;
color: var(--muted);
font-weight: 700;
}
.clock {
text-align: right;
}
.clock b {
display: block;
font-size: 3rem;
font-weight: 900;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.clock small {
color: var(--label);
font-size: 1.05rem;
font-weight: 700;
}
.prog {
height: .3rem;
margin: 0 2.2rem;
background: #1b2846;
border-radius: 1rem;
overflow: hidden;
}
.prog span {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, #2563eb, #38bdf8);
}
/* ---------- area slide ---------- */
main {
flex: 1;
min-height: 0;
position: relative;
padding: 1.6rem 2.2rem 1rem;
}
.slide {
position: absolute;
inset: 1.6rem 2.2rem 1rem;
display: flex;
flex-direction: column;
opacity: 0;
transform: translateY(1.5rem);
transition: opacity .7s ease, transform .7s ease;
}
.slide.on {
opacity: 1;
transform: none;
}
.dots {
display: flex;
justify-content: center;
gap: .5rem;
padding: .3rem 0 .7rem;
}
.dots span {
width: .7rem;
height: .7rem;
border-radius: 50%;
background: #2a3a5c;
transition: all .3s;
}
.dots span.on {
width: 2.2rem;
border-radius: 1rem;
background: var(--accent);
}
/* ---------- avvisi scorrevoli ---------- */
footer {
display: flex;
align-items: stretch;
height: 4.6rem;
background: #0e1830;
border-top: 1px solid var(--line);
}
.tk-label {
display: flex;
align-items: center;
gap: .6rem;
padding: 0 1.6rem;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #1a1205;
font-weight: 900;
font-size: 1.3rem;
letter-spacing: .08em;
flex-shrink: 0;
}
.tk-track {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
}
.tk-move {
white-space: nowrap;
font-size: 1.7rem;
font-weight: 700;
padding-left: 100%;
animation: tk linear infinite;
}
.tk-move .sep {
color: var(--pauseink);
margin: 0 2rem;
}
@keyframes tk {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
.offline {
position: fixed;
right: 1rem;
bottom: 5.4rem;
background: #7f1d1d;
color: #fff;
border-radius: 1rem;
padding: .4rem .9rem;
font-weight: 800;
font-size: .9rem;
display: none;
}
/* ---------- elementi comuni ---------- */
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.6rem;
}
.lbl {
font-size: 1.15rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--label);
}
.up {
color: var(--runink);
}
.down {
color: var(--badink);
}
.flat {
color: var(--muted);
}
/* linee */
.lines {
flex: 1;
display: grid;
gap: 1.4rem;
min-height: 0;
}
.ln {
padding: 1.6rem 1.8rem;
border-top: .6rem solid var(--c);
display: flex;
flex-direction: column;
min-height: 0;
}
.ln .h {
display: flex;
align-items: center;
gap: 1rem;
}
.ln .name {
font-size: 2.1rem;
font-weight: 900;
}
.pill {
margin-left: auto;
border-radius: 2rem;
padding: .35rem 1.1rem;
font-weight: 900;
font-size: 1rem;
letter-spacing: .08em;
color: #0a1120;
}
.pill.run {
background: var(--run);
}
.pill.pause {
background: var(--pause);
}
.pill.free {
background: #64748b;
color: #fff;
}
.ln .mat {
font-size: 4.2rem;
font-weight: 900;
color: var(--accent);
margin-top: 1.4rem;
line-height: 1;
text-shadow: 0 0 2rem rgba(125, 211, 252, .3);
}
.ln .mes {
color: var(--label);
font-size: 1.3rem;
font-weight: 700;
margin-top: .6rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ln .tm {
font-size: 4.6rem;
font-weight: 900;
font-variant-numeric: tabular-nums;
margin-top: auto;
line-height: 1;
}
.ln .tm.run {
color: var(--runink);
text-shadow: 0 0 1.8rem rgba(74, 222, 128, .4);
}
.ln .tm.pause {
color: var(--pauseink);
}
.ln .tm.free {
color: var(--muted);
font-size: 3rem;
}
.bar {
height: .8rem;
background: #1f2d4f;
border-radius: 1rem;
overflow: hidden;
margin-top: 1rem;
}
.bar span {
display: block;
height: 100%;
border-radius: 1rem;
background: var(--run);
}
.ln .eta {
font-size: 1.35rem;
font-weight: 800;
margin-top: .7rem;
color: var(--label);
}
.ln .q {
color: var(--muted);
font-size: 1.2rem;
font-weight: 700;
margin-top: .9rem;
}
/* KPI */
.kgrid {
flex: 1.5;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.4rem;
min-height: 0;
}
.kt {
padding: 1.8rem 2rem;
display: flex;
flex-direction: column;
justify-content: center;
}
.kt .v {
font-size: 5.4rem;
font-weight: 900;
line-height: 1.05;
margin-top: .4rem;
font-variant-numeric: tabular-nums;
}
.kt .v small {
font-size: 2rem;
color: var(--muted);
margin-left: .4rem;
}
.kt .cmp {
font-size: 1.3rem;
font-weight: 700;
color: var(--muted);
margin-top: .6rem;
}
.kt .cmp b {
display: block;
font-size: 2.2rem;
margin-top: .3rem;
}
.kt.good {
border-color: rgba(34, 197, 94, .6);
}
.kt.warn {
border-color: rgba(245, 158, 11, .7);
}
.kt.bad {
border-color: rgba(239, 68, 68, .7);
}
.kt.good .v {
color: var(--runink);
}
.kt.warn .v {
color: var(--pauseink);
}
.kt.bad .v {
color: var(--badink);
}
.wk {
flex: 1;
margin-top: 1.4rem;
padding: 1.4rem 2rem;
display: grid;
grid-template-columns: auto repeat(3, 1fr);
gap: 2rem;
align-items: center;
}
.wk .t {
font-size: 1.8rem;
font-weight: 900;
}
.wk .v {
font-size: 3.8rem;
font-weight: 900;
}
.wk .v small {
font-size: 1.3rem;
color: var(--muted);
}
.wk .cmp {
font-size: 1.4rem;
color: var(--muted);
font-weight: 700;
}
/* grafico */
.chartbox {
flex: 1;
min-height: 0;
padding: 1.4rem 1.6rem .6rem;
}
/* fermi */
.stops {
flex: 1;
display: grid;
grid-template-columns: 26rem 1fr;
gap: 1.4rem;
min-height: 0;
}
.stops .tot {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 2rem;
}
.stops .tot b {
font-size: 8rem;
font-weight: 900;
line-height: 1;
color: var(--pauseink);
}
.stops .tot span {
font-size: 1.6rem;
font-weight: 800;
color: var(--label);
margin-top: .6rem;
}
.sbars {
padding: 1.8rem 2.2rem;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.3rem;
}
.sb {
display: grid;
grid-template-columns: 18rem 1fr 7rem;
gap: 1.2rem;
align-items: center;
font-size: 1.5rem;
font-weight: 800;
}
.sb .n {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sb .t {
height: 2.2rem;
background: #1b2846;
border-radius: .8rem;
overflow: hidden;
}
.sb .t span {
display: block;
height: 100%;
background: linear-gradient(90deg, #f59e0b, #fbbf24);
border-radius: .8rem;
}
.sb.problem .t span {
background: linear-gradient(90deg, #dc2626, #f87171);
}
.sb .h {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* coda */
.queue {
flex: 1;
display: grid;
gap: 1.4rem;
}
.qq {
padding: 1.8rem;
border-top: .6rem solid var(--c);
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.qq .name {
font-size: 2.2rem;
font-weight: 900;
}
.qq .n {
font-size: 7rem;
font-weight: 900;
line-height: 1;
margin-top: 1rem;
color: var(--accent);
}
.qq .l {
font-size: 1.5rem;
font-weight: 800;
color: var(--label);
}
.qq .h {
font-size: 2.4rem;
font-weight: 900;
margin-top: 1.2rem;
}
.qq .next {
font-size: 1.3rem;
color: var(--muted);
font-weight: 700;
margin-top: .6rem;
}
/* sicurezza */
.safe {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: radial-gradient(800px 400px at 50% 40%, rgba(34, 197, 94, .18), transparent 70%);
}
.safe .d {
font-size: 16rem;
font-weight: 900;
line-height: .95;
color: var(--runink);
text-shadow: 0 0 4rem rgba(74, 222, 128, .45);
font-variant-numeric: tabular-nums;
}
.safe .t {
font-size: 3rem;
font-weight: 900;
margin-top: 1rem;
}
.safe .s {
font-size: 1.6rem;
color: var(--label);
font-weight: 700;
margin-top: .8rem;
}
.safe .note {
font-size: 1.8rem;
margin-top: 1.6rem;
max-width: 70rem;
font-weight: 600;
}
.safe .shield {
font-size: 5rem;
color: var(--runink);
margin-bottom: 2rem;
line-height: 1;
}
/* obiettivi */
.goals {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.4rem;
}
.gl {
padding: 1.4rem 2rem;
}
.gl .r {
display: flex;
align-items: baseline;
gap: 1rem;
}
.gl .t {
font-size: 2rem;
font-weight: 900;
}
.gl .p {
color: var(--muted);
font-size: 1.1rem;
font-weight: 700;
}
.gl .v {
margin-left: auto;
font-size: 2.2rem;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.gl .v small {
color: var(--muted);
font-size: 1.3rem;
}
.gl .bar {
height: 1.6rem;
}
.gl .bar span {
background: linear-gradient(90deg, #2563eb, #38bdf8);
}
.gl.warn .bar span {
background: linear-gradient(90deg, #d97706, #fbbf24);
}
.gl.ok .bar span {
background: linear-gradient(90deg, #16a34a, #4ade80);
}
.gl.ok .v {
color: var(--runink);
}
/* comunicazioni */
.msg {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
min-height: 0;
}
.msg.img {
grid-template-columns: 1.1fr 1fr;
}
.msg .txt {
padding: 3rem 3.4rem;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 0;
}
.msg .tag {
align-self: flex-start;
background: rgba(125, 211, 252, .15);
color: var(--accent);
border-radius: 2rem;
padding: .4rem 1.2rem;
font-weight: 900;
font-size: 1.1rem;
letter-spacing: .08em;
text-transform: uppercase;
}
.msg h2 {
font-size: 4rem;
font-weight: 900;
line-height: 1.1;
margin: 1.2rem 0 1.4rem;
}
.msg .body {
font-size: 2rem;
line-height: 1.45;
white-space: pre-wrap;
color: #e2e8f5;
overflow: hidden;
}
.msg .pic {
border-radius: 1.6rem;
overflow: hidden;
background: #000;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.msg .pic img {
width: 100%;
height: 100%;
object-fit: cover;
}
.msg.center .txt {
text-align: center;
align-items: center;
}
.msg.center .tag {
align-self: center;
}
/* benvenuti */
.hello {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4rem;
background: radial-gradient(800px 400px at 50% 50%, rgba(56, 189, 248, .14), transparent 70%);
}
.hello .ph {
width: 26rem;
height: 26rem;
border-radius: 50%;
overflow: hidden;
border: .6rem solid rgba(125, 211, 252, .5);
box-shadow: 0 0 4rem rgba(56, 189, 248, .35);
flex-shrink: 0;
}
.hello .ph img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hello .ic {
font-size: 10rem;
color: #fcd34d;
}
.hello h2 {
font-size: 4.6rem;
font-weight: 900;
margin: .6rem 0 1rem;
line-height: 1.05;
}
.hello .body {
font-size: 2.1rem;
line-height: 1.45;
white-space: pre-wrap;
max-width: 60rem;
color: #e2e8f5;
}
/* turni / menu */
.list {
flex: 1;
padding: 2.4rem 3rem;
display: flex;
flex-direction: column;
min-height: 0;
}
.list h2 {
font-size: 3rem;
font-weight: 900;
margin: 0 0 1.6rem;
}
.list .rows {
display: grid;
grid-template-columns: repeat(var(--cols, 1), 1fr);
gap: .8rem 3rem;
align-content: start;
}
.list .row {
font-size: 2rem;
font-weight: 700;
padding: .8rem 1.2rem;
border-radius: 1rem;
background: var(--panel2);
border-left: .4rem solid var(--accent);
}
.list .row b {
color: var(--accent);
margin-right: .8rem;
}
.empty {
margin: auto;
color: var(--muted);
font-size: 2rem;
font-weight: 700;
}
</style>
</head>
<body>
<div class="k">
<header>
<div class="brand">
<div class="logo" id="logo">Z</div>
<div><b id="company"></b><small>Bacheca di reparto</small></div>
</div>
<div class="stitle" id="stitle"></div>
<div class="right">
<div class="wx" id="wx"></div>
<div class="clock"><b id="clock">--:--</b><small id="date"></small></div>
</div>
</header>
<div class="prog"><span id="prog"></span></div>
<main id="stage"></main>
<div class="dots" id="dots"></div>
<footer>
<div class="tk-label"><i class="bi bi-megaphone-fill"></i> AVVISI</div>
<div class="tk-track">
<div class="tk-move" id="ticker"></div>
</div>
</footer>
</div>
<div class="offline" id="offline"><i class="bi bi-wifi-off"></i> dati non aggiornati</div>
<script>
(function() {
const K = <?= json_encode($k) ?>;
const SOLO = <?= json_encode((string)($_GET['solo'] ?? '')) ?>;
const H = 3600e3;
let D = null,
skew = 0,
lastOk = 0;
let seq = [],
pos = -1,
timer = null,
chart = null;
const $ = s => document.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [c]));
const now = () => Date.now() - skew;
const num = (v, d = 0) => (+v || 0).toLocaleString('it-IT', {
minimumFractionDigits: d,
maximumFractionDigits: d
});
const hms = ms => {
const s = Math.max(0, Math.floor(ms / 1000));
return `${String(Math.floor(s / 3600)).padStart(2, '0')}:${String(Math.floor(s % 3600 / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};
/* confronto con il periodo precedente: ▲ ▼ e percentuale */
function cmp(cur, prev, lowerBetter) {
if (cur === null || prev === null || prev === undefined) return '<span class="flat">—</span>';
if (!prev && !cur) return '<span class="flat">= uguale</span>';
if (!prev) return `<span class="${lowerBetter ? 'down' : 'up'}">▲ nuovo</span>`;
const p = Math.round((cur - prev) / prev * 100);
if (p === 0) return '<span class="flat">= uguale</span>';
const good = lowerBetter ? p < 0 : p > 0;
return `<span class="${good ? 'up' : 'down'}">${p > 0 ? '▲' : '▼'} ${Math.abs(p)}%</span>`;
}
/* ---------- dati ---------- */
function load() {
return fetch('?k=' + encodeURIComponent(K) + '&a=data', {
cache: 'no-store'
}).then(r => r.json()).then(r => {
if (!r.success) throw new Error(r.message || 'errore');
skew = Date.now() - r.now_ms;
D = r;
lastOk = Date.now();
$('#company').textContent = r.company || '';
$('#logo').textContent = (r.company || 'Z').trim().charAt(0).toUpperCase();
ticker();
weatherInit();
}).catch(() => {}).finally(() => {
$('#offline').style.display = lastOk && Date.now() - lastOk > 180000 ? 'block' : (lastOk ? 'none' : 'block');
});
}
/* ---------- sequenza delle slide ---------- */
function build() {
const S = [];
const on = SOLO ? [SOLO] : D.settings.slides;
const has = k => on.includes(k);
if (has('linee') && D.lines.length) S.push({
k: 'linee',
t: 'Linee adesso',
i: 'bi-gear-wide-connected',
f: slideLines
});
if (has('oggi')) S.push({
k: 'oggi',
t: 'Produzione di oggi',
i: 'bi-speedometer2',
f: slideToday
});
if (has('andamento') && D.trend.series.some(s => s.data.some(v => v > 0))) S.push({
k: 'andamento',
t: 'Metri prodotti · ultimi 14 giorni',
i: 'bi-bar-chart-fill',
f: slideTrend
});
if (has('fermi') && D.stops.total_h > 0) S.push({
k: 'fermi',
t: 'Fermi della settimana',
i: 'bi-pause-circle-fill',
f: slideStops
});
if (has('coda') && D.lines.length) S.push({
k: 'coda',
t: 'Lavoro in arrivo',
i: 'bi-list-ol',
f: slideQueue
});
if (has('sicurezza') && D.safety) S.push({
k: 'sicurezza',
t: 'Sicurezza',
i: 'bi-shield-check',
f: slideSafety
});
if (has('obiettivi') && D.goals.length) S.push({
k: 'obiettivi',
t: 'I nostri obiettivi',
i: 'bi-bullseye',
f: slideGoals
});
if (has('comunicazioni')) D.items.comunicazione.forEach(it => S.push({
k: 'comunicazioni',
t: 'Comunicazioni',
i: 'bi-megaphone-fill',
f: () => slideMsg(it, 'Comunicazione')
}));
if (has('benvenuti')) D.items.benvenuto.forEach(it => S.push({
k: 'benvenuti',
t: 'Benvenuti e grazie',
i: 'bi-stars',
f: () => slideHello(it)
}));
if (has('turni')) D.items.turni.forEach(it => S.push({
k: 'turni',
t: 'Turni e calendario',
i: 'bi-calendar-week',
f: () => slideList(it)
}));
if (has('menu')) D.items.menu.forEach(it => S.push({
k: 'menu',
t: 'Menu',
i: 'bi-cup-hot-fill',
f: () => slideList(it)
}));
return S;
}
function next() {
if (!D) return setTimeout(next, 2000);
pos++;
if (pos >= seq.length) {
seq = build();
pos = 0;
}
if (!seq.length) {
$('#stage').innerHTML = '<div class="slide on"><div class="empty">Nessun contenuto da mostrare</div></div>';
return setTimeout(next, 10000);
}
show(seq[pos]);
const sec = D.settings.slide_seconds * (seq[pos].k === 'comunicazioni' || seq[pos].k === 'turni' || seq[pos].k === 'menu' ? 1.3 : 1);
const p = $('#prog');
p.style.transition = 'none';
p.style.width = '0';
requestAnimationFrame(() => requestAnimationFrame(() => {
p.style.transition = `width ${sec}s linear`;
p.style.width = '100%';
}));
clearTimeout(timer);
timer = setTimeout(next, sec * 1000);
}
function show(s) {
$('#stitle').innerHTML = `<i class="bi ${s.i}"></i>${esc(s.t)}`;
const old = $('#stage .slide');
const el = document.createElement('div');
el.className = 'slide';
el.innerHTML = s.f();
$('#stage').appendChild(el);
if (chart) {
try {
chart.destroy();
} catch (e) {}
chart = null;
}
if (s.k === 'andamento') drawChart(el.querySelector('#chart'));
requestAnimationFrame(() => el.classList.add('on'));
if (old) {
old.classList.remove('on');
setTimeout(() => old.remove(), 800);
}
$('#dots').innerHTML = seq.map((x, i) => `<span class="${i === pos ? 'on' : ''}"></span>`).join('');
tick();
}
/* ---------- slide ---------- */
function slideLines() {
const n = D.lines.length;
const cols = n <= 4 ? n : Math.ceil(n / 2);
return `<div class="lines" style="grid-template-columns:repeat(${cols},1fr)">${D.lines.map(l => {
const j = l.job;
const st = j ? (j.paused ? 'pause' : 'run') : 'free';
return `<div class="panel ln" style="--c:${esc(l.color)}">
<div class="h"><span class="name">${esc(l.name)}</span><span class="pill ${st}">${j ? (j.paused ? 'IN PAUSA' : 'IN PRODUZIONE') : 'FERMA'}</span></div>
${j ? `<div class="mat">${esc(j.matrice || '')}</div><div class="mes">${esc(j.mescole || '')}</div>
<div class="tm ${st}" data-line="${l.id}">--:--:--</div>
${j.ore ? `<div class="bar"><span data-bar="${l.id}" style="width:0"></span></div><div class="eta" data-eta="${l.id}"></div>` : ''}`
: `<div class="mat" style="color:var(--muted);text-shadow:none">—</div><div class="mes">${l.next ? 'Prossimo: ' + esc(l.next) : 'Nessun lavoro in coda'}</div><div class="tm free">pronta</div>`}
<div class="q">${l.queue_n} in coda · ${num(l.queue_h, 0)} ore programmate</div>
</div>`;
}).join('')}</div>`;
}
function slideToday() {
const c = D.today.cur,
p = D.today.prev,
w = D.week.cur,
wp = D.week.prev;
const tgt = D.settings.scarto_target;
const sc = c.scarto;
const scCls = sc === null ? '' : (sc <= tgt ? 'good' : (sc <= tgt * 1.5 ? 'warn' : 'bad'));
return `<div class="kgrid">
<div class="panel kt"><div class="lbl">Metri prodotti oggi</div><div class="v">${num(c.mt)}<small>m</small></div><div class="cmp">ieri a quest'ora ${num(p.mt)} m<b>${cmp(c.mt, p.mt)}</b></div></div>
<div class="panel kt"><div class="lbl">Kg prodotti oggi</div><div class="v">${num(c.kg)}<small>kg</small></div><div class="cmp">ieri a quest'ora ${num(p.kg)} kg<b>${cmp(c.kg, p.kg)}</b></div></div>
<div class="panel kt"><div class="lbl">Lavori completati oggi</div><div class="v">${c.n}</div><div class="cmp">ieri a quest'ora ${p.n}<b>${cmp(c.n, p.n)}</b></div></div>
<div class="panel kt ${scCls}"><div class="lbl">Scarto di oggi</div><div class="v">${sc === null ? '—' : num(sc, 1) + '<small>%</small>'}</div><div class="cmp">obiettivo ≤ ${num(tgt, 1)}%<b>${sc === null ? '<span class="flat">—</span>' : (sc <= tgt ? '<span class="up">✓ sotto obiettivo</span>' : '<span class="down">sopra obiettivo</span>')}</b></div></div>
</div>
<div class="panel wk">
<div class="t"><i class="bi bi-calendar3-week" style="color:var(--accent)"></i> Questa settimana</div>
<div><div class="lbl">Metri</div><div class="v">${num(w.mt)} <small>m</small></div><div class="cmp">vs settimana scorsa <b>${cmp(w.mt, wp.mt)}</b></div></div>
<div><div class="lbl">Kg</div><div class="v">${num(w.kg)} <small>kg</small></div><div class="cmp">vs settimana scorsa <b>${cmp(w.kg, wp.kg)}</b></div></div>
<div><div class="lbl">Lavori · scarto</div><div class="v">${w.n} <small>· ${w.scarto === null ? '—' : num(w.scarto, 1) + '%'}</small></div><div class="cmp">vs settimana scorsa <b>${cmp(w.n, wp.n)}</b></div></div>
</div>`;
}
function slideTrend() {
return '<div class="panel chartbox"><div id="chart" style="height:100%"></div></div>';
}
function drawChart(el) {
if (!window.ApexCharts || !el) return;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
chart = new ApexCharts(el, {
chart: {
type: 'bar',
stacked: true,
height: '100%',
toolbar: {
show: false
},
animations: {
enabled: true,
speed: 900
},
foreColor: '#c6d2ea',
fontFamily: 'Segoe UI, system-ui, sans-serif',
background: 'transparent'
},
theme: {
mode: 'dark'
},
series: D.trend.series.map(s => ({
name: s.name,
data: s.data
})),
colors: D.trend.series.map(s => s.color),
xaxis: {
categories: D.trend.labels,
labels: {
style: {
fontSize: (1.05 * rem) + 'px',
fontWeight: 700
}
},
axisBorder: {
show: false
},
axisTicks: {
show: false
}
},
yaxis: {
labels: {
style: {
fontSize: (1.05 * rem) + 'px'
},
formatter: v => num(v)
}
},
plotOptions: {
bar: {
columnWidth: '62%',
borderRadius: 6,
dataLabels: {
total: {
enabled: true,
style: {
fontSize: (1.1 * rem) + 'px',
fontWeight: 900,
color: '#f1f5ff'
},
formatter: v => v ? num(v) : ''
}
}
}
},
dataLabels: {
enabled: false
},
grid: {
borderColor: '#24345a',
strokeDashArray: 4,
padding: {
right: 3 * rem
}
},
legend: {
position: 'top',
fontSize: (1.3 * rem) + 'px',
fontWeight: 800,
markers: {
width: rem,
height: rem,
radius: 4
},
itemMargin: {
horizontal: rem
}
},
tooltip: {
enabled: false
}
});
chart.render();
}
function slideStops() {
const it = D.stops.items.slice(0, 7);
const max = Math.max(...it.map(x => x.h), 0.1);
return `<div class="stops">
<div class="panel tot"><i class="bi bi-hourglass-split" style="font-size:4rem;color:var(--pauseink)"></i><b>${num(D.stops.total_h, D.stops.total_h < 10 ? 1 : 0)}</b><span>ore di fermo<br>da lunedì</span></div>
<div class="panel sbars">${it.map(x => `<div class="sb ${x.problem ? 'problem' : ''}"><span class="n">${esc(x.name)}</span><span class="t"><span style="width:${Math.max(2, x.h / max * 100)}%"></span></span><span class="h">${num(x.h, 1)} h</span></div>`).join('')}
<div class="lbl" style="margin-top:.6rem;font-size:1rem"><span style="color:#f87171">■</span> guasti e problemi tecnici &nbsp; <span style="color:#fbbf24">■</span> altre pause</div>
</div>
</div>`;
}
function slideQueue() {
const n = D.lines.length;
return `<div class="queue" style="grid-template-columns:repeat(${n <= 4 ? n : Math.ceil(n / 2)},1fr)">${D.lines.map(l => `
<div class="panel qq" style="--c:${esc(l.color)}">
<div class="name">${esc(l.name)}</div>
<div class="n">${l.queue_n}</div><div class="l">lavori in coda</div>
<div class="h">${num(l.queue_h, 0)} ore</div><div class="l">di lavoro programmato</div>
<div class="next">${l.next ? 'prossimo: ' + esc(l.next) : '&nbsp;'}</div>
</div>`).join('')}</div>`;
}
function slideSafety() {
const s = D.safety;
return `<div class="safe">
<i class="bi bi-shield-fill-check shield"></i>
<div class="d">${s.days}</div>
<div class="t">giorni senza infortuni</div>
<div class="s">dal ${esc(s.since)}${s.record ? ` · record: <b>${s.record}</b> giorni` : ''}</div>
${s.note ? `<div class="note">${esc(s.note)}</div>` : ''}
</div>`;
}
function slideGoals() {
return `<div class="goals">${D.goals.slice(0, 5).map(g => {
const unit = g.unit ? ' ' + esc(g.unit) : '';
const dec = g.unit === '%' || g.unit === 'h' ? 1 : 0;
return `<div class="panel gl ${g.ok ? 'ok' : (g.lower ? 'warn' : '')}">
<div class="r"><span class="t">${g.ok ? '<i class="bi bi-check-circle-fill" style="color:var(--runink)"></i> ' : ''}${esc(g.title)}</span><span class="p">${esc(g.period)}</span>
<span class="v">${g.current === null ? '—' : num(g.current, dec)}${unit} <small>/ ${g.lower ? '≤ ' : ''}${num(g.target, dec)}${unit}</small></span></div>
<div class="bar"><span style="width:${g.pct || 0}%"></span></div>
</div>`;
}).join('')}</div>`;
}
function slideMsg(it, tag) {
const img = it.image ? `<div class="pic"><img src="photos/kiosk/${esc(it.image)}" alt=""></div>` : '';
return `<div class="msg ${img ? 'img' : 'center'}">
<div class="panel txt"><span class="tag">${esc(tag)}</span><h2>${esc(it.title)}</h2><div class="body">${esc(it.body)}</div></div>${img}
</div>`;
}
function slideHello(it) {
return `<div class="hello">
${it.image ? `<div class="ph"><img src="photos/kiosk/${esc(it.image)}" alt=""></div>` : '<i class="bi bi-stars ic"></i>'}
<div><div class="lbl" style="color:var(--accent)">Benvenuti e grazie</div><h2>${esc(it.title)}</h2><div class="body">${esc(it.body)}</div></div>
</div>`;
}
// turni e menu: una riga per ogni riga di testo; "Lunedì: Mario" mette in evidenza la parte prima dei due punti
function slideList(it) {
const rows = String(it.body || '').split(/\r?\n/).map(r => r.trim()).filter(Boolean);
const cols = rows.length > 7 ? 2 : 1;
return `<div class="panel list"><h2>${esc(it.title)}</h2>
<div class="rows" style="--cols:${cols}">${rows.map(r => {
const i = r.indexOf(':');
return `<div class="row">${i > 0 && i < 30 ? `<b>${esc(r.slice(0, i))}</b>${esc(r.slice(i + 1))}` : esc(r)}</div>`;
}).join('')}</div>
${it.image ? `<img src="photos/kiosk/${esc(it.image)}" alt="" style="margin-top:1.4rem;max-height:30vh;border-radius:1.2rem;align-self:flex-start">` : ''}
</div>`;
}
/* ---------- avvisi scorrevoli ---------- */
function ticker() {
let parts = D.items.ticker.map(t => `<b>${esc(t.title)}</b>${t.body ? ' — ' + esc(t.body) : ''}`);
if (!parts.length) {
// senza avvisi del manager: messaggi automatici
if (D.safety) parts.push(`<b>${D.safety.days}</b> giorni senza infortuni`);
if (D.today.cur.mt) parts.push(`Oggi prodotti <b>${num(D.today.cur.mt)}</b> metri`);
parts.push('Buon lavoro a tutti!');
}
const html = parts.join('<span class="sep">●</span>');
const el = $('#ticker');
if (el.dataset.html === html) return;
el.dataset.html = html;
el.innerHTML = html;
const len = el.textContent.length;
el.style.animationDuration = Math.max(20, len * 0.22) + 's';
}
/* ---------- orologio, timer delle linee ---------- */
const GIORNI = ['domenica', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato'];
const MESI = ['gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'];
function tick() {
const d = new Date(now());
$('#clock').textContent = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
$('#date').textContent = `${GIORNI[d.getDay()]} ${d.getDate()} ${MESI[d.getMonth()]}`;
if (!D) return;
document.querySelectorAll('[data-line]').forEach(el => {
const l = D.lines.find(x => x.id === +el.dataset.line);
const j = l && l.job;
if (!j || !j.start_ms) return;
const t = now();
const cur = j.open_pause_ms ? t - j.open_pause_ms : 0;
const net = Math.max(0, t - j.start_ms - j.pause_sec * 1000 - cur);
el.textContent = hms(j.paused ? cur : net);
const bar = document.querySelector(`[data-bar="${l.id}"]`);
if (bar && j.ore) {
const p = net / (j.ore * H);
bar.style.width = Math.min(100, p * 100) + '%';
bar.style.background = p > 1 ? 'var(--bad)' : (j.paused ? 'var(--pause)' : 'var(--run)');
const eta = document.querySelector(`[data-eta="${l.id}"]`);
if (eta) {
const end = new Date(t + Math.max(0, j.ore * H - net));
eta.innerHTML = p >= 1 ? `<span class="down">oltre le ${num(j.ore, 1)} ore previste</span>` :
`${Math.round(p * 100)}% di ${num(j.ore, 1)} h · fine prevista ~ <b style="color:var(--ink)">${new Date(t).toDateString() === end.toDateString() ? '' : GIORNI[end.getDay()].slice(0, 3) + ' '}${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}</b>`;
}
}
});
}
setInterval(tick, 1000);
/* ---------- meteo (open-meteo, senza chiave) ---------- */
let wxDone = false;
function weatherInit() {
if (wxDone || !D.settings.lat || !D.settings.lon) return;
wxDone = true;
weather();
setInterval(weather, 30 * 60000);
}
function weather() {
const s = D.settings;
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${encodeURIComponent(s.lat)}&longitude=${encodeURIComponent(s.lon)}&current=temperature_2m,weather_code&timezone=auto`)
.then(r => r.json()).then(r => {
const c = r.current;
if (!c) return;
const w = +c.weather_code;
const ic = w === 0 ? 'bi-sun-fill' : w <= 2 ? 'bi-cloud-sun-fill' : w === 3 ? 'bi-cloud-fill' : w <= 48 ? 'bi-cloud-fog2-fill' : w <= 67 ? 'bi-cloud-rain-fill' : w <= 77 ? 'bi-snow' : w <= 82 ? 'bi-cloud-rain-heavy-fill' : 'bi-cloud-lightning-rain-fill';
$('#wx').innerHTML = `<i class="bi ${ic}"></i><div>${Math.round(c.temperature_2m)}°<small>${esc(s.city || '')}</small></div>`;
}).catch(() => {});
}
/* ---------- avvio ---------- */
tick();
load().then(() => next());
setInterval(load, 60000);
// ricarica completa ogni 6 ore (memoria e nuove versioni della pagina)
setTimeout(() => location.reload(), 6 * H);
})();
</script>
</body>
</html>