1468 lines
63 KiB
PHP
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 => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
} [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>
|