🚚

Logística

Carregando dados do Planejamento Tático…
PCP Logística · Fictícia
Entre com seu e-mail e senha
`; } /* (v1.57) Abre o documento numa janela e manda imprimir — o usuário salva como PDF pelo próprio navegador. Sem dependência nova: jsPDF seriam ~250 KB no vendor, e o html2canvas que já existe rasteriza (o texto deixaria de ser selecionável e o arquivo ficaria pesado). */ function exportarProgramacaoPDF(){ const html = montarHtmlProgramacao(); if(!html){ alert('Selecione uma única obra para exportar a programação.'); return; } const w = window.open('', '_blank'); if(!w){ alert('O navegador bloqueou a janela. Libere pop-ups para este site e tente de novo.'); return; } w.document.write(html); w.document.close(); w.focus(); // (v1.63) NÃO chama print() aqui: a aba abre como visualização e quem // decide é a pessoa, pelo botão "IMPRIMIR / SALVAR PDF" do documento — // padrão do módulo Acabamento. Some junto a antiga corrida entre montar // a janela e disparar a impressão. } function cargasProgramadas(){ const un=new Set(unidadesAtivas()), cts=obrasAtivasSet(); const fams=multiSet('familias','flt-fam'), prods=multiSet('produtos','flt-prod'); const ini=LF.dini||'', fim=LF.dfim||''; const sit=LF.expsit||'todas'; return (L.cargas||[]).filter(c=>{ if(sit==='exped' && !c.exped) return false; if(sit==='aberto' && c.exped) return false; const ref=diaRef(c); if(!ref) return false; if(ini && reffim) return false; if(cts && !cts.has(c.ct)) return false; if(fams && !c.familias.some(f=>fams.has(f))) return false; if(prods && !c.produtos.some(p=>prods.has(p))) return false; if(c.unidades.length ? !c.unidades.some(u=>un.has(u)) : un.sizeidx[d.iso]=i); const m=new Map(); lista.forEach(c=>{ const k=chaveFn(c)||'—'; const i=idx[diaRef(c)]; if(i==null) return; if(!m.has(k)) m.set(k, dias.map(()=>0)); m.get(k)[i]++; }); return m; } const somaArr=(a)=>a.reduce((s,v)=>s+v,0); function tabelaMedia(alvo,mapa,dias,nUteis,corFn,rotulo){ if(!mapa.size){ alvo.innerHTML='
Sem cargas para os filtros selecionados.
'; return; } const chaves=Array.from(mapa.keys()).sort((a,b)=>somaArr(mapa.get(b))-somaArr(mapa.get(a))); const totDia=dias.map(()=>0); let h=``+ dias.map(d=>``).join('')+ ``; chaves.forEach(k=>{ const arr=mapa.get(k), t=somaArr(arr); arr.forEach((v,i)=>totDia[i]+=v); h+=``+ arr.map(v=>``).join('')+ ``; }); const tg=somaArr(totDia); h+=``+totDia.map(v=>``).join('')+ `
${esc(rotulo)}${esc(d.curto)}TotalMédia/d.ú.
${esc(k)}${v?nf0(v):'·'}${nf0(t)}${nf1(t/nUteis)}
Total${nf0(v)}${nf0(tg)}${nf1(tg/nUteis)}
`; alvo.innerHTML=h; } /* Rótulos nas barras: valor dentro de cada segmento (só quando cabe) e o total do dia acima do ponto da linha. Sem plugin externo. */ const _corTexto=(hex)=>{ const m=/^#?([0-9a-f]{6})$/i.exec(String(hex||'')); if(!m) return '#fff'; const n=parseInt(m[1],16), r=(n>>16)&255, g=(n>>8)&255, b=n&255; return (0.299*r+0.587*g+0.114*b)>150 ? '#22303f' : '#fff'; // laranja/amarelo → texto escuro }; /* (v1.72) Clareia uma cor misturando com branco (t = 0..1). Usada para o trecho JÁ EXPEDIDO de cada barra: mesma cor da série, tom mais claro, para separar do que ainda não saiu sem inventar uma segunda paleta. */ const _clarear=(hex,t)=>{ const m=/^#?([0-9a-f]{6})$/i.exec(String(hex||'')); if(!m) return hex; const n=parseInt(m[1],16), c=[(n>>16)&255,(n>>8)&255,n&255]; return '#'+c.map(v=>Math.round(v+(255-v)*t).toString(16).padStart(2,'0')).join(''); }; const plugRotulos={ id:'rotulosLog', afterDatasetsDraw(chart){ const ctx=chart.ctx; ctx.save(); const FONTE=(px)=>'700 '+px+'px "Segoe UI",system-ui,-apple-system,sans-serif'; ctx.font=FONTE(11); ctx.textAlign='center'; chart.data.datasets.forEach((ds,di)=>{ const meta=chart.getDatasetMeta(di); if(meta.hidden) return; // formatador do gráfico (cargas → nf0, volume → nf1); ver graficoEmpilhado const F=((L.fmtChart||{})[chart.canvas&&chart.canvas.id]||{}).f||nf0; if(ds.type==='line'){ // total do dia ctx.textBaseline='bottom'; ctx.fillStyle='#0F2340'; meta.data.forEach((pt,i)=>{ const v=ds.data[i]; if(!v) return; ctx.fillText(F(v),pt.x,pt.y-7); }); return; } ctx.textBaseline='middle'; ctx.fillStyle=_corTexto(ds.backgroundColor); meta.data.forEach((bar,i)=>{ const v=ds.data[i]; if(!v) return; const p=bar.getProps(['x','y','base','width'],true); if(Math.abs(p.base-p.y)<15) return; // segmento baixo → sem rótulo const txt=F(v); /* Antes só a ALTURA era testada. Rótulo mais largo que a barra encostava nas duas bordas e sangrava para a vizinha — some no volume, onde "1.183,9" tem o dobro da largura de "261". Tenta 11px, cai para 9px, e desiste se nem assim couber: o tooltip continua tendo o número, e barra sem rótulo é melhor que rótulo ilegível. (mesma regra em pcp-graficos.js) */ const larg=p.width||0; ctx.font=FONTE(11); if(larg && ctx.measureText(txt).width > larg-8){ ctx.font=FONTE(9); if(ctx.measureText(txt).width > larg-4) return; } ctx.fillText(txt, p.x, (p.y+p.base)/2); }); ctx.font=FONTE(11); }); ctx.restore(); } }; /* Tabela produto → obras: as linhas-filhas nascem recolhidas e o ▸ abre as obras daquele produto (útil quando um produto concentra o dia). */ const PROG_ABERTOS=new Set(); function tabelaMediaHier(alvo,mapa,sub,dias,nUteis,corFn,rotulo){ if(!mapa.size){ alvo.innerHTML='
Sem cargas para os filtros selecionados.
'; return; } const chaves=Array.from(mapa.keys()).sort((a,b)=>somaArr(mapa.get(b))-somaArr(mapa.get(a))); const totDia=dias.map(()=>0); let h=``+ dias.map(d=>``).join('')+ ``; chaves.forEach(k=>{ const arr=mapa.get(k), t=somaArr(arr), aberto=PROG_ABERTOS.has(k); arr.forEach((v,i)=>totDia[i]+=v); h+=``+ arr.map(v=>``).join('')+ ``; const obras=Array.from((sub.get(k)||new Map()).entries()) .sort((a,b)=>somaArr(b[1])-somaArr(a[1])||String(a[0]).localeCompare(String(b[0]),'pt')); obras.forEach(([o,arrO])=>{ const tO=somaArr(arrO); h+=``+ ``+ arrO.map(v=>``).join('')+ ``; }); }); const tg=somaArr(totDia); h+=``+totDia.map(v=>``).join('')+ `
${esc(rotulo)}${esc(d.curto)}TotalMédia/d.ú.
`+ ``+ `${esc(k)}${v?nf0(v):'·'}${nf0(t)}${nf1(t/nUteis)}
Total${nf0(v)}${nf0(tg)}${nf1(tg/nUteis)}
`; alvo.innerHTML=h; alvo.onclick=(ev)=>{ // onclick (não addEventListener) → não acumula const b=ev.target.closest('button[data-prod]'); if(!b) return; const k=b.dataset.prod, abrir=!PROG_ABERTOS.has(k); if(abrir) PROG_ABERTOS.add(k); else PROG_ABERTOS.delete(k); keepScroll(()=>{ b.textContent=abrir?'▾':'▸'; alvo.querySelectorAll(`tr.chi[data-pai="${CSS.escape(k)}"]`).forEach(tr=>{ tr.style.display=abrir?'':'none'; }); }); }; } // fmt (opcional) = { f:formatador, un:'carga(s)'|'m³' } — usado pelos rótulos // e tooltips quando o gráfico está mostrando volume em vez de nº de cargas // mapaExped (opcional) = mesmo formato de `mapa`, só com as cargas já // expedidas. Quando vem, cada série vira 2 segmentos empilhados: o que // ainda não saiu (cor cheia) e o já expedido (mesma cor, tom claro). function graficoEmpilhado(canvasId,mapa,dias,corFn,comTotal,detalhe,fmt,mapaExped){ const cv=el(canvasId); if(!cv||typeof Chart==='undefined') return; L.charts=L.charts||{}; L.fmtChart=L.fmtChart||{}; L.fmtChart[canvasId]=fmt||{f:nf0,un:'carga(s)'}; if(L.charts[canvasId]){ L.charts[canvasId].destroy(); delete L.charts[canvasId]; } if(!mapa.size) return; const F=L.fmtChart[canvasId]; const chaves=Array.from(mapa.keys()).sort((a,b)=>somaArr(mapa.get(b))-somaArr(mapa.get(a))); const ds=[]; chaves.forEach(k=>{ const tot=mapa.get(k), cor=corFn(k); const ex=mapaExped ? (mapaExped.get(k)||dias.map(()=>0)) : null; // `_chave` guarda o nome real da série: o rótulo do segmento expedido // ganha sufixo, e quem indexa por chave (tooltip do detalhe) precisa // do nome puro. const base={ stack:'s', borderRadius:3, maxBarThickness:54, _chave:k }; if(ex && somaArr(ex)>0){ ds.push(Object.assign({ label:k, data:tot.map((v,i)=>v-(ex[i]||0)), backgroundColor:cor }, base)); ds.push(Object.assign({ label:k+' · expedida', data:ex.slice(), backgroundColor:_clarear(cor,.55), _exped:true }, base)); } else { ds.push(Object.assign({ label:k, data:tot.slice(), backgroundColor:cor }, base)); } }); // (v1.71) Com UMA série só (ex.: uma única obra selecionada), a linha de // total é ponto a ponto igual à própria barra — só polui o gráfico e a // legenda. Fica só quando há mais de uma série para somar. if(comTotal && chaves.length>1){ ds.push({ type:'line', label:(typeof comTotal==='string'?comTotal:'Total do dia'), data:dias.map((d,i)=>chaves.reduce((s,k)=>s+mapa.get(k)[i],0)), borderColor:'#0F2340', backgroundColor:'#0F2340', borderWidth:2, pointRadius:3, tension:0, order:-1 }); } L.charts[canvasId]=new Chart(cv,{ type:'bar', data:{ labels:dias.map(d=>d.lb), datasets:ds }, plugins:[plugRotulos], options:{ responsive:true, maintainAspectRatio:false, animation:{duration:200}, layout:{ padding:{ top:18 } }, // com detalhe (produto → obras) o tooltip é do segmento apontado interaction: detalhe ? {mode:'nearest', intersect:true} : {mode:'index', intersect:false}, plugins:{ legend:{ position:'bottom', labels:{ boxWidth:10, boxHeight:10, padding:12, font:{size:11}, // (v1.72) O segmento expedido NÃO entra na legenda: é a mesma // série, só num tom mais claro — duplicaria cada obra ali. filter:(item,data)=>!((data.datasets[item.datasetIndex]||{})._exped) } }, tooltip:{ // (v1.72) Com a série partida em 2, o modo 'index' listaria o // dobro de entradas, metade delas zerada. Só mostra quem tem valor. filter:(it)=>it.parsed.y!==0, callbacks:{ // obras que compõem aquele produto no dia afterBody:(itens)=>{ if(!detalhe) return ''; const linhas=[]; itens.forEach(it=>{ if(it.dataset.type==='line') return; // `_chave` e não `label`: o segmento expedido tem sufixo no rótulo const sub=detalhe.get(it.dataset._chave||it.dataset.label); if(!sub) return; Array.from(sub.entries()) .map(([o,arr])=>[o,arr[it.dataIndex]||0]).filter(x=>x[1]>0) .sort((a,b)=>b[1]-a[1]) .forEach(([o,n])=>linhas.push(' '+o+': '+nf0(n))); }); return linhas.length?['Obras:'].concat(linhas):''; }, footer:(itens)=>{ if(detalhe){ const i=itens[0]&&itens[0].dataIndex; if(i==null) return ''; const t=(itens[0].chart.data.datasets||[]).filter(d=>d.type!=='line') .reduce((s,d)=>s+(d.data[i]||0),0); return 'Total do dia: '+F.f(t)+' '+F.un; } const t=itens.filter(i=>i.dataset.type!=='line').reduce((s,i)=>s+i.parsed.y,0); return itens.length>1?('Total: '+F.f(t)+' '+F.un):''; }, label:(it)=>it.dataset.label+': '+F.f(it.parsed.y)+(F.un==='m³'?' m³':'') } } }, scales:{ x:{ stacked:true, grid:{display:false} }, y:{ stacked:true, beginAtZero:true, ticks:(F.un==='m³'?{}:{precision:0}), grid:{color:'#eef2f7'} } } } }); } // Barras agrupadas (comparativo SP × Interior) function graficoBarras(canvasId,labels,series,fmt){ const cv=el(canvasId); if(!cv||typeof Chart==='undefined') return; L.charts=L.charts||{}; L.fmtChart=L.fmtChart||{}; L.fmtChart[canvasId]=fmt||{f:nf0,un:'carga(s)'}; const F=L.fmtChart[canvasId]; if(L.charts[canvasId]){ L.charts[canvasId].destroy(); delete L.charts[canvasId]; } L.charts[canvasId]=new Chart(cv,{ type:'bar', data:{ labels, datasets:series.map(s=>({ label:s.label, data:s.data, backgroundColor:s.color, borderRadius:3, maxBarThickness:34 })) }, plugins:[plugRotulos], options:{ responsive:true, maintainAspectRatio:false, animation:{duration:200}, layout:{ padding:{ top:18 } }, plugins:{ legend:{ position:'bottom', labels:{ boxWidth:10, boxHeight:10, padding:12, font:{size:11} } }, tooltip:{ callbacks:{ label:(it)=>it.dataset.label+': '+F.f(it.parsed.y)+(F.un==='m³'?' m³':''), footer:(itens)=>{ const t=itens.reduce((s,i)=>s+i.parsed.y,0); return itens.length>1?('Total: '+F.f(t)+' '+F.un):''; } } } }, scales:{ x:{ grid:{display:false} }, y:{ beginAtZero:true, ticks:(F.un==='m³'?{}:{precision:0}), grid:{color:'#eef2f7'} } } } }); } function mapaCalor(alvo,mapa,dias){ if(!mapa.size){ alvo.innerHTML='
Sem cargas para os filtros selecionados.
'; return; } const chaves=Array.from(mapa.keys()).sort((a,b)=>somaArr(mapa.get(b))-somaArr(mapa.get(a))); const max=Math.max(1,...chaves.map(k=>Math.max(...mapa.get(k)))); let h=''+ dias.map(d=>``).join('')+''; const totDia=dias.map(()=>0); chaves.forEach(k=>{ const arr=mapa.get(k); arr.forEach((v,i)=>totDia[i]+=v); h+=``+ arr.map(v=>{ const a=v===0?0:(0.15+0.85*(v/max)); return ``; }).join('')+ ``; }); h+=``+ totDia.map(v=>``).join('')+ ``; h+='
Obra${esc(d.lb)}Total
${esc(k)}${v?nf0(v):'·'}${nf0(somaArr(arr))}
Total${v?nf0(v):'·'}${nf0(somaArr(totDia))}
'; alvo.innerHTML=h; } /* ── Alertas de carga fora da unidade ────────────────────────── Uma coisa é a unidade da OBRA (de quem é o contrato), outra é onde a carga vai ser carregada (unidade de estoque predominante). Quando as duas divergem, a programação da fábrica selecionada engana: vermelho → obra de outra unidade ocupando a nossa programação; amarelo → obra nossa que vai carregar em outra unidade. Só faz sentido com uma parte das unidades marcada — com todas selecionadas não existe "outra unidade". */ function alertasProg(){ const box=el('prog-alertas'); if(!box) return; const un=new Set(unidadesAtivas()); if(un.size>=UNITS.length){ box.innerHTML=''; return; } const unObra=new Map((L.obras||[]).map(o=>[o.ct,o.unidade])); const ini=LF.dini||'', fim=LF.dfim||'', sit=LF.expsit||'todas'; const cts=obrasAtivasSet(); const fora=[], nossas=[]; (L.cargas||[]).forEach(c=>{ if(sit==='exped' && !c.exped) return; if(sit==='aberto' && c.exped) return; const ref=diaRef(c); if(!ref) return; if(ini && reffim) return; if(cts && !cts.has(c.ct)) return; const uCarga=c.unidades[0]||'', uObra=unObra.get(c.ct)||''; if(!uCarga || !uObra || uCarga===uObra) return; if(un.has(uCarga) && !un.has(uObra)) fora.push({c,uObra,uCarga}); else if(un.has(uObra) && !un.has(uCarga)) nossas.push({c,uObra,uCarga}); }); const linhas=(arr,campo)=>arr.slice(0,12).map(x=> `
${esc(x.c.carga)}${esc(x.c.ct)} · ${esc(x.c.obra||'—')}`+ `${esc(isoToBR(diaRef(x.c)))} · ${esc(ULAB[x[campo]]||x[campo])}
` ).join('')+(arr.length>12?`
… e mais ${nf0(arr.length-12)}
`:''); const bloco=(arr,cls,ico,txt,campo)=>arr.length ? `
${ico}
${txt}
${linhas(arr,campo)}
` : ''; box.innerHTML= bloco(fora,'alerta-r','🚩', `${nf0(fora.length)} carga(s) de obra de outra unidade programada(s) para carregar aqui.`,'uObra')+ bloco(nossas,'alerta-a','⚠️', `${nf0(nossas.length)} carga(s) de obra desta unidade programada(s) para carregar em outra unidade.`,'uCarga'); box.querySelectorAll('.a-ver').forEach(b=>b.addEventListener('click',()=>{ b.closest('.alerta').classList.toggle('on'); })); } function renderProg(){ if(!el('pk-total')) return; syncBtnProgPDF(); // (v1.57) a seleção de obra manda no botão de PDF alertasProg(); const dias=diasDoPeriodo(); const rotSit=()=>{ const s=LF.expsit||'todas'; return s==='aberto'?' · só não expedidas':s==='exped'?' · só expedidas':' · expedidas + em aberto'; }; const zerar=(txt)=>{ ['pk-total','pk-media','pk-obras','pk-pico'].forEach(id=>el(id).textContent='—'); ['pk-total-p','pk-media-p','pk-obras-p','pk-pico-p'].forEach(id=>el(id).textContent=''); el('pk-total-p').textContent=txt||''; ['prog-tb-obra','prog-tb-fam','prog-tb-prod','prog-heat'].forEach(id=>{ el(id).innerHTML='
'+(txt||'Sem cargas para os filtros selecionados.')+'
'; }); ['prog-ch-obra','prog-ch-fam','prog-ch-prod'].forEach(id=>{ if(L.charts&&L.charts[id]){ L.charts[id].destroy(); delete L.charts[id]; } }); }; if(!dias.length){ zerar('Informe Período início e fim.'); return; } const lista=cargasProgramadas(); const nUteis=Math.max(1, dias.filter(d=>d.util).length); const rotObra=(c)=>(c.ct?c.ct+' · ':'')+(c.obra||'—'); const mObra=agruparPorDia(lista,dias,rotObra); const mFam =agruparPorDia(lista,dias,c=>c.familia||'—'); const mProd=agruparPorDia(lista,dias,c=>c.produto||'—'); // (v1.72) Mesmos agrupamentos só com o que JÁ SAIU (expedido = faturado), // para os gráficos pintarem essa parte da barra num tom mais claro. // As tabelas e o mapa de calor seguem usando os totais. const jaExped=lista.filter(c=>c.exped); const mObraEx=agruparPorDia(jaExped,dias,rotObra); const mFamEx =agruparPorDia(jaExped,dias,c=>c.familia||'—'); const mProdEx=agruparPorDia(jaExped,dias,c=>c.produto||'—'); // detalhe produto → obra (linhas-filhas da tabela + tooltip do gráfico) const mProdObra=new Map(); Array.from(mProd.keys()).forEach(p=>{ mProdObra.set(p, agruparPorDia(lista.filter(c=>(c.produto||'—')===p), dias, rotObra)); }); // ── KPIs ── const totDia=dias.map((d,i)=>Array.from(mObra.values()).reduce((s,a)=>s+a[i],0)); const total=somaArr(totDia); el('pk-total').textContent=nf0(total); el('pk-total-p').textContent=`${dias.length} dia(s) · ${dias[0].curto} a ${dias[dias.length-1].curto}` + rotSit(); el('pk-media').innerHTML=nf1(total/nUteis)+' cargas/d.ú.'; el('pk-media-p').textContent=`÷ ${nUteis} dia(s) útil(eis)`; el('pk-obras').textContent=nf0(mObra.size); el('pk-obras-p').textContent='obras com carga programada no período'; if(total>0){ const i=totDia.indexOf(Math.max(...totDia)); el('pk-pico').innerHTML=nf0(totDia[i])+' cargas'; el('pk-pico-p').textContent=dias[i].lb; } else { el('pk-pico').textContent='—'; el('pk-pico-p').textContent=''; } if(!lista.length){ zerar('Sem cargas programadas no período/filtros atuais.'); el('pk-total').textContent='0'; el('pk-total-p').textContent=`${dias.length} dia(s) · ${dias[0].curto} a ${dias[dias.length-1].curto}` + rotSit(); return; } // ── Gráficos + tabelas ── L.corObra=L.corObra||{}; const cObra=(k)=>corDe(k,L.corObra); const cFam =(k)=>COR_FAM[k]||corDe(k,(L.corFam=L.corFam||{})); const cProd=(k)=>corDe(k,(L.corProd=L.corProd||{})); graficoEmpilhado('prog-ch-obra',mObra,dias,cObra,true, null, null,mObraEx); graficoEmpilhado('prog-ch-prod',mProd,dias,cProd,false,mProdObra,null,mProdEx); graficoEmpilhado('prog-ch-fam', mFam, dias,cFam, false,null, null,mFamEx); tabelaMedia(el('prog-tb-obra'),mObra,dias,nUteis,cObra,'Obra'); tabelaMediaHier(el('prog-tb-prod'),mProd,mProdObra,dias,nUteis,cProd,'Produto'); tabelaMedia(el('prog-tb-fam'), mFam, dias,nUteis,cFam, 'Família'); mapaCalor(el('prog-heat'),mObra,dias); } const RENDER={ vis:renderVis, prog:renderProg, carr:renderCarr, busca:renderBusca, fin:renderFin, cad:renderCad, mot:renderMot, perm:renderPerm }; /* ── Navegação entre abas ─────────────────────────────────────── */ window.goTab=(pg,elBtn)=>{ // trava de visibilidade: aba restrita (ou Permissões) só para quem pode const podeTudo = ME.admin || meuTipo()==='logistica'; if((pg==='perm'&&!ME.admin) || (TABS_LOGISTICA.includes(pg)&&!podeTudo)){ pg='vis'; elBtn=document.querySelector('#log-tabs .modtab[data-pg="vis"]'); } document.querySelectorAll('#log-tabs .modtab').forEach(t=>t.classList.remove('on')); if(elBtn){ elBtn.classList.add('on'); try{ elBtn.scrollIntoView({block:'nearest',inline:'nearest'}); }catch(e){} } document.querySelectorAll('.pg').forEach(p=>p.classList.remove('on')); const panel=el('p-'+pg); if(panel) panel.classList.add('on'); // Carregamentos tem muita coluna → usa a largura toda da janela const mn=document.querySelector('main'); if(mn) mn.classList.toggle('wide',pg==='carr'); L.tab=pg; LF=estadoAba(pg); // cada aba tem o seu conjunto de filtros try{ localStorage.setItem('pcp_logistica_tab',pg); }catch(e){} aplicarVisibilidadeFiltros(pg); montarObras(); // a lista de obras muda conforme a aba sincronizarControles(); if(RENDER[pg]) RENDER[pg](); }; /* ── IndexedDB cache do dataset ───────────────────────────────── */ function idbOpen(){ return new Promise((res,rej)=>{ const r=indexedDB.open(IDB_DB,1); r.onupgradeneeded=()=>{ const db=r.result; if(!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE); }; r.onsuccess=()=>res(r.result); r.onerror=()=>rej(r.error); }); } async function idbGet(k){ try{ const db=await idbOpen(); return await new Promise((res,rej)=>{ const q=db.transaction(IDB_STORE,'readonly').objectStore(IDB_STORE).get(k); q.onsuccess=()=>res(q.result); q.onerror=()=>rej(q.error); }); }catch(e){ return null; } } async function idbSet(k,v){ try{ const db=await idbOpen(); return await new Promise((res,rej)=>{ const tx=db.transaction(IDB_STORE,'readwrite'); tx.objectStore(IDB_STORE).put(v,k); tx.oncomplete=()=>res(); tx.onerror=()=>rej(tx.error); }); }catch(e){} } async function gunzip(blob){ if(typeof DecompressionStream!=='undefined'){ try{ const ds=blob.stream().pipeThrough(new DecompressionStream('gzip')); return await new Response(ds).text(); }catch(e){} } return await blob.text(); } async function versaoRemota(file){ try{ const {data,error}=await SB.storage.from(BUCKET).list('',{search:file,limit:100}); if(error) throw error; const f=(data||[]).find(o=>o.name===file); return f?(f.updated_at||f.created_at||null):null; }catch(e){ return null; } } // Mesmo fluxo para os 2 datasets: versão remota → cache IndexedDB → download. // valido=fn que diz se o payload em cache serve (os 2 formatos: cols/rows e estacas) async function carregarDataset(file,seenKey,idbKey,rotulo,valido){ const ok=valido||((p)=>!!(p&&p.cols&&p.rows)); const remoteV=await versaoRemota(file); const seen=localStorage.getItem(seenKey); if(remoteV && seen===remoteV){ const c=await idbGet(idbKey); if(ok(c)) return c; } if(!remoteV){ const c=await idbGet(idbKey); return ok(c)?c:null; } el('log-loading-txt').textContent='Baixando '+(rotulo||'base de peças')+' do Tático…'; try{ const {data,error}=await SB.storage.from(BUCKET).download(file); if(error) throw error; el('log-loading-txt').textContent='Processando dados…'; const payload=JSON.parse(await gunzip(data)); if(!ok(payload)) throw new Error('payload inválido'); await idbSet(idbKey,payload); try{ localStorage.setItem(seenKey,remoteV); }catch(e){} return payload; }catch(e){ console.warn('[PCP] download '+file,e); const c=await idbGet(idbKey); return ok(c)?c:null; } } /* ── app_state (estado compartilhado do Tático) ───────────────── */ async function carregarAppState(){ // feriados: MESMA lista do Calendário do Tático (não duplicar cadastro) const KEYS=['pcp_estrutura_obras_cfg','pcp_estrutura_manual_pecas_v1','pcp_estrutura_ind_feriados']; try{ const {data,error}=await SB.from('app_state').select('chave,valor').in('chave',KEYS); if(error) throw error; (data||[]).forEach(r=>{ let v=null; try{ v=JSON.parse(r.valor); }catch(e){} if(r.chave==='pcp_estrutura_obras_cfg') L.cfg=v||{}; if(r.chave==='pcp_estrutura_manual_pecas_v1') L.manualPecas=Array.isArray(v)?v:[]; if(r.chave==='pcp_estrutura_ind_feriados'){ // formatos aceitos: 'YYYY-MM-DD' ou {data,desc,unidades[]}; unidades // ausente/vazia = feriado em todas as fábricas L.feriados=(Array.isArray(v)?v:[]).map(x=>{ if(typeof x==='string' && /^\d{4}-\d{2}-\d{2}$/.test(x)) return {data:x,unidades:null}; if(x && typeof x==='object' && /^\d{4}-\d{2}-\d{2}$/.test(x.data)){ const u=Array.isArray(x.unidades)?x.unidades.map(z=>normUnit(z)).filter(Boolean):null; return {data:x.data, unidades:(u&&u.length)?u:null}; } return null; }).filter(Boolean); } }); }catch(e){ console.warn('[PCP] app_state indisponível', e); } } /* ═══ Boot ═══ */ try{ carregarFiltros(); el('log-loading-txt').textContent='Carregando estado do Tático…'; const [payload,expPayload,estPayload]=await Promise.all([ carregarDataset(DS_FILE,SEEN_KEY,IDB_KEY,'base de peças'), carregarDataset(DS_EXP,SEEN_EXP,IDB_KEY_EXP,'histórico de expedição'), carregarDataset(DS_EST,SEEN_EST,IDB_KEY_EST,'histórico de estacas',(p)=>!!(p&&Array.isArray(p.expedicao))), carregarAppState() ]); if(!payload){ el('log-loading').style.display='none'; const err=el('log-erro'); err.style.display='block'; err.innerHTML='Não foi possível carregar o dataset do Tático.
Verifique a conexão ou se o agente do ERP já subiu a base (Storage datasets/tatico.json.gz).'; return; } /* Fase 4: a frase que fica na tela no trecho mais longo do boot, entre os dados chegarem e a tela ser pintada. Ver a tabela de frases no spec do skeleton de carregamento. */ el('log-loading-txt').textContent='Organizando as cargas…'; L.cols=payload.cols; L.rows=payload.rows; L.savedAt=payload.savedAt||null; // Sem o dataset de eventos (agente antigo), cai no estado atual: cada peça // entra com a ÚLTIMA expedição — some só o histórico de reexpedição. L.exp=(expPayload&&expPayload.cols)?expPayload:payload; L.estacas=(estPayload&&Array.isArray(estPayload.expedicao))?estPayload:null; // Chip "Atualizado em" no topo, ao lado da conta if(L.savedAt){ const d=new Date(L.savedAt); const chip=el('log-updated-chip'); if(chip){ chip.textContent='Atualizado em '+d.toLocaleDateString('pt-BR')+' '+d.toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'}); chip.style.display='inline-flex'; } } el('log-loading').style.display='none'; el('log-app').style.display='block'; el('log-tabs').style.display='flex'; computeObras(); // lista de obras (CT/nome/unidade) vinda do dataset computeCargas(); // cargas expedidas (aba Finalizadas) montarAnoMes(); // depende das cargas (não oferece ano futuro) montarUnidades(); // opções de Família / Produto vêm das próprias cargas montarMulti('flt-fam','familias', Array.from(new Set((L.cargas||[]).flatMap(c=>c.familias))).sort((a,b)=>a.localeCompare(b,'pt')),'Todas'); montarMulti('flt-prod','produtos', Array.from(new Set((L.cargas||[]).flatMap(c=>c.produtos))).sort((a,b)=>a.localeCompare(b,'pt')),'Todos'); ligarFiltros(); ligarCad(); ligarMot(); ligarImportMot(); // TEMPORÁRIO: carga inicial dos motoristas ligarFin(); ligarCarr(); ligarDia(); ligarMetricas(); ligarPerm(); // cabeçalhos clicáveis (ordenação) — padrão do módulo Carteira ligarSort('fin-thead','fin',renderFin); ligarSort('busca-thead','busca',renderBusca); ligarSort('cad-thead','cad',renderCad); ligarSort('mot-thead','mot',renderMot); // o cabeçalho de Carregamentos é montado a cada render (colunas variáveis), // e é lá que o ligarSort acontece // Cadastros vivem em logistica_state (PCPData). Se a camada ainda não // subiu, as abas mostram "conectando…" e redesenham quando ficar pronta. // O tipo do usuário (Comum/Logística) vem daí — por isso as abas // restritas só aparecem depois da hidratação. aplicarPermissoes(); if(window.PCPData){ cadHidratar(); aplicarPermissoes(); } else document.addEventListener('pcp-dados-prontos',()=>{ try{ cadHidratar(); aplicarPermissoes(); onFiltroMudou(); }catch(e){ console.warn('[PCP] cadastros',e); } },{once:true}); // Alterações feitas em OUTRA máquina → recarrega e redesenha a aba aberta. const ligarRealtime=()=>{ if(!window.PCPData) return; PCPData.onChange('*',()=>{ try{ cadHidratar(); aplicarPermissoes(); // 'carr' entra aqui porque duas pessoas operam o mesmo dia ao mesmo tempo if(['cad','mot','perm','carr'].includes(L.tab)) keepScroll(()=>RENDER[L.tab]()); }catch(e){} }); }; if(window.PCPData) ligarRealtime(); else document.addEventListener('pcp-dados-prontos',ligarRealtime,{once:true}); // restaura a última aba aberta let tab='vis'; try{ const t=localStorage.getItem('pcp_logistica_tab'); if(t&&el('p-'+t)) tab=t; }catch(e){} const btn=document.querySelector(`#log-tabs .modtab[data-pg="${tab}"]`); goTab(tab,btn); }catch(e){ console.error('[PCP] logistica boot',e); el('log-loading').style.display='none'; const err=el('log-erro'); err.style.display='block'; err.innerHTML='Erro ao montar o painel da Logística.
'+esc(e&&e.message||e); } };