LOGISTOCK

Conectando ao banco de dados…

');w.document.close()} function prefetchFiles(urls){if(!urls.length||!navigator.canShare)return Promise.resolve([]);return Promise.all(urls.slice(0,10).map(async(u,i)=>{try{const b=await (await fetch(u)).blob();const ext=((b.type||'image/jpeg').split('/')[1]||'jpg').replace('jpeg','jpg');return new File([b],`logistock-foto-${i+1}.${ext}`,{type:b.type||'image/jpeg'})}catch(e){return null}})).then(a=>a.filter(Boolean))} async function shareWhatsApp(text,filesP,photos){let files=[];try{files=await filesP}catch(e){}if(files.length&&navigator.canShare&&navigator.canShare({files})){try{await navigator.share({files,text,title:'LOGISTOCK'});toast('Relatório compartilhado.','ok');return}catch(e){if(e.name==='AbortError')return}}else if(!photos.length&&navigator.share&&/Android|iPhone|iPad/i.test(navigator.userAgent)){try{await navigator.share({text,title:'LOGISTOCK'});return}catch(e){if(e.name==='AbortError')return}}const links=photos.length?'\n\n📷 Fotos:\n'+photos.join('\n'):'';const n=String(S.settings.whatsapp_number||'').replace(/\D/g,'');const url='https://wa.me/'+n+'?text='+encodeURIComponent((text+links).slice(0,6000));const w=window.open(url,'_blank');if(!w)location.href=url} function showReport({title,html,text,photos=[],csv}){const filesP=prefetchFiles(photos);const {el}=openModal(`

${esc(title)}

Pré-visualização — confira antes de enviar.

${html}
${csv?``:''}
${photos.length?`

No celular, as ${Math.min(photos.length,10)} foto(s) são anexadas automaticamente pelo compartilhamento do dispositivo. No computador, o WhatsApp abre com o texto e os links das fotos.

`:''}`,{wide:true}); el.querySelector('#rWa').onclick=()=>shareWhatsApp(text,filesP,photos);el.querySelector('#rPrint').onclick=()=>printHTML(title,html);el.querySelector('#rCopy').onclick=async()=>{try{await navigator.clipboard.writeText(text);toast('Texto copiado.','ok')}catch(e){toast('Não foi possível copiar.','err')}};if(csv)el.querySelector('#rCsv').onclick=()=>downloadCSV(csv.name,csv.rows);$('.rep img',el).forEach((im,i,all)=>im.onclick=()=>lightbox(all.map(x=>x.src),i))} const photoList=r=>String(r.photos||'').split('\n').map(s=>s.trim()).filter(Boolean); const QCSV_HEAD=['ID','Data','Hora','Turno','Código','Produto','Usuário','Descrição','Observação','Fotos','Status']; const qcsvRow=r=>[r.id,fmtD(r.date),r.time,r.shift,r.product_code,r.product_name||'',r.username,r.description,r.notes||'',photoList(r).join(' | '),r.status]; function qualityReport(recs,meta={}){const photos=recs.flatMap(photoList);const per=meta.from||meta.to?`${fmtD(meta.from)} a ${fmtD(meta.to)}`:recs.length?`${fmtD(recs[recs.length-1].date)} a ${fmtD(recs[0].date)}`:'—';const codes=[...new Set(recs.map(r=>r.product_code))]; const head=`

Relatório de Qualidade

