Files
zibo-dashboard/public/userarea/kiosk_admin.php
T
2026-10-21 12:24:12 +02:00

1104 lines
50 KiB
PHP

<?php
/**
* kiosk_admin.php - gestione dello schermo di reparto (kiosk.php).
* Comunicazioni, benvenuti, turni, menu, avvisi scorrevoli, obiettivi, sicurezza e impostazioni.
*/
ob_start();
include('include/headscript.php');
require_once __DIR__ . '/class/Kiosk.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
/** Permesso richiesto: cambialo se crei un permesso dedicato (es. 'kiosk.manage'). */
const KIOSK_PERM = 'production.programming.view';
const KIOSK_IMG_DIR = __DIR__ . '/photos/kiosk/';
function kOut(array $a)
{
while (ob_get_level()) ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode($a);
exit;
}
if (!userCan(KIOSK_PERM)) {
if (isset($_GET['action']) || isset($_POST['action'])) kOut(['success' => false, 'message' => 'Non hai i permessi per questa pagina']);
while (ob_get_level()) ob_end_clean();
http_response_code(403);
echo '<div style="font-family:sans-serif;padding:60px;text-align:center;"><h3>Accesso non consentito</h3><a href="production_dashboard.php">Torna alla Dashboard</a></div>';
exit;
}
$userName = '';
if (!empty($iduserlogin)) {
try {
$st = $pdo->prepare("SELECT COALESCE(NULLIF(TRIM(CONCAT_WS(' ', first_name, last_name)), ''), username, email) FROM auth_users WHERE id = ?");
$st->execute([(int)$iduserlogin]);
$userName = (string)$st->fetchColumn();
} catch (Throwable $e) {
}
}
function kDate($v)
{
$v = trim((string)$v);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
/** Salva l'immagine caricata (ridotta a 1920 px se serve) e restituisce il nome del file. */
function kSaveImage(array $f)
{
if (($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return null;
if ($f['error'] !== UPLOAD_ERR_OK) throw new RuntimeException('Immagine non caricata (errore ' . $f['error'] . ')');
if ($f['size'] > 15 * 1024 * 1024) throw new RuntimeException('Immagine troppo grande (massimo 15 MB)');
$info = @getimagesize($f['tmp_name']);
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'][$info['mime'] ?? ''] ?? null;
if (!$ext) throw new RuntimeException('Formato non supportato: usa JPG, PNG o WEBP');
if (!is_dir(KIOSK_IMG_DIR) && !mkdir(KIOSK_IMG_DIR, 0775, true)) throw new RuntimeException('Impossibile creare la cartella photos/kiosk');
$name = date('Ymd_His') . '_' . bin2hex(random_bytes(4));
// riduco le foto grandi (dal telefono) a 1920 px: lo schermo non ne ha bisogno di più
if (function_exists('imagecreatefromstring') && max($info[0], $info[1]) > 1920 && $ext !== 'gif') {
$src = @imagecreatefromstring(file_get_contents($f['tmp_name']));
if ($src) {
$r = 1920 / max($info[0], $info[1]);
$w = (int)round($info[0] * $r);
$h = (int)round($info[1] * $r);
$dst = imagecreatetruecolor($w, $h);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $w, $h, $info[0], $info[1]);
imagejpeg($dst, KIOSK_IMG_DIR . $name . '.jpg', 82);
imagedestroy($src);
imagedestroy($dst);
return $name . '.jpg';
}
}
if (!move_uploaded_file($f['tmp_name'], KIOSK_IMG_DIR . $name . '.' . $ext)) throw new RuntimeException('Impossibile salvare l\'immagine');
return $name . '.' . $ext;
}
function kDeleteImage($name)
{
$name = basename((string)$name);
if ($name !== '' && is_file(KIOSK_IMG_DIR . $name)) @unlink(KIOSK_IMG_DIR . $name);
}
function kioskUrl(PDO $pdo)
{
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
$dir = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/');
return ($https ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . $dir . '/kiosk.php?k=' . Kiosk::token($pdo);
}
/* ===================== AJAX ===================== */
$action = $_GET['action'] ?? $_POST['action'] ?? '';
if ($action !== '') {
try {
if ($action === 'load') {
$s = Kiosk::settings($pdo);
unset($s['token']);
$goals = Kiosk::goals($pdo, false);
kOut([
'success' => true,
'url' => kioskUrl($pdo),
'settings' => $s,
'safety' => Kiosk::safety(Kiosk::settings($pdo)),
'items' => Kiosk::items($pdo, false),
'goals' => $goals,
'slides' => Kiosk::SLIDES,
'types' => Kiosk::ITEM_TYPES,
'sources' => Kiosk::GOAL_SOURCES,
'today' => Kiosk::day()->format('Y-m-d'),
]);
}
if ($action === 'save_settings') {
$slides = array_values(array_intersect(array_keys(Kiosk::SLIDES), (array)($_POST['slides'] ?? [])));
$vals = [
'company' => mb_substr(trim((string)($_POST['company'] ?? '')), 0, 60),
'slide_seconds' => (string)max(6, min(120, (int)($_POST['slide_seconds'] ?? 15))),
'slides' => implode(',', $slides),
'slide_durations' => json_encode((object)array_filter(array_map('intval', array_intersect_key((array)($_POST['dur'] ?? []), Kiosk::SLIDES)), fn($v) => $v > 0)),
'scarto_target' => (string)max(0, (float)str_replace(',', '.', (string)($_POST['scarto_target'] ?? 3))),
'city' => mb_substr(trim((string)($_POST['city'] ?? '')), 0, 60),
'lat' => is_numeric($_POST['lat'] ?? '') ? (string)(float)$_POST['lat'] : '',
'lon' => is_numeric($_POST['lon'] ?? '') ? (string)(float)$_POST['lon'] : '',
'last_incident' => (string)kDate($_POST['last_incident'] ?? ''),
'record_days' => ($_POST['record_days'] ?? '') === '' ? '' : (string)max(0, (int)$_POST['record_days']),
'safety_note' => mb_substr(trim((string)($_POST['safety_note'] ?? '')), 0, 250),
];
foreach ($vals as $k => $v) Kiosk::set($pdo, $k, $v);
kOut(['success' => true]);
}
if ($action === 'new_token') {
Kiosk::token($pdo, true);
kOut(['success' => true, 'url' => kioskUrl($pdo)]);
}
if ($action === 'item_save') {
$id = (int)($_POST['id'] ?? 0);
$type = (string)($_POST['item_type'] ?? '');
if (!isset(Kiosk::ITEM_TYPES[$type])) throw new RuntimeException('Scegli il tipo di contenuto');
$title = mb_substr(trim((string)($_POST['title'] ?? '')), 0, 150);
if ($title === '') throw new RuntimeException('Scrivi il titolo');
$from = kDate($_POST['valid_from'] ?? '');
$to = kDate($_POST['valid_to'] ?? '');
if ($from && $to && $to < $from) throw new RuntimeException('La data di fine è prima di quella di inizio');
$old = '';
if ($id) {
$st = $pdo->prepare("SELECT image FROM kiosk_items WHERE id = ?");
$st->execute([$id]);
$old = $st->fetchColumn();
if ($old === false) throw new RuntimeException('Contenuto non trovato');
}
$img = $old;
$new = kSaveImage($_FILES['image'] ?? ['error' => UPLOAD_ERR_NO_FILE]);
if ($new !== null || !empty($_POST['remove_image'])) {
kDeleteImage($old);
$img = $new ?? '';
}
$f = [
'item_type' => $type,
'title' => $title,
'body' => trim((string)($_POST['body'] ?? '')),
'image' => $img,
'valid_from' => $from,
'valid_to' => $to,
'sort' => (int)($_POST['sort'] ?? 0),
'active' => !empty($_POST['active']) ? 1 : 0,
];
if ($id) {
$f['id'] = $id;
$pdo->prepare("UPDATE kiosk_items SET item_type = :item_type, title = :title, body = :body, image = :image, valid_from = :valid_from,
valid_to = :valid_to, sort = :sort, active = :active, updated_at = NOW() WHERE id = :id")->execute($f);
} else {
$f['by'] = mb_substr($userName, 0, 100);
$pdo->prepare("INSERT INTO kiosk_items (item_type, title, body, image, valid_from, valid_to, sort, active, created_by, created_at, updated_at)
VALUES (:item_type, :title, :body, :image, :valid_from, :valid_to, :sort, :active, :by, NOW(), NOW())")->execute($f);
}
kOut(['success' => true]);
}
if ($action === 'item_toggle') {
$pdo->prepare("UPDATE kiosk_items SET active = 1 - active, updated_at = NOW() WHERE id = ?")->execute([(int)($_POST['id'] ?? 0)]);
kOut(['success' => true]);
}
if ($action === 'item_delete') {
$id = (int)($_POST['id'] ?? 0);
$st = $pdo->prepare("SELECT image FROM kiosk_items WHERE id = ?");
$st->execute([$id]);
kDeleteImage($st->fetchColumn());
$pdo->prepare("DELETE FROM kiosk_items WHERE id = ?")->execute([$id]);
kOut(['success' => true]);
}
if ($action === 'goal_save') {
$id = (int)($_POST['id'] ?? 0);
$src = (string)($_POST['source'] ?? 'manuale');
if (!isset(Kiosk::GOAL_SOURCES[$src])) $src = 'manuale';
$title = mb_substr(trim((string)($_POST['title'] ?? '')), 0, 150);
if ($title === '') throw new RuntimeException('Scrivi il nome dell\'obiettivo');
$target = (float)str_replace(',', '.', (string)($_POST['target'] ?? 0));
if ($target <= 0) throw new RuntimeException('Indica il valore da raggiungere');
$from = kDate($_POST['period_from'] ?? '');
$to = kDate($_POST['period_to'] ?? '');
if ($from && $to && $to < $from) throw new RuntimeException('La data di fine è prima di quella di inizio');
$unit = mb_substr(trim((string)($_POST['unit'] ?? '')), 0, 20);
if ($src !== 'manuale') $unit = Kiosk::GOAL_SOURCES[$src][1];
$cur = ($_POST['current_value'] ?? '') === '' ? null : (float)str_replace(',', '.', (string)$_POST['current_value']);
$f = [
'title' => $title,
'source' => $src,
'unit' => $unit,
'target' => $target,
'cur' => $src === 'manuale' ? $cur : null,
'lower' => $src === 'manuale' ? (!empty($_POST['lower_is_better']) ? 1 : 0) : (Kiosk::GOAL_SOURCES[$src][2] ? 1 : 0),
'pf' => $from,
'pt' => $to,
'sort' => (int)($_POST['sort'] ?? 0),
'active' => !empty($_POST['active']) ? 1 : 0,
];
if ($id) {
$f['id'] = $id;
$pdo->prepare("UPDATE kiosk_goals SET title = :title, source = :source, unit = :unit, target = :target, current_value = :cur,
lower_is_better = :lower, period_from = :pf, period_to = :pt, sort = :sort, active = :active, updated_at = NOW() WHERE id = :id")->execute($f);
} else {
$pdo->prepare("INSERT INTO kiosk_goals (title, source, unit, target, current_value, lower_is_better, period_from, period_to, sort, active, updated_at)
VALUES (:title, :source, :unit, :target, :cur, :lower, :pf, :pt, :sort, :active, NOW())")->execute($f);
}
kOut(['success' => true]);
}
if ($action === 'goal_delete') {
$pdo->prepare("DELETE FROM kiosk_goals WHERE id = ?")->execute([(int)($_POST['id'] ?? 0)]);
kOut(['success' => true]);
}
kOut(['success' => false, 'message' => 'Azione sconosciuta']);
} catch (Throwable $e) {
kOut(['success' => false, 'message' => $e->getMessage()]);
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title>Schermo di reparto - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
:root {
--ink: #1f2d3d;
--muted: #64748b;
--line: #e2e8f0;
}
body {
background: #f8fafc;
}
.k-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.k-title {
display: flex;
align-items: center;
gap: 14px;
}
.k-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #0f172a, #334155);
color: #7dd3fc;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.45rem;
box-shadow: 0 6px 16px rgba(15, 23, 42, .25);
}
.k-title h5 {
font-weight: 800;
margin: 0 0 2px;
}
.k-title small {
color: var(--muted);
}
.kc {
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 18px;
}
.kc h6 {
font-weight: 800;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.kc h6 .ms-auto {
font-weight: 600;
}
.preview {
position: relative;
width: 100%;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
background: #0a1120;
border: 4px solid #0f172a;
box-shadow: 0 10px 30px rgba(15, 23, 42, .25);
}
.preview iframe {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 1080px;
border: 0;
transform-origin: 0 0;
pointer-events: none;
}
.url {
font-family: monospace;
font-size: .85rem;
}
.tchips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.tchip {
border: 1.5px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
font-size: .85rem;
color: var(--ink);
}
.tchip.on {
background: #0f172a;
border-color: #0f172a;
color: #fff;
}
.tchip .n {
opacity: .6;
margin-left: 4px;
}
.it {
display: flex;
gap: 12px;
align-items: center;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 8px;
}
.it.off {
opacity: .5;
}
.it .th {
width: 64px;
height: 48px;
border-radius: 8px;
background: #f1f5f9;
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 1.3rem;
}
.it .th img {
width: 100%;
height: 100%;
object-fit: cover;
}
.it .tx {
flex: 1;
min-width: 0;
}
.it .tx b {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.it .tx small {
color: var(--muted);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ttag {
font-size: .72rem;
font-weight: 800;
border-radius: 6px;
padding: 2px 8px;
white-space: nowrap;
}
.ttag.comunicazione {
background: #e0f2fe;
color: #075985;
}
.ttag.benvenuto {
background: #fef3c7;
color: #92400e;
}
.ttag.turni {
background: #ede9fe;
color: #5b21b6;
}
.ttag.menu {
background: #dcfce7;
color: #166534;
}
.ttag.ticker {
background: #ffedd5;
color: #9a3412;
}
.state {
font-size: .75rem;
font-weight: 700;
}
.gl {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 14px;
margin-bottom: 8px;
}
.gl .r {
display: flex;
align-items: center;
gap: 10px;
}
.gl .bar {
height: 8px;
background: #e2e8f0;
border-radius: 99px;
overflow: hidden;
margin-top: 6px;
}
.gl .bar span {
display: block;
height: 100%;
background: #2563eb;
}
.gl.ok .bar span {
background: #16a34a;
}
.safe-n {
font-size: 3rem;
font-weight: 900;
color: #16a34a;
line-height: 1;
}
.sl-row {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
border-bottom: 1px dashed var(--line);
}
.sl-row .form-check {
flex: 1;
}
.sl-sec {
width: 92px;
flex-shrink: 0;
}
.slide-list .form-check {
padding: 6px 10px 6px 2.6em;
border-radius: 8px;
}
.slide-list .form-check:hover {
background: #f8fafc;
}
.k-toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 3000;
background: #0f172a;
color: #fff;
border-radius: 12px;
padding: 12px 16px;
font-weight: 600;
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
opacity: 0;
transform: translateY(10px);
transition: all .25s;
}
.k-toast.err {
background: #b91c1c;
}
.k-toast.show {
opacity: 1;
transform: none;
}
.type-pick .btn {
font-weight: 700;
}
#imgPrev {
max-height: 160px;
border-radius: 10px;
border: 1px solid var(--line);
}
.hint {
font-size: .82rem;
color: var(--muted);
margin-top: 4px;
}
</style>
</head>
<body>
<div class="wrapper toggled" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="k-head mb-3">
<div class="k-title">
<div class="k-icon"><i class="fa-solid fa-tv"></i></div>
<div>
<h5>Schermo di reparto</h5>
<small>Cosa vedono i dipendenti in sala pausa: contenuti, obiettivi e sicurezza</small>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary" id="btnCopy"><i class="fa-regular fa-copy me-1"></i> Copia link</button>
<a class="btn btn-dark" id="btnOpen" target="_blank" rel="noopener"><i class="fa-solid fa-up-right-from-square me-1"></i> Apri lo schermo</a>
</div>
</div>
<div class="row">
<div class="col-xl-8">
<div class="kc">
<h6><i class="fa-solid fa-layer-group text-primary"></i> Contenuti
<button class="btn btn-primary btn-sm ms-auto" id="btnNewItem"><i class="fa-solid fa-plus me-1"></i> Nuovo contenuto</button>
</h6>
<div class="tchips" id="typeChips"></div>
<div id="items"></div>
</div>
<div class="kc">
<h6><i class="fa-solid fa-bullseye text-danger"></i> Obiettivi
<button class="btn btn-outline-primary btn-sm ms-auto" id="btnNewGoal"><i class="fa-solid fa-plus me-1"></i> Nuovo obiettivo</button>
</h6>
<div id="goals"></div>
</div>
</div>
<div class="col-xl-4">
<div class="kc">
<h6><i class="fa-solid fa-display text-dark"></i> Anteprima dal vivo</h6>
<div class="preview" id="prevBox"><iframe id="prevFrame" title="Anteprima schermo" tabindex="-1"></iframe></div>
<div class="hint mt-2">Apri il link sul PC o sul TV della sala pausa e mettilo a schermo intero (F11). Nessun login: chi ha il link vede lo schermo.</div>
<div class="input-group input-group-sm mt-2">
<input class="form-control url" id="url" readonly>
<button class="btn btn-outline-danger" id="btnToken" title="Crea un nuovo link: quello vecchio smette di funzionare"><i class="fa-solid fa-rotate"></i></button>
</div>
</div>
<form class="kc" id="fSafety">
<h6><i class="fa-solid fa-shield-halved text-success"></i> Sicurezza</h6>
<div class="d-flex align-items-center gap-3 mb-2">
<div class="safe-n" id="safeN">—</div>
<div class="small text-muted" id="safeTxt">giorni senza infortuni</div>
</div>
<div class="row g-2">
<div class="col-7"><label class="form-label small fw-bold mb-1">Ultimo infortunio</label><input type="date" class="form-control" name="last_incident" id="sLast"></div>
<div class="col-5"><label class="form-label small fw-bold mb-1">Record (giorni)</label><input type="number" min="0" class="form-control" name="record_days" id="sRec"></div>
<div class="col-12"><label class="form-label small fw-bold mb-1">Messaggio</label><input type="text" maxlength="250" class="form-control" name="safety_note" id="sNote" placeholder="Es. Indossa sempre i DPI vicino agli estrusori"></div>
</div>
<button class="btn btn-success btn-sm mt-3"><i class="fa-solid fa-check me-1"></i> Salva</button>
</form>
<form class="kc" id="fSettings">
<h6><i class="fa-solid fa-sliders text-secondary"></i> Impostazioni</h6>
<div class="row g-2">
<div class="col-7"><label class="form-label small fw-bold mb-1">Nome azienda</label><input class="form-control" name="company" id="cCompany"></div>
<div class="col-5"><label class="form-label small fw-bold mb-1">Secondi predefiniti</label><input type="number" min="6" max="120" class="form-control" name="slide_seconds" id="cSec"></div>
<div class="col-7"><label class="form-label small fw-bold mb-1">Obiettivo scarto %</label><input type="number" step="0.1" min="0" class="form-control" name="scarto_target" id="cScarto"></div>
<div class="col-12"><label class="form-label small fw-bold mb-1">Città per il meteo</label>
<div class="input-group"><input class="form-control" name="city" id="cCity" placeholder="Es. Lecco"><button type="button" class="btn btn-outline-secondary" id="btnGeo">Trova</button></div>
<input type="hidden" name="lat" id="cLat"><input type="hidden" name="lon" id="cLon">
<div class="hint" id="geoTxt"></div>
</div>
</div>
<label class="form-label small fw-bold mt-3 mb-1">Slide da mostrare e durata</label>
<div class="hint mb-1">Secondi vuoti = durata predefinita. Le slide con più contenuti (es. 3 comunicazioni) usano la durata per ciascuno.</div>
<div class="slide-list" id="slideList"></div>
<button class="btn btn-primary btn-sm mt-2"><i class="fa-solid fa-check me-1"></i> Salva</button>
</form>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<!-- MODULO CONTENUTO -->
<div class="modal fade" id="mItem" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<form class="modal-content" id="fItem" enctype="multipart/form-data">
<div class="modal-header">
<h5 class="modal-title fw-bold" id="mItemTitle">Nuovo contenuto</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id" id="iId">
<label class="form-label fw-bold">Tipo</label>
<div class="d-flex flex-wrap gap-2 type-pick mb-1" id="typePick"></div>
<div class="hint mb-3" id="typeHint"></div>
<div class="mb-3"><label class="form-label fw-bold">Titolo</label><input class="form-control form-control-lg" name="title" id="iTitle" maxlength="150" required></div>
<div class="mb-3"><label class="form-label fw-bold">Testo</label><textarea class="form-control" name="body" id="iBody" rows="6"></textarea></div>
<div class="row g-3">
<div class="col-md-4"><label class="form-label fw-bold">Visibile dal</label><input type="date" class="form-control" name="valid_from" id="iFrom"></div>
<div class="col-md-4"><label class="form-label fw-bold">al</label><input type="date" class="form-control" name="valid_to" id="iTo">
<div class="hint">vuoto = sempre</div>
</div>
<div class="col-md-2"><label class="form-label fw-bold">Ordine</label><input type="number" class="form-control" name="sort" id="iSort" value="0"></div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" name="active" value="1" id="iActive" checked><label class="form-check-label fw-bold" for="iActive">Attivo</label></div>
</div>
<div class="col-12" id="wImg">
<label class="form-label fw-bold">Immagine <span class="text-muted fw-normal">(facoltativa)</span></label>
<input type="file" class="form-control" name="image" id="iImg" accept="image/jpeg,image/png,image/webp">
<div class="d-flex align-items-center gap-3 mt-2 d-none" id="wImgPrev">
<img id="imgPrev" alt="">
<div class="form-check"><input class="form-check-input" type="checkbox" name="remove_image" value="1" id="iRemImg"><label class="form-check-label" for="iRemImg">Togli l'immagine</label></div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger me-auto" id="btnDelItem"><i class="fa-solid fa-trash me-1"></i> Elimina</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
<button class="btn btn-primary" id="btnSaveItem"><i class="fa-solid fa-check me-1"></i> Salva</button>
</div>
</form>
</div>
</div>
<!-- MODULO OBIETTIVO -->
<div class="modal fade" id="mGoal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<form class="modal-content" id="fGoal">
<div class="modal-header">
<h5 class="modal-title fw-bold" id="mGoalTitle">Nuovo obiettivo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id" id="gId">
<div class="mb-3"><label class="form-label fw-bold">Nome</label><input class="form-control" name="title" id="gTitle" maxlength="150" placeholder="Es. Metri prodotti a ottobre" required></div>
<div class="mb-3"><label class="form-label fw-bold">Valore attuale</label><select class="form-select" name="source" id="gSource"></select>
<div class="hint">I valori automatici si calcolano da soli dai dati di produzione del periodo.</div>
</div>
<div class="row g-3">
<div class="col-6"><label class="form-label fw-bold">Obiettivo</label><input type="number" step="0.01" min="0" class="form-control" name="target" id="gTarget" required></div>
<div class="col-6 gman"><label class="form-label fw-bold">Unità</label><input class="form-control" name="unit" id="gUnit" maxlength="20" placeholder="es. pezzi, %"></div>
<div class="col-6 gman"><label class="form-label fw-bold">Valore attuale</label><input type="number" step="0.01" class="form-control" name="current_value" id="gCur"></div>
<div class="col-6 gman d-flex align-items-end">
<div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" name="lower_is_better" value="1" id="gLower"><label class="form-check-label" for="gLower">Meno è meglio</label></div>
</div>
<div class="col-6"><label class="form-label fw-bold">Dal</label><input type="date" class="form-control" name="period_from" id="gFrom"></div>
<div class="col-6"><label class="form-label fw-bold">Al</label><input type="date" class="form-control" name="period_to" id="gTo"></div>
<div class="col-12 hint" style="margin-top:2px">Date vuote = mese in corso.</div>
<div class="col-6"><label class="form-label fw-bold">Ordine</label><input type="number" class="form-control" name="sort" id="gSort" value="0"></div>
<div class="col-6 d-flex align-items-end">
<div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" name="active" value="1" id="gActive" checked><label class="form-check-label fw-bold" for="gActive">Attivo</label></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger me-auto" id="btnDelGoal"><i class="fa-solid fa-trash me-1"></i> Elimina</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
<button class="btn btn-primary"><i class="fa-solid fa-check me-1"></i> Salva</button>
</div>
</form>
</div>
</div>
<?php include('jsinclude.php'); ?>
<script>
$(function() {
let L = null;
let filter = '';
const mItem = new bootstrap.Modal(document.getElementById('mItem'));
const mGoal = new bootstrap.Modal(document.getElementById('mGoal'));
const HINTS = {
comunicazione: 'Una slide con titolo, testo e immagine facoltativa (es. formazione, nuovi macchinari, chiusure).',
benvenuto: 'Nuovo collega o ringraziamento: metti una foto (con il consenso della persona).',
turni: 'Una riga per voce, es. "Lunedì: Rossi, Bianchi". La parte prima dei due punti viene evidenziata.',
menu: 'Una riga per piatto, es. "Primo: Pasta al pomodoro".',
ticker: 'Frase breve che scorre in basso su tutte le slide. Il testo è facoltativo.'
};
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [c]));
const num = (v, d = 0) => (+v || 0).toLocaleString('it-IT', {
minimumFractionDigits: d,
maximumFractionDigits: d
});
const dIt = s => s ? s.slice(8, 10) + '/' + s.slice(5, 7) + '/' + s.slice(0, 4) : '';
function toast(msg, err) {
const el = $(`<div class="k-toast ${err ? 'err' : ''}">${esc(msg)}</div>`).appendTo('body');
setTimeout(() => el.addClass('show'), 10);
setTimeout(() => {
el.removeClass('show');
setTimeout(() => el.remove(), 300);
}, err ? 4500 : 2200);
}
function post(data, isForm) {
const o = isForm ? {
url: '?',
type: 'POST',
data,
processData: false,
contentType: false,
dataType: 'json'
} : {
url: '?',
type: 'POST',
data,
dataType: 'json'
};
return $.ajax(o).then(r => {
if (!r.success) {
toast(r.message || 'Errore', true);
return $.Deferred().reject().promise();
}
return r;
}, () => {
toast('Errore di rete', true);
return $.Deferred().reject().promise();
});
}
function refreshPreview() {
const f = document.getElementById('prevFrame');
f.src = L.url + '&_=' + Date.now();
}
function scalePreview() {
const w = document.getElementById('prevBox').clientWidth;
document.getElementById('prevFrame').style.transform = `scale(${w / 1920})`;
}
$(window).on('resize', scalePreview);
function load(preview) {
return $.getJSON('?action=load').then(r => {
if (!r.success) return toast(r.message || 'Errore', true);
const first = !L;
L = r;
$('#url').val(r.url);
$('#btnOpen').attr('href', r.url);
renderItems();
renderGoals();
if (first) fillSettings();
renderSafety();
if (first || preview) refreshPreview();
scalePreview();
});
}
/* ---------- contenuti ---------- */
function itemState(it) {
const t = L.today;
if (+it.active !== 1) return '<span class="state text-muted">disattivato</span>';
if (it.valid_from && it.valid_from > t) return `<span class="state text-primary">dal ${dIt(it.valid_from)}</span>`;
if (it.valid_to && it.valid_to < t) return '<span class="state text-danger">scaduto</span>';
return `<span class="state text-success"><i class="fa-solid fa-circle" style="font-size:.5rem"></i> in onda${it.valid_to ? ' fino al ' + dIt(it.valid_to) : ''}</span>`;
}
function renderItems() {
const cnt = {};
L.items.forEach(i => cnt[i.item_type] = (cnt[i.item_type] || 0) + 1);
$('#typeChips').html(`<button class="tchip ${filter ? '' : 'on'}" data-f="">Tutti<span class="n">${L.items.length}</span></button>` +
Object.entries(L.types).map(([k, v]) => `<button class="tchip ${filter === k ? 'on' : ''}" data-f="${k}">${esc(v)}<span class="n">${cnt[k] || 0}</span></button>`).join(''));
const list = L.items.filter(i => !filter || i.item_type === filter);
$('#items').html(list.length ? list.map(i => `<div class="it ${+i.active ? '' : 'off'}" data-item="${i.id}" role="button">
<div class="th">${i.image ? `<img src="photos/kiosk/${esc(i.image)}" alt="">` : '<i class="fa-regular fa-image"></i>'}</div>
<div class="tx"><b>${esc(i.title)}</b><small>${esc((i.body || '').replace(/\s+/g, ' ').slice(0, 140))}</small></div>
<span class="ttag ${esc(i.item_type)}">${esc(L.types[i.item_type] || i.item_type)}</span>
<div style="min-width:120px;text-align:right">${itemState(i)}</div>
<div class="form-check form-switch mb-0 ms-2" title="Attivo"><input class="form-check-input item-toggle" type="checkbox" data-id="${i.id}" ${+i.active ? 'checked' : ''}></div>
</div>`).join('') : '<div class="text-muted p-3 text-center">Nessun contenuto. Crea il primo con "Nuovo contenuto".</div>');
}
$(document).on('click', '.tchip', function() {
filter = this.dataset.f;
renderItems();
});
$(document).on('click', '.item-toggle', function(e) {
e.stopPropagation();
post({
action: 'item_toggle',
id: this.dataset.id
}).then(() => load(true));
});
$(document).on('click', '[data-item]', function(e) {
if ($(e.target).closest('.form-check').length) return;
openItem(L.items.find(i => +i.id === +this.dataset.item));
});
$('#btnNewItem').on('click', () => openItem(null));
function setType(t) {
$('#typePick input[value="' + t + '"]').prop('checked', true);
$('#typeHint').text(HINTS[t] || '');
$('#wImg').toggle(t !== 'ticker');
$('#iBody').attr('rows', t === 'ticker' ? 2 : 6);
}
$('#typePick').html(Object.entries(<?= json_encode(Kiosk::ITEM_TYPES) ?>).map(([k, v]) =>
`<input type="radio" class="btn-check" name="item_type" id="t_${k}" value="${k}"><label class="btn btn-outline-dark" for="t_${k}">${esc(v)}</label>`).join(''));
$(document).on('change', '#typePick input', function() {
setType(this.value);
});
let editingItem = null;
function openItem(i) {
editingItem = i;
$('#fItem')[0].reset();
$('#mItemTitle').text(i ? 'Modifica contenuto' : 'Nuovo contenuto');
$('#iId').val(i ? i.id : '');
setType(i ? i.item_type : (filter || 'comunicazione'));
$('#iTitle').val(i ? i.title : '');
$('#iBody').val(i ? i.body || '' : '');
$('#iFrom').val(i ? i.valid_from || '' : L.today);
$('#iTo').val(i ? i.valid_to || '' : '');
$('#iSort').val(i ? i.sort : 0);
$('#iActive').prop('checked', i ? +i.active === 1 : true);
$('#wImgPrev').toggleClass('d-none', !(i && i.image));
if (i && i.image) $('#imgPrev').attr('src', 'photos/kiosk/' + i.image);
$('#btnDelItem').toggle(!!i).removeClass('btn-danger').addClass('btn-outline-danger').html('<i class="fa-solid fa-trash me-1"></i> Elimina');
mItem.show();
}
$('#iImg').on('change', function() {
const f = this.files[0];
if (!f) return;
$('#imgPrev').attr('src', URL.createObjectURL(f));
$('#wImgPrev').removeClass('d-none');
});
$('#fItem').on('submit', function(e) {
e.preventDefault();
const fd = new FormData(this);
fd.append('action', 'item_save');
$('#btnSaveItem').prop('disabled', true);
post(fd, true).then(() => {
mItem.hide();
toast(editingItem ? 'Contenuto aggiornato' : 'Contenuto creato');
load(true);
}).always(() => $('#btnSaveItem').prop('disabled', false));
});
$('#btnDelItem').on('click', function() {
const b = $(this);
if (!b.hasClass('btn-danger')) return b.removeClass('btn-outline-danger').addClass('btn-danger').html('<i class="fa-solid fa-trash me-1"></i> Conferma eliminazione');
post({
action: 'item_delete',
id: editingItem.id
}).then(() => {
mItem.hide();
toast('Contenuto eliminato');
load(true);
});
});
/* ---------- obiettivi ---------- */
function renderGoals() {
$('#goals').html(L.goals.length ? L.goals.map(g => {
const dec = g.unit === '%' || g.unit === 'h' ? 1 : 0;
const src = L.sources[g.source] ? L.sources[g.source][0] : g.source;
return `<div class="gl ${g.ok ? 'ok' : ''} ${+g.active ? '' : 'opacity-50'}" data-goal="${g.id}" role="button">
<div class="r"><b>${esc(g.title)}</b><span class="text-muted small">${esc(g.period_label)} · ${esc(src)}</span>
<span class="ms-auto fw-bold">${g.current === null ? '—' : num(g.current, dec)} / ${+g.lower_is_better ? '≤ ' : ''}${num(g.target, dec)} ${esc(g.unit)}</span></div>
<div class="bar"><span style="width:${g.pct || 0}%"></span></div></div>`;
}).join('') : '<div class="text-muted p-3 text-center">Nessun obiettivo. Esempio: "Metri prodotti a ottobre" con valore automatico.</div>');
}
$('#gSource').html(Object.entries(<?= json_encode(Kiosk::GOAL_SOURCES) ?>).map(([k, v]) => `<option value="${k}">${esc(v[0])}${k !== 'manuale' ? ' (automatico)' : ''}</option>`).join(''));
$('#gSource').on('change', function() {
$('.gman').toggle(this.value === 'manuale');
});
let editingGoal = null;
function openGoal(g) {
editingGoal = g;
$('#fGoal')[0].reset();
$('#mGoalTitle').text(g ? 'Modifica obiettivo' : 'Nuovo obiettivo');
$('#gId').val(g ? g.id : '');
$('#gTitle').val(g ? g.title : '');
$('#gSource').val(g ? g.source : 'metri_mese').trigger('change');
$('#gTarget').val(g ? +g.target : '');
$('#gUnit').val(g ? g.unit : '');
$('#gCur').val(g && g.current_value !== null ? +g.current_value : '');
$('#gLower').prop('checked', g ? +g.lower_is_better === 1 : false);
$('#gFrom').val(g ? g.period_from || '' : '');
$('#gTo').val(g ? g.period_to || '' : '');
$('#gSort').val(g ? g.sort : 0);
$('#gActive').prop('checked', g ? +g.active === 1 : true);
$('#btnDelGoal').toggle(!!g).removeClass('btn-danger').addClass('btn-outline-danger').html('<i class="fa-solid fa-trash me-1"></i> Elimina');
mGoal.show();
}
$('#btnNewGoal').on('click', () => openGoal(null));
$(document).on('click', '[data-goal]', function() {
openGoal(L.goals.find(g => +g.id === +this.dataset.goal));
});
$('#fGoal').on('submit', function(e) {
e.preventDefault();
const d = $(this).serializeArray();
d.push({
name: 'action',
value: 'goal_save'
});
post(d).then(() => {
mGoal.hide();
toast('Obiettivo salvato');
load(true);
});
});
$('#btnDelGoal').on('click', function() {
const b = $(this);
if (!b.hasClass('btn-danger')) return b.removeClass('btn-outline-danger').addClass('btn-danger').html('<i class="fa-solid fa-trash me-1"></i> Conferma eliminazione');
post({
action: 'goal_delete',
id: editingGoal.id
}).then(() => {
mGoal.hide();
toast('Obiettivo eliminato');
load(true);
});
});
/* ---------- sicurezza e impostazioni ---------- */
function renderSafety() {
const s = L.safety;
$('#safeN').text(s ? s.days : '—');
$('#safeTxt').html(s ? `giorni senza infortuni<br>dal ${esc(s.since)}${s.record ? ' · record ' + s.record : ''}` : 'Inserisci la data dell\'ultimo infortunio per mostrare il contatore');
}
function fillSettings() {
const s = L.settings;
$('#sLast').val(s.last_incident);
$('#sRec').val(s.record_days);
$('#sNote').val(s.safety_note);
$('#cCompany').val(s.company);
$('#cSec').val(s.slide_seconds);
$('#cScarto').val(s.scarto_target);
$('#cCity').val(s.city);
$('#cLat').val(s.lat);
$('#cLon').val(s.lon);
$('#geoTxt').text(s.lat ? `Posizione: ${s.lat}, ${s.lon}` : 'Scrivi la città e premi "Trova" per attivare il meteo');
const on = s.slides.split(',');
let dur = {};
try {
dur = JSON.parse(s.slide_durations || '{}') || {};
} catch (e) {}
$('#slideList').html(Object.entries(L.slides).map(([k, v]) => `<div class="sl-row">
<div class="form-check form-switch mb-0"><input class="form-check-input" type="checkbox" name="slides[]" value="${k}" id="sl_${k}" ${on.includes(k) ? 'checked' : ''}><label class="form-check-label" for="sl_${k}">${esc(v)}</label></div>
<div class="input-group input-group-sm sl-sec"><input type="number" min="4" max="300" class="form-control" name="dur[${k}]" value="${dur[k] || ''}" placeholder="${k === 'comunicazioni' || k === 'turni' || k === 'menu' ? Math.round((+s.slide_seconds || 15) * 1.3) : (+s.slide_seconds || 15)}"><span class="input-group-text">s</span></div>
</div>`).join(''));
}
// sicurezza e impostazioni si salvano insieme (stessa tabella)
function saveAll() {
const d = $('#fSettings').serializeArray().concat($('#fSafety').serializeArray());
d.push({
name: 'action',
value: 'save_settings'
});
return post(d).then(() => {
toast('Impostazioni salvate');
load(true);
});
}
$('#fSettings, #fSafety').on('submit', function(e) {
e.preventDefault();
saveAll();
});
$('#btnGeo').on('click', function() {
const q = $('#cCity').val().trim();
if (!q) return;
$('#geoTxt').text('Cerco…');
fetch('https://geocoding-api.open-meteo.com/v1/search?count=1&language=it&name=' + encodeURIComponent(q))
.then(r => r.json()).then(r => {
const g = r.results && r.results[0];
if (!g) return $('#geoTxt').text('Città non trovata');
$('#cLat').val(g.latitude.toFixed(4));
$('#cLon').val(g.longitude.toFixed(4));
$('#cCity').val(g.name);
$('#geoTxt').text(`Trovata: ${g.name} (${g.admin1 || g.country || ''}). Premi Salva.`);
}).catch(() => $('#geoTxt').text('Ricerca non disponibile'));
});
/* ---------- link ---------- */
$('#btnCopy').on('click', () => {
const u = $('#url').val();
(navigator.clipboard ? navigator.clipboard.writeText(u) : Promise.reject()).then(() => toast('Link copiato'), () => {
$('#url').trigger('select');
document.execCommand('copy');
toast('Link copiato');
});
});
let armToken = false;
$('#btnToken').on('click', function() {
if (!armToken) {
armToken = true;
$(this).removeClass('btn-outline-danger').addClass('btn-danger').html('Nuovo link?');
setTimeout(() => {
armToken = false;
$('#btnToken').removeClass('btn-danger').addClass('btn-outline-danger').html('<i class="fa-solid fa-rotate"></i>');
}, 4000);
return;
}
post({
action: 'new_token'
}).then(() => {
toast('Nuovo link creato: aggiornalo sullo schermo');
armToken = false;
$('#btnToken').removeClass('btn-danger').addClass('btn-outline-danger').html('<i class="fa-solid fa-rotate"></i>');
load(true);
});
});
load();
});
</script>
</body>
</html>