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

1468 lines
63 KiB
PHP

<?php
/**
* production_board.php - DEMO nuova programmazione produzione "a tabellone".
*
* Una colonna per linea: sopra cosa c'è in macchina, sotto la coda dei programmati nell'ordine di produzione.
* A sinistra i lavori da programmare. Si trascinano le schede tra le colonne; fine lavoro e ritardi sono stimati
* dalle ore previste. Legge e scrive le stesse tabelle della programmazione classica (productiondata, ...).
*/
ob_start();
include('include/headscript.php');
require_once __DIR__ . '/class/MagoProduction.php';
$db = DBHandlerSelect::getInstance();
$pdo = $db->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 '<div style="font-family:sans-serif;padding:60px;text-align:center;"><h3>Accesso non consentito</h3><p>Non hai i permessi per visualizzare questa pagina.</p><a href="production_dashboard.php">Torna alla Dashboard</a></div>';
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]);
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title>Programmazione - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<style>
:root {
--ink: #1f2d3d;
--muted: #64748b;
--line: #e2e8f0;
--late: #dc2626;
--soon: #d97706;
--ok: #16a34a;
}
body {
background: #f8fafc;
}
/* ---------- header ---------- */
.pb-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pb-title {
display: flex;
align-items: center;
gap: 14px;
}
.pb-title-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #2563eb, #60a5fa);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.45rem;
box-shadow: 0 6px 16px rgba(37, 99, 235, .28);
}
.pb-title h5 {
font-weight: 800;
margin: 0 0 2px;
}
.pb-title small {
color: var(--muted);
}
/* ---------- riepilogo ---------- */
.pb-sum {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.pb-sum .s {
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px 14px;
min-width: 130px;
cursor: default;
}
.pb-sum .s .n {
font-size: 1.35rem;
font-weight: 800;
line-height: 1.1;
color: var(--ink);
}
.pb-sum .s .l {
font-size: .74rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--muted);
font-weight: 700;
}
.pb-sum .s.click {
cursor: pointer;
}
.pb-sum .s.click:hover {
border-color: #94a3b8;
}
.pb-sum .s.on {
border-color: var(--late);
box-shadow: 0 0 0 2px rgba(220, 38, 38, .15);
}
.pb-sum .s.bad .n {
color: var(--late);
}
/* ---------- barra filtri ---------- */
.pb-tools {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.pb-search {
position: relative;
flex: 0 1 300px;
}
.pb-search i {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
}
.pb-search input {
padding-left: 32px;
border-radius: 10px;
}
.line-toggle {
border: 1.5px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 4px 12px 4px 8px;
font-weight: 700;
font-size: .85rem;
color: var(--ink);
display: inline-flex;
align-items: center;
gap: 6px;
}
.line-toggle .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--c);
}
.line-toggle.off {
opacity: .45;
text-decoration: line-through;
}
/* ---------- tabellone ---------- */
.board {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 10px;
align-items: flex-start;
}
.col {
flex: 1 1 0;
min-width: 280px;
background: #f1f5f9;
border-radius: 14px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 250px);
min-height: 300px;
}
.col.backlog {
background: #fff7ed;
}
.col-head {
padding: 12px 14px 10px;
border-top: 5px solid var(--c, #94a3b8);
border-radius: 14px 14px 0 0;
background: #fff;
border-bottom: 1px solid var(--line);
}
.col.backlog .col-head {
background: #fffbf5;
}
.col-head .t {
font-weight: 800;
font-size: 1.05rem;
color: var(--ink);
display: flex;
align-items: center;
gap: 8px;
}
.col-head .t .cnt {
margin-left: auto;
font-size: .8rem;
background: #e2e8f0;
border-radius: 999px;
padding: 1px 9px;
}
.col-head .meta {
font-size: .8rem;
color: var(--muted);
margin-top: 4px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.col-head .meta b {
color: var(--ink);
}
.col-head .meta .bad {
color: var(--late);
font-weight: 700;
}
.load {
height: 6px;
border-radius: 99px;
background: #e2e8f0;
margin-top: 8px;
overflow: hidden;
}
.load span {
display: block;
height: 100%;
background: var(--c, #64748b);
border-radius: 99px;
}
.col-body {
overflow-y: auto;
padding: 10px;
flex: 1;
}
.sec {
font-size: .72rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted);
margin: 2px 2px 6px;
}
.list {
min-height: 60px;
display: flex;
flex-direction: column;
gap: 8px;
border-radius: 10px;
padding-bottom: 30px;
}
.list.empty::after {
content: 'Trascina qui i lavori';
display: block;
text-align: center;
color: #94a3b8;
font-size: .85rem;
border: 2px dashed #cbd5e1;
border-radius: 10px;
padding: 18px 8px;
}
/* ---------- scheda lavoro ---------- */
.job {
background: #fff;
border: 1px solid var(--line);
border-left: 4px solid var(--c, #cbd5e1);
border-radius: 10px;
padding: 8px 10px;
cursor: grab;
font-size: .85rem;
color: var(--ink);
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
transition: box-shadow .15s;
}
.job:hover {
box-shadow: 0 4px 12px rgba(15, 23, 42, .1);
}
.job.late {
border-left-color: var(--late);
background: #fff8f8;
}
.job.soon {
border-left-color: var(--soon);
}
.job .r1 {
display: flex;
align-items: center;
gap: 6px;
}
.job .pos {
font-size: .72rem;
font-weight: 800;
background: #eef2ff;
color: #3730a3;
border-radius: 6px;
padding: 0 6px;
}
.job .mat {
font-weight: 800;
font-size: .95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job .ph {
width: 26px;
height: 26px;
border-radius: 5px;
object-fit: cover;
border: 1px solid var(--line);
}
.job .hrs {
margin-left: auto;
font-weight: 800;
white-space: nowrap;
}
.job .hrs.zero {
color: var(--soon);
}
.job .r2 {
color: var(--muted);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job .chips {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.mchip {
font-size: .72rem;
line-height: 1.4;
background: #f1f5f9;
border-radius: 6px;
padding: 1px 6px;
color: #334155;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.job .r3 {
display: flex;
gap: 8px;
align-items: center;
margin-top: 5px;
font-size: .78rem;
flex-wrap: wrap;
}
.dz {
font-weight: 700;
}
.dz.late {
color: var(--late);
}
.dz.soon {
color: var(--soon);
}
.eta {
margin-left: auto;
color: var(--muted);
white-space: nowrap;
}
.eta b {
color: var(--ink);
}
.tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 5px;
}
.tag {
font-size: .7rem;
font-weight: 700;
border-radius: 6px;
padding: 1px 6px;
}
.tag.mago {
background: #fff7ed;
color: #c2410c;
border: 1px solid #fed7aa;
}
.tag.late {
background: #fee2e2;
color: #b91c1c;
}
.tag.warn {
background: #fef3c7;
color: #92400e;
}
.tag.line {
background: #fff;
border: 1px solid var(--line);
color: var(--ink);
}
.tag.line i {
color: var(--c);
}
.tag.note {
background: #f1f5f9;
color: #475569;
}
/* ---------- in macchina ---------- */
.job.active {
cursor: pointer;
border-left-width: 6px;
background: linear-gradient(180deg, #fff, #f8fafc);
}
.st {
font-size: .7rem;
font-weight: 800;
color: #fff;
border-radius: 6px;
padding: 1px 7px;
}
.timer {
font-variant-numeric: tabular-nums;
font-weight: 800;
}
.prog {
height: 5px;
background: #e2e8f0;
border-radius: 99px;
margin-top: 6px;
overflow: hidden;
}
.prog span {
display: block;
height: 100%;
background: var(--ok);
}
.prog.over span {
background: var(--late);
}
.sortable-ghost {
opacity: .35;
}
.sortable-chosen {
box-shadow: 0 10px 24px rgba(15, 23, 42, .18) !important;
}
.hide {
display: none !important;
}
/* ---------- toast ---------- */
.pb-toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2000;
background: #0f172a;
color: #fff;
border-radius: 12px;
padding: 12px 16px;
font-weight: 600;
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
opacity: 0;
transform: translateY(10px);
transition: all .25s;
max-width: 420px;
}
.pb-toast.err {
background: #b91c1c;
}
.pb-toast.warn {
background: #b45309;
}
.pb-toast.show {
opacity: 1;
transform: none;
}
/* ---------- modulo ---------- */
#mJob .form-label {
font-weight: 700;
margin-bottom: 3px;
}
.hint {
font-size: .8rem;
color: var(--muted);
margin-top: 3px;
}
.hint a {
cursor: pointer;
}
.legend {
font-size: .78rem;
color: var(--muted);
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-left: auto;
}
.legend i {
margin-right: 3px;
}
@media (max-width: 768px) {
.col {
flex-basis: 85vw;
min-width: 85vw;
max-height: none;
}
}
</style>
</head>
<body>
<div class="wrapper toggled" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card p-3">
<div class="pb-head mb-3">
<div class="pb-title">
<div class="pb-title-icon"><i class="fa-solid fa-table-columns"></i></div>
<div>
<h5>Programmazione produzione</h5>
<small>Trascina i lavori nelle linee: l'ordine nella colonna è l'ordine di produzione</small>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="production_mago_queue.php" class="btn btn-warning fw-bold"><i class="fa-solid fa-inbox me-1"></i> Nuovi da Mago <span class="badge bg-dark ms-1 hide" id="nMago"></span></a>
<button class="btn btn-primary" id="btnNew"><i class="fa-solid fa-plus me-1"></i> Nuovo lavoro</button>
<a href="produzione_programmazione_drag.php" class="btn btn-outline-secondary"><i class="fa-solid fa-table-list me-1"></i> Vista classica</a>
</div>
</div>
<div class="pb-sum mb-3" id="sum"></div>
<div class="pb-tools mb-3">
<div class="pb-search">
<i class="fa-solid fa-magnifying-glass"></i>
<input type="search" class="form-control" id="q" placeholder="Cerca matrice, cliente, ordine, mescola">
</div>
<div class="d-flex gap-2 flex-wrap" id="lineToggles"></div>
<div class="legend">
<span><i class="fa-solid fa-square" style="color:var(--late)"></i>fine stimata oltre la data Zibo</span>
<span><i class="fa-solid fa-square" style="color:var(--soon)"></i>margine sotto 1 giorno</span>
</div>
</div>
<div class="board" id="board">
<div class="text-muted p-4"><i class="fa-solid fa-spinner fa-spin me-2"></i>Caricamento…</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<!-- MODULO LAVORO -->
<div class="modal fade" id="mJob" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<form class="modal-content" id="fJob">
<div class="modal-header">
<h5 class="modal-title fw-bold" id="mTitle">Nuovo lavoro</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id" id="fId">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Matrice</label>
<select class="form-select" name="idmatrice" id="fMat" required></select>
<div class="hint" id="hMat"></div>
</div>
<div class="col-md-6">
<label class="form-label">Linea</label>
<select class="form-select" name="id_linea" id="fLin" required></select>
</div>
<div class="col-12">
<label class="form-label">Mescole</label>
<select class="form-select" name="mescole[]" id="fMes" multiple></select>
</div>
<div class="col-md-6">
<label class="form-label">Cliente</label>
<select class="form-select" name="id_cliente" id="fCli"></select>
</div>
<div class="col-md-6">
<label class="form-label">Conferma d'ordine</label>
<input type="text" class="form-control" name="conferma_ordine" id="fOrd" maxlength="50">
</div>
<div class="col-md-6">
<label class="form-label">Data Zibo</label>
<input type="date" class="form-control" name="data_zibo" id="fDz">
</div>
<div class="col-md-6">
<label class="form-label">Data cliente</label>
<input type="date" class="form-control" name="data_cliente" id="fDc">
</div>
<div class="col-6 col-md-3">
<label class="form-label">Metri</label>
<input type="number" step="0.01" min="0" class="form-control" name="metri" id="fMt">
</div>
<div class="col-6 col-md-3">
<label class="form-label">Kg SP</label>
<input type="number" step="0.01" min="0" class="form-control" name="kg_sp" id="fKs">
</div>
<div class="col-6 col-md-3">
<label class="form-label">Kg P</label>
<input type="number" step="0.01" min="0" class="form-control" name="kg_p" id="fKp">
</div>
<div class="col-6 col-md-3">
<label class="form-label">Ore previste</label>
<input type="number" step="0.01" min="0" class="form-control" name="ore_previste" id="fOre">
<div class="hint" id="hOre"></div>
</div>
<div class="col-12">
<label class="form-label">Note</label>
<textarea class="form-control" name="note_extra" id="fNote" rows="3"></textarea>
</div>
<div class="col-12" id="wSt">
<label class="form-label d-block">Stato</label>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="id_status" id="fSt1" value="1">
<label class="btn btn-outline-secondary" for="fSt1">Da programmare</label>
<input type="radio" class="btn-check" name="id_status" id="fSt6" value="6">
<label class="btn btn-outline-primary" for="fSt6">Programmato (in fondo alla linea)</label>
</div>
</div>
<div class="col-12 hide" id="wActive">
<div class="alert alert-info mb-0 py-2"><i class="fa-solid fa-gears me-1"></i> Lavoro in macchina: lo stato si cambia dalla pagina di produzione.</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger me-auto" id="btnDel"><i class="fa-solid fa-trash me-1"></i> Elimina</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-primary" id="btnSave"><i class="fa-solid fa-check me-1"></i> Salva</button>
</div>
</form>
</div>
</div>
<?php include('jsinclude.php'); ?>
<script>
$(function() {
const CFG = <?= json_encode([
'todo' => PB_STATUS_TODO,
'plan' => PB_STATUS_PLAN,
'active' => PB_STATUS_ACTIVE,
'shift' => PB_SHIFT_START,
'workDays' => PB_WORK_DAYS,
]) ?>;
const HOUR = 3600e3;
let skew = 0; // differenza tra orologio del PC e del server
const nowS = () => new Date(Date.now() - skew);
let B = {
lines: [],
jobs: []
};
let opts = null;
let sortables = [];
let busy = false; // trascinamento o salvataggio in corso
let onlyLate = false;
let hidden = {};
try {
hidden = JSON.parse(localStorage.getItem('pb_hidden_lines') || '{}');
} catch (e) {}
/* ---------- utilità ---------- */
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [c]));
const num = (v, d = 0) => (+v || 0).toLocaleString('it-IT', {
minimumFractionDigits: d,
maximumFractionDigits: d
});
const hrs = h => (h >= 10 ? num(h, 0) : num(h, 1)) + ' h';
const parseDT = s => s ? new Date(String(s).replace(' ', 'T')) : null;
const day0 = d => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const GG = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab'];
const fmtD = d => d ? `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}` : '—';
const fmtDay = d => d ? `${GG[d.getDay()]} ${fmtD(d)}` : '—';
const fmtDT = d => {
if (!d) return '—';
const diff = Math.round((day0(d) - day0(nowS())) / 864e5);
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
if (diff === 0) return 'oggi ' + hm;
if (diff === 1) return 'domani ' + hm;
return fmtDay(d) + (diff < 7 ? ' ' + hm : '');
};
const dateOnly = s => s ? new Date(s + 'T00:00:00') : null;
function toast(msg, kind) {
const el = $(`<div class="pb-toast ${kind || ''}">${esc(msg)}</div>`).appendTo('body');
setTimeout(() => el.addClass('show'), 10);
setTimeout(() => {
el.removeClass('show');
setTimeout(() => el.remove(), 300);
}, kind ? 5000 : 2500);
}
/* ---------- stima fine lavori ---------- */
// aggiunge ore di lavoro rispettando giorni lavorativi e ore al giorno della linea
function addWork(start, hours, perDay) {
let t = new Date(start);
let left = hours * HOUR;
const full = perDay >= 24;
for (let guard = 0; guard < 2000; guard++) {
const iso = t.getDay() === 0 ? 7 : t.getDay();
const d0 = day0(t);
const ws = full ? d0 : new Date(d0.getTime() + CFG.shift * HOUR);
const we = full ? new Date(d0.getTime() + 24 * HOUR) : new Date(ws.getTime() + perDay * HOUR);
if (!CFG.workDays.includes(iso) || t >= we) {
t = new Date(d0.getTime() + 24 * HOUR);
continue;
}
if (t < ws) t = ws;
const take = Math.min(left, we - t);
t = new Date(t.getTime() + take);
left -= take;
if (left <= 0) return t;
}
return t;
}
function compute() {
const now = nowS();
B.lines.forEach(l => {
let t = now;
let load = 0;
l.late = 0;
const act = B.jobs.filter(j => j.id_linea === l.id && CFG.active.includes(j.id_status));
act.forEach(j => {
const st = parseDT(j.start_time);
j.elapsed = st ? Math.max(0, (now - st) / HOUR) : 0;
j.remaining = Math.max(0, j.ore_previste - j.elapsed);
j.eta_end = addWork(t, j.remaining, l.hours);
t = j.eta_end;
load += j.remaining;
});
const queue = B.jobs.filter(j => j.id_linea === l.id && j.id_status === CFG.plan);
queue.forEach((j, i) => {
j.pos = i + 1;
j.eta_start = t;
j.eta_end = addWork(t, j.ore_previste, l.hours);
t = j.eta_end;
load += j.ore_previste;
});
[...act, ...queue].forEach(j => {
j.lateDays = 0;
j.soon = false;
const dz = dateOnly(j.data_zibo);
if (dz && j.eta_end) {
const limit = new Date(dz.getTime() + 24 * HOUR); // fine della giornata Zibo
if (j.eta_end > limit) {
j.lateDays = Math.ceil((day0(j.eta_end) - dz) / 864e5);
l.late++;
} else if (limit - j.eta_end < 24 * HOUR) j.soon = true;
}
});
l.free = t;
l.load = load;
l.queue = queue.length;
});
// da programmare: urgenza sulla data Zibo
const today = day0(now);
B.jobs.filter(j => j.id_status === CFG.todo).forEach(j => {
const dz = dateOnly(j.data_zibo);
j.lateDays = dz && dz < today ? Math.round((today - dz) / 864e5) : 0;
j.soon = dz && !j.lateDays && (dz - today) / 864e5 <= 7;
});
}
/* ---------- disegno ---------- */
const lineById = id => B.lines.find(l => l.id === id);
// nota scritta da qualcuno (non solo le righe automatiche dell'import Mago)
const userNote = n => String(n || '').split(/\r?\n/).some(r => r.trim() && !/^(Da Mago:|Matrice\/Misura Mago:|Kg stimati:|Quantità ordinata:)/.test(r.trim()));
function card(j) {
const l = lineById(j.id_linea) || {};
const isTodo = j.id_status === CFG.todo;
const isAct = CFG.active.includes(j.id_status);
const mes = j.mescole.slice(0, 3).map(m => `<span class="mchip" title="${esc(m)}">${esc(m)}</span>`).join('') + (j.mescole.length > 3 ? `<span class="mchip">+${j.mescole.length - 3}</span>` : '');
const qta = [j.metri ? num(j.metri) + ' m' : '', j.kg_sp ? num(j.kg_sp) + ' kg' : ''].filter(Boolean).join(' · ');
const dz = dateOnly(j.data_zibo);
const dzCls = j.lateDays ? 'late' : (j.soon ? 'soon' : '');
const tags = [];
if (j.from_mago) tags.push('<span class="tag mago"><i class="fa-solid fa-inbox me-1"></i>Mago</span>');
if (j.mago_changed) tags.push(`<span class="tag late" title="${esc(j.mago_changes)}"><i class="fa-solid fa-triangle-exclamation me-1"></i>cambiato in Mago</span>`);
if (!isTodo && j.lateDays) tags.push(`<span class="tag late">ritardo ${j.lateDays} gg</span>`);
if (isTodo && j.lateDays) tags.push(`<span class="tag late">data Zibo passata da ${j.lateDays} gg</span>`);
if (j.matrice_lines.length && !j.matrice_lines.includes(j.id_linea)) tags.push('<span class="tag warn" title="La linea non è tra quelle della matrice"><i class="fa-solid fa-triangle-exclamation me-1"></i>linea insolita</span>');
if (isTodo) tags.push(`<span class="tag line" style="--c:${esc(l.color)}"><i class="fa-solid fa-circle me-1"></i>${esc(l.name || '?')}</span>`);
if (userNote(j.note_extra)) tags.push(`<span class="tag note" title="${esc(j.note_extra)}"><i class="fa-regular fa-note-sticky"></i></span>`);
let head = isTodo || isAct ? '' : `<span class="pos">${j.pos}</span>`;
let right = `<span class="hrs ${j.ore_previste ? '' : 'zero'}" title="${j.ore_previste ? 'ore previste' : 'ore previste mancanti: la stima non lo conta'}">${j.ore_previste ? hrs(j.ore_previste) : '? h'}</span>`;
let r3end = '';
if (isAct) {
head = `<span class="st" style="background:${esc(j.status_color)}">${esc(j.status_nome)}</span>`;
right = `<span class="hrs timer" data-start="${esc(j.start_time || '')}">—</span>`;
r3end = `<span class="eta">fine ~ <b>${fmtDT(j.eta_end)}</b></span>`;
} else if (!isTodo) {
r3end = `<span class="eta" title="inizio stimato ${fmtDT(j.eta_start)}">fine ~ <b>${fmtDT(j.eta_end)}</b></span>`;
}
const prog = isAct && j.ore_previste ? `<div class="prog ${j.elapsed > j.ore_previste ? 'over' : ''}"><span style="width:${Math.min(100, j.elapsed / j.ore_previste * 100).toFixed(0)}%"></span></div>` : '';
const search = [j.matrice, j.cliente, j.conferma_ordine, ...j.mescole].join(' ').toLowerCase();
const cls = ['job', isAct ? 'active' : '', j.lateDays ? 'late' : (j.soon ? 'soon' : '')].join(' ');
return `<div class="${cls}" data-id="${j.id}" data-s="${esc(search)}" style="--c:${esc(l.color)}">
<div class="r1">${head}<span class="mat">${esc(j.matrice || '?')}</span>${j.photo ? `<img class="ph" src="photos/matrici/${esc(j.photo)}" alt="" loading="lazy">` : ''}${right}</div>
<div class="r2">${esc(j.cliente || 'nessun cliente')}${j.conferma_ordine ? ' · ' + esc(j.conferma_ordine) : ''}</div>
${mes ? `<div class="chips">${mes}</div>` : ''}
<div class="r3">${qta ? `<span>${qta}</span>` : ''}<span class="dz ${dzCls}" title="data Zibo${j.data_cliente ? ' · cliente ' + fmtD(dateOnly(j.data_cliente)) : ''}"><i class="fa-regular fa-calendar me-1"></i>${dz ? fmtDay(dz) : 'senza data'}</span>${r3end}</div>
${prog}
${tags.length ? `<div class="tags">${tags.join('')}</div>` : ''}
</div>`;
}
function render() {
compute();
sortables.forEach(s => s.destroy());
sortables = [];
const todo = B.jobs.filter(j => j.id_status === CFG.todo).sort((a, b) => (a.data_zibo || '9999').localeCompare(b.data_zibo || '9999') || a.id - b.id);
const planned = B.jobs.filter(j => j.id_status === CFG.plan);
const act = B.jobs.filter(j => CFG.active.includes(j.id_status));
const lateTot = B.lines.reduce((s, l) => s + l.late, 0);
const loadTot = B.lines.reduce((s, l) => s + l.load, 0);
const noHours = planned.filter(j => !j.ore_previste).length;
$('#sum').html(`
<div class="s"><div class="n">${act.length}</div><div class="l"><i class="fa-solid fa-gears me-1"></i>In macchina</div></div>
<div class="s"><div class="n">${planned.length}</div><div class="l"><i class="fa-solid fa-list-ol me-1"></i>Programmati · ${hrs(loadTot)}</div></div>
<div class="s"><div class="n">${todo.length}</div><div class="l"><i class="fa-solid fa-inbox me-1"></i>Da programmare</div></div>
<div class="s click ${lateTot ? 'bad' : ''} ${onlyLate ? 'on' : ''}" id="sLate" title="Mostra solo i lavori in ritardo"><div class="n">${lateTot}</div><div class="l"><i class="fa-solid fa-clock me-1"></i>Fine oltre data Zibo</div></div>
${noHours ? `<div class="s"><div class="n" style="color:var(--soon)">${noHours}</div><div class="l">Programmati senza ore</div></div>` : ''}`);
$('#lineToggles').html(B.lines.filter(l => l.active || B.jobs.some(j => j.id_linea === l.id)).map(l =>
`<button type="button" class="line-toggle ${hidden[l.id] ? 'off' : ''}" data-line="${l.id}" style="--c:${esc(l.color)}"><span class="dot"></span>${esc(l.name)}</button>`).join(''));
let h = `<div class="col backlog">
<div class="col-head" style="--c:#f59e0b"><div class="t"><i class="fa-solid fa-inbox" style="color:#f59e0b"></i> Da programmare <span class="cnt">${todo.length}</span></div>
<div class="meta">ordinati per data Zibo · trascina in una linea per programmare</div></div>
<div class="col-body"><div class="list ${todo.length ? '' : 'empty'}" data-list="todo">${todo.map(card).join('')}</div></div></div>`;
B.lines.forEach(l => {
const a = act.filter(j => j.id_linea === l.id);
const q = planned.filter(j => j.id_linea === l.id);
if (!l.active && !a.length && !q.length) return;
const days = l.load / l.hours;
const pct = Math.min(100, days / 10 * 100); // barra: 10 giorni pieni = 100%
h += `<div class="col ${hidden[l.id] ? 'hide' : ''}" data-col="${l.id}" style="--c:${esc(l.color)}">
<div class="col-head"><div class="t"><i class="fa-solid fa-industry" style="color:${esc(l.color)}"></i> ${esc(l.name)} <span class="cnt">${q.length}</span></div>
<div class="meta"><span><b>${hrs(l.load)}</b> di lavoro</span><span>libera ~ <b>${l.load ? fmtDT(l.free) : 'ora'}</b></span>${l.late ? `<span class="bad"><i class="fa-solid fa-clock me-1"></i>${l.late} in ritardo</span>` : ''}</div>
<div class="load" title="${num(days, 1)} giorni di lavoro (${l.hours} h/giorno)"><span style="width:${pct}%"></span></div></div>
<div class="col-body">
${a.length ? `<div class="sec">In macchina</div><div class="d-flex flex-column gap-2 mb-3">${a.map(card).join('')}</div>` : ''}
<div class="sec">In coda</div>
<div class="list ${q.length ? '' : 'empty'}" data-list="line" data-line="${l.id}">${q.map(card).join('')}</div>
</div></div>`;
});
$('#board').html(h);
$('#nMago').text(B.nuoviMago || '').toggleClass('hide', !B.nuoviMago);
applyFilter();
tick();
document.querySelectorAll('#board .list').forEach(el => {
sortables.push(Sortable.create(el, {
group: 'prod',
sort: el.dataset.list !== 'todo', // i da programmare restano ordinati per data
animation: 150,
forceFallback: true,
fallbackTolerance: 4,
filter: '.hide',
onStart: () => busy = true,
onEnd: e => {
busy = false;
if (e.from === e.to && e.oldIndex === e.newIndex) return;
moved(e);
}
}));
});
}
function applyFilter() {
const q = $('#q').val().trim().toLowerCase();
$('#board .job').each(function() {
const j = B.jobs.find(x => x.id === +this.dataset.id);
const okQ = !q || this.dataset.s.includes(q);
const okL = !onlyLate || (j && j.lateDays > 0);
$(this).toggleClass('hide', !(okQ && okL));
});
}
/* ---------- trascinamento ---------- */
function moved(e) {
const id = +e.item.dataset.id;
const j = B.jobs.find(x => x.id === id);
const to = e.to.dataset;
if (to.list === 'line') {
const lid = +to.line;
if (j.matrice_lines.length && !j.matrice_lines.includes(lid)) {
toast(`${lineById(lid).name} non è tra le linee della matrice ${j.matrice}`, 'warn');
}
j.id_linea = lid;
j.id_status = CFG.plan;
} else {
j.id_status = CFG.todo;
}
// nuovo ordine dei programmati: come appare nelle colonne
const pos = {};
document.querySelectorAll('#board .list[data-list="line"]').forEach(el => {
[...el.querySelectorAll('.job')].forEach((c, i) => pos[+c.dataset.id] = [+el.dataset.line, i]);
});
B.jobs.forEach(x => {
if (pos[x.id]) {
x.id_linea = pos[x.id][0];
x._o = pos[x.id][1];
}
});
const li = id => B.lines.findIndex(l => l.id === id);
B.jobs = [
...B.jobs.filter(x => x.id_status === CFG.plan).sort((a, b) => li(a.id_linea) - li(b.id_linea) || a._o - b._o),
...B.jobs.filter(x => x.id_status !== CFG.plan)
];
render();
saveOrder();
}
function saveOrder() {
const payload = {
lines: B.lines.map(l => ({
line: l.id,
ids: B.jobs.filter(j => j.id_linea === l.id && j.id_status === CFG.plan).map(j => j.id)
})),
backlog: B.jobs.filter(j => j.id_status === CFG.todo).sort((a, b) => (a.data_zibo || '9999').localeCompare(b.data_zibo || '9999') || a.id - b.id).map(j => j.id)
};
busy = true;
$.post('?', {
action: 'order',
payload: JSON.stringify(payload)
}, null, 'json').then(r => {
busy = false;
if (!r.success) {
toast(r.message || 'Errore nel salvataggio', 'err');
load();
} else toast('Ordine salvato');
}).fail(() => {
busy = false;
toast('Errore di rete: ricarico', 'err');
load();
});
}
/* ---------- caricamento ---------- */
function load() {
return $.getJSON('?action=board').then(r => {
if (!r.success) return toast(r.message || 'Errore', 'err');
// ordine dei programmati: per linea, poi posizione
const lo = {};
r.lines.forEach((l, i) => lo[l.id] = i);
r.jobs.forEach((j, i) => j._o = i);
r.jobs.sort((a, b) => (lo[a.id_linea] ?? 99) - (lo[b.id_linea] ?? 99) || a._o - b._o);
skew = Date.now() - parseDT(r.now).getTime();
B = r;
render();
});
}
// timer dei lavori in macchina
function tick() {
const now = nowS();
$('.timer[data-start]').each(function() {
const st = parseDT(this.dataset.start);
if (!st) return;
const s = Math.max(0, Math.floor((now - st) / 1000));
$(this).text(`${String(Math.floor(s / 3600)).padStart(2, '0')}:${String(Math.floor(s % 3600 / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`);
});
}
setInterval(tick, 1000);
// aggiornamento automatico (non durante trascinamenti o con il modulo aperto)
setInterval(() => {
if (!busy && !$('#mJob').hasClass('show')) load();
}, 60000);
/* ---------- filtri ---------- */
$('#q').on('input', applyFilter);
$(document).on('click', '#sLate', function() {
onlyLate = !onlyLate;
render();
});
$(document).on('click', '.line-toggle', function() {
const id = $(this).data('line');
hidden[id] = !hidden[id];
try {
localStorage.setItem('pb_hidden_lines', JSON.stringify(hidden));
} catch (e) {}
render();
});
/* ---------- modulo ---------- */
const modal = new bootstrap.Modal(document.getElementById('mJob'));
let editing = null;
let matInfo = null;
function loadOpts() {
if (opts) return $.Deferred().resolve().promise();
return $.getJSON('?action=options').then(r => {
opts = r;
$('#fMat').html('<option value=""></option>' + r.matrici.map(m => `<option value="${m.id}">${esc(m.nome)}</option>`).join(''));
$('#fMes').html(r.mescole.map(m => `<option value="${m.id}">${esc(m.nome)}</option>`).join(''));
$('#fCli').html('<option value="">Nessuno</option>' + r.clienti.map(c => `<option value="${c.id}">${esc(c.nome)}</option>`).join(''));
const s2 = {
theme: 'bootstrap-5',
width: '100%',
dropdownParent: $('#mJob')
};
$('#fMat').select2({
...s2,
placeholder: 'Cerca la matrice'
});
$('#fMes').select2({
...s2,
placeholder: 'Mescole'
});
$('#fCli').select2(s2);
});
}
function lineOptions(pref) {
const a = B.lines.filter(l => pref.includes(l.id)),
b = B.lines.filter(l => !pref.includes(l.id) && l.active);
let h = '<option value="">Scegli la linea…</option>';
if (a.length) h += '<optgroup label="Linee della matrice">' + a.map(l => `<option value="${l.id}">★ ${esc(l.name)}</option>`).join('') + '</optgroup>';
h += `<optgroup label="${a.length ? 'Altre linee' : 'Linee'}">` + b.map(l => `<option value="${l.id}">${esc(l.name)}</option>`).join('') + '</optgroup>';
return h;
}
// matrice scelta: linee preferite, mescole e velocità dalla scheda di lavoro
function onMatrice(fill) {
const id = +$('#fMat').val();
matInfo = null;
$('#hMat, #hOre').html('');
const keepLine = $('#fLin').val();
if (!id) {
$('#fLin').html(lineOptions([])).val(keepLine);
return;
}
$.getJSON('?action=matinfo&id=' + id).then(r => {
const info = r.info;
matInfo = info;
$('#fLin').html(lineOptions(info.lines));
if (fill && info.lines.length === 1) $('#fLin').val(info.lines[0]);
else $('#fLin').val(keepLine && $(`#fLin option[value="${keepLine}"]`).length ? keepLine : '');
if (fill && info.mescole.length) $('#fMes').val(info.mescole.map(String)).trigger('change');
$('#hMat').html(info.worksheet ? `<i class="fa-solid fa-file-lines me-1"></i>Scheda di lavoro n° ${info.worksheet}` + (info.speed_m_h ? ` · ${num(info.speed_m_h)} m/h` : '') : 'Nessuna scheda di lavoro attiva');
suggestHours();
});
}
function suggestHours() {
$('#hOre').html('');
if (!matInfo) return;
const mt = +$('#fMt').val() || 0,
kg = +$('#fKs').val() || 0;
let h = 0;
if (mt && matInfo.speed_m_h) h = mt / matInfo.speed_m_h;
else if (kg && matInfo.speed_kg_h) h = kg / matInfo.speed_kg_h;
if (h > 0) $('#hOre').html(`<a class="use-h" data-h="${h.toFixed(2)}">usa ${num(h, 1)} h</a> (da scheda)`);
}
$('#fMat').on('select2:select', () => onMatrice(true));
$('#fMt, #fKs').on('input', suggestHours);
$(document).on('click', '.use-h', function() {
$('#fOre').val($(this).data('h'));
});
function openJob(j) {
loadOpts().then(() => {
editing = j || null;
const isAct = j && CFG.active.includes(j.id_status);
$('#mTitle').text(j ? `${j.matrice} · ${j.cliente || 'nessun cliente'}` : 'Nuovo lavoro');
$('#fId').val(j ? j.id : '');
$('#fMat').val(j ? j.idmatrice : '').trigger('change');
$('#fLin').html(lineOptions(j ? j.matrice_lines : [])).val(j ? j.id_linea : '');
$('#fMes').val(j ? j.mescole_ids.map(String) : []).trigger('change');
$('#fCli').val(j && j.id_cliente ? j.id_cliente : '').trigger('change');
$('#fOrd').val(j ? j.conferma_ordine || '' : '');
$('#fDz').val(j ? j.data_zibo || '' : '');
$('#fDc').val(j ? j.data_cliente || '' : '');
$('#fMt').val(j ? j.metri : '');
$('#fKs').val(j ? j.kg_sp : '');
$('#fKp').val(j ? j.kg_p : '');
$('#fOre').val(j ? j.ore_previste : '');
$('#fNote').val(j ? j.note_extra || '' : '');
$(`#fSt${j && j.id_status === CFG.plan ? 6 : 1}`).prop('checked', true);
$('#wSt').toggleClass('hide', !!isAct);
$('#wActive').toggleClass('hide', !isAct);
$('#btnDel').toggleClass('hide', !j || isAct).removeClass('btn-danger').addClass('btn-outline-danger').html('<i class="fa-solid fa-trash me-1"></i> Elimina');
$('#hMat, #hOre').html('');
if (j) onMatrice(false);
modal.show();
});
}
$('#btnNew').on('click', () => openJob(null));
$(document).on('click', '#board .job', function(e) {
if ($(e.target).closest('a').length) return;
openJob(B.jobs.find(x => x.id === +this.dataset.id));
});
$('#fJob').on('submit', function(e) {
e.preventDefault();
const data = $(this).serializeArray();
data.push({
name: 'action',
value: 'save'
});
$('#btnSave').prop('disabled', true);
$.post('?', data, null, 'json').then(r => {
$('#btnSave').prop('disabled', false);
if (!r.success) return toast(r.message || 'Errore', 'err');
modal.hide();
toast(editing ? 'Lavoro aggiornato' : 'Lavoro creato');
load();
}).fail(() => {
$('#btnSave').prop('disabled', false);
toast('Errore di rete', 'err');
});
});
// elimina: secondo clic per confermare (niente finestre del browser)
$('#btnDel').on('click', function() {
const b = $(this);
if (!b.hasClass('btn-danger')) {
b.removeClass('btn-outline-danger').addClass('btn-danger').html('<i class="fa-solid fa-trash me-1"></i> Conferma eliminazione');
return;
}
$.post('?', {
action: 'delete',
id: editing.id
}, null, 'json').then(r => {
if (!r.success) return toast(r.message || 'Errore', 'err');
modal.hide();
toast(editing.from_mago ? 'Eliminato: la riga torna tra i Nuovi da Mago' : 'Lavoro eliminato');
load();
});
});
load();
});
</script>
</body>
</html>