Files
dtf-system/kanban/static/kanban.html
Cauê Faleiros 98c951d374
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s
first commit
2026-09-15 16:42:34 -03:00

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>&nbsp;')+'</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>