LOGISTOCK · ${esc(S.settings.company||'')}
Gerado em ${new Date().toLocaleString('pt-BR')}
por ${esc(S.user.username)}
Período${per}Turno${esc(meta.shift||'Todos')}
Produto/código${esc(meta.code||codes.join(', ')||'Todos')}Ocorrências${recs.length}
`; const body=recs.map((r,i)=>`
${i+1}. Ocorrência #${r.id} — ${esc(r.product_code)} ${esc(r.product_name||'')}
Data/hora: ${fmtD(r.date)} ${esc(r.time)} · ${esc(r.shift)} · Usuário: ${esc(r.username)} · Status: ${esc(r.status)}
Descrição: ${esc(r.description)}
Observação: ${esc(r.notes||'—')}
${photoList(r).map(u=>`Foto da ocorrência`).join('')}
`).join(''); const text=`*LOGISTOCK — RELATÓRIO DE QUALIDADE*\nPeríodo: ${per}\nTurno: ${meta.shift||'Todos'}\nOcorrências: ${recs.length}\n\n`+recs.map((r,i)=>`*${i+1}. #${r.id} — ${r.product_code}* ${r.product_name||''}\n📅 ${fmtD(r.date)} ${r.time} · ${r.shift}\n👤 ${r.username}\n⚠ ${r.description}${r.notes?'\n📝 '+r.notes:''}\nStatus: ${r.status} · ${photoList(r).length} foto(s)`).join('\n\n'); showReport({title:recs.length===1?'Ocorrência de Qualidade #'+recs[0].id:'Relatório de Qualidade',html:head+(body||'

Nenhuma ocorrência.

'),text,photos,csv:{name:`qualidade-${todayISO()}.csv`,rows:[QCSV_HEAD,...recs.map(qcsvRow)]}})} /* ================= GESTÃO DE QUALIDADE ================= */ Views.qualidade=async(v,tk)=>{await Promise.all([loadCore(),loadQuality(true)]);if(tk!==S.routeToken)return; v.innerHTML=`
`; const tabs=$('[data-tab]',v);tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('on',x===b));if(b.dataset.tab==='novo')qNew(v);else{Scanner.stop();S.routeScan=null;qSheet(v)}});qNew(v)}; function qNew(v){const st={code:'',p:null,photos:[]};const body=$('#qBody',v); body.innerHTML=`

1 Ler código da peça / produto

${scanBlock('scanQ')}
Aguardando leitura do código…

2 Fotos da não conformidade

“Capturar foto” usa a câmera já aberta. Adicione quantas fotos precisar.

3 Descrever o problema

