272 lines
13 KiB
HTML
272 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="pt-BR">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>DTF · sala de impressão</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#7C828C;
|
|
--ciano:#00B8DA;--magenta:#E0357C;--amarelo:#EFB700;--branco:#EDEBE6;--verde:#48B072;
|
|
--alerta:#E0642A;--roxo:#8E7CE8}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{background:var(--bg);color:var(--tx);font-family:"IBM Plex Mono",ui-monospace,monospace;
|
|
font-size:14px;line-height:1.45;-webkit-font-smoothing:antialiased;padding:16px 14px 34px}
|
|
h1,h2{font-family:"Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
|
|
.topo{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
|
|
h1{font-size:23px;font-weight:700}
|
|
.sub{color:var(--fraco);font-size:12px}
|
|
.rel{margin-left:auto;font-family:"Space Grotesk",sans-serif;font-size:19px;font-weight:700;color:var(--ciano)}
|
|
|
|
.aviso{background:#152026;border:1px solid #24404A;border-left:3px solid var(--ciano);
|
|
border-radius:0 8px 8px 0;padding:10px 14px;font-size:12.5px;color:#B9CBD2;margin:10px 0 14px}
|
|
.aviso b{color:var(--ciano);font-family:"Space Grotesk",sans-serif}
|
|
|
|
/* máquinas */
|
|
.maqs{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:14px}
|
|
@media(max-width:900px){.maqs{grid-template-columns:repeat(3,1fr)}}
|
|
.mq{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 12px;position:relative}
|
|
.mq.ocup{border-color:var(--alerta)}
|
|
.mq .luz{width:11px;height:11px;border-radius:50%;position:absolute;top:11px;right:11px;background:var(--verde)}
|
|
.mq.ocup .luz{background:var(--alerta);box-shadow:0 0 0 3px rgba(224,100,42,.2)}
|
|
.mq b{font-family:"Space Grotesk",sans-serif;font-size:13px;display:block;margin-bottom:4px}
|
|
.mq .st{font-size:10.5px;color:var(--fraco);line-height:1.4;min-height:28px}
|
|
.mq button{margin-top:8px;width:100%;font-family:inherit;font-size:11px;padding:6px;border-radius:6px;
|
|
border:1px solid var(--linha);background:var(--card2);color:var(--fraco);cursor:pointer}
|
|
.mq.livre button{background:var(--ciano);border-color:var(--ciano);color:#03181D;font-weight:600}
|
|
.mq.livre button:hover{filter:brightness(1.1)}
|
|
.mq.ocup button{opacity:.4;cursor:default}
|
|
|
|
/* kpis */
|
|
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:9px;margin-bottom:14px}
|
|
.k{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 14px;border-left:3px solid var(--c,var(--linha))}
|
|
.k dt{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--fraco);margin-bottom:3px}
|
|
.k dd{font-family:"Space Grotesk",sans-serif;font-size:23px;font-weight:700}
|
|
.k dd small{font-size:12px;color:var(--fraco);font-weight:400;font-family:"IBM Plex Mono",monospace}
|
|
|
|
/* kanban */
|
|
.kan{display:grid;grid-template-columns:repeat(7,minmax(140px,1fr));gap:8px;overflow-x:auto;padding-bottom:16px}
|
|
@media(max-width:1200px){.kan{grid-template-columns:repeat(7,168px)}}
|
|
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;display:flex;flex-direction:column;min-height:250px}
|
|
.col.alvo{border-color:var(--ciano);background:#101820}
|
|
.ch{padding:10px 11px 8px;border-bottom:1px solid var(--linha)}
|
|
.ch b{font-family:"Space Grotesk",sans-serif;font-size:12px;display:block}
|
|
.ch b .p{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--cc)}
|
|
.ch small{font-size:10px;color:var(--fraco)}
|
|
.cb{padding:8px;display:flex;flex-direction:column;gap:6px;flex:1}
|
|
.vazio{color:var(--fraco);font-size:10.5px;text-align:center;padding:14px 4px}
|
|
.cd{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc);
|
|
border-radius:5px;padding:8px 9px;cursor:grab;font-size:11px}
|
|
.cd:active{cursor:grabbing}
|
|
.cd.arrasta{opacity:.35}
|
|
.cd b{display:block;font-family:"Space Grotesk",sans-serif;font-size:12.5px}
|
|
.cd .cli{color:var(--fraco);font-size:10px;margin:1px 0 5px}
|
|
.cd .l{display:flex;justify-content:space-between;font-size:10px}
|
|
.cd .l i{font-style:normal;color:var(--fraco)}
|
|
.cd .mq2{display:inline-block;font-size:9px;padding:1px 5px;border-radius:3px;background:var(--linha);margin-top:4px}
|
|
.cd.q .l i{color:var(--amarelo)}
|
|
.cd.r{border-left-color:var(--alerta)}
|
|
.cd.r .l i{color:var(--alerta)}
|
|
|
|
.rod{margin-top:14px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--fraco);font-size:11.5px}
|
|
.rod button{font-family:inherit;font-size:11.5px;padding:7px 12px;border-radius:7px;
|
|
border:1px solid var(--linha);background:none;color:var(--fraco);cursor:pointer}
|
|
.rod button:hover{border-color:var(--ciano);color:var(--ciano)}
|
|
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="topo">
|
|
<h1>Kanban da sala de DTF</h1>
|
|
<span class="sub" id="ctx">carregando…</span>
|
|
<span class="rel" id="hora">—</span>
|
|
</div>
|
|
|
|
<div class="aviso" id="aviso" style="display:none"></div>
|
|
|
|
<div class="maqs" id="maqs"></div>
|
|
<dl class="kpis" id="kpis"></dl>
|
|
<div class="kan" id="kan"></div>
|
|
|
|
<div class="rod">
|
|
<button id="atualizar">Atualizar</button>
|
|
<span id="msg"></span>
|
|
</div>
|
|
|
|
<script>
|
|
const COLS=[
|
|
{id:'rec', nome:'Arte recebida', cor:'var(--ciano)'},
|
|
{id:'tra', nome:'Arte tratada', cor:'var(--magenta)'},
|
|
{id:'cor2',nome:'Prova de cor', cor:'var(--roxo)'},
|
|
{id:'fil', nome:'Fila', cor:'var(--amarelo)'},
|
|
{id:'imp', nome:'Imprimindo', cor:'var(--branco)'},
|
|
{id:'cor', nome:'Correção', cor:'var(--alerta)'},
|
|
{id:'fin', nome:'Finalizado', cor:'var(--verde)'}
|
|
];
|
|
const API=''; // mesma origem do PCP
|
|
const USUARIO=window.PCP_USUARIO || 'operador';
|
|
const MAQUINA=new URLSearchParams(location.search).get('maq'); // ?maq=4
|
|
const PARADO=['rec','tra','cor2','fil','cor'];
|
|
const H=36e5, M=6e4;
|
|
let cards=[], maquinas=[], erro=false;
|
|
|
|
async function api(rota,opt){
|
|
const r=await fetch(API+rota,{headers:{'Content-Type':'application/json'},...opt});
|
|
if(!r.ok){
|
|
let m='erro '+r.status;
|
|
try{ m=(await r.json()).detail || m; }catch(e){}
|
|
throw new Error(m);
|
|
}
|
|
return r.status===204? null : r.json();
|
|
}
|
|
async function carregar(){
|
|
try{
|
|
const d=await api('/api/quadro');
|
|
cards=[]; maquinas=d.maquinas||[];
|
|
Object.entries(d.cards||{}).forEach(([col,lista])=>
|
|
lista.forEach(c=>cards.push({
|
|
id:String(c.arte_id), pedido:c.pedido, cli:c.cliente,
|
|
m:c.metros, min:c.minutos_maquina || Math.round(c.metros/20*60),
|
|
col, desde:Date.parse(c.desde), maq:c.maquina? +String(c.maquina).replace(/\D/g,''):null
|
|
})));
|
|
erro=false; esconderAviso();
|
|
}catch(e){
|
|
erro=true; mostrarAviso('Sem conexão com o servidor — '+e.message+'. A tela não está atualizando.');
|
|
}
|
|
pinta();
|
|
}
|
|
function mostrarAviso(t){
|
|
const a=document.getElementById('aviso');
|
|
a.style.display='block'; a.innerHTML='<b>Atenção</b> '+t;
|
|
}
|
|
function esconderAviso(){ document.getElementById('aviso').style.display='none'; }
|
|
function salvar(){ /* estado vive no servidor */ }
|
|
|
|
async function puxar(n){
|
|
try{
|
|
const r=await api('/api/puxar',{method:'POST',
|
|
body:JSON.stringify({maquina:n, usuario:USUARIO})});
|
|
msg('Maq '+n+' puxou o #'+r.pedido+' · '+r.minutos+' min · reserva de '+r.reserva_expira_em+' min');
|
|
}catch(e){ msg(e.message); }
|
|
carregar();
|
|
}
|
|
async function mover(arte_id,para,maq){
|
|
try{
|
|
await api('/api/mover',{method:'POST',
|
|
body:JSON.stringify({arte_id:+arte_id, para, usuario:USUARIO, maquina:maq||null})});
|
|
}catch(e){ msg(e.message); }
|
|
carregar();
|
|
}
|
|
async function devolver(arte_id){
|
|
try{
|
|
await api('/api/devolver',{method:'POST',
|
|
body:JSON.stringify({arte_id:+arte_id, usuario:USUARIO})});
|
|
msg('devolvido ao topo da fila');
|
|
}catch(e){ msg(e.message); }
|
|
carregar();
|
|
}
|
|
|
|
function pintaMaqs(){
|
|
const el=document.getElementById('maqs'); el.innerHTML='';
|
|
for(let n=1;n<=6;n++){
|
|
const info=maquinas.find(x=>x.n===n) || {};
|
|
const job=cards.find(c=>c.col==='imp' && c.maq===n) ||
|
|
(info.ocupada? {pedido:info.pedido,m:info.metros,desde:Date.now()-(info.rodando_seg||0)*1000}:null);
|
|
if(MAQUINA && String(n)!==MAQUINA) continue;
|
|
const d=document.createElement('div');
|
|
d.className='mq '+(job?'ocup':'livre');
|
|
d.innerHTML='<span class="luz"></span><b>Maq '+n+'</b>'+
|
|
'<div class="st">'+(job
|
|
? '#'+job.pedido+' · '+job.m.toFixed(1).replace('.',',')+' m<br>rodando há '+dur(Date.now()-job.desde)
|
|
: 'livre<br> ')+'</div>'+
|
|
'<button '+(job?'disabled':'')+'>'+(job?'ocupada':'puxar próximo')+'</button>';
|
|
if(!job) d.querySelector('button').addEventListener('click',()=>puxar(n));
|
|
el.appendChild(d);
|
|
}
|
|
}
|
|
function puxar(n){
|
|
const fila=cards.filter(c=>c.col==='fil').sort((a,b)=>a.desde-b.desde);
|
|
if(!fila.length) return msg('a fila está vazia');
|
|
const c=fila[0];
|
|
c.col='imp'; c.maq=n; c.desde=Date.now();
|
|
salvar(); pinta();
|
|
msg('Maq '+n+' puxou o #'+c.pedido+' — o mais antigo da fila');
|
|
}
|
|
function pintaKpis(){
|
|
const fila=cards.filter(c=>['rec','tra','cor2','fil'].includes(c.col));
|
|
const mf=fila.reduce((s,c)=>s+c.m,0), mn=fila.reduce((s,c)=>s+c.min,0);
|
|
const feito=cards.filter(c=>c.col==='fin').reduce((s,c)=>s+c.m,0);
|
|
const livres=[1,2,3,4,5,6].filter(n=>!cards.some(c=>c.col==='imp'&&c.maq===n)).length;
|
|
const cap=20*6*4.5; // 6 máquinas, 20 m/h, 4h30 restantes
|
|
const risco=cards.filter(c=>PARADO.includes(c.col)&&Date.now()-c.desde>3*H).length;
|
|
const k=[
|
|
['Na fila', mf.toFixed(1).replace('.',',')+' <small>m</small>','var(--amarelo)'],
|
|
['Minutos de máquina', mn+' <small>min</small>','var(--amarelo)'],
|
|
['Capacidade até 20h', Math.round(cap)+' <small>m</small>', mf>cap?'var(--alerta)':'var(--verde)'],
|
|
['Máquinas livres',livres+' <small>de 6</small>','var(--ciano)'],
|
|
['Impresso hoje', feito.toFixed(1).replace('.',',')+' <small>m</small>','var(--verde)'],
|
|
['Parados +3h', risco+' <small>pedido'+(risco===1?'':'s')+'</small>', risco?'var(--alerta)':'var(--verde)']
|
|
];
|
|
document.getElementById('kpis').innerHTML=k.map(x=>
|
|
'<div class="k" style="--c:'+x[2]+'"><dt>'+x[0]+'</dt><dd>'+x[1]+'</dd></div>').join('');
|
|
}
|
|
function pinta(){
|
|
pintaMaqs(); pintaKpis();
|
|
const agora=Date.now(), el=document.getElementById('kan'); el.innerHTML='';
|
|
COLS.forEach(col=>{
|
|
const its=cards.filter(c=>c.col===col.id).sort((a,b)=>a.desde-b.desde);
|
|
const mt=its.reduce((s,c)=>s+c.m,0);
|
|
const d=document.createElement('div');
|
|
d.className='col'; d.dataset.col=col.id; d.style.setProperty('--cc',col.cor);
|
|
d.innerHTML='<div class="ch"><b><span class="p"></span>'+col.nome+'</b>'+
|
|
'<small>'+its.length+' · '+mt.toFixed(1).replace('.',',')+' m</small></div><div class="cb"></div>';
|
|
const cb=d.querySelector('.cb');
|
|
if(!its.length) cb.innerHTML='<div class="vazio">—</div>';
|
|
its.forEach(c=>{
|
|
const p=agora-c.desde, fin=col.id==='fin';
|
|
const cl=fin?'':(p>3*H?' r':(p>1*H?' q':''));
|
|
const e=document.createElement('div');
|
|
e.className='cd'+cl; e.draggable=true; e.dataset.id=c.id;
|
|
e.innerHTML='<b>#'+c.pedido+'</b><div class="cli">'+c.cli+'</div>'+
|
|
'<div class="l"><i>'+(fin?'concluído':'há '+dur(p))+'</i>'+
|
|
'<span>'+c.m.toFixed(1).replace('.',',')+' m · '+c.min+' min</span></div>'+
|
|
(c.maq?'<span class="mq2">Maq '+c.maq+'</span>':'');
|
|
e.addEventListener('dragstart',ev=>{ev.dataTransfer.setData('text/plain',c.id);e.classList.add('arrasta')});
|
|
e.addEventListener('dragend',()=>e.classList.remove('arrasta'));
|
|
cb.appendChild(e);
|
|
});
|
|
d.addEventListener('dragover',ev=>{ev.preventDefault();d.classList.add('alvo')});
|
|
d.addEventListener('dragleave',()=>d.classList.remove('alvo'));
|
|
d.addEventListener('drop',ev=>{
|
|
ev.preventDefault(); d.classList.remove('alvo');
|
|
const c=cards.find(x=>x.id===ev.dataTransfer.getData('text/plain'));
|
|
if(!c||c.col===col.id) return;
|
|
if(c.col==='imp'&&col.id==='fil') return devolver(c.id);
|
|
let maq=null;
|
|
if(col.id==='imp'){
|
|
maq=[1,2,3,4,5,6].find(n=>!cards.some(x=>x.col==='imp'&&x.maq===n));
|
|
if(!maq) return msg('todas as máquinas estão ocupadas');
|
|
}
|
|
mover(c.id,col.id,maq);
|
|
});
|
|
el.appendChild(d);
|
|
});
|
|
}
|
|
document.getElementById('atualizar').addEventListener('click',carregar);
|
|
document.getElementById('ctx').textContent =
|
|
MAQUINA ? 'Maq '+MAQUINA+' · '+USUARIO : 'visão geral · '+USUARIO;
|
|
function tic(){
|
|
document.getElementById('hora').textContent=
|
|
new Date().toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'});
|
|
}
|
|
tic(); setInterval(tic,30000);
|
|
setInterval(pinta,30000); // relógio dos cards
|
|
setInterval(carregar,15000); // busca o estado no servidor
|
|
carregar();
|
|
</script>
|
|
</body>
|
|
</html>
|