Files
2026-10-21 10:03:59 +02:00

2753 lines
104 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* production_station.php - DEMO postazione di linea per tablet (touch).
*
* Una schermata per linea: cosa c'è in macchina con timer grande, pulsanti enormi (pausa, riprendi, fine),
* foto/parametri/disegni a portata di dito e la coda dei prossimi lavori. Vista "Tutte" con lo stato di ogni linea.
* Stessa logica e stesse tabelle della pagina di produzione attuale (productiondata, production_pauses, ...).
* Usa gli stessi servizi per foto e allegati: get_photos.php, upload_photo.php, get_matrice_attachments.php.
*/
ob_start();
include('include/headscript.php');
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
const ST_PROGRAMMATO = 6;
const ST_PRODUZIONE = 2;
const ST_PAUSA = 7;
const ST_QUALITA = 3;
const ST_PROBLEMA = 8;
const ST_DA_COMPILARE = 11;
function stOut(array $a)
{
while (ob_get_level()) ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode($a);
exit;
}
/** I tempi in tabella sono salvati in UTC (UTC_TIMESTAMP): li passo al browser in millisecondi. */
function stMs($dt)
{
if (!$dt) return null;
return (new DateTime($dt, new DateTimeZone('UTC')))->getTimestamp() * 1000;
}
function stHas(PDO $pdo, $t)
{
static $c = [];
if (!isset($c[$t])) {
try {
$pdo->query("SELECT 1 FROM `$t` LIMIT 0");
$c[$t] = true;
} catch (Throwable $e) {
$c[$t] = false;
}
}
return $c[$t];
}
/** Lavori con tutti i dati utili alla postazione. */
function stJobs(PDO $pdo, $where, array $params = [])
{
$tools = stHas($pdo, 'productiondata_tools') && stHas($pdo, 'inv_equipment')
? "(SELECT GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR '||') FROM productiondata_tools pt JOIN inv_equipment t ON t.id = pt.tool_id WHERE pt.productiondata_id = p.id)"
: 'NULL';
$photos = stHas($pdo, 'production_photos')
? "(SELECT GROUP_CONCAT(CONCAT(ph.photo_type, ':', COALESCE(ph.param_position, 0))) FROM production_photos ph WHERE ph.production_id = p.id)"
: 'NULL';
$st = $pdo->prepare("
SELECT p.id, p.id_status, p.id_linea, p.priority, p.conferma_ordine, p.idmatrice, m.nome AS matrice, m.photo,
c.nome AS cliente, p.data_zibo, p.data_cliente, p.metri, p.kg_sp, p.kg_p, p.ore_previste,
p.note_extra, p.note_operatore, p.start_time,
s.nome AS status_nome, COALESCE(s.badge_color, '#64748b') AS status_color,
(SELECT GROUP_CONCAT(ms.nome ORDER BY ms.nome SEPARATOR '||') FROM productiondata_mescole pm
JOIN mescole ms ON ms.id = pm.id_mescola WHERE pm.id_productiondata = p.id) AS mescole,
(SELECT COALESCE(SUM(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)), 0) FROM production_pauses pp
WHERE pp.id_production = p.id AND pp.end_pause IS NOT NULL) AS pause_sec,
(SELECT pp.start_pause FROM production_pauses pp WHERE pp.id_production = p.id AND pp.end_pause IS NULL
ORDER BY pp.start_pause DESC LIMIT 1) AS open_pause,
(SELECT pr.name FROM production_pauses pp JOIN pause_reasons pr ON pr.id = pp.reason_id
WHERE pp.id_production = p.id AND pp.end_pause IS NULL ORDER BY pp.start_pause DESC LIMIT 1) AS open_reason,
$tools AS tools,
$photos AS photos
FROM productiondata p
LEFT JOIN matrice m ON m.id = p.idmatrice
LEFT JOIN clients c ON c.id = p.id_cliente
LEFT JOIN production_status s ON s.id = p.id_status
WHERE $where
ORDER BY p.priority IS NULL, p.priority, p.id");
$st->execute($params);
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $j) {
$ph = ['lotto_mescola' => 0, 'parametri_macchina' => 0, 'problema' => 0, 'slots' => []];
foreach (array_filter(explode(',', (string)$j['photos'])) as $x) {
[$type, $pos] = array_pad(explode(':', $x), 2, 0);
if (isset($ph[$type])) $ph[$type]++;
if ($type === 'parametri_macchina' && $pos) $ph['slots'][(int)$pos] = true;
}
$out[] = [
'id' => (int)$j['id'],
'status' => (int)$j['id_status'],
'status_nome' => $j['status_nome'],
'status_color' => $j['status_color'],
'line' => (int)$j['id_linea'],
'matrice' => $j['matrice'],
'matrice_id' => (int)$j['idmatrice'],
'photo' => $j['photo'],
'cliente' => $j['cliente'],
'ordine' => $j['conferma_ordine'],
'data_zibo' => $j['data_zibo'],
'data_cliente' => $j['data_cliente'],
'metri' => (float)$j['metri'],
'kg_sp' => (float)$j['kg_sp'],
'kg_p' => (float)$j['kg_p'],
'ore' => (float)$j['ore_previste'],
'note' => (string)$j['note_extra'],
'note_operatore' => (string)$j['note_operatore'],
'mescole' => $j['mescole'] ? explode('||', $j['mescole']) : [],
'tools' => $j['tools'] ? explode('||', $j['tools']) : [],
'start_ms' => stMs($j['start_time']),
'pause_sec' => (int)$j['pause_sec'],
'open_pause_ms' => stMs($j['open_pause']),
'open_reason' => $j['open_reason'],
'photos' => $ph,
];
}
return $out;
}
/** Secondi di occupazione linea (lordo) e di pausa chiusa, come la pagina di produzione. */
function stTimes(PDO $pdo, $id)
{
$st = $pdo->prepare("SELECT start_time FROM productiondata WHERE id = ?");
$st->execute([$id]);
$start = $st->fetchColumn();
$occ = 0;
if ($start) {
$occ = max(0, time() - (new DateTime($start, new DateTimeZone('UTC')))->getTimestamp());
}
$st = $pdo->prepare("SELECT COALESCE(SUM(TIMESTAMPDIFF(SECOND, start_pause, end_pause)), 0) FROM production_pauses
WHERE id_production = ? AND end_pause IS NOT NULL");
$st->execute([$id]);
$pause = (int)$st->fetchColumn();
return [$occ, $pause];
}
/* ===================== AJAX ===================== */
$a = $_GET['a'] ?? $_POST['a'] ?? '';
if ($a !== '') {
try {
// ---------- stato della postazione ----------
if ($a === 'state') {
$lines = $pdo->query("SELECT id, name, COALESCE(NULLIF(color, ''), '#64748b') AS color FROM production_lines ORDER BY line_number, id")->fetchAll(PDO::FETCH_ASSOC);
foreach ($lines as &$l) $l['id'] = (int)$l['id'];
unset($l);
$line = (int)($_GET['line'] ?? 0);
$res = ['success' => true, 'now_ms' => time() * 1000, 'lines' => $lines];
// panoramica: in macchina e coda di ogni linea
$res['overview'] = stJobs($pdo, 'p.id_status IN (' . ST_PRODUZIONE . ',' . ST_PAUSA . ',' . ST_PROBLEMA . ')');
$cnt = [];
foreach ($pdo->query("SELECT id_linea, COUNT(*) n, SUM(ore_previste) h FROM productiondata WHERE id_status = " . ST_PROGRAMMATO . " GROUP BY id_linea") as $r) {
$cnt[(int)$r['id_linea']] = ['n' => (int)$r['n'], 'h' => (float)$r['h']];
}
$res['queue_count'] = $cnt;
// primo lavoro in coda per ogni linea (per la panoramica)
foreach (
$pdo->query("SELECT p.id_linea, m.nome FROM productiondata p JOIN matrice m ON m.id = p.idmatrice
WHERE p.id_status = " . ST_PROGRAMMATO . " ORDER BY p.priority IS NULL, p.priority, p.id") as $r
) {
foreach ($res['lines'] as &$l) if ($l['id'] === (int)$r['id_linea'] && !isset($l['next'])) $l['next'] = $r['nome'];
unset($l);
}
if ($line) {
$res['queue'] = stJobs($pdo, 'p.id_status = ' . ST_PROGRAMMATO . ' AND p.id_linea = ?', [$line]);
$st = $pdo->prepare("SELECT position, short_label, label, icon FROM production_line_params WHERE line_id = ? ORDER BY position");
$st->execute([$line]);
$res['params'] = $st->fetchAll(PDO::FETCH_ASSOC);
}
$res['reasons'] = $pdo->query("SELECT id, name, is_problem FROM pause_reasons ORDER BY is_problem, name")->fetchAll(PDO::FETCH_ASSOC);
stOut($res);
}
// ---------- foto di un lavoro ----------
if ($a === 'photos') {
$st = $pdo->prepare("SELECT id, photo_type, param_position, filename, created_at FROM production_photos
WHERE production_id = ? ORDER BY created_at DESC, id DESC");
$st->execute([(int)($_GET['id'] ?? 0)]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['id'] = (int)$r['id'];
$r['param_position'] = $r['param_position'] === null ? null : (int)$r['param_position'];
}
unset($r);
stOut(['success' => true, 'rows' => $rows]);
}
// ---------- avvio (da programmato) ----------
if ($a === 'start') {
$id = (int)($_POST['id'] ?? 0);
$pdo->beginTransaction();
$st = $pdo->prepare("SELECT id_status, id_linea FROM productiondata WHERE id = ? FOR UPDATE");
$st->execute([$id]);
$r = $st->fetch(PDO::FETCH_ASSOC);
if (!$r) throw new RuntimeException('Lavoro non trovato');
if ((int)$r['id_status'] !== ST_PROGRAMMATO) throw new RuntimeException('Il lavoro non è più tra i programmati');
$st = $pdo->prepare("SELECT COUNT(*) FROM productiondata WHERE id_linea = ? AND id_status IN (" . ST_PRODUZIONE . "," . ST_PAUSA . ")");
$st->execute([$r['id_linea']]);
if ($st->fetchColumn()) throw new RuntimeException('La linea è già occupata: termina prima il lavoro in macchina');
$pdo->prepare("UPDATE productiondata SET id_status = ?, start_time = UTC_TIMESTAMP(), end_time = NULL WHERE id = ? AND id_status = ?")
->execute([ST_PRODUZIONE, $id, ST_PROGRAMMATO]);
$pdo->commit();
stOut(['success' => true]);
}
// ---------- pausa ----------
if ($a === 'pause') {
$id = (int)($_POST['id'] ?? 0);
$reason = (int)($_POST['reason_id'] ?? 0) ?: null;
$note = trim((string)($_POST['note'] ?? ''));
if ($reason) {
$st = $pdo->prepare("SELECT is_problem FROM pause_reasons WHERE id = ?");
$st->execute([$reason]);
$isProblem = $st->fetchColumn();
if ($isProblem === false) throw new RuntimeException('Motivo non trovato');
if ((int)$isProblem && $note === '') throw new RuntimeException('Per un problema tecnico scrivi una nota');
}
$pdo->beginTransaction();
$st = $pdo->prepare("UPDATE productiondata SET id_status = ?, note_operatore = ? WHERE id = ? AND id_status = ?");
$st->execute([ST_PAUSA, $note ?: null, $id, ST_PRODUZIONE]);
if ($st->rowCount()) {
$pdo->prepare("INSERT INTO production_pauses (id_production, reason_id, start_pause, note) VALUES (?, ?, UTC_TIMESTAMP(), ?)")
->execute([$id, $reason, $note ?: null]);
}
$pdo->commit();
stOut(['success' => true]);
}
// ---------- ripresa ----------
if ($a === 'resume') {
$id = (int)($_POST['id'] ?? 0);
$reason = (int)($_POST['reason_id'] ?? 0);
$pdo->beginTransaction();
$st = $pdo->prepare("SELECT reason_id FROM production_pauses WHERE id_production = ? AND end_pause IS NULL ORDER BY start_pause DESC LIMIT 1");
$st->execute([$id]);
$has = $st->fetchColumn();
if (($has === false || $has === null) && $reason <= 0) throw new RuntimeException('Scegli il motivo della pausa per riprendere');
$pdo->prepare("UPDATE production_pauses SET end_pause = UTC_TIMESTAMP()" . ($reason > 0 ? ", reason_id = ?" : "") . "
WHERE id_production = ? AND end_pause IS NULL ORDER BY start_pause DESC LIMIT 1")
->execute($reason > 0 ? [$reason, $id] : [$id]);
$st = $pdo->prepare("UPDATE productiondata SET id_status = ?, end_time = NULL WHERE id = ? AND id_status = ?");
$st->execute([ST_PRODUZIONE, $id, ST_PAUSA]);
if (!$st->rowCount()) throw new RuntimeException('Il lavoro non è in pausa');
$pdo->commit();
stOut(['success' => true]);
}
// ---------- fine produzione (dati finali o "compila dopo") ----------
if ($a === 'finish') {
$id = (int)($_POST['id'] ?? 0);
$reason = ($_POST['reason'] ?? 'fine') === 'problema' ? 'problema' : 'fine';
$later = !empty($_POST['later']);
$kg = max(0, (float)str_replace(',', '.', (string)($_POST['kgprod'] ?? 0)));
$mt = max(0, (float)str_replace(',', '.', (string)($_POST['mtprod'] ?? 0)));
$sc = max(0, (float)str_replace(',', '.', (string)($_POST['scarto'] ?? 0)));
$note = trim((string)($_POST['note'] ?? ''));
if ($reason === 'problema' && $note === '') throw new RuntimeException('Descrivi il problema nella nota');
if (!$later && $kg <= 0 && $mt <= 0) throw new RuntimeException('Inserisci i kg o i metri prodotti, oppure "Compila dopo"');
$pdo->beginTransaction();
$pdo->prepare("UPDATE production_pauses SET end_pause = UTC_TIMESTAMP() WHERE id_production = ? AND end_pause IS NULL")->execute([$id]);
[$occ, $pause] = stTimes($pdo, $id);
$status = $later ? ST_DA_COMPILARE : ($reason === 'problema' ? ST_PROBLEMA : ST_QUALITA);
$st = $pdo->prepare("
UPDATE productiondata
SET kgprod = :kg, mtprod = :mt, scarto = :sc, scarto_perc = :scp, note_operatore = :note,
hourprod = SEC_TO_TIME(:occ), tempo_totale_produzione = :occ2, seconds_pause = :pause,
seconds_produzione = :netto, id_status = :status, end_time = COALESCE(end_time, UTC_TIMESTAMP())
WHERE id = :id AND id_status IN (" . ST_PRODUZIONE . "," . ST_PAUSA . ")");
$st->execute([
'kg' => $later ? 0 : $kg,
'mt' => $later ? 0 : $mt,
'sc' => $later ? 0 : $sc,
'scp' => (!$later && $kg + $sc > 0) ? round($sc / ($kg + $sc) * 100, 2) : 0,
'note' => $note,
'occ' => $occ,
'occ2' => $occ,
'pause' => $pause,
'netto' => max(0, $occ - $pause),
'status' => $status,
'id' => $id,
]);
if (!$st->rowCount()) throw new RuntimeException('Il lavoro non è più in macchina');
$pdo->commit();
stOut(['success' => true, 'status' => $status]);
}
// ---------- nuovo lavoro rapido sulla linea ----------
if ($a === 'matrici') {
stOut([
'success' => true,
'rows' => $pdo->query("SELECT id, nome FROM matrice WHERE TRIM(nome) <> '' ORDER BY nome")->fetchAll(PDO::FETCH_ASSOC),
'mescole' => $pdo->query("SELECT id, nome FROM mescole WHERE is_active = 1 ORDER BY nome")->fetchAll(PDO::FETCH_ASSOC)
]);
}
if ($a === 'matmescole') {
$st = $pdo->prepare("SELECT me.id FROM matrici_mescole mm JOIN mescole me ON me.id = mm.idmescola WHERE mm.idmatrice = ? AND me.is_active = 1");
$st->execute([(int)($_GET['id'] ?? 0)]);
stOut(['success' => true, 'ids' => array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN))]);
}
if ($a === 'quick') {
$idm = (int)($_POST['idmatrice'] ?? 0);
$line = (int)($_POST['line'] ?? 0);
$avvia = !empty($_POST['avvia']);
if (!$idm) throw new RuntimeException('Scegli la matrice');
if (!$line) throw new RuntimeException('Scegli la linea');
$pdo->beginTransaction();
if ($avvia) {
$st = $pdo->prepare("SELECT COUNT(*) FROM productiondata WHERE id_linea = ? AND id_status IN (" . ST_PRODUZIONE . "," . ST_PAUSA . ")");
$st->execute([$line]);
if ($st->fetchColumn()) throw new RuntimeException('La linea è occupata: crealo in coda');
}
$prio = (int)$pdo->query("SELECT COALESCE(MAX(priority), 0) + 1 FROM productiondata WHERE id_status = " . ST_PROGRAMMATO)->fetchColumn();
$pdo->prepare("INSERT INTO productiondata (Data, idmatrice, id_linea, note_extra, priority, id_status, start_time, end_time,
fermomacchina, `hour`, hourprod, kgprod, mtprod, scarto)
VALUES (CURDATE(), ?, ?, ?, ?, ?, " . ($avvia ? 'UTC_TIMESTAMP()' : 'NULL') . ", NULL, 0, '00:00:00', '00:00:00', 0, 0, 0)")
->execute([$idm, $line, trim((string)($_POST['descrizione'] ?? '')) ?: null, $prio, $avvia ? ST_PRODUZIONE : ST_PROGRAMMATO]);
$id = (int)$pdo->lastInsertId();
$ins = $pdo->prepare("INSERT INTO productiondata_mescole (id_productiondata, id_mescola) VALUES (?, ?)");
foreach (array_unique(array_filter(array_map('intval', (array)($_POST['mescole'] ?? [])))) as $m) $ins->execute([$id, $m]);
$pdo->commit();
stOut(['success' => true, 'id' => $id]);
}
stOut(['success' => false, 'message' => 'Azione sconosciuta']);
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
stOut(['success' => false, 'message' => $e->getMessage()]);
}
}
// linea preferita dell'utente (come la pagina di produzione)
$preferred = '';
if (!empty($iduserlogin)) {
try {
$st = $pdo->prepare("SELECT preferred_line_id FROM auth_users WHERE id = ?");
$st->execute([(int)$iduserlogin]);
$preferred = (string)($st->fetchColumn() ?: '');
} catch (Throwable $e) {
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0b1220">
<title>Postazione linea - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<?php include('cssinclude.php'); ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.full.min.js"></script>
<style>
:root {
--bg: #0a1120;
--panel: #131d33;
--panel2: #1a2742;
--line: #2a3a5c;
--line2: #3d5078;
--ink: #f1f5ff;
--muted: #b4c2dd;
--label: #c6d2ea;
--box: #22335a;
--box2: #1b2a4b;
--sub: #d5def0;
--chipink: #e6edfb;
--track: #24345a;
--top: #0e1628;
--pressed: #22325a;
--soft: #1e2a44;
--soft2: #3a4a6b;
--accent: #93c5fd;
--r3: #cfd8ea;
--badink: #f87171;
--warnink: #fbbf24;
--scrim: rgba(2, 6, 23, .7);
--sheet: #0f182e;
--field: #0b1426;
--selbg: #0c2a3d;
--selbad: #3a1212;
--disbg: #1d1538;
--toastbg: #0f172a;
--reasonink: #fcd34d;
--dotoff: #4b5b7a;
--runink: #4ade80;
--runglow: 0 0 28px rgba(74, 222, 128, .45);
--pauseink: #fbbf24;
--matink: #7dd3fc;
--matglow: 0 0 22px rgba(125, 211, 252, .35);
--run: #22c55e;
--pause: #f59e0b;
--bad: #ef4444;
--free: #38bdf8;
--lc: #3b82f6;
}
/* modalità chiara: per reparti luminosi */
[data-theme="light"] {
--bg: #d6e4f3;
--panel: #eaf2fb;
--panel2: #dce8f6;
--line: #c0d2e8;
--line2: #a6bcd9;
--ink: #0f172a;
--muted: #5b6b86;
--sub: #334155;
--chipink: #1e293b;
--track: #e2e8f0;
--top: #e3edf8;
--pressed: #cddcef;
--soft: #d6e4f3;
--soft2: #cbd5e1;
--accent: #1d4ed8;
--r3: #475569;
--badink: #dc2626;
--warnink: #b45309;
--scrim: rgba(15, 23, 42, .45);
--sheet: #eaf2fb;
--field: #f4f8fd;
--selbg: #e0f2fe;
--selbad: #fee2e2;
--disbg: #f3e8ff;
--toastbg: #0f172a;
--reasonink: #92400e;
--dotoff: #cbd5e1;
--runink: #15803d;
--runglow: none;
--pauseink: #c2410c;
--matink: #1e3a8a;
--label: #475569;
--box: #f4f8fd;
--box2: #f4f8fd;
--matglow: none;
--track: #c9d8ec;
}
[data-theme="light"] .hero::before {
opacity: .12;
}
[data-theme="light"] .hero,
[data-theme="light"] .side,
[data-theme="light"] .ovc {
box-shadow: 0 6px 20px rgba(15, 23, 42, .08);
}
[data-theme="light"] .tab.on {
color: #fff;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--ink);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
overscroll-behavior: none;
user-select: none;
-webkit-user-select: none;
}
button {
font-family: inherit;
touch-action: manipulation;
}
.app {
height: 100%;
display: flex;
flex-direction: column;
}
/* ---------- barra alta ---------- */
.top {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--top);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.ibtn {
width: 54px;
height: 54px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel);
color: var(--ink);
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ibtn:active {
transform: scale(.95);
}
.tabs {
display: flex;
gap: 8px;
flex: 1;
overflow-x: auto;
scrollbar-width: none;
}
.tab {
min-height: 54px;
padding: 0 22px;
border-radius: 14px;
border: 2px solid var(--line);
background: var(--panel);
color: var(--muted);
font-size: 1.2rem;
font-weight: 800;
white-space: nowrap;
display: flex;
align-items: center;
gap: 10px;
}
.tab .dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--dotoff);
box-shadow: 0 0 0 3px rgba(255, 255, 255, .06);
}
.tab .dot.run {
background: var(--run);
animation: pulse 1.6s infinite;
}
.tab .dot.pause {
background: var(--pause);
}
.tab.on {
background: var(--c);
border-color: var(--c);
color: #fff;
}
.clock {
font-size: 1.6rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--ink);
padding: 0 6px;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, .7);
}
70% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
/* ---------- area principale ---------- */
.main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 380px;
gap: 14px;
padding: 14px;
}
.hero {
position: relative;
border-radius: 24px;
background: var(--panel);
border: 1px solid var(--line);
padding: 22px 26px;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(1200px 400px at 0% 0%, var(--glow, transparent), transparent 60%);
opacity: .22;
pointer-events: none;
}
.hero.run {
--glow: var(--run);
border-color: rgba(34, 197, 94, .45);
}
.hero.pause {
--glow: var(--pause);
border-color: rgba(245, 158, 11, .55);
}
.hero.free {
--glow: var(--free);
}
.hero>* {
position: relative;
}
.h-top {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pill {
font-size: 1rem;
font-weight: 900;
letter-spacing: .08em;
text-transform: uppercase;
border-radius: 999px;
padding: 6px 16px;
color: #0b1220;
}
.pill.run {
background: var(--run);
}
.pill.pause {
background: var(--pause);
animation: blink 1.4s infinite;
}
.pill.free {
background: var(--free);
}
@keyframes blink {
50% {
opacity: .55;
}
}
.h-order {
color: var(--muted);
font-size: 1.05rem;
font-weight: 600;
}
.h-mat {
display: flex;
align-items: center;
gap: 16px;
margin-top: 14px;
}
.h-mat .name {
color: var(--matink);
text-shadow: var(--matglow);
font-size: clamp(2.2rem, 4.5vw, 3.6rem);
font-weight: 900;
line-height: 1;
letter-spacing: -.01em;
}
.h-mat img {
width: 76px;
height: 76px;
border-radius: 14px;
object-fit: cover;
border: 2px solid var(--line);
}
.h-cli {
font-size: 1.3rem;
color: var(--sub);
margin-top: 8px;
font-weight: 600;
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.schip {
display: inline-block;
height: auto;
line-height: 1.3;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 12px;
font-size: 1rem;
font-weight: 700;
color: var(--chipink);
}
.h-body {
display: flex;
align-items: center;
gap: 28px;
margin: 18px 0;
flex: 1;
min-height: 0;
}
.ring {
position: relative;
width: 230px;
height: 230px;
flex-shrink: 0;
}
.ring svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ring .bg {
stroke: var(--track);
}
.ring .fg {
transition: stroke-dashoffset .8s ease;
}
.ring .in {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.ring .pct {
font-size: 2.6rem;
font-weight: 900;
}
.ring .lbl {
color: var(--muted);
font-weight: 700;
font-size: .95rem;
}
.hero.run .timer {
color: var(--runink);
text-shadow: var(--runglow);
}
.ovc .tm.run {
color: var(--runink);
text-shadow: var(--runglow);
}
.timer {
font-size: clamp(3.2rem, 7.5vw, 6rem);
font-weight: 900;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -.02em;
}
.t-sub {
color: var(--muted);
font-size: 1.15rem;
font-weight: 600;
margin-top: 8px;
}
.t-sub b {
color: var(--ink);
}
.kpis {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
}
.kpi {
background: var(--box);
border: 1px solid var(--line2);
border-radius: 14px;
padding: 8px 14px;
min-width: 0;
}
.hero .kpi .v {
color: var(--ink);
font-size: 1.35rem;
font-weight: 900;
line-height: 1.25;
white-space: nowrap;
}
@media (max-height: 860px) {
.ring {
width: 190px;
height: 190px;
}
.h-mat .name {
font-size: clamp(2rem, 4vw, 3rem);
}
.h-body {
margin: 12px 0;
}
}
.kpi .l {
font-size: .8rem;
color: var(--label);
font-weight: 800;
text-transform: uppercase;
letter-spacing: .05em;
}
.hero .kpi.late .v {
color: var(--badink);
}
.hero .kpi.soon .v {
color: var(--warnink);
}
.reason {
display: inline-flex;
gap: 8px;
align-items: center;
background: rgba(245, 158, 11, .15);
border: 1px solid rgba(245, 158, 11, .5);
color: var(--reasonink);
border-radius: 12px;
padding: 8px 14px;
font-weight: 800;
font-size: 1.1rem;
margin-top: 10px;
}
.reason.missing {
animation: blink 1.4s infinite;
}
/* ---------- pulsanti grandi ---------- */
.big-actions {
display: flex;
gap: 14px;
}
.big {
flex: 1;
min-height: 96px;
border: none;
border-radius: 20px;
font-size: 1.7rem;
font-weight: 900;
letter-spacing: .06em;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
box-shadow: 0 10px 24px rgba(0, 0, 0, .35), inset 0 -4px 0 rgba(0, 0, 0, .18);
transition: transform .08s;
}
.big:active {
transform: scale(.97);
box-shadow: 0 4px 10px rgba(0, 0, 0, .35);
}
.big i {
font-size: 2.1rem;
}
.big.go {
background: linear-gradient(135deg, #22c55e, #15803d);
}
.big.pause {
background: linear-gradient(135deg, #f59e0b, #b45309);
}
.big.stop {
background: linear-gradient(135deg, #ef4444, #b91c1c);
}
.big[disabled] {
opacity: .35;
}
.tools {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-top: 14px;
}
.tool {
position: relative;
min-height: 76px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--panel2);
color: var(--ink);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-weight: 800;
font-size: .9rem;
}
.tool i {
font-size: 1.7rem;
}
.tool:active {
transform: scale(.96);
background: var(--pressed);
}
.tool .n {
position: absolute;
top: 6px;
right: 8px;
min-width: 24px;
height: 24px;
border-radius: 12px;
background: var(--run);
color: #0b1220;
font-size: .8rem;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
}
.tool .n.zero {
background: var(--soft2);
color: var(--ink);
}
.tool.danger i {
color: var(--badink);
}
.tool.mago i {
color: #fb923c;
}
/* ---------- coda ---------- */
.side {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
overflow: hidden;
}
.side-h {
padding: 16px 18px 12px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: baseline;
gap: 10px;
}
.side-h .t {
font-size: 1.25rem;
font-weight: 900;
}
.side-h .m {
color: var(--muted);
font-weight: 700;
margin-left: auto;
}
.qlist {
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
-webkit-overflow-scrolling: touch;
}
.qc {
border-radius: 16px;
background: var(--panel2);
border: 1px solid var(--line);
border-left: 6px solid var(--lc);
padding: 12px 14px;
text-align: left;
color: var(--ink);
width: 100%;
}
.qc:active {
background: var(--pressed);
}
.qc .r1 {
display: flex;
align-items: center;
gap: 10px;
}
.qc .pos {
width: 32px;
height: 32px;
border-radius: 10px;
background: var(--soft);
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
color: var(--accent);
flex-shrink: 0;
}
.qc .mat {
color: var(--matink);
font-size: 1.3rem;
font-weight: 900;
}
.qc .h {
margin-left: auto;
font-weight: 900;
font-size: 1.1rem;
}
.qc .r2 {
color: var(--muted);
margin-top: 4px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qc .r3 {
display: flex;
gap: 8px;
margin-top: 6px;
font-size: .9rem;
font-weight: 700;
color: var(--r3);
flex-wrap: wrap;
}
.late {
color: var(--badink) !important;
}
.soon {
color: var(--warnink) !important;
}
.qc.next {
border-color: rgba(56, 189, 248, .6);
box-shadow: 0 0 0 2px rgba(56, 189, 248, .25);
}
.empty {
color: var(--muted);
text-align: center;
padding: 40px 10px;
font-weight: 700;
font-size: 1.1rem;
}
/* ---------- panoramica ---------- */
.ov {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
align-content: start;
}
.ovc {
border-radius: 22px;
background: var(--panel);
border: 1px solid var(--line);
border-top: 8px solid var(--c);
padding: 18px 20px;
text-align: left;
color: var(--ink);
min-height: 220px;
display: flex;
flex-direction: column;
}
.ovc:active {
transform: scale(.99);
}
.ovc .n {
font-size: 1.5rem;
font-weight: 900;
display: flex;
align-items: center;
gap: 10px;
}
.ovc .n .pill {
font-size: .8rem;
margin-left: auto;
}
.ovc .mat {
color: var(--matink);
font-size: 2.2rem;
font-weight: 900;
margin-top: 14px;
}
.ovc .cli {
color: var(--muted);
font-weight: 600;
}
.ovc .tm {
font-size: 2.4rem;
font-weight: 900;
font-variant-numeric: tabular-nums;
margin-top: auto;
}
.ovc .q {
color: var(--muted);
font-weight: 700;
margin-top: 4px;
}
/* ---------- pannello dal basso ---------- */
.sheet-bg {
position: fixed;
inset: 0;
background: var(--scrim);
backdrop-filter: blur(3px);
opacity: 0;
pointer-events: none;
transition: opacity .2s;
z-index: 50;
}
.sheet-bg.on {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
left: 50%;
bottom: 0;
transform: translate(-50%, 105%);
width: min(1500px, 96vw);
max-height: 94vh;
background: var(--sheet);
border: 1px solid var(--line);
border-bottom: none;
border-radius: 28px 28px 0 0;
z-index: 51;
transition: transform .28s cubic-bezier(.2, .9, .3, 1);
display: flex;
flex-direction: column;
}
.sheet.on {
transform: translate(-50%, 0);
}
.sheet .grab {
width: 64px;
height: 6px;
border-radius: 3px;
background: var(--soft2);
margin: 10px auto 0;
}
.sheet-h {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 22px;
}
.sheet-h h3 {
margin: 0;
font-size: 1.6rem;
font-weight: 900;
}
.sheet-h .ibtn {
margin-left: auto;
}
.sheet-b {
overflow-y: auto;
padding: 6px 22px 26px;
-webkit-overflow-scrolling: touch;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
}
.tile {
min-height: 70px;
border-radius: 16px;
border: 2px solid var(--line);
background: var(--panel2);
color: var(--ink);
font-size: 1.2rem;
font-weight: 800;
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
text-align: left;
}
.tile i {
font-size: 1.6rem;
}
.tile.problem {
border-color: rgba(239, 68, 68, .45);
}
.tile.problem i {
color: var(--badink);
}
.tile.sel {
border-color: var(--free);
background: var(--selbg);
box-shadow: 0 0 0 3px rgba(56, 189, 248, .25);
}
.tile.sel.problem {
border-color: var(--bad);
background: var(--selbad);
box-shadow: 0 0 0 3px rgba(239, 68, 68, .25);
}
.tile.huge {
min-height: 150px;
font-size: 1.6rem;
justify-content: center;
flex-direction: column;
text-align: center;
}
.tile.huge i {
font-size: 2.6rem;
}
.fld {
margin-top: 14px;
}
.fld label {
display: block;
font-weight: 800;
color: var(--label);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: .05em;
font-size: .85rem;
}
.fld input,
.fld textarea,
.fld select {
width: 100%;
background: var(--field);
border: 2px solid var(--line);
border-radius: 14px;
color: var(--ink);
font-size: 1.5rem;
padding: 14px 16px;
font-weight: 700;
user-select: text;
-webkit-user-select: text;
}
.fld textarea {
font-size: 1.15rem;
font-weight: 600;
}
.fld input:focus,
.fld textarea:focus {
outline: none;
border-color: var(--free);
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.teo {
color: var(--muted);
font-weight: 700;
margin-top: 6px;
}
.teo b {
color: var(--accent);
}
.sheet-f {
display: flex;
gap: 12px;
margin-top: 20px;
}
.sheet-f .big {
min-height: 80px;
font-size: 1.35rem;
}
.big.ghost {
background: var(--soft);
box-shadow: none;
color: var(--r3);
}
.big.violet {
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
}
.descr {
white-space: pre-wrap;
color: var(--ink);
background: var(--box2);
border: 1px solid var(--line2);
border-left: 6px solid var(--matink);
border-radius: 16px;
padding: 16px 18px;
font-size: 1.2rem;
line-height: 1.5;
user-select: text;
-webkit-user-select: text;
}
.dl {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.dl div {
background: var(--box);
border: 1px solid var(--line2);
border-radius: 14px;
padding: 10px 14px;
}
.dl span {
display: block;
font-size: .85rem;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--label);
font-weight: 800;
}
.dl b {
font-size: 1.45rem;
color: var(--ink);
}
.dl div.late {
border-color: var(--bad);
}
.dl div.late b {
color: var(--badink);
}
.dl div.soon {
border-color: var(--pause);
}
.dl div.soon b {
color: var(--warnink);
}
/* foto */
.seg {
display: flex;
gap: 8px;
background: var(--field);
border-radius: 16px;
padding: 6px;
margin-bottom: 14px;
}
.seg button {
flex: 1;
min-height: 56px;
border: none;
border-radius: 12px;
background: transparent;
color: var(--muted);
font-weight: 900;
font-size: 1.05rem;
}
.seg button.on {
background: var(--panel2);
color: var(--ink);
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
}
.gal {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.gal img {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
border-radius: 12px;
border: 1px solid var(--line);
}
.slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.slot {
border-radius: 16px;
border: 2px solid var(--line);
background: var(--panel2);
color: var(--ink);
padding: 12px;
min-height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
font-weight: 900;
}
.slot i {
font-size: 1.8rem;
}
.slot small {
color: var(--muted);
font-weight: 700;
text-align: center;
}
.slot.done {
border-color: rgba(34, 197, 94, .6);
}
.slot.done i {
color: var(--run);
}
.cam {
width: 100%;
min-height: 90px;
border-radius: 18px;
border: 2px dashed var(--line2);
background: var(--field);
color: var(--ink);
font-size: 1.4rem;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 14px;
}
.cam i {
font-size: 2rem;
}
.cam2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.cam2 .cam {
margin: 0;
}
.cam.alt {
border-style: solid;
border-color: var(--line);
background: var(--panel2);
}
.gal .ph,
.ph {
position: relative;
padding: 0;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
background: var(--field);
display: block;
width: 100%;
}
.gal .ph img {
border: 0;
border-radius: 0;
display: block;
}
.ph span {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, .75));
color: #fff;
font-size: .85rem;
font-weight: 800;
padding: 16px 8px 6px;
text-align: left;
}
.ph.big1 {
margin: 10px 0 14px;
}
.ph.big1 img {
width: 100%;
max-height: 46vh;
object-fit: contain;
background: #000;
display: block;
}
.slot img {
width: 100%;
height: 84px;
object-fit: cover;
border-radius: 10px;
}
.slot .sl {
font-size: 1.15rem;
}
.slot .sl em {
font-style: normal;
font-size: .75rem;
background: var(--run);
color: #0b1220;
border-radius: 8px;
padding: 0 6px;
margin-left: 4px;
}
.back {
min-height: 52px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel2);
color: var(--ink);
font-weight: 800;
font-size: 1.05rem;
padding: 0 18px;
}
.slot-h {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.3rem;
margin-top: 12px;
font-weight: 700;
}
.slot-h b {
font-weight: 900;
}
.sec2 {
font-size: .85rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted);
margin: 6px 0 8px;
}
.vnav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 72px;
height: 72px;
border-radius: 50%;
border: 0;
background: rgba(255, 255, 255, .18);
color: #fff;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.vnav.prev {
left: 16px;
}
.vnav.next {
right: 16px;
}
.viewer .vb {
position: relative;
}
.files {
display: flex;
flex-direction: column;
gap: 10px;
}
.file {
display: flex;
align-items: center;
gap: 14px;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--panel2);
color: var(--ink);
text-align: left;
width: 100%;
}
.file.dis {
border-color: #7c3aed;
background: var(--disbg);
}
.file .ic {
width: 64px;
height: 64px;
border-radius: 12px;
background: var(--field);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
overflow: hidden;
flex-shrink: 0;
}
.file .ic img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file b {
font-size: 1.1rem;
}
.file small {
display: block;
color: var(--muted);
}
/* visore a schermo intero */
.viewer {
position: fixed;
inset: 0;
background: #000;
z-index: 60;
display: none;
flex-direction: column;
}
.viewer.on {
display: flex;
}
.viewer .vh {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #0b1220;
}
.viewer .vh b {
font-size: 1.2rem;
}
.viewer .vh .ibtn {
margin-left: auto;
}
.viewer .vb {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
}
.viewer img {
max-width: 100%;
max-height: 100%;
touch-action: pinch-zoom;
}
.viewer iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
/* toast */
.toast2 {
position: fixed;
left: 50%;
top: 18px;
transform: translate(-50%, -140%);
background: var(--toastbg);
border: 1px solid var(--line);
color: #fff;
border-radius: 16px;
padding: 16px 22px;
font-size: 1.2rem;
font-weight: 800;
z-index: 80;
transition: transform .25s;
box-shadow: 0 20px 40px rgba(0, 0, 0, .5);
max-width: 90%;
}
.toast2.on {
transform: translate(-50%, 0);
}
.toast2.ok {
border-color: var(--run);
}
.toast2.err {
border-color: var(--bad);
background: var(--selbad);
}
.spin {
animation: rot 1s linear infinite;
display: inline-block;
}
@keyframes rot {
to {
transform: rotate(360deg);
}
}
.select2-container {
width: 100% !important;
}
.select2-container--default .select2-selection--single,
.select2-container--default .select2-selection--multiple {
background: var(--field);
border: 2px solid var(--line);
border-radius: 14px;
min-height: 60px;
padding: 8px;
}
.select2-container--default .select2-selection--single .select2-selection__rendered {
color: var(--ink);
font-size: 1.3rem;
font-weight: 700;
line-height: 40px;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice {
font-size: 1.05rem;
padding: 4px 10px;
border-radius: 8px;
}
.select2-dropdown {
z-index: 70;
}
.select2-results__option {
font-size: 1.15rem;
padding: 12px 14px !important;
}
@media (max-width: 1000px) {
.main {
display: flex;
flex-direction: column;
overflow-y: auto;
}
.hero,
.side {
flex: none;
overflow: visible;
}
.h-body {
flex: none;
}
.side {
min-height: 360px;
}
.ring {
width: 170px;
height: 170px;
}
.tools {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 640px) {
.h-body {
flex-direction: column;
align-items: flex-start;
}
.grid2 {
grid-template-columns: 1fr;
}
.big {
font-size: 1.3rem;
min-height: 80px;
}
.clock {
display: none;
}
}
</style>
</head>
<body>
<div class="app">
<div class="top">
<button class="ibtn" onclick="location.href='production_dashboard.php'" aria-label="Indietro"><i class="bi bi-arrow-left"></i></button>
<div class="tabs" id="tabs"></div>
<div class="clock" id="clock">--:--</div>
<button class="ibtn" id="btnQuick" aria-label="Nuovo lavoro"><i class="bi bi-plus-lg"></i></button>
<button class="ibtn" id="btnTheme" aria-label="Cambia colori"><i class="bi bi-sun"></i></button>
<button class="ibtn" id="btnFull" aria-label="Schermo intero"><i class="bi bi-arrows-fullscreen"></i></button>
</div>
<div id="view" style="flex:1;min-height:0;display:flex;flex-direction:column;"></div>
</div>
<div class="sheet-bg" id="sheetBg"></div>
<div class="sheet" id="sheet">
<div class="grab"></div>
<div class="sheet-h">
<h3 id="sheetTitle"></h3>
<button class="ibtn" id="sheetClose" aria-label="Chiudi"><i class="bi bi-x-lg"></i></button>
</div>
<div class="sheet-b" id="sheetBody"></div>
</div>
<div class="viewer" id="viewer">
<div class="vh"><b id="viewerTitle"></b><button class="ibtn" id="viewerClose" aria-label="Chiudi"><i class="bi bi-x-lg"></i></button></div>
<div class="vb" id="viewerBody"></div>
</div>
<input type="file" id="camInput" accept="image/*" capture="environment" style="display:none">
<input type="file" id="galInput" accept="image/*" style="display:none">
<audio id="beep" src="beep.wav" preload="auto"></audio>
<?php include('jsinclude.php'); ?>
<script>
$(function() {
const ST = {
PROG: <?= ST_PROGRAMMATO ?>,
RUN: <?= ST_PRODUZIONE ?>,
PAUSE: <?= ST_PAUSA ?>,
PROBLEM: <?= ST_PROBLEMA ?>
};
const PREF = '<?= (int)$preferred ?: '' ?>';
const H = 3600e3;
let S = null; // ultimo stato dal server
let skew = 0;
let line = 0; // 0 = panoramica
let sheetOpen = false;
let photoCtx = null;
try {
const saved = localStorage.getItem('station_line');
line = saved !== null ? +saved : +(PREF || 0);
} catch (e) {
line = +(PREF || 0);
}
/* ---------- utilità ---------- */
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')}`;
};
const hm = d => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const GG = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab'];
const dShort = s => {
if (!s) return '—';
const d = new Date(s + 'T00:00:00');
return `${GG[d.getDay()]} ${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
};
const daysTo = s => s ? Math.round((new Date(s + 'T00:00:00') - new Date(new Date(now()).toDateString())) / 864e5) : null;
const lineOf = id => (S.lines || []).find(l => l.id === id) || {};
const beep = () => {
try {
const b = document.getElementById('beep');
b.currentTime = 0;
b.play().catch(() => {});
} catch (e) {}
if (navigator.vibrate) navigator.vibrate(30);
};
function toast(msg, kind) {
const t = $(`<div class="toast2 ${kind || 'ok'}">${esc(msg)}</div>`).appendTo('body');
setTimeout(() => t.addClass('on'), 10);
setTimeout(() => {
t.removeClass('on');
setTimeout(() => t.remove(), 300);
}, kind === 'err' ? 4500 : 2200);
}
function post(data) {
return $.post('?', data, null, 'json').then(r => {
if (!r.success) {
toast(r.message || 'Errore', 'err');
return $.Deferred().reject().promise();
}
return r;
}, () => {
toast('Connessione assente: riprova', 'err');
return $.Deferred().reject().promise();
});
}
/* tempi di un lavoro in macchina */
function times(j) {
const t = now();
if (!j.start_ms) return {
net: 0,
gross: 0,
pause: 0,
cur: 0
};
const gross = t - j.start_ms;
const cur = j.open_pause_ms ? t - j.open_pause_ms : 0;
const pause = j.pause_sec * 1000 + cur;
return {
net: Math.max(0, gross - pause),
gross,
pause,
cur
};
}
/* ---------- caricamento ---------- */
function load() {
return $.getJSON('?a=state&line=' + line).then(r => {
if (!r.success) return toast(r.message || 'Errore', 'err');
skew = Date.now() - r.now_ms;
S = r;
if (line && !r.lines.some(l => l.id === line)) line = 0;
render();
});
}
function render() {
renderTabs();
if (!line) renderOverview();
else renderLine();
tick();
}
function renderTabs() {
const act = id => S.overview.find(j => j.line === id && (j.status === ST.RUN || j.status === ST.PAUSE));
let h = `<button class="tab ${line ? '' : 'on'}" data-line="0" style="--c:#475569"><i class="bi bi-grid-3x3-gap-fill"></i> Tutte</button>`;
S.lines.forEach(l => {
const a = act(l.id);
h += `<button class="tab ${line === l.id ? 'on' : ''}" data-line="${l.id}" style="--c:${esc(l.color)}"><span class="dot ${a ? (a.status === ST.RUN ? 'run' : 'pause') : ''}"></span>${esc(l.name)}</button>`;
});
$('#tabs').html(h);
}
/* ---------- panoramica di tutte le linee ---------- */
function renderOverview() {
let h = '<div class="ov">';
S.lines.forEach(l => {
const a = S.overview.find(j => j.line === l.id && (j.status === ST.RUN || j.status === ST.PAUSE));
const pb = S.overview.filter(j => j.line === l.id && j.status === ST.PROBLEM).length;
const q = S.queue_count[l.id] || {
n: 0,
h: 0
};
const cls = a ? (a.status === ST.RUN ? 'run' : 'pause') : 'free';
h += `<button class="ovc" data-line="${l.id}" style="--c:${esc(l.color)}">
<div class="n">${esc(l.name)}<span class="pill ${cls}">${a ? (a.status === ST.RUN ? 'In produzione' : 'In pausa') : 'Libera'}</span></div>
${a ? `<div class="mat">${esc(a.matrice)}</div><div class="cli">${esc(a.cliente || '')}</div>
<div class="tm" data-ov="${a.id}">--:--:--</div>`
: `<div class="mat" style="color:var(--muted)">ferma</div><div class="cli">${l.next ? 'prossimo: <b style="color:var(--ink)">' + esc(l.next) + '</b>' : 'nessun lavoro in coda'}</div><div class="tm" style="color:var(--free);font-size:1.6rem">pronta</div>`}
<div class="q">${q.n} in coda · ${num(q.h, 0)} h${pb ? ` · <span class="late">${pb} problema</span>` : ''}</div>
</button>`;
});
$('#view').html(h + '</div>');
}
/* ---------- postazione di una linea ---------- */
function renderLine() {
const l = lineOf(line);
const a = S.overview.find(j => j.line === line && (j.status === ST.RUN || j.status === ST.PAUSE));
const problems = S.overview.filter(j => j.line === line && j.status === ST.PROBLEM);
const q = S.queue || [];
let hero;
if (a) {
const run = a.status === ST.RUN;
const dz = daysTo(a.data_zibo);
hero = `<div class="hero ${run ? 'run' : 'pause'}" style="--lc:${esc(l.color)}">
<div class="h-top"><span class="pill ${run ? 'run' : 'pause'}">${run ? 'In produzione' : 'In pausa'}</span>
<span class="h-order">${a.ordine ? 'Ordine ' + esc(a.ordine) : ''}</span></div>
<div class="h-mat"><div class="name">${esc(a.matrice)}</div>${a.photo ? `<img src="photos/matrici/${esc(a.photo)}" class="zoom" data-src="photos/matrici/${esc(a.photo)}" alt="">` : ''}</div>
<div class="h-cli">${esc(a.cliente || 'Nessun cliente')}</div>
${a.mescole.length ? `<div class="chips">${a.mescole.map(m => `<span class="schip">${esc(m)}</span>`).join('')}</div>` : ''}
${!run ? `<div><span class="reason ${a.open_reason ? '' : 'missing'}"><i class="bi bi-pause-circle-fill"></i>${a.open_reason ? esc(a.open_reason) : 'Motivo della pausa da indicare'}</span></div>` : ''}
<div class="h-body">
<div class="ring" id="ring">${ringSvg()}<div class="in"><div class="pct" id="ringPct">—</div><div class="lbl">${a.ore ? 'di ' + num(a.ore, 1) + ' h' : 'ore non previste'}</div></div></div>
<div>
<div class="timer" id="tMain">--:--:--</div>
<div class="t-sub" id="tSub"></div>
<div class="kpis">
${a.metri ? `<div class="kpi"><div class="v">${num(a.metri)}</div><div class="l">metri da fare</div></div>` : ''}
${a.kg_sp ? `<div class="kpi"><div class="v">${num(a.kg_sp)}</div><div class="l">kg teorici</div></div>` : ''}
<div class="kpi ${dz !== null && dz < 0 ? 'late' : (dz !== null && dz <= 1 ? 'soon' : '')}"><div class="v">${dShort(a.data_zibo)}</div><div class="l">data Zibo</div></div>
<div class="kpi" id="kEta"><div class="v">—</div><div class="l">fine prevista</div></div>
</div>
</div>
</div>
<div class="big-actions">
${run ? `<button class="big pause" data-act="pause"><i class="bi bi-pause-fill"></i> PAUSA</button>`
: `<button class="big go" data-act="resume"><i class="bi bi-play-fill"></i> RIPRENDI</button>`}
<button class="big stop" data-act="finish"><i class="bi bi-stop-fill"></i> FINE</button>
</div>
<div class="tools">
<button class="tool" data-photo="lotto_mescola"><span class="n ${a.photos.lotto_mescola ? '' : 'zero'}">${a.photos.lotto_mescola}</span><i class="bi bi-camera-fill"></i>Lotto mescola</button>
<button class="tool" data-photo="parametri_macchina"><span class="n ${Object.keys(a.photos.slots).length ? '' : 'zero'}">${Object.keys(a.photos.slots).length}/${(S.params || []).length || '–'}</span><i class="bi bi-sliders"></i>Parametri</button>
<button class="tool danger" data-photo="problema"><span class="n ${a.photos.problema ? '' : 'zero'}">${a.photos.problema}</span><i class="bi bi-exclamation-triangle-fill"></i>Foto problema</button>
<button class="tool" data-files="${a.matrice_id}"><i class="bi bi-rulers"></i>Disegni</button>
<button class="tool mago" data-info="${a.id}"><i class="bi bi-card-text"></i>Dettagli</button>
</div>
</div>`;
} else {
const n = q[0];
hero = `<div class="hero free" style="--lc:${esc(l.color)}">
<div class="h-top"><span class="pill free">Linea libera</span></div>
${n ? `<div class="h-cli" style="margin-top:18px;color:var(--muted);font-size:1.1rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em">Prossimo lavoro</div>
<div class="h-mat" style="margin-top:6px"><div class="name">${esc(n.matrice)}</div>${n.photo ? `<img src="photos/matrici/${esc(n.photo)}" class="zoom" data-src="photos/matrici/${esc(n.photo)}" alt="">` : ''}</div>
<div class="h-cli">${esc(n.cliente || 'Nessun cliente')}${n.ordine ? ' · ' + esc(n.ordine) : ''}</div>
${n.mescole.length ? `<div class="chips">${n.mescole.map(m => `<span class="schip">${esc(m)}</span>`).join('')}</div>` : ''}
<div class="kpis">
${n.metri ? `<div class="kpi"><div class="v">${num(n.metri)}</div><div class="l">metri</div></div>` : ''}
<div class="kpi"><div class="v">${n.ore ? num(n.ore, 1) + ' h' : '?'}</div><div class="l">ore previste</div></div>
<div class="kpi ${daysTo(n.data_zibo) !== null && daysTo(n.data_zibo) < 0 ? 'late' : ''}"><div class="v">${dShort(n.data_zibo)}</div><div class="l">data Zibo</div></div>
</div>
<div style="flex:1"></div>
<div class="big-actions"><button class="big go" data-start="${n.id}"><i class="bi bi-play-fill"></i> AVVIA ${esc(n.matrice)}</button></div>
<div class="tools" style="grid-template-columns:repeat(2,1fr)">
<button class="tool" data-files="${n.matrice_id}"><i class="bi bi-rulers"></i>Disegni</button>
<button class="tool mago" data-info="${n.id}"><i class="bi bi-card-text"></i>Dettagli</button>
</div>`
: `<div class="empty" style="margin:auto"><i class="bi bi-cup-hot" style="font-size:3rem;display:block;margin-bottom:10px"></i>Nessun lavoro programmato su questa linea</div>
<div class="big-actions"><button class="big go" id="heroQuick"><i class="bi bi-plus-lg"></i> NUOVO LAVORO</button></div>`}
</div>`;
}
const totH = q.reduce((s, j) => s + j.ore, 0);
let side = `<div class="side"><div class="side-h"><span class="t">In coda</span><span class="m">${q.length} · ${num(totH, 0)} h</span></div><div class="qlist">`;
problems.forEach(p => side += qCard(p, '!', true));
side += q.length ? q.map((j, i) => qCard(j, i + 1, false, !a && i === 0)).join('') : '<div class="empty">Coda vuota</div>';
side += '</div></div>';
$('#view').html(`<div class="main">${hero}${side}</div>`);
}
function qCard(j, pos, problem, next) {
const dz = daysTo(j.data_zibo);
const l = lineOf(j.line);
return `<button class="qc ${next ? 'next' : ''}" data-info="${j.id}" style="--lc:${problem ? 'var(--bad)' : esc(l.color)}">
<div class="r1"><span class="pos" ${problem ? 'style="color:#f87171"' : ''}>${pos}</span><span class="mat">${esc(j.matrice)}</span><span class="h">${problem ? '<span class="late">PROBLEMA</span>' : (j.ore ? num(j.ore, 1) + ' h' : '<span class="soon">? h</span>')}</span></div>
<div class="r2">${esc(j.cliente || 'Nessun cliente')}${j.ordine ? ' · ' + esc(j.ordine) : ''}</div>
<div class="r3">${j.metri ? `<span>${num(j.metri)} m</span>` : ''}<span class="${dz !== null && dz < 0 ? 'late' : (dz !== null && dz <= 2 ? 'soon' : '')}"><i class="bi bi-calendar-event"></i> ${dShort(j.data_zibo)}</span>${j.mescole.length ? `<span>${esc(j.mescole[0])}${j.mescole.length > 1 ? ' +' + (j.mescole.length - 1) : ''}</span>` : ''}</div>
</button>`;
}
function ringSvg() {
const r = 100,
c = 2 * Math.PI * r;
return `<svg viewBox="0 0 230 230"><circle class="bg" cx="115" cy="115" r="${r}" fill="none" stroke-width="18"/>
<circle class="fg" id="ringFg" cx="115" cy="115" r="${r}" fill="none" stroke-width="18" stroke-linecap="round" stroke="var(--run)" stroke-dasharray="${c}" stroke-dashoffset="${c}"/></svg>`;
}
/* aggiornamento ogni secondo: orologio, timer, anello */
function tick() {
const d = new Date(now());
$('#clock').text(hm(d));
if (!S) return;
$('[data-ov]').each(function() {
const j = S.overview.find(x => x.id === +this.dataset.ov);
if (j) $(this).text(hms(j.status === ST.PAUSE ? times(j).cur : times(j).net)).toggleClass('run', j.status === ST.RUN).css('color', j.status === ST.PAUSE ? 'var(--pauseink)' : '');
});
if (!line) return;
const a = S.overview.find(j => j.line === line && (j.status === ST.RUN || j.status === ST.PAUSE));
if (!a) return;
const t = times(a);
const run = a.status === ST.RUN;
if (run) {
$('#tMain').text(hms(t.net)).css('color', '');
$('#tSub').html(`in macchina da <b>${hms(t.gross)}</b> · pause <b>${hms(t.pause)}</b>`);
} else {
$('#tMain').text(hms(t.cur)).css('color', 'var(--pauseink)');
$('#tSub').html(`in pausa · lavorato <b>${hms(t.net)}</b>`);
}
if (a.ore > 0) {
const p = t.net / (a.ore * H);
const c = 2 * Math.PI * 100;
$('#ringFg').attr('stroke-dashoffset', c * (1 - Math.min(1, p))).attr('stroke', p > 1 ? 'var(--bad)' : (run ? 'var(--run)' : 'var(--pause)'));
$('#ringPct').text(Math.round(p * 100) + '%').css('color', p > 1 ? 'var(--badink)' : (run ? 'var(--runink)' : 'var(--pauseink)'));
const end = new Date(now() + Math.max(0, a.ore * H - t.net));
const late = a.data_zibo && end > new Date(a.data_zibo + 'T23:59:59');
const dd = Math.round((new Date(end.toDateString()) - new Date(new Date(now()).toDateString())) / 864e5);
$('#kEta').toggleClass('late', !!late).find('.v').text(p >= 1 ? 'oltre' : (dd === 0 ? hm(end) : (dd < 7 ? GG[end.getDay()] + ' ' + hm(end) : `${String(end.getDate()).padStart(2, '0')}/${String(end.getMonth() + 1).padStart(2, '0')}`)));
}
}
setInterval(tick, 1000);
setInterval(() => {
if (!sheetOpen && !$('#viewer').hasClass('on')) load();
}, 20000);
/* ---------- navigazione ---------- */
$(document).on('click', '.tab, .ovc', function() {
line = +this.dataset.line;
try {
localStorage.setItem('station_line', String(line));
} catch (e) {}
beep();
load();
});
/* ---------- pannello dal basso ---------- */
function openSheet(title, html) {
$('#sheetTitle').html(title);
$('#sheetBody').html(html).scrollTop(0);
$('#sheet, #sheetBg').addClass('on');
sheetOpen = true;
}
function closeSheet() {
$('#sheet, #sheetBg').removeClass('on');
sheetOpen = false;
photoCtx = null;
if ($('#qMat').data('select2')) $('#qMat, #qMes').select2('destroy');
}
$('#sheetClose, #sheetBg').on('click', closeSheet);
const findJob = id => S.overview.find(j => j.id === id) || (S.queue || []).find(j => j.id === id);
/* ---------- dettagli lavoro (descrizione Mago compatta qui) ---------- */
$(document).on('click', '[data-info]', function(e) {
e.stopPropagation();
const j = findJob(+this.dataset.info);
if (!j) return;
const lineBusy = S.overview.some(x => x.line === j.line && (x.status === ST.RUN || x.status === ST.PAUSE));
const canStart = j.status === ST.PROG && !lineBusy;
openSheet(`${esc(j.matrice)} <small style="color:var(--muted);font-weight:700">· ${esc(j.cliente || '')}</small>`, `
<div class="dl">
<div><span>Ordine</span><b>${esc(j.ordine || '—')}</b></div>
<div class="${daysTo(j.data_zibo) !== null && daysTo(j.data_zibo) < 0 ? 'late' : (daysTo(j.data_zibo) !== null && daysTo(j.data_zibo) <= 2 ? 'soon' : '')}"><span>Data Zibo</span><b>${dShort(j.data_zibo)}</b></div>
<div><span>Data cliente</span><b>${dShort(j.data_cliente)}</b></div>
<div><span>Metri</span><b>${j.metri ? num(j.metri) : '—'}</b></div>
<div><span>Kg teorici</span><b>${j.kg_sp ? num(j.kg_sp) : '—'}</b></div>
<div><span>Ore previste</span><b>${j.ore ? num(j.ore, 1) : '—'}</b></div>
</div>
${j.mescole.length ? `<div class="fld"><label>Mescole</label><div class="chips" style="margin:0">${j.mescole.map(m => `<span class="schip">${esc(m)}</span>`).join('')}</div></div>` : ''}
${j.tools.length ? `<div class="fld"><label>Attrezzi</label><div class="chips" style="margin:0">${j.tools.map(m => `<span class="schip">${esc(m)}</span>`).join('')}</div></div>` : ''}
<div class="fld"><label>Descrizione</label><div class="descr">${j.note ? esc(j.note) : '<span style="color:var(--muted)">Nessuna descrizione</span>'}</div></div>
${j.note_operatore ? `<div class="fld"><label>Nota operatore</label><div class="descr">${esc(j.note_operatore)}</div></div>` : ''}
<div class="sheet-f">
<button class="big ghost" data-files="${j.matrice_id}"><i class="bi bi-rulers"></i> Disegni</button>
${j.status === ST.PROG ? `<button class="big go" data-start="${j.id}" ${canStart ? '' : 'disabled'}><i class="bi bi-play-fill"></i> ${canStart ? 'AVVIA' : 'LINEA OCCUPATA'}</button>` : ''}
</div>`);
});
/* ---------- avvio ---------- */
$(document).on('click', '[data-start]', function() {
const id = +this.dataset.start;
const j = findJob(id);
beep();
openSheet('Avviare la produzione?', `
<div class="h-mat" style="margin:0 0 6px"><div class="name" style="font-size:2.6rem">${esc(j.matrice)}</div></div>
<div class="h-cli" style="margin-bottom:10px">${esc(j.cliente || '')} · ${esc(lineOf(j.line).name || '')}</div>
<div class="sheet-f"><button class="big ghost" id="cancelSheet">ANNULLA</button><button class="big go" id="doStart"><i class="bi bi-play-fill"></i> AVVIA ORA</button></div>`);
$('#doStart').on('click', () => post({
a: 'start',
id
}).then(() => {
beep();
closeSheet();
toast('Produzione avviata');
load();
}));
});
$(document).on('click', '#cancelSheet', closeSheet);
/* ---------- pausa / ripresa / fine ---------- */
function reasonTiles(withLater) {
return `<div class="tiles" id="rTiles">
${S.reasons.map(r => `<button class="tile ${+r.is_problem ? 'problem' : ''}" data-r="${r.id}" data-p="${r.is_problem}"><i class="bi ${+r.is_problem ? 'bi-exclamation-octagon-fill' : 'bi-pause-circle'}"></i>${esc(r.name)}</button>`).join('')}
${withLater ? '<button class="tile" data-r="0" data-p="0"><i class="bi bi-clock-history"></i>Lo indico dopo</button>' : ''}
</div>`;
}
$(document).on('click', '#rTiles .tile', function() {
$('#rTiles .tile').removeClass('sel');
$(this).addClass('sel');
$('#needNote').toggle(+this.dataset.p === 1);
});
$(document).on('click', '[data-act]', function() {
const a = S.overview.find(j => j.line === line && (j.status === ST.RUN || j.status === ST.PAUSE));
if (!a) return;
const act = this.dataset.act;
beep();
if (act === 'pause') {
openSheet('<i class="bi bi-pause-fill" style="color:var(--pause)"></i> Motivo della pausa', `${reasonTiles(true)}
<div class="fld"><label>Nota <span id="needNote" style="display:none;color:#f87171">(obbligatoria per un problema)</span></label><textarea id="pNote" rows="2"></textarea></div>
<div class="sheet-f"><button class="big ghost" id="cancelSheet">ANNULLA</button><button class="big pause" id="doPause"><i class="bi bi-pause-fill"></i> METTI IN PAUSA</button></div>`);
$('#doPause').on('click', () => {
const sel = $('#rTiles .sel');
if (!sel.length) return toast('Tocca un motivo (o "Lo indico dopo")', 'err');
post({
a: 'pause',
id: a.id,
reason_id: sel.data('r'),
note: $('#pNote').val()
}).then(() => {
closeSheet();
toast('In pausa');
load();
});
});
}
if (act === 'resume') {
const doResume = rid => post({
a: 'resume',
id: a.id,
reason_id: rid || 0
}).then(() => {
beep();
closeSheet();
toast('Produzione ripresa');
load();
});
if (a.open_reason) {
openSheet('Riprendere la produzione?', `<div class="sheet-f"><button class="big ghost" id="cancelSheet">ANNULLA</button><button class="big go" id="doResume"><i class="bi bi-play-fill"></i> RIPRENDI</button></div>`);
$('#doResume').on('click', () => doResume(0));
} else {
openSheet('Prima di riprendere: perché si è fermata?', `${reasonTiles(false)}
<div class="sheet-f"><button class="big ghost" id="cancelSheet">ANNULLA</button><button class="big go" id="doResume"><i class="bi bi-play-fill"></i> RIPRENDI</button></div>`);
$('#doResume').on('click', () => {
const sel = $('#rTiles .sel');
if (!sel.length) return toast('Tocca il motivo della pausa', 'err');
doResume(sel.data('r'));
});
}
}
if (act === 'finish') finishStep1(a);
});
function finishStep1(a) {
openSheet('<i class="bi bi-stop-fill" style="color:var(--bad)"></i> Come è finita?', `
<div class="tiles" style="grid-template-columns:1fr 1fr">
<button class="tile huge" id="fOk"><i class="bi bi-check-circle-fill" style="color:var(--run)"></i>Fine produzione</button>
<button class="tile huge problem" id="fKo"><i class="bi bi-exclamation-octagon-fill"></i>Problema</button>
</div>`);
$('#fOk').on('click', () => finishStep2(a, 'fine'));
$('#fKo').on('click', () => finishStep2(a, 'problema'));
}
function finishStep2(a, reason) {
openSheet(reason === 'fine' ? 'Dati di fine produzione' : '<span class="late">Fine con problema</span>', `
<div class="grid2">
<div class="fld"><label>Kg prodotti</label><input type="text" inputmode="decimal" id="fKg" autocomplete="off"><div class="teo">teorici <b>${num(a.kg_sp, 1)}</b></div></div>
<div class="fld"><label>Metri prodotti</label><input type="text" inputmode="decimal" id="fMt" autocomplete="off"><div class="teo">teorici <b>${num(a.metri)}</b></div></div>
<div class="fld"><label>Scarto kg</label><input type="text" inputmode="decimal" id="fSc" autocomplete="off"></div>
<div class="fld"><label>Scarto %</label><input type="text" id="fScp" readonly tabindex="-1" style="color:var(--muted)"></div>
</div>
<div class="fld"><label>Nota${reason === 'problema' ? ' <span class="late">(obbligatoria)</span>' : ''}</label><textarea id="fNote" rows="2">${esc(a.note_operatore || '')}</textarea></div>
<div class="sheet-f">
<button class="big ghost" id="cancelSheet">ANNULLA</button>
<button class="big violet" id="fLater"><i class="bi bi-clock-history"></i> COMPILA DOPO</button>
<button class="big ${reason === 'fine' ? 'go' : 'stop'}" id="fSave"><i class="bi bi-check-lg"></i> CONFERMA</button>
</div>`);
const n = s => +String($(s).val() || '').replace(',', '.') || 0;
$('#fKg, #fSc').on('input', () => {
const t = n('#fKg') + n('#fSc');
$('#fScp').val(t > 0 ? num(n('#fSc') / t * 100, 2) + ' %' : '');
});
let armed = false;
const send = later => post({
a: 'finish',
id: a.id,
reason,
later: later ? 1 : 0,
kgprod: n('#fKg'),
mtprod: n('#fMt'),
scarto: n('#fSc'),
note: $('#fNote').val()
}).then(() => {
beep();
closeSheet();
toast(later ? 'Chiusa: dati da compilare' : (reason === 'fine' ? 'Produzione terminata · a Qualità' : 'Chiusa con problema'));
load();
});
$('#fSave').on('click', () => send(false));
// "compila dopo": secondo tocco per confermare
$('#fLater').on('click', function() {
if (!armed) {
armed = true;
$(this).html('<i class="bi bi-question-circle"></i> TOCCA DI NUOVO');
return;
}
send(true);
});
}
/* ---------- foto ---------- */
$(document).on('click', '[data-photo]', function() {
const a = S.overview.find(j => j.line === line && (j.status === ST.RUN || j.status === ST.PAUSE));
if (!a) return;
openPhotos(a, this.dataset.photo);
});
function openPhotos(a, type) {
photoCtx = {
id: a.id,
type,
slot: null
};
const T = {
lotto_mescola: 'Lotto mescola',
parametri_macchina: 'Parametri macchina',
problema: 'Problema'
};
openSheet(`<i class="bi bi-camera-fill"></i> Foto · ${esc(a.matrice)}`, `
<div class="seg">${Object.keys(T).map(k => `<button data-seg="${k}" class="${k === type ? 'on' : ''}">${T[k]}</button>`).join('')}</div>
<div id="photoArea"></div>`);
photoArea(a);
}
$(document).on('click', '[data-seg]', function() {
const a = S.overview.find(j => j.id === photoCtx.id);
photoCtx.type = this.dataset.seg;
photoCtx.slot = null;
$('[data-seg]').removeClass('on');
$(this).addClass('on');
photoArea(a);
});
// foto del lavoro lette direttamente da production_photos (con posizione e ora)
let PH = [];
function loadPhotos(id) {
return $.getJSON('?a=photos&id=' + id).then(r => {
PH = (r && r.rows) || [];
});
}
const camButtons = slot => `<div class="cam2">
<button class="cam" data-cam="${slot || 0}" data-src="cam"><i class="bi bi-camera-fill"></i> SCATTA</button>
<button class="cam alt" data-cam="${slot || 0}" data-src="gal"><i class="bi bi-images"></i> DALLA GALLERIA</button>
</div>`;
const when = s => s ? `${s.slice(11, 16)} · ${s.slice(8, 10)}/${s.slice(5, 7)}` : '';
const thumbs = list => list.length ?
`<div class="gal">${list.map((p, i) => `<button class="ph" data-ph="${p.id}"><img src="photos/${esc(p.filename)}" alt="" loading="lazy"><span>${p.param_position ? 'P' + p.param_position + ' · ' : ''}${when(p.created_at)}</span></button>`).join('')}</div>` :
'<div class="teo">Nessuna foto ancora</div>';
function photoArea(a) {
$('#photoArea').html('<div class="teo"><i class="bi bi-arrow-repeat spin"></i> Caricamento…</div>');
loadPhotos(a.id).then(() => drawPhotos(a), () => $('#photoArea').html('<div class="teo late">Foto non disponibili</div>'));
}
function drawPhotos(a) {
const type = photoCtx.type;
const list = PH.filter(p => p.photo_type === type);
if (type === 'parametri_macchina' && (S.params || []).length) {
// dettaglio di un parametro
if (photoCtx.slot) {
const p = S.params.find(x => +x.position === photoCtx.slot) || {};
const sl = list.filter(x => +x.param_position === photoCtx.slot);
$('#photoArea').html(`<button class="back" id="slotBack"><i class="bi bi-arrow-left"></i> Tutti i parametri</button>
<div class="slot-h"><i class="bi ${esc(p.icon || 'bi-sliders')}"></i><b>${esc(p.short_label || '')}</b> ${esc(p.label || '')}</div>
${sl.length ? `<button class="ph big1" data-ph="${sl[0].id}"><img src="photos/${esc(sl[0].filename)}" alt=""><span>ultima · ${when(sl[0].created_at)}</span></button>` : '<div class="teo" style="margin:10px 0">Nessuna foto per questo parametro</div>'}
${camButtons(photoCtx.slot)}
${sl.length > 1 ? '<div class="sec2">Foto precedenti</div>' + thumbs(sl.slice(1)) : ''}`);
return;
}
// griglia dei parametri con l'ultima foto di ciascuno
$('#photoArea').html(`<div class="slots">${S.params.map(p => {
const last = list.find(x => +x.param_position === +p.position);
const n = list.filter(x => +x.param_position === +p.position).length;
return `<button class="slot ${last ? 'done' : ''}" data-slot="${p.position}">
${last ? `<img src="photos/${esc(last.filename)}" alt="" loading="lazy">` : `<i class="bi ${esc(p.icon || 'bi-camera')}"></i>`}
<span class="sl">${esc(p.short_label)}${n > 1 ? ` <em>${n}</em>` : ''}</span><small>${last ? when(last.created_at) : esc(p.label)}</small></button>`;
}).join('')}</div>
<div class="teo" style="margin:12px 0">Tocca un parametro per vedere la foto o farne una nuova. Bordo verde = foto presente.</div>`);
return;
}
$('#photoArea').html(camButtons(0) + thumbs(list));
}
$(document).on('click', '[data-slot]', function() {
photoCtx.slot = +this.dataset.slot;
drawPhotos(S.overview.find(j => j.id === photoCtx.id));
});
$(document).on('click', '#slotBack', function() {
photoCtx.slot = null;
drawPhotos(S.overview.find(j => j.id === photoCtx.id));
});
// scatta con la fotocamera oppure scegli dalla galleria del tablet
$(document).on('click', '[data-cam]', function() {
photoCtx.slot = +this.dataset.cam || photoCtx.slot || null;
$(this.dataset.src === 'gal' ? '#galInput' : '#camInput').val('').trigger('click');
});
// visore foto a schermo intero, con scorrimento tra le foto dello stesso gruppo
let viewList = [],
viewIdx = 0;
$(document).on('click', '[data-ph]', function() {
const id = +this.dataset.ph;
const cur = PH.find(p => p.id === id);
viewList = PH.filter(p => p.photo_type === cur.photo_type && (!photoCtx.slot || +p.param_position === photoCtx.slot));
viewIdx = Math.max(0, viewList.findIndex(p => p.id === id));
showPhoto();
});
function showPhoto() {
const p = viewList[viewIdx];
if (!p) return;
const par = p.param_position ? (S.params || []).find(x => +x.position === +p.param_position) : null;
$('#viewerTitle').text(`${par ? par.short_label + ' · ' + par.label + ' · ' : ''}${when(p.created_at)} (${viewIdx + 1}/${viewList.length})`);
$('#viewerBody').html(`<img src="photos/${esc(p.filename)}" alt="">
${viewList.length > 1 ? '<button class="vnav prev" id="vPrev"><i class="bi bi-chevron-left"></i></button><button class="vnav next" id="vNext"><i class="bi bi-chevron-right"></i></button>' : ''}`);
$('#viewer').addClass('on');
}
$(document).on('click', '#vPrev', () => {
viewIdx = (viewIdx - 1 + viewList.length) % viewList.length;
showPhoto();
});
$(document).on('click', '#vNext', () => {
viewIdx = (viewIdx + 1) % viewList.length;
showPhoto();
});
// scorrimento col dito
let tx = null;
$('#viewerBody').on('touchstart', e => tx = e.originalEvent.touches[0].clientX).on('touchend', e => {
if (tx === null || viewList.length < 2 || !$('#vNext').length) return;
const dx = e.originalEvent.changedTouches[0].clientX - tx;
tx = null;
if (Math.abs(dx) > 60) $(dx < 0 ? '#vNext' : '#vPrev').trigger('click');
});
function resize(file, max, q) {
return new Promise((ok, ko) => {
const fr = new FileReader();
fr.onload = e => {
const img = new Image();
img.onload = () => {
let w = img.width,
h = img.height;
if (w > h && w > max) {
h *= max / w;
w = max;
} else if (h > max) {
w *= max / h;
h = max;
}
const c = document.createElement('canvas');
c.width = w;
c.height = h;
c.getContext('2d').drawImage(img, 0, 0, w, h);
c.toBlob(b => ok(b), 'image/jpeg', q);
};
img.onerror = ko;
img.src = e.target.result;
};
fr.onerror = ko;
fr.readAsDataURL(file);
});
}
$('#camInput, #galInput').on('change', async function() {
const f = this.files[0];
if (!f || !photoCtx) return;
toast('Caricamento foto…');
try {
const blob = await resize(f, 1600, .7);
const fd = new FormData();
fd.append('production_id', photoCtx.id);
fd.append('photo_type', photoCtx.type);
fd.append('param_position', photoCtx.slot || '');
fd.append('photo', blob, f.name || 'foto.jpg');
$.ajax({
url: 'upload_photo.php',
type: 'POST',
data: fd,
processData: false,
contentType: false,
dataType: 'json'
}).then(r => {
if (!r.success) return toast(r.message || 'Foto non caricata', 'err');
beep();
toast('Foto salvata');
const a = S.overview.find(j => j.id === photoCtx.id);
if (a) {
if (photoCtx.slot) a.photos.slots[photoCtx.slot] = true;
a.photos[photoCtx.type] = (a.photos[photoCtx.type] || 0) + 1;
loadPhotos(a.id).then(() => drawPhotos(a));
}
}, () => toast('Errore di rete: foto non caricata', 'err'));
} catch (e) {
toast('Immagine non leggibile', 'err');
}
});
/* ---------- disegni e allegati della matrice ---------- */
$(document).on('click', '[data-files]', function(e) {
e.stopPropagation();
const id = +this.dataset.files;
openSheet('<i class="bi bi-rulers"></i> Disegni e file', '<div class="files" id="files"><div class="teo"><i class="bi bi-arrow-repeat spin"></i> Caricamento…</div></div>');
$.getJSON('get_matrice_attachments.php', {
id
}).then(r => {
const f = ((r && r.attachments) || []).sort((a, b) => (+b.is_disegno || 0) - (+a.is_disegno || 0));
if (!f.length) return $('#files').html('<div class="empty">Nessun file per questa matrice</div>');
$('#files').html(f.map(x => {
const name = x.file_name || '',
url = x.file_url || x.file_path || '',
ext = name.split('.').pop().toLowerCase();
const img = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext);
return `<button class="file ${+x.is_disegno ? 'dis' : ''}" data-view="${esc(url)}" data-name="${esc(name)}" data-kind="${img ? 'img' : (ext === 'pdf' ? 'pdf' : 'other')}">
<div class="ic">${img ? `<img src="${esc(url)}" alt="">` : (ext === 'pdf' ? '<i class="bi bi-file-earmark-pdf-fill" style="color:#f87171"></i>' : '<i class="bi bi-paperclip"></i>')}</div>
<div><b>${esc(name)}</b>${+x.is_disegno ? '<small style="color:#c4b5fd">Disegno tecnico</small>' : ''}<small>${esc(x.description || '')}</small></div>
</button>`;
}).join(''));
}).fail(() => $('#files').html('<div class="empty late">File non disponibili</div>'));
});
function view(url, name, kind) {
$('#viewerTitle').text(name || '');
$('#viewerBody').html(kind === 'img' ? `<img src="${esc(url)}" alt="">` : (kind === 'pdf' ? `<iframe src="${esc(url)}#zoom=page-width"></iframe>` : `<a href="${esc(url)}" target="_blank" class="big go" style="max-width:360px;text-decoration:none">APRI FILE</a>`));
$('#viewer').addClass('on');
}
$(document).on('click', '[data-view]', function() {
view(this.dataset.view, this.dataset.name, this.dataset.kind);
});
$(document).on('click', '.zoom', function(e) {
e.stopPropagation();
view(this.dataset.src, '', 'img');
});
$('#viewerClose').on('click', () => {
$('#viewer').removeClass('on').find('#viewerBody').html('');
viewList = [];
});
/* ---------- nuovo lavoro rapido ---------- */
let M = null;
$(document).on('click', '#btnQuick, #heroQuick', function() {
const go = () => {
openSheet('<i class="bi bi-plus-lg"></i> Nuovo lavoro', `
<div class="fld"><label>Matrice</label><select id="qMat"><option value=""></option>${M.rows.map(m => `<option value="${m.id}">${esc(m.nome)}</option>`).join('')}</select></div>
<div class="fld"><label>Linea</label><select id="qLin">${S.lines.map(l => `<option value="${l.id}" ${l.id === line ? 'selected' : ''}>${esc(l.name)}</option>`).join('')}</select></div>
<div class="fld"><label>Mescole</label><select id="qMes" multiple>${M.mescole.map(m => `<option value="${m.id}">${esc(m.nome)}</option>`).join('')}</select></div>
<div class="fld"><label>Descrizione</label><input type="text" id="qDes" autocomplete="off"></div>
<div class="sheet-f"><button class="big ghost" id="qQueue"><i class="bi bi-list-ol"></i> IN CODA</button><button class="big go" id="qStart"><i class="bi bi-play-fill"></i> CREA E AVVIA</button></div>`);
$('#qMat').select2({
dropdownParent: $('#sheet'),
placeholder: 'Cerca la matrice'
}).on('change', function() {
if (!this.value) return;
$.getJSON('?a=matmescole&id=' + this.value).then(r => $('#qMes').val(r.ids.map(String)).trigger('change'));
});
$('#qMes').select2({
dropdownParent: $('#sheet'),
closeOnSelect: false
});
const send = avvia => post({
a: 'quick',
idmatrice: $('#qMat').val(),
line: $('#qLin').val(),
mescole: $('#qMes').val() || [],
descrizione: $('#qDes').val(),
avvia: avvia ? 1 : 0
}).then(() => {
beep();
closeSheet();
toast(avvia ? 'Creato e avviato' : 'Aggiunto in coda');
load();
});
$('#qQueue').on('click', () => send(false));
$('#qStart').on('click', () => send(true));
};
if (M) go();
else $.getJSON('?a=matrici').then(r => {
M = r;
go();
});
});
/* ---------- colori: scuro / chiaro ---------- */
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
$('#btnTheme i').attr('class', t === 'light' ? 'bi bi-moon-stars' : 'bi bi-sun');
$('meta[name="theme-color"]').attr('content', t === 'light' ? '#e3edf8' : '#0b1220');
try {
localStorage.setItem('station_theme', t);
} catch (e) {}
}
$('#btnTheme').on('click', () => setTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'));
try {
setTheme(localStorage.getItem('station_theme') || 'dark');
} catch (e) {
setTheme('dark');
}
/* ---------- schermo intero e schermo sempre acceso ---------- */
$('#btnFull').on('click', () => {
try {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
} catch (e) {}
});
let lock = null;
async function keepAwake() {
try {
if ('wakeLock' in navigator && document.visibilityState === 'visible') lock = await navigator.wakeLock.request('screen');
} catch (e) {}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
keepAwake();
load();
}
});
keepAwake();
load();
});
</script>
</body>
</html>