Turno${esc(currentShiftName())}
Data${fmtD(todayISO())}
Hora${nowHM()}
Usuário${esc(S.user.username)}
`; const q=id=>$('#'+id,body); const drawPh=()=>{q('qPhotos').innerHTML=st.photos.map((p,i)=>`
Foto ${i+1}
`).join('')}; const addPhoto=b=>{st.photos.push({blob:b,url:URL.createObjectURL(b)});drawPh()}; q('qPhotos').onclick=e=>{const r=e.target.closest('[data-rp]');if(r){URL.revokeObjectURL(st.photos[+r.dataset.rp].url);st.photos.splice(+r.dataset.rp,1);drawPh();return}const im=e.target.closest('[data-pi]');if(im)lightbox(st.photos.map(p=>p.url),+im.dataset.pi)}; q('qCap').onclick=()=>{const c=Scanner.frame();if(!c)return toast('Câmera indisponível. Use “Câmera do celular / galeria”.','err');const cam=$('[data-scan="scanQ"] .scan-cam',body);cam&&(cam.classList.remove('flash'),void cam.offsetWidth,cam.classList.add('flash'));c.toBlob(b=>{if(b){addPhoto(b);toast('Foto adicionada ('+st.photos.length+').','ok',1500)}},'image/jpeg',.85)}; q('qFile').onchange=e=>{[...e.target.files].forEach(f=>addPhoto(f));e.target.value=''}; const setCode=code=>{const p=findProduct(code);st.code=p?p.code:code;st.p=p;q('qProd').outerHTML=`
${p?prodCard(p,code):`
${esc(code)}${rackId(code)?` RACK ${esc(rackId(code).letter)}`:''}
Código não cadastrado — o registro será salvo com o código lido.
`}
`;q('qStep2').classList.remove('locked');q('qDone').innerHTML='';toast('Código lido. Agora tire as fotos e descreva o problema.','ok',2500)}; S.routeScan=()=>startScanner('scanQ',setCode,{keepVideo:true});S.routeScan(); q('qSave').onclick=async()=>{const desc=q('qDesc').value.trim();if(!st.code)return toast('Leia o código do produto primeiro.','err');if(!desc)return toast('Descreva o problema encontrado.','err');const btn=q('qSave');btn.disabled=true; try{const urls=[];for(let i=0;iURL.revokeObjectURL(p.url));st.photos=[];st.code='';st.p=null;drawPh();q('qDesc').value='';q('qNotes').value='';q('qStep2').classList.add('locked');q('qProd').outerHTML='
Aguardando leitura do código…
'; q('qDone').innerHTML=`
✓ Registro #${rec.id} salvo.
`;q('qWa').onclick=()=>qualityReport([rec]);await loadQuality(true);Scanner.resume()} catch(x){toast(errMsg(x),'err',5000)}finally{btn.disabled=false;btn.innerHTML=ic('qualidade')+' Salvar registro'}}} function qSheet(v){const L=isLeader();const F={from:'',to:'',shift:L?'':currentShiftName(),code:'',status:'',q:''};const sel=new Set();const body=$('#qBody',v); body.innerHTML=`
900?'open':''}>${ic('search',18)} Filtros da planilha
${L?shiftSelect('shift',''):``}
`; const filtered=()=>S.quality.filter(r=>(!F.from||r.date>=F.from)&&(!F.to||r.date<=F.to)&&(!F.shift||r.shift===F.shift)&&(!F.code||String(r.product_code).toLowerCase().includes(F.code.toLowerCase()))&&(!F.status||r.status===F.status)&&(!F.q||(r.description+' '+r.notes+' '+r.username+' '+r.product_name).toLowerCase().includes(F.q.toLowerCase()))); const target=()=>{const f=filtered();const s=f.filter(r=>sel.has(r.id));return s.length?s:f}; const draw=()=>{const rows=filtered();$('#qCount',body).textContent=`${rows.length} ocorrência(s)${sel.size?` · ${sel.size} selecionada(s)`:''}`;$('#qTable',body).innerHTML=table([{label:'',render:r=>``},{label:'ID',render:r=>`#${r.id}`},{label:'Data',render:r=>fmtD(r.date)},{label:'Hora',k:'time'},{label:'Turno',k:'shift'},{label:'Código',render:r=>`${esc(r.product_code)}`},{label:'Usuário',k:'username'},{label:'Descrição',render:r=>esc(String(r.description||'').slice(0,70))},{label:'Observação',render:r=>esc(String(r.notes||'—').slice(0,40))},{label:'Fotos',render:r=>{const p=photoList(r);return p.length?`Foto${p.length>1?` +${p.length-1}`:''}`:'—'}},{label:'Status',render:r=>`${esc(r.status)}`},{label:'',cls:'act',render:r=>``}],rows,{empty:'Nenhuma ocorrência encontrada.'})};draw(); $('[data-k]',body).forEach(i=>i.addEventListener(i.tagName==='SELECT'||i.type==='date'?'change':'input',()=>{F[i.dataset.k]=i.value.trim();draw()}));const ss=$('[data-f=shift]',body);if(ss)ss.onchange=()=>{F.shift=ss.value;draw()}; body.onchange=e=>{if(e.target.id==='qAll'){filtered().forEach(r=>e.target.checked?sel.add(r.id):sel.delete(r.id));draw()}else if(e.target.dataset.sel){const id=+e.target.dataset.sel;e.target.checked?sel.add(id):sel.delete(id);$('#qCount',body).textContent=`${filtered().length} ocorrência(s)${sel.size?` · ${sel.size} selecionada(s)`:''}`}}; body.onclick=e=>{const z=e.target.closest('[data-qz]');if(z){const r=S.quality.find(x=>x.id==z.dataset.qz);return lightbox(photoList(r))}const o=e.target.closest('[data-qo]');if(o){const r=S.quality.find(x=>x.id==o.dataset.qo);r&&qDetail(r,()=>loadQuality(true).then(draw))}}; $('#qRep',body).onclick=$('#qWaAll',body).onclick=()=>{const t=target();if(!t.length)return toast('Nenhuma ocorrência para o relatório.','err');qualityReport(t,F)}; $('#qCsv',body).onclick=()=>downloadCSV(`planilha-qualidade-${todayISO()}.csv`,[QCSV_HEAD,...target().map(qcsvRow)])} function qDetail(r,after){const L=isLeader();const ph=photoList(r);const {el,close}=openModal(`

