diff --git a/db/migrations/20261020150202_create_kiosk.php b/db/migrations/20261020150202_create_kiosk.php
new file mode 100644
index 00000000..66ef89c2
--- /dev/null
+++ b/db/migrations/20261020150202_create_kiosk.php
@@ -0,0 +1,53 @@
+table('kiosk_settings', ['id' => false, 'primary_key' => ['setting_key'], 'comment' => 'Impostazioni schermo di reparto'])
+ ->addColumn('setting_key', 'string', ['limit' => 50])
+ ->addColumn('setting_value', 'text', ['null' => true])
+ ->addColumn('updated_at', 'datetime', ['null' => true])
+ ->create();
+
+ $this->table('kiosk_items', ['comment' => 'Contenuti dello schermo di reparto'])
+ ->addColumn('item_type', 'string', ['limit' => 20, 'comment' => 'comunicazione, benvenuto, turni, menu, ticker'])
+ ->addColumn('title', 'string', ['limit' => 150, 'default' => ''])
+ ->addColumn('body', 'text', ['null' => true])
+ ->addColumn('image', 'string', ['limit' => 255, 'default' => ''])
+ ->addColumn('valid_from', 'date', ['null' => true])
+ ->addColumn('valid_to', 'date', ['null' => true])
+ ->addColumn('sort', 'integer', ['default' => 0])
+ ->addColumn('active', 'boolean', ['default' => true])
+ ->addColumn('created_by', 'string', ['limit' => 100, 'default' => ''])
+ ->addColumn('created_at', 'datetime', ['null' => true])
+ ->addColumn('updated_at', 'datetime', ['null' => true])
+ ->addIndex(['item_type', 'active'])
+ ->create();
+
+ $this->table('kiosk_goals', ['comment' => 'Obiettivi mostrati sullo schermo di reparto'])
+ ->addColumn('title', 'string', ['limit' => 150])
+ ->addColumn('source', 'string', ['limit' => 30, 'default' => 'manuale', 'comment' => 'manuale, metri_mese, kg_mese, lavori_mese, scarto_mese, fermi_mese'])
+ ->addColumn('unit', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('target', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0])
+ ->addColumn('current_value', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true, 'comment' => 'Solo per gli obiettivi manuali'])
+ ->addColumn('lower_is_better', 'boolean', ['default' => false])
+ ->addColumn('period_from', 'date', ['null' => true])
+ ->addColumn('period_to', 'date', ['null' => true])
+ ->addColumn('sort', 'integer', ['default' => 0])
+ ->addColumn('active', 'boolean', ['default' => true])
+ ->addColumn('updated_at', 'datetime', ['null' => true])
+ ->create();
+ }
+}
diff --git a/public/userarea/class/Kiosk.php b/public/userarea/class/Kiosk.php
new file mode 100644
index 00000000..ba8cb431
--- /dev/null
+++ b/public/userarea/class/Kiosk.php
@@ -0,0 +1,384 @@
+ 'Linee adesso',
+ 'oggi' => 'Oggi e settimana',
+ 'andamento' => 'Andamento 14 giorni',
+ 'fermi' => 'Fermi della settimana',
+ 'coda' => 'Lavoro in arrivo',
+ 'sicurezza' => 'Giorni senza infortuni',
+ 'obiettivi' => 'Obiettivi',
+ 'comunicazioni' => 'Comunicazioni',
+ 'benvenuti' => 'Benvenuti e ringraziamenti',
+ 'turni' => 'Turni e calendario',
+ 'menu' => 'Menu',
+ ];
+
+ const ITEM_TYPES = [
+ 'comunicazione' => 'Comunicazione',
+ 'benvenuto' => 'Benvenuto / grazie',
+ 'turni' => 'Turni e calendario',
+ 'menu' => 'Menu',
+ 'ticker' => 'Avviso scorrevole',
+ ];
+
+ /** Obiettivi: calcolati dai dati (sul periodo dell'obiettivo) o inseriti a mano. */
+ const GOAL_SOURCES = [
+ 'manuale' => ['Valore inserito a mano', '', false],
+ 'metri_mese' => ['Metri prodotti', 'm', false],
+ 'kg_mese' => ['Kg prodotti', 'kg', false],
+ 'lavori_mese' => ['Lavori completati', '', false],
+ 'scarto_mese' => ['Scarto %', '%', true],
+ 'fermi_mese' => ['Ore di fermo', 'h', true],
+ ];
+
+ const DEFAULTS = [
+ 'company' => 'Zibo',
+ 'slide_seconds' => '15',
+ 'slides' => 'linee,oggi,andamento,fermi,coda,sicurezza,obiettivi,comunicazioni,benvenuti,turni,menu',
+ 'scarto_target' => '3',
+ 'last_incident' => '',
+ 'record_days' => '',
+ 'safety_note' => '',
+ 'city' => '',
+ 'lat' => '',
+ 'lon' => '',
+ 'token' => '',
+ ];
+
+ /* ===================== IMPOSTAZIONI ===================== */
+
+ public static function settings(PDO $pdo)
+ {
+ $s = self::DEFAULTS;
+ foreach ($pdo->query("SELECT setting_key, setting_value FROM kiosk_settings") as $r) {
+ $s[$r['setting_key']] = (string)$r['setting_value'];
+ }
+ return $s;
+ }
+
+ public static function set(PDO $pdo, $key, $value)
+ {
+ $pdo->prepare("INSERT INTO kiosk_settings (setting_key, setting_value, updated_at) VALUES (?, ?, NOW())
+ ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()")
+ ->execute([$key, (string)$value]);
+ }
+
+ /** Codice segreto del link dello schermo (creato al primo uso). */
+ public static function token(PDO $pdo, $renew = false)
+ {
+ $t = self::settings($pdo)['token'];
+ if ($t === '' || $renew) {
+ $t = bin2hex(random_bytes(12));
+ self::set($pdo, 'token', $t);
+ }
+ return $t;
+ }
+
+ /* ===================== DATE ===================== */
+
+ private static function tz()
+ {
+ return new DateTimeZone(self::TZ);
+ }
+
+ /** Mezzanotte locale di oggi + $days giorni. */
+ public static function day($days = 0)
+ {
+ $d = new DateTime('today', self::tz());
+ if ($days) $d->modify(($days > 0 ? '+' : '') . $days . ' day');
+ return $d;
+ }
+
+ public static function monday($weeks = 0)
+ {
+ $d = new DateTime('monday this week', self::tz());
+ if ($weeks) $d->modify(($weeks > 0 ? '+' : '') . $weeks . ' week');
+ return $d;
+ }
+
+ /** DateTime locale → stringa UTC per confrontare con start_time / end_time. */
+ public static function utc(DateTime $d)
+ {
+ $x = clone $d;
+ $x->setTimezone(new DateTimeZone('UTC'));
+ return $x->format('Y-m-d H:i:s');
+ }
+
+ public static function ms($utc)
+ {
+ return $utc ? (new DateTime($utc, new DateTimeZone('UTC')))->getTimestamp() * 1000 : null;
+ }
+
+ /* ===================== PRODUZIONE ===================== */
+
+ /** Lavori conclusi tra due istanti locali. */
+ public static function produced(PDO $pdo, DateTime $from, DateTime $to)
+ {
+ $st = $pdo->prepare("SELECT COUNT(*) n, COALESCE(SUM(mtprod), 0) mt, COALESCE(SUM(kgprod), 0) kg, COALESCE(SUM(scarto), 0) sc
+ FROM productiondata
+ WHERE end_time >= ? AND end_time < ? AND id_status NOT IN (" . implode(',', self::NOT_FINISHED) . ")");
+ $st->execute([self::utc($from), self::utc($to)]);
+ $r = $st->fetch(PDO::FETCH_ASSOC);
+ $kg = (float)$r['kg'];
+ $sc = (float)$r['sc'];
+ return [
+ 'n' => (int)$r['n'],
+ 'mt' => round((float)$r['mt']),
+ 'kg' => round($kg),
+ 'scarto' => ($kg + $sc) > 0 ? round($sc / ($kg + $sc) * 100, 1) : null,
+ ];
+ }
+
+ /** Ore di fermo (pause) tra due istanti locali, per motivo. */
+ public static function stops(PDO $pdo, DateTime $from, DateTime $to)
+ {
+ $f = self::utc($from);
+ $t = self::utc($to);
+ $st = $pdo->prepare("
+ SELECT COALESCE(pr.name, 'Motivo non indicato') AS name, COALESCE(MAX(pr.is_problem), 0) AS problem,
+ SUM(GREATEST(0, TIMESTAMPDIFF(SECOND, GREATEST(pp.start_pause, ?), LEAST(COALESCE(pp.end_pause, UTC_TIMESTAMP()), ?)))) AS sec
+ FROM production_pauses pp
+ LEFT JOIN pause_reasons pr ON pr.id = pp.reason_id
+ WHERE pp.start_pause < ? AND COALESCE(pp.end_pause, UTC_TIMESTAMP()) > ?
+ GROUP BY COALESCE(pr.name, 'Motivo non indicato')
+ ORDER BY sec DESC");
+ $st->execute([$f, $t, $t, $f]);
+ $items = [];
+ $tot = 0;
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
+ $h = (int)$r['sec'] / 3600;
+ if ($h <= 0) continue;
+ $tot += $h;
+ $items[] = ['name' => $r['name'], 'h' => round($h, 1), 'problem' => (int)$r['problem'] === 1];
+ }
+ return ['total_h' => round($tot, 1), 'items' => $items];
+ }
+
+ private static function lines(PDO $pdo)
+ {
+ $lines = [];
+ foreach ($pdo->query("SELECT * FROM production_lines ORDER BY line_number, id")->fetchAll(PDO::FETCH_ASSOC) as $l) {
+ if (isset($l['status']) && $l['status'] !== '' && $l['status'] !== 'active') continue;
+ $lines[(int)$l['id']] = [
+ 'id' => (int)$l['id'],
+ 'name' => $l['name'],
+ 'color' => !empty($l['color']) ? $l['color'] : '#64748b',
+ 'job' => null,
+ 'queue_n' => 0,
+ 'queue_h' => 0,
+ 'next' => null,
+ ];
+ }
+
+ // in macchina
+ $st = $pdo->query("
+ SELECT p.id, p.id_linea, p.id_status, p.start_time, p.ore_previste, m.nome AS matrice,
+ (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
+ FROM productiondata p LEFT JOIN matrice m ON m.id = p.idmatrice
+ WHERE p.id_status IN (" . self::ST_PRODUZIONE . "," . self::ST_PAUSA . ")
+ ORDER BY p.start_time");
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $j) {
+ $lid = (int)$j['id_linea'];
+ if (!isset($lines[$lid]) || $lines[$lid]['job']) continue;
+ $lines[$lid]['job'] = [
+ 'matrice' => $j['matrice'],
+ 'mescole' => (string)$j['mescole'],
+ 'paused' => (int)$j['id_status'] === self::ST_PAUSA,
+ 'start_ms' => self::ms($j['start_time']),
+ 'pause_sec' => (int)$j['pause_sec'],
+ 'open_pause_ms' => self::ms($j['open_pause']),
+ 'ore' => (float)$j['ore_previste'],
+ ];
+ }
+
+ // coda
+ foreach (
+ $pdo->query("SELECT p.id_linea, m.nome, p.ore_previste FROM productiondata p LEFT JOIN matrice m ON m.id = p.idmatrice
+ WHERE p.id_status = " . self::ST_PROGRAMMATO . " ORDER BY p.priority IS NULL, p.priority, p.id") as $r
+ ) {
+ $lid = (int)$r['id_linea'];
+ if (!isset($lines[$lid])) continue;
+ $lines[$lid]['queue_n']++;
+ $lines[$lid]['queue_h'] += (float)$r['ore_previste'];
+ if ($lines[$lid]['next'] === null) $lines[$lid]['next'] = $r['nome'];
+ }
+ foreach ($lines as &$l) $l['queue_h'] = round($l['queue_h'], 1);
+ unset($l);
+ return array_values($lines);
+ }
+
+ /** Metri prodotti negli ultimi $days giorni, per giorno e linea. */
+ private static function trend(PDO $pdo, array $lines, $days = 14)
+ {
+ $from = self::day(- ($days - 1));
+ $labels = [];
+ $idx = [];
+ $GG = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab'];
+ for ($i = 0; $i < $days; $i++) {
+ $d = (clone $from)->modify("+$i day");
+ $idx[$d->format('Y-m-d')] = $i;
+ $labels[] = $GG[(int)$d->format('w')] . ' ' . $d->format('d/m');
+ }
+ $series = [];
+ foreach ($lines as $l) $series[$l['id']] = ['name' => $l['name'], 'color' => $l['color'], 'data' => array_fill(0, $days, 0)];
+
+ $st = $pdo->prepare("SELECT id_linea, end_time, mtprod FROM productiondata
+ WHERE end_time >= ? AND id_status NOT IN (" . implode(',', self::NOT_FINISHED) . ")");
+ $st->execute([self::utc($from)]);
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
+ $d = (new DateTime($r['end_time'], new DateTimeZone('UTC')))->setTimezone(self::tz())->format('Y-m-d');
+ $lid = (int)$r['id_linea'];
+ if (isset($idx[$d], $series[$lid])) $series[$lid]['data'][$idx[$d]] += round((float)$r['mtprod']);
+ }
+ return ['labels' => $labels, 'series' => array_values($series)];
+ }
+
+ /** Contenuti validi oggi, raggruppati per tipo. */
+ public static function items(PDO $pdo, $onlyValid = true)
+ {
+ $sql = "SELECT * FROM kiosk_items" . ($onlyValid ? " WHERE active = 1 AND (valid_from IS NULL OR valid_from <= :d) AND (valid_to IS NULL OR valid_to >= :d2)" : "") . " ORDER BY sort, id DESC";
+ $st = $pdo->prepare($sql);
+ $today = self::day()->format('Y-m-d');
+ $st->execute($onlyValid ? ['d' => $today, 'd2' => $today] : []);
+ return $st->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ public static function goals(PDO $pdo, $onlyActive = true)
+ {
+ $rows = $pdo->query("SELECT * FROM kiosk_goals" . ($onlyActive ? " WHERE active = 1" : "") . " ORDER BY sort, id")->fetchAll(PDO::FETCH_ASSOC);
+ foreach ($rows as &$g) {
+ $from = $g['period_from'] ? new DateTime($g['period_from'], self::tz()) : new DateTime('first day of this month 00:00', self::tz());
+ $to = $g['period_to'] ? (new DateTime($g['period_to'], self::tz()))->modify('+1 day') : (new DateTime('first day of next month 00:00', self::tz()));
+ $now = new DateTime('now', self::tz());
+ $end = $to < $now ? $to : $now;
+ $cur = null;
+ switch ($g['source']) {
+ case 'metri_mese':
+ $cur = self::produced($pdo, $from, $end)['mt'];
+ break;
+ case 'kg_mese':
+ $cur = self::produced($pdo, $from, $end)['kg'];
+ break;
+ case 'lavori_mese':
+ $cur = self::produced($pdo, $from, $end)['n'];
+ break;
+ case 'scarto_mese':
+ $cur = self::produced($pdo, $from, $end)['scarto'];
+ break;
+ case 'fermi_mese':
+ $cur = self::stops($pdo, $from, $end)['total_h'];
+ break;
+ default:
+ $cur = $g['current_value'] !== null ? (float)$g['current_value'] : null;
+ }
+ $target = (float)$g['target'];
+ $lower = (int)$g['lower_is_better'] === 1;
+ $pct = null;
+ if ($cur !== null && $target > 0) {
+ $pct = $lower ? ($cur <= $target ? 100 : max(0, round($target / max($cur, 0.0001) * 100))) : min(100, round($cur / $target * 100));
+ }
+ $g['current'] = $cur;
+ $g['pct'] = $pct;
+ $g['ok'] = $cur !== null && ($lower ? $cur <= $target : $cur >= $target);
+ $g['period_label'] = $from->format('d/m') . ' – ' . (clone $to)->modify('-1 day')->format('d/m');
+ }
+ unset($g);
+ return $rows;
+ }
+
+ public static function safety(array $s)
+ {
+ if ($s['last_incident'] === '') return null;
+ $last = new DateTime($s['last_incident'], self::tz());
+ $days = (int)$last->diff(self::day())->format('%r%a');
+ $record = $s['record_days'] !== '' ? (int)$s['record_days'] : null;
+ return [
+ 'days' => max(0, $days),
+ 'record' => $record !== null ? max($record, $days) : null,
+ 'since' => $last->format('d/m/Y'),
+ 'note' => $s['safety_note'],
+ ];
+ }
+
+ /* ===================== TUTTO PER LO SCHERMO ===================== */
+
+ public static function data(PDO $pdo)
+ {
+ $s = self::settings($pdo);
+ $now = new DateTime('now', self::tz());
+ $lines = self::lines($pdo);
+
+ // oggi a quest'ora contro ieri alla stessa ora; settimana in corso contro la scorsa allo stesso punto
+ $today = self::day();
+ $yest = self::day(-1);
+ $yestSame = (clone $yest)->setTime((int)$now->format('H'), (int)$now->format('i'), (int)$now->format('s'));
+ $mon = self::monday();
+ $monPrev = self::monday(-1);
+ $samePrev = (clone $monPrev)->modify('+' . ($now->getTimestamp() - $mon->getTimestamp()) . ' seconds');
+
+ $items = ['comunicazione' => [], 'benvenuto' => [], 'turni' => [], 'menu' => [], 'ticker' => []];
+ foreach (self::items($pdo) as $it) {
+ if (!isset($items[$it['item_type']])) continue;
+ $items[$it['item_type']][] = [
+ 'id' => (int)$it['id'],
+ 'title' => $it['title'],
+ 'body' => (string)$it['body'],
+ 'image' => $it['image'],
+ ];
+ }
+
+ $goals = array_map(function ($g) {
+ return [
+ 'title' => $g['title'],
+ 'unit' => $g['unit'],
+ 'target' => (float)$g['target'],
+ 'current' => $g['current'],
+ 'pct' => $g['pct'],
+ 'ok' => $g['ok'],
+ 'lower' => (int)$g['lower_is_better'] === 1,
+ 'period' => $g['period_label'],
+ ];
+ }, self::goals($pdo));
+
+ return [
+ 'success' => true,
+ 'now_ms' => time() * 1000,
+ 'company' => $s['company'],
+ 'settings' => [
+ 'slide_seconds' => max(6, min(120, (int)$s['slide_seconds'] ?: 15)),
+ 'slides' => array_values(array_filter(explode(',', $s['slides']))),
+ 'scarto_target' => (float)str_replace(',', '.', $s['scarto_target']),
+ 'city' => $s['city'],
+ 'lat' => $s['lat'],
+ 'lon' => $s['lon'],
+ ],
+ 'lines' => $lines,
+ 'today' => ['cur' => self::produced($pdo, $today, $now), 'prev' => self::produced($pdo, $yest, $yestSame)],
+ 'week' => ['cur' => self::produced($pdo, $mon, $now), 'prev' => self::produced($pdo, $monPrev, $samePrev)],
+ 'trend' => self::trend($pdo, $lines),
+ 'stops' => self::stops($pdo, $mon, $now),
+ 'safety' => self::safety($s),
+ 'goals' => $goals,
+ 'items' => $items,
+ ];
+ }
+}
diff --git a/public/userarea/class/MagoProduction.php b/public/userarea/class/MagoProduction.php
index 42b937d0..a837d93e 100644
--- a/public/userarea/class/MagoProduction.php
+++ b/public/userarea/class/MagoProduction.php
@@ -447,6 +447,18 @@ class MagoProduction
return (int)$pdo->lastInsertId();
}
+ /**
+ * Prossima posizione libera (in fondo) per uno stato della programmazione.
+ * La pagina numera le posizioni di tutte le linee in un'unica sequenza per stato,
+ * quindi MAX + 1 mette il lavoro in fondo anche alla sua linea.
+ */
+ public static function nextPriority(PDO $pdo, $status)
+ {
+ $st = $pdo->prepare("SELECT COALESCE(MAX(priority), 0) + 1 FROM productiondata WHERE id_status = ?");
+ $st->execute([(int)$status]);
+ return (int)$st->fetchColumn();
+ }
+
/**
* Crea il lavoro in productiondata e collega la riga della coda.
* $d: idmatrice, id_linea, mescole[], metri, kg_sp, kg_p, ore_previste, data_zibo, data_cliente,
@@ -475,16 +487,19 @@ class MagoProduction
return MagoSalesStats::isValidDate((string)$v) ? $v : null;
};
+ $priority = self::nextPriority($pdo, $status);
+
$pdo->prepare("
INSERT INTO productiondata
(Data, data_produzione, conferma_ordine, idmatrice, id_linea, id_cliente, data_zibo, data_cliente,
- metri, kg_sp, kg_p, ore_previste, note_extra, id_status,
+ metri, kg_sp, kg_p, ore_previste, note_extra, id_status, priority,
fermomacchina, `hour`, hourprod, kgprod, mtprod, scarto)
VALUES
(CURDATE(), NULL, :conferma, :idmatrice, :id_linea, :id_cliente, :data_zibo, :data_cliente,
- :metri, :kg_sp, :kg_p, :ore, :note, :status,
+ :metri, :kg_sp, :kg_p, :ore, :note, :status, :priority,
0, '00:00:00', '00:00:00', 0, 0, 0)
")->execute([
+ 'priority' => $priority,
'conferma' => mb_substr($q['internal_ord_no'], 0, 50),
'idmatrice' => $idm,
'id_linea' => $lin,
diff --git a/public/userarea/kiosk.php b/public/userarea/kiosk.php
new file mode 100644
index 00000000..6ecfd866
--- /dev/null
+++ b/public/userarea/kiosk.php
@@ -0,0 +1,1486 @@
+safeLoad();
+ break;
+ }
+ }
+}
+require_once __DIR__ . '/class/db-functions.php';
+require_once __DIR__ . '/class/Kiosk.php';
+
+$pdo = DBHandlerSelect::getInstance()->getConnection();
+$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+$k = (string)($_GET['k'] ?? '');
+$token = Kiosk::settings($pdo)['token'];
+if ($token === '' || !hash_equals($token, $k)) {
+ http_response_code(403);
+ echo '
Schermo non autorizzato
Apri il link completo dalla pagina di gestione dello schermo.
';
+ exit;
+}
+
+if (($_GET['a'] ?? '') === 'data') {
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store');
+ try {
+ echo json_encode(Kiosk::data($pdo));
+ } catch (Throwable $e) {
+ echo json_encode(['success' => false, 'message' => $e->getMessage()]);
+ }
+ exit;
+}
+?>
+
+
+
+
+
+
+
+ Bacheca di reparto
+
+
+
+
+
+
+
+
+
+
Z
+
Bacheca di reparto
+
+
+
+
+
+
+
+
+
+ dati non aggiornati
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/kiosk_admin.php b/public/userarea/kiosk_admin.php
new file mode 100644
index 00000000..7908df29
--- /dev/null
+++ b/public/userarea/kiosk_admin.php
@@ -0,0 +1,1078 @@
+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 '';
+ 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),
+ '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()]);
+ }
+}
+?>
+
+
+
+
+
+
+
+
+ Schermo di reparto - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Schermo di reparto
+ Cosa vedono i dipendenti in sala pausa: contenuti, obiettivi e sicurezza
+
+
+
+
+
+
+
+
+
Contenuti
+
+
+
+
+
+
+
+
Obiettivi
+
+
+
+
+
+
+
+
+
Anteprima dal vivo
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/photos/13-19-1792504568.jpeg b/public/userarea/photos/13-19-1792504568.jpeg
new file mode 100644
index 00000000..a71d65ce
Binary files /dev/null and b/public/userarea/photos/13-19-1792504568.jpeg differ
diff --git a/public/userarea/photos/13-20-1792504579.jpeg b/public/userarea/photos/13-20-1792504579.jpeg
new file mode 100644
index 00000000..3f5b74d2
Binary files /dev/null and b/public/userarea/photos/13-20-1792504579.jpeg differ
diff --git a/public/userarea/photos/25-23-1792505763.jpeg b/public/userarea/photos/25-23-1792505763.jpeg
new file mode 100644
index 00000000..a71d65ce
Binary files /dev/null and b/public/userarea/photos/25-23-1792505763.jpeg differ
diff --git a/public/userarea/photos/25-24-1792505777.jpg b/public/userarea/photos/25-24-1792505777.jpg
new file mode 100644
index 00000000..5a6d80f7
Binary files /dev/null and b/public/userarea/photos/25-24-1792505777.jpg differ
diff --git a/public/userarea/photos/25-25-1792505798.jpeg b/public/userarea/photos/25-25-1792505798.jpeg
new file mode 100644
index 00000000..3f5b74d2
Binary files /dev/null and b/public/userarea/photos/25-25-1792505798.jpeg differ
diff --git a/public/userarea/photos/26-21-1792504777.jpeg b/public/userarea/photos/26-21-1792504777.jpeg
new file mode 100644
index 00000000..a71d65ce
Binary files /dev/null and b/public/userarea/photos/26-21-1792504777.jpeg differ
diff --git a/public/userarea/photos/26-22-1792504790.jpeg b/public/userarea/photos/26-22-1792504790.jpeg
new file mode 100644
index 00000000..3f5b74d2
Binary files /dev/null and b/public/userarea/photos/26-22-1792504790.jpeg differ
diff --git a/public/userarea/photos/26-26-1792505854.jpeg b/public/userarea/photos/26-26-1792505854.jpeg
new file mode 100644
index 00000000..3f5b74d2
Binary files /dev/null and b/public/userarea/photos/26-26-1792505854.jpeg differ
diff --git a/public/userarea/photos/kiosk/20261020_150706_5b7c1814.jpg b/public/userarea/photos/kiosk/20261020_150706_5b7c1814.jpg
new file mode 100644
index 00000000..f57a4fdc
Binary files /dev/null and b/public/userarea/photos/kiosk/20261020_150706_5b7c1814.jpg differ
diff --git a/public/userarea/photos/kiosk/20261021_071545_cadf2806.png b/public/userarea/photos/kiosk/20261021_071545_cadf2806.png
new file mode 100644
index 00000000..c006c132
Binary files /dev/null and b/public/userarea/photos/kiosk/20261021_071545_cadf2806.png differ
diff --git a/public/userarea/production_board.php b/public/userarea/production_board.php
new file mode 100644
index 00000000..67ce2e0b
--- /dev/null
+++ b/public/userarea/production_board.php
@@ -0,0 +1,1468 @@
+getConnection();
+$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+/* ===================== IMPOSTAZIONI ===================== */
+const PB_STATUS_TODO = 1; // Da programmare
+const PB_STATUS_PLAN = 6; // Programmato
+const PB_STATUS_ACTIVE = [2, 7, 8]; // In produzione, Pausa, Problema: "in macchina"
+const PB_SHIFT_START = 6; // ora di inizio turno per le linee che non lavorano 24 ore
+const PB_WORK_DAYS = [1, 2, 3, 4, 5]; // giorni lavorativi per le stime (1 = lunedì … 7 = domenica)
+const PB_DEFAULT_HOURS = 24; // ore al giorno se la linea non ha max_hours_per_day
+
+if (!userCan('production.programming.view')) {
+ if (isset($_GET['action']) || isset($_POST['action'])) {
+ MagoSalesStats::jsonOut(['success' => false, 'message' => 'Non hai i permessi per questa pagina']);
+ }
+ while (ob_get_level()) ob_end_clean();
+ http_response_code(403);
+ echo '';
+ exit;
+}
+
+function pbTableExists(PDO $pdo, $t)
+{
+ try {
+ $pdo->query("SELECT 1 FROM `$t` LIMIT 0");
+ return true;
+ } catch (Throwable $e) {
+ return false;
+ }
+}
+
+function pbLines(PDO $pdo)
+{
+ $out = [];
+ foreach ($pdo->query("SELECT * FROM production_lines ORDER BY line_number, id")->fetchAll(PDO::FETCH_ASSOC) as $l) {
+ $out[] = [
+ 'id' => (int)$l['id'],
+ 'name' => $l['name'],
+ 'color' => !empty($l['color']) ? $l['color'] : '#64748b',
+ 'hours' => !empty($l['max_hours_per_day']) && (float)$l['max_hours_per_day'] > 0 ? min(24, (float)$l['max_hours_per_day']) : PB_DEFAULT_HOURS,
+ 'active' => !isset($l['status']) || $l['status'] === '' || $l['status'] === 'active',
+ ];
+ }
+ return $out;
+}
+
+/* ===================== AJAX ===================== */
+$action = $_GET['action'] ?? $_POST['action'] ?? '';
+if ($action !== '') {
+ try {
+ // ---------- tabellone ----------
+ if ($action === 'board') {
+ $hasQueue = pbTableExists($pdo, 'mago_prod_queue');
+ $mago = $hasQueue
+ ? "LEFT JOIN (SELECT productiondata_id, MAX(mago_changed) AS mago_changed, MAX(mago_changes) AS mago_changes
+ FROM mago_prod_queue WHERE productiondata_id IS NOT NULL GROUP BY productiondata_id) q ON q.productiondata_id = p.id"
+ : '';
+ $magoCols = $hasQueue ? 'q.productiondata_id IS NOT NULL AS from_mago, COALESCE(q.mago_changed, 0) AS mago_changed, q.mago_changes' : '0 AS from_mago, 0 AS mago_changed, NULL AS mago_changes';
+
+ $in = implode(',', array_merge([PB_STATUS_TODO, PB_STATUS_PLAN], PB_STATUS_ACTIVE));
+ $jobs = $pdo->query("
+ SELECT p.id, p.id_status, p.id_linea, p.priority, p.conferma_ordine, p.idmatrice, m.nome AS matrice, m.photo,
+ p.id_cliente, 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.start_time, p.Data AS created,
+ s.nome AS status_nome, COALESCE(s.badge_color, '#6c757d') 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 GROUP_CONCAT(pm.id_mescola) FROM productiondata_mescole pm WHERE pm.id_productiondata = p.id) AS mescole_ids,
+ (SELECT GROUP_CONCAT(ml.idlinea) FROM matrici_lines ml WHERE ml.idmatrice = p.idmatrice) AS matrice_lines,
+ $magoCols
+ 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
+ $mago
+ WHERE p.id_status IN ($in)
+ ORDER BY p.priority IS NULL, p.priority, p.id
+ ")->fetchAll(PDO::FETCH_ASSOC);
+
+ foreach ($jobs as &$j) {
+ foreach (['id', 'id_status', 'id_linea', 'idmatrice', 'id_cliente', 'from_mago', 'mago_changed'] as $k) $j[$k] = $j[$k] === null ? null : (int)$j[$k];
+ foreach (['metri', 'kg_sp', 'kg_p', 'ore_previste'] as $k) $j[$k] = (float)$j[$k];
+ $j['mescole'] = $j['mescole'] ? explode('||', $j['mescole']) : [];
+ $j['mescole_ids'] = $j['mescole_ids'] ? array_map('intval', explode(',', $j['mescole_ids'])) : [];
+ $j['matrice_lines'] = $j['matrice_lines'] ? array_map('intval', explode(',', $j['matrice_lines'])) : [];
+ }
+ unset($j);
+
+ $nuoviMago = $hasQueue ? (int)$pdo->query("SELECT COUNT(*) FROM mago_prod_queue WHERE queue_status = 'nuovo' OR (queue_status = 'importato' AND mago_changed = 1)")->fetchColumn() : 0;
+
+ MagoSalesStats::jsonOut([
+ 'success' => true,
+ 'lines' => pbLines($pdo),
+ 'jobs' => $jobs,
+ 'nuoviMago' => $nuoviMago,
+ 'now' => date('Y-m-d H:i:s'),
+ ]);
+ }
+
+ // ---------- liste per il modulo ----------
+ if ($action === 'options') {
+ MagoSalesStats::jsonOut([
+ 'success' => true,
+ 'matrici' => $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),
+ 'clienti' => $pdo->query("SELECT id, nome FROM clients ORDER BY nome")->fetchAll(PDO::FETCH_ASSOC),
+ ]);
+ }
+
+ // ---------- dati tecnici della matrice (linee, mescole, velocità) ----------
+ if ($action === 'matinfo') {
+ MagoSalesStats::jsonOut(['success' => true, 'info' => MagoProduction::matriceInfo($pdo, (int)($_GET['id'] ?? 0))]);
+ }
+
+ // ---------- nuovo / modifica ----------
+ if ($action === 'save') {
+ $id = (int)($_POST['id'] ?? 0);
+ $idm = (int)($_POST['idmatrice'] ?? 0);
+ $lin = (int)($_POST['id_linea'] ?? 0);
+ if (!$idm) throw new RuntimeException('Scegli la matrice');
+ if (!$lin) throw new RuntimeException('Scegli la linea');
+ $status = (int)($_POST['id_status'] ?? PB_STATUS_TODO);
+ if (!in_array($status, [PB_STATUS_TODO, PB_STATUS_PLAN], true)) $status = PB_STATUS_TODO;
+ $date = function ($k) {
+ $v = trim((string)($_POST[$k] ?? ''));
+ return MagoSalesStats::isValidDate($v) ? $v : null;
+ };
+ $num = function ($k, $max = 99999999) {
+ return min($max, round((float)str_replace(',', '.', (string)($_POST[$k] ?? 0)), 2));
+ };
+ $f = [
+ 'conferma_ordine' => mb_substr(trim((string)($_POST['conferma_ordine'] ?? '')), 0, 50) ?: null,
+ 'idmatrice' => $idm,
+ 'id_linea' => $lin,
+ 'id_cliente' => (int)($_POST['id_cliente'] ?? 0) ?: null,
+ 'data_zibo' => $date('data_zibo'),
+ 'data_cliente' => $date('data_cliente'),
+ 'metri' => $num('metri'),
+ 'kg_sp' => $num('kg_sp'),
+ 'kg_p' => $num('kg_p'),
+ 'ore_previste' => $num('ore_previste', 999.99),
+ 'note_extra' => trim((string)($_POST['note_extra'] ?? '')) ?: null,
+ ];
+
+ $pdo->beginTransaction();
+ if ($id) {
+ $st = $pdo->prepare("SELECT id_status FROM productiondata WHERE id = ? FOR UPDATE");
+ $st->execute([$id]);
+ $cur = $st->fetchColumn();
+ if ($cur === false) throw new RuntimeException('Lavoro non trovato');
+ $cur = (int)$cur;
+ $set = implode(', ', array_map(fn($k) => "$k = :$k", array_keys($f)));
+ // lo stato si cambia da qui solo tra Da programmare e Programmato; chi cambia stato va in fondo
+ if (in_array($cur, [PB_STATUS_TODO, PB_STATUS_PLAN], true) && $status !== $cur) {
+ $set .= ', id_status = :st, priority = :pr';
+ $f['st'] = $status;
+ $f['pr'] = MagoProduction::nextPriority($pdo, $status);
+ }
+ $f['id'] = $id;
+ $pdo->prepare("UPDATE productiondata SET $set WHERE id = :id")->execute($f);
+ } else {
+ $f['id_status'] = $status;
+ $f['priority'] = MagoProduction::nextPriority($pdo, $status);
+ $cols = array_keys($f);
+ $pdo->prepare("INSERT INTO productiondata (Data, " . implode(', ', $cols) . ", fermomacchina, `hour`, hourprod, kgprod, mtprod, scarto)
+ VALUES (CURDATE(), :" . implode(', :', $cols) . ", 0, '00:00:00', '00:00:00', 0, 0, 0)")->execute($f);
+ $id = (int)$pdo->lastInsertId();
+ }
+ $pdo->prepare("DELETE FROM productiondata_mescole WHERE id_productiondata = ?")->execute([$id]);
+ $mescole = array_values(array_unique(array_filter(array_map('intval', (array)($_POST['mescole'] ?? [])))));
+ $ins = $pdo->prepare("INSERT INTO productiondata_mescole (id_productiondata, id_mescola) VALUES (?, ?)");
+ foreach ($mescole as $m) $ins->execute([$id, $m]);
+ $pdo->commit();
+ MagoSalesStats::jsonOut(['success' => true, 'id' => $id]);
+ }
+
+ // ---------- ordine dopo un trascinamento ----------
+ if ($action === 'order') {
+ $p = json_decode((string)($_POST['payload'] ?? ''), true);
+ if (!is_array($p)) throw new RuntimeException('Dati non validi');
+ $upd = $pdo->prepare("UPDATE productiondata SET id_status = ?, id_linea = ?, priority = ?
+ WHERE id = ? AND id_status IN (" . PB_STATUS_TODO . "," . PB_STATUS_PLAN . ")");
+ $updTodo = $pdo->prepare("UPDATE productiondata SET id_status = ?, priority = ?
+ WHERE id = ? AND id_status IN (" . PB_STATUS_TODO . "," . PB_STATUS_PLAN . ")");
+ $pdo->beginTransaction();
+ // i programmati sono numerati in un'unica sequenza, linea dopo linea (come la vista classica)
+ $n = 0;
+ foreach ((array)($p['lines'] ?? []) as $l) {
+ foreach ((array)($l['ids'] ?? []) as $id) $upd->execute([PB_STATUS_PLAN, (int)$l['line'], ++$n, (int)$id]);
+ }
+ $n = 0;
+ foreach ((array)($p['backlog'] ?? []) as $id) $updTodo->execute([PB_STATUS_TODO, ++$n, (int)$id]);
+ $pdo->commit();
+ MagoSalesStats::jsonOut(['success' => true]);
+ }
+
+ // ---------- elimina (solo lavori non ancora avviati) ----------
+ if ($action === 'delete') {
+ $id = (int)($_POST['id'] ?? 0);
+ $pdo->beginTransaction();
+ $st = $pdo->prepare("SELECT id_status FROM productiondata WHERE id = ? FOR UPDATE");
+ $st->execute([$id]);
+ $cur = $st->fetchColumn();
+ if ($cur === false) throw new RuntimeException('Lavoro non trovato');
+ if (!in_array((int)$cur, [PB_STATUS_TODO, PB_STATUS_PLAN], true)) throw new RuntimeException('Si possono eliminare solo i lavori non ancora avviati');
+ if (pbTableExists($pdo, 'mago_prod_queue')) {
+ // la riga Mago torna tra i "Nuovi da Mago"
+ $pdo->prepare("UPDATE mago_prod_queue SET queue_status = 'nuovo', productiondata_id = NULL, imported_at = NULL,
+ mago_changed = 0, mago_changes = '', updated_at = NOW() WHERE productiondata_id = ?")->execute([$id]);
+ }
+ $pdo->prepare("DELETE FROM productiondata_mescole WHERE id_productiondata = ?")->execute([$id]);
+ $pdo->prepare("DELETE FROM productiondata WHERE id = ?")->execute([$id]);
+ $pdo->commit();
+ MagoSalesStats::jsonOut(['success' => true]);
+ }
+
+ MagoSalesStats::jsonOut(['success' => false, 'message' => 'Azione sconosciuta']);
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ $msg = $e->getMessage();
+ if (strpos($msg, '1451') !== false) $msg = 'Il lavoro ha dati collegati (foto, registrazioni): eliminalo dalla vista classica';
+ MagoSalesStats::jsonOut(['success' => false, 'message' => $msg]);
+ }
+}
+?>
+
+
+
+
+
+
+
+
+ Programmazione - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Programmazione produzione
+ Trascina i lavori nelle linee: l'ordine nella colonna è l'ordine di produzione
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/production_line_view2.php b/public/userarea/production_line_view2.php
index 7e1b861e..344d25e7 100644
--- a/public/userarea/production_line_view2.php
+++ b/public/userarea/production_line_view2.php
@@ -1426,6 +1426,62 @@ if (!empty($_GET['ajax'])) {
color: #fff;
opacity: .95;
}
+
+ .descr-btn {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ width: 100%;
+ min-height: 44px;
+ padding: .55rem .8rem;
+ border: 1px solid #cbd5e1;
+ border-radius: .7rem;
+ background: #f8fafc;
+ color: #334155;
+ font-size: .95rem;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+ }
+
+ .descr-btn span {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: #334155;
+ font-weight: 600;
+ }
+
+ .descr-cell {
+ grid-column: span 3;
+ min-width: 0;
+ }
+
+ @media (max-width: 700px) {
+ .descr-cell {
+ grid-column: 1 / -1;
+ }
+ }
+
+ .descr-btn .bi-card-text {
+ color: #ea580c;
+ font-size: 1.2rem;
+ }
+
+ #descrText {
+ white-space: pre-wrap;
+ text-align: left;
+ font-size: 1.15rem;
+ line-height: 1.5;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: .8rem;
+ padding: 1rem 1.2rem;
+ max-height: 60vh;
+ overflow-y: auto;
+ }
@@ -2824,6 +2880,17 @@ if (!empty($_GET['ajax'])) {
$(document).on("click", "#closeMescole", function() {
$("#mescoleModal").removeClass("active");
});
+
+ // --- MODALE DESCRIZIONE ---
+ $(document).on("click", ".descr-btn", function(e) {
+ e.stopPropagation();
+ $("#descrTitle").text($(this).attr("data-title") || "Descrizione");
+ $("#descrText").text($(this).attr("data-descr") || "");
+ $("#descrModal").addClass("active");
+ });
+ $(document).on("click", "#closeDescr, #descrModal", function(e) {
+ if (e.target === this) $("#descrModal").removeClass("active");
+ });
@@ -2864,6 +2931,17 @@ if (!empty($_GET['ajax'])) {
+
+
+
+
Descrizione
+
+
+
+
+
+
+
diff --git a/public/userarea/production_station.php b/public/userarea/production_station.php
new file mode 100644
index 00000000..e70e0b51
--- /dev/null
+++ b/public/userarea/production_station.php
@@ -0,0 +1,2753 @@
+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) {
+ }
+}
+?>
+
+
+
+
+
+
+
+
+
Postazione linea - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/produzione_programmazione_drag.php b/public/userarea/produzione_programmazione_drag.php
index 257688e3..97e520d3 100644
--- a/public/userarea/produzione_programmazione_drag.php
+++ b/public/userarea/produzione_programmazione_drag.php
@@ -394,6 +394,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action'])) {
🆕 Nuovi da Mago = $nuoviMago ?>
+
+ Vista tabellone
+
diff --git a/public/userarea/render_production_card.php b/public/userarea/render_production_card.php
index a8346654..e6bdeb5d 100644
--- a/public/userarea/render_production_card.php
+++ b/public/userarea/render_production_card.php
@@ -81,10 +81,17 @@ if ($matricePhoto) {
-
-
+
+
+
Descrizione
- = htmlspecialchars($r['note_extra']) ?>
+
@@ -161,8 +168,8 @@ if ($matricePhoto) {
+ data-start="= $r['start_time'] ? strtotime($r['start_time'] . ' UTC') : '' ?>"
+ data-end="= $r['end_time'] ? strtotime($r['end_time'] . ' UTC') : '' ?>">
00:00:00