Ocorrência #${r.id}

Data/hora${fmtD(r.date)} ${esc(r.time)}
Turno${esc(r.shift)}
Usuário${esc(r.username)}
Código${esc(r.product_code)}${r.rack_id?' · Rack '+esc(r.rack_id):''}

${esc(r.product_name||'')}

${ph.map((u,i)=>`
Foto ${i+1}
`).join('')||'

Sem fotos.

'}
${L?``:''}
${L?``:''}${L?'':''}
`,{wide:true}); $('#dPh',el).onclick=e=>{const i=e.target.closest('[data-i]');if(i)lightbox(ph,+i.dataset.i)};el.querySelector('#dWa').onclick=()=>qualityReport([r]); if(!L)return; el.querySelector('#dAdd').onchange=async e=>{const files=[...e.target.files];if(!files.length)return;try{toast('Enviando foto(s)…','info',2000);const urls=[];for(const f of files)urls.push(await uploadImage(await compressImage(f)));const all=[...ph,...urls];await DB.update('quality_records',r.id,{photos:all.join('\n'),photo_count:all.length});for(const u of urls)await DB.create('quality_photos',{record_id:r.id,url:u,username:S.user.username,date:todayISO()});await log('EDIÇÃO','quality_records',r.id,{code:r.product_code,details:`${urls.length} foto(s) adicionada(s)`});toast('Foto(s) adicionada(s).','ok');close();after&&after()}catch(x){toast(errMsg(x),'err')}}; el.querySelector('#dSave').onclick=async()=>{const o=collect(el);delete o.dAdd;const ch={description:o.description,notes:o.notes,status:o.status};if(!ch.description)return toast('A descrição não pode ficar vazia.','err');try{await DB.update('quality_records',r.id,ch);await log('CORREÇÃO','quality_records',r.id,{code:r.product_code,details:'Ocorrência atualizada'+(ch.status!==r.status?` (status: ${r.status} → ${ch.status})`:''),before:{description:r.description,notes:r.notes,status:r.status},after:ch});toast('Registro atualizado.','ok');close();after&&after()}catch(x){toast(errMsg(x),'err')}}; el.querySelector('#dDel').onclick=async()=>{if(!await confirmDlg(`Excluir a ocorrência de qualidade #${r.id}? As fotos vinculadas também serão desvinculadas.`,{ok:'Excluir',danger:true}))return;try{const phs=await DB.list('quality_photos',{record_id:r.id,limit:100});for(const p of (phs.records||[]))await DB.remove('quality_photos',p.id);await DB.remove('quality_records',r.id);await log('EXCLUSÃO','quality_records',r.id,{code:r.product_code,details:'Ocorrência excluída',before:r});toast('Ocorrência excluída.','ok');close();after&&after()}catch(x){toast(errMsg(x),'err')}}} /* ================= RELATÓRIOS ================= */ Views.relatorios=async(v,tk)=>{await Promise.all([loadCore(true),loadQuality(true)]);if(tk!==S.routeToken)return;const L=isLeader();const users=[...new Set([...S.entries,...S.exits,...S.quality].map(r=>r.username).filter(Boolean))].sort();const myShift=currentShiftName(); v.innerHTML=`

${ic('historico')} Relatório de turno

Gere durante o turno ou ao final.

${L?``:``}

${ic('qualidade')} Relatório de qualidade

${L?``:``}

${ic('down')} Exportar planilhas

`; const g=id=>$('#'+id,v); g('tGen').onclick=()=>{const date=g('tDate').value,shift=g('tShift').value,op=g('tUser').value,obs=g('tObs').value.trim();const m=r=>r.date===date&&(!shift||r.shift===shift)&&(!op||r.username===op);const en=S.entries.filter(m),ex=S.exits.filter(m),qs=S.quality.filter(m);const nfs=[...new Set(en.map(e=>e.nf_number).filter(Boolean))];const prods=[...new Set([...en,...ex].map(r=>r.product_code))];const eR=en.reduce((a,r)=>a+num(r.racks),0),eU=en.reduce((a,r)=>a+num(r.quantity),0),eL=en.reduce((a,r)=>a+num(r.loose_units),0);const xR=ex.filter(r=>r.mode==='rack').reduce((a,r)=>a+num(r.racks),0),xU=ex.reduce((a,r)=>a+num(r.quantity),0),xC=ex.filter(r=>r.mode==='caixa').reduce((a,r)=>a+num(r.quantity),0);const venc=S.validity.filter(r=>r.status!=='green'&&r.status!=='none'); const html=`

Relatório de Turno

LOGISTOCK · ${esc(S.settings.company||'')}
${fmtD(date)} · ${esc(shift)}
Operador: ${esc(op||'Todos')}
Gerado ${new Date().toLocaleString('pt-BR')} por ${esc(S.user.username)}
Entradas${en.length} reg. · ${eR} rack(s) · ${eL} un. caixa · ${fmtN(eU)} un.Saídas${ex.length} reg. · ${xR} rack(s) · ${xC} un. caixa · ${fmtN(xU)} un.
NFs${nfs.join(', ')||'—'}Produtos${prods.join(', ')||'—'}
Qualidade${qs.length} ocorrência(s)Vencimentos${venc.filter(r=>r.status==='red').length} vencido(s) · ${venc.filter(r=>r.status==='yel').length} próximo(s)

Entradas

${en.map(r=>``).join('')||''}
HoraNFCódigoRacksCaixaUn.Usuário
${esc(r.time)}${esc(r.nf_number)}${esc(r.product_code)}${num(r.racks)}${num(r.loose_units)}${num(r.quantity)}${esc(r.username)}
Nenhuma

Saídas

${ex.map(r=>``).join('')||''}
HoraCódigoTipoRacksUn.Rack/caixaUsuário
${esc(r.time)}${esc(r.product_code)}${r.mode==='rack'?'Rack fechado':'Caixa'}${r.mode==='rack'?num(r.racks):'—'}${num(r.quantity)}${esc(r.rack_label||'')}${esc(r.username)}
Nenhuma

Qualidade

${qs.map(r=>``).join('')||''}
#HoraCódigoDescriçãoStatus
${r.id}${esc(r.time)}${esc(r.product_code)}${esc(r.description)}${esc(r.status)}
Nenhuma

Vencimentos em alerta

${venc.map(r=>``).join('')||''}
ProdutoCódigoValidadeRacksUn.Status
${esc(r.name)}${esc(r.code)}${fmtD(r.validity)}${r.racks}${r.units}${r.status==='red'?'🔴 Vencido':'🟡 Próximo'}
Nenhum

Observações

${esc(obs||'—')}

`; const text=`*LOGISTOCK — RELATÓRIO DE TURNO*\n📅 ${fmtD(date)} · ${shift}\n👤 Operador: ${op||'Todos'}\n\n⬇ Entradas: ${en.length} · ${eR} rack(s) · ${fmtN(eU)} un.\n⬆ Saídas: ${ex.length} · ${xR} rack(s) · ${fmtN(xU)} un.\n📦 Caixas abertas: ${eL} un. entrada / ${xC} un. saída\n🧾 NFs: ${nfs.join(', ')||'—'}\n🏷 Produtos: ${prods.join(', ')||'—'}\n🛡 Qualidade: ${qs.length} ocorrência(s)\n⏳ Vencidos: ${venc.filter(r=>r.status==='red').length} · Próximos: ${venc.filter(r=>r.status==='yel').length}\n📝 Obs.: ${obs||'—'}`; showReport({title:'Relatório de Turno',html,text,csv:{name:`turno-${date}.csv`,rows:[['Tipo','Data','Hora','Turno','Código','Produto','NF/Rack','Racks','Un.','Usuário'],...en.map(r=>['Entrada',fmtD(r.date),r.time,r.shift,r.product_code,r.product_name,r.nf_number,r.racks,r.quantity,r.username]),...ex.map(r=>['Saída',fmtD(r.date),r.time,r.shift,r.product_code,r.product_name,r.rack_label,r.mode==='rack'?r.racks:0,r.quantity,r.username])]}})}; g('qGen').onclick=()=>{const F={from:g('qFrom').value,to:g('qTo').value,shift:g('qShift').value,code:g('qCode').value.trim()};const recs=S.quality.filter(r=>(!F.from||r.date>=F.from)&&(!F.to||r.date<=F.to)&&(!F.shift||r.shift===F.shift)&&(!F.code||String(r.product_code).includes(F.code)));if(!recs.length)return toast('Nenhuma ocorrência no período/filtros escolhidos.','warn');qualityReport(recs,F)}; v.onclick=e=>{const b=e.target.closest('[data-x]');if(!b)return;const f=g('xFrom').value,t=g('xTo').value;const inP=r=>(!f||r.date>=f)&&(!t||r.date<=t)&&(L||r.shift===myShift);const x=b.dataset.x; if(x==='stock')downloadCSV(`estoque-${todayISO()}.csv`,[['Código','Produto','Rack','Racks fechados','Un. em racks','Produção/caixas','Total un.'],...Object.values(S.stock).map(c=>[c.code,c.name,c.rid?c.rid.letter:'',c.closed,c.closedU,c.open,c.total])]); if(x==='val')downloadCSV(`validade-${todayISO()}.csv`,[['Produto','Código','Validade','Dias','Racks','Unidades','Status'],...S.validity.map(r=>[r.name,r.code,fmtD(r.validity),r.days??'',r.racks,r.units,{green:'VERDE',yel:'AMARELO',red:'VERMELHO',none:'—'}[r.status]])]); if(x==='in')downloadCSV(`entradas-${f}-a-${t}.csv`,[['ID','Data','Hora','Turno','NF','Código','Produto','Unidade','Racks','Caixa aberta','Quantidade','Validade','Usuário'],...S.entries.filter(inP).map(r=>[r.id,fmtD(r.date),r.time,r.shift,r.nf_number,r.product_code,r.product_name,r.unit,r.racks,r.loose_units,r.quantity,fmtD(r.validity),r.username])]); if(x==='out')downloadCSV(`saidas-${f}-a-${t}.csv`,[['ID','Data','Hora','Turno','Código','Produto','Tipo','Racks','Quantidade','Rack/caixa','Observação','Usuário'],...S.exits.filter(inP).map(r=>[r.id,fmtD(r.date),r.time,r.shift,r.product_code,r.product_name,r.mode==='rack'?'Rack fechado':'Caixa',r.racks,r.quantity,r.rack_label,r.notes,r.username])]); if(x==='q')downloadCSV(`planilha-qualidade-${todayISO()}.csv`,[QCSV_HEAD,...S.quality.filter(inP).map(qcsvRow)])}};