/* Kanban DTF: production board, quote review, payments and integrations. Everything is built with textContent; nothing from an order or a customer is ever parsed as HTML. The previous screen is in git tag ui-v1. */ const $ = id=>document.getElementById(id); // Remove credentials saved by older local builds. Only HttpOnly sessions now. sessionStorage.removeItem('dtf-operator'); const STAGE_COLORS={rec:'var(--rec)',tra:'var(--tra)',fil:'var(--fil)',imp:'var(--imp)',cor:'var(--cor)',fin:'var(--fin)'}; const FLOW=['rec','tra','fil','imp','fin']; const PRODUCT={file:'DTF Têxtil · folha montada',avulsa:'DTF Têxtil · artes avulsas',uvfile:'DTF UV · folha montada',uv:'DTF UV · artes avulsas'}; const SHORT={file:'Têxtil folha',avulsa:'Têxtil avulsa',uvfile:'UV folha',uv:'UV avulsa'}; const EVENTS={payment_approved:'Pagamento aprovado',production_started:'Produção iniciada',correction_needed:'Correção necessária',ready:'Pedido pronto'}; const LATE_HOURS=4; // Payment outcomes are recorded by the API in English; operators read Portuguese. function outcomeText(outcome){ const rules=[ [/^refused: paid (\S+) but quote total is (\d+)/,m=>'valor pago ('+(m[1]==='None'?'não informado':money(Number(m[1])))+') diferente do total da cotação ('+money(Number(m[2]))+')'], [/^refused: quote expired/,()=>'a cotação venceu antes do pagamento'], [/^refused: quote not found/,()=>'cotação não encontrada'], [/^refused: reference is not a quote id/,()=>'a referência do pagamento não é uma cotação'], [/^refused: quote was never reviewed/,()=>'a cotação ainda não tinha sido revisada'], [/^refused: quote uses an obsolete/,()=>'a cotação usa uma montagem antiga'], [/^attention: payment (\w+) for order (\d+)/,m=>'pagamento '+(m[1]==='refunded'?'estornado':'cancelado')+' depois do pedido #'+m[2]], ]; for(const [pattern,text] of rules){const m=outcome.match(pattern);if(m)return text(m);} return outcome; } let board=null; let tab='board', filter='all', search='', openOrder=null, openToken=0; let quoteKind='pending', pickedQuote=null; let olderFinished=[], finishedMore=null; const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m'; const when=value=>new Date(value).toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); function ago(value){ const minutes=Math.max(0,Math.round((Date.now()-new Date(value))/60000)); if(minutes<60)return 'há '+minutes+' min'; const hours=Math.round(minutes/60); return hours<48?'há '+hours+' h':'há '+Math.round(hours/24)+' dias'; } function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined&&text!==null)e.textContent=text;if(className)e.className=className;return e;} function button(text,fn,className='btn'){ const b=node('button',text,className);b.type='button'; b.onclick=async event=>{event.stopPropagation();b.disabled=true;try{await fn();}catch(e){say(e.message,true);}finally{b.disabled=false;}}; return b; } // Messages are notifications, not state: they clear themselves. Errors stay // longer so they can be read; a caller can hold one for as long as it needs. let sayTimer=null; function say(text,error=false,ms){ clearTimeout(sayTimer); for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);} if(text)sayTimer=setTimeout(()=>say(''),ms??(error?8000:4000)); } function icon(path){ const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg'); svg.setAttribute('width','14');svg.setAttribute('height','14');svg.setAttribute('viewBox','0 0 24 24'); svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','2.2');svg.setAttribute('aria-hidden','true'); for(const d of path){const p=document.createElementNS(ns,d.startsWith('circle')?'circle':'path'); if(d.startsWith('circle')){const [cx,cy,r]=d.split(' ').slice(1);p.setAttribute('cx',cx);p.setAttribute('cy',cy);p.setAttribute('r',r);}else p.setAttribute('d',d);svg.append(p);} return svg; } const ICON_OK=['M5 12l4 4 10-10'], ICON_WARN=['M12 8v5M12 17h.01','circle 12 12 9']; async function api(path,body){ const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})}); const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d; } function download(uid){return async()=>{ const result=await api('/uploads/'+uid+'/download'); const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click(); };} function manifest(production){ const url=URL.createObjectURL(new Blob([JSON.stringify(production,null,2)],{type:'application/json'})); const link=node('a');link.href=url;link.download='dtf-layout-'+production.sources[0].upload_id.slice(0,8)+'.json'; link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); } // ---- Loading and tabs ------------------------------------------------------ async function load(){ try{ board=await api('/board'); const known=new Set(board.orders.map(o=>o.id)); board.orders.push(...olderFinished.filter(o=>!known.has(o.id))); if(finishedMore===null)finishedMore=board.finished_total>board.finished_shown; $('login').hidden=true;$('app').hidden=false; say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice,false,tinyNotice?6000:2500); render(); }catch(e){ if(e.status===401||e.status===503){$('app').hidden=true;$('login').hidden=false;$('drawer').hidden=true;} // Not being signed in yet is the normal start, not an error. if(e.status===401){say('');return;} say(e.message,true); } } function showTab(name){ tab=name; for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name)); for(const id of ['board','quotes','payments','integrations'])$('tab-'+id).hidden=name!==id; const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents}; if(board&&lists[name])lists[name]().catch(e=>say(e.message,true)); } function render(){ $('who').textContent=board.operator||''; $('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length); $('badge-quotes').textContent=board.pending_total?String(board.pending_total):''; $('badge-payments').textContent=board.payment_issues_total?String(board.payment_issues_total):''; tinyHeader();renderBoard();renderIntegrations(); // Lists live on their own pages of the API; refresh the one being looked at. const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents}; if(lists[tab])lists[tab]().catch(e=>say(e.message,true)); if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();} } // ---- Production board ---------------------------------------------------- function printState(order){ if(order.state==='fil'||order.state==='imp')return {text:'Final aprovado',tone:'ok'}; if(order.state==='fin')return {text:'Concluído',tone:'ok'}; if(order.state==='cor')return {text:'Aguardando o cliente',tone:''}; const rows=order.print_files||[], items=order.snapshot.items.length; const ready=rows.filter(r=>r.status==='ready').length; if(rows.some(r=>r.status==='pending'||r.status==='rendering'))return {text:'Gerando arquivo…',tone:''}; if(ready===items)return {text:'PDF pronto',tone:'ok'}; if(rows.some(r=>r.status==='manual'||r.status==='failed')) return {text:items>1&&ready?ready+' de '+items+' pronto':'Preparar à mão',tone:'warn'}; return {text:'Sem arquivo gerado',tone:'warn'}; } function matches(order){ const modes=order.snapshot.items.map(i=>i.mode); if(filter==='textil'&&!modes.some(m=>m==='file'||m==='avulsa'))return false; if(filter==='uv'&&!modes.some(m=>m==='uvfile'||m==='uv'))return false; if(filter==='manual'&&!(order.print_files||[]).some(r=>r.status==='manual'||r.status==='failed'))return false; if(!search)return true; const c=order.snapshot.customer, d=order.snapshot.destination||{}; const hay=[String(order.number),c.mail,c.cnpj,d.recipient,d.city].join(' ').toLowerCase(); const digits=search.replace(/\D/g,''); return hay.includes(search)||(digits.length>=3&&hay.replace(/\D/g,'').includes(digits)); } function renderBoard(){ const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['manual','Preparar à mão']]; $('filters').replaceChildren(...filters.map(([key,label])=>{ const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(filter===key)); b.onclick=()=>{filter=key;renderBoard();};return b;})); const active=board.orders.filter(o=>['rec','tra','fil','imp'].includes(o.state)); const queued=active.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0); $('board-summary').textContent=metres(queued)+' em produção'; $('kan').replaceChildren(...Object.entries(board.states).map(([state,title])=>{ const column=node('section',undefined,'col');column.dataset.state=state; const orders=board.orders.filter(o=>o.state===state&&matches(o)); const total=orders.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0); const head=node('div',undefined,'col-head');const line=node('div'); const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]); // Finished orders are a recent window, not the whole history: say so. const count=state==='fin'&&board.finished_total>orders.length?orders.length+' de '+board.finished_total:String(orders.length); line.append(swatch,node('h2',title),node('span',count,'n')); head.append(line,node('small',orders.length?metres(total)+' no total':'vazio')); column.append(head); for(const order of orders)column.append(card(order)); if(state==='fin'&&finishedMore&&!search&&filter==='all')moreButton(column,true,loadOlderFinished); // Dragging only goes forward to the next stage. Going back or asking for a // correction needs a reason, so those happen in the order panel. column.ondragover=e=>{if(!column.classList.contains('allowed'))return;e.preventDefault();column.classList.add('target');}; column.ondragleave=()=>column.classList.remove('target'); column.ondrop=async e=>{e.preventDefault();column.classList.remove('target'); const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain')); if(order&&order.state!==state)try{await move(order,state);}catch(error){say(error.message,true);}}; return column; })); } function card(order){ const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true; if(order.id===openOrder)c.classList.add('open'); const top=node('div',undefined,'row'); top.append(node('span','#'+order.number,'num'),node('span',ago(order.updated_at),'age')); const tags=node('div',undefined,'tags'); for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag')); const foot=node('div',undefined,'foot');const p=printState(order); const status=node('span',p.text,p.tone);status.style.display='flex';status.style.alignItems='center';status.style.gap='5px'; if(p.tone==='ok')status.prepend(icon(ICON_OK));if(p.tone==='warn')status.prepend(icon(ICON_WARN)); const dest=order.snapshot.destination; foot.append(status,node('span',dest?'Entrega · '+dest.state:'Retirada','where')); c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot); if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000) c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late')); c.onclick=()=>openPanel(order.id); c.ondragstart=e=>{ e.dataTransfer.setData('text/plain',order.id); const forward=board.transitions[order.state].filter(s=>s!=='cor'); for(const col of document.querySelectorAll('.col[data-state]'))col.classList.toggle('allowed',forward.includes(col.dataset.state)); say(forward.length?'Mover para '+forward.map(s=>board.states[s]).join(' ou '):'Pedido finalizado',false,60000); }; c.ondragend=()=>{for(const col of document.querySelectorAll('.col'))col.classList.remove('allowed','target');say('');}; return c; } async function loadOlderFinished(){ const shown=board.orders.filter(o=>o.state==='fin').sort((a,b)=>a.created_ato.id)); const fresh=page.orders.filter(o=>!known.has(o.id)); olderFinished.push(...fresh);board.orders.push(...fresh);finishedMore=page.has_more;renderBoard(); } async function move(order,state,reason=''){ await api('/orders/'+order.id+'/move',{state,version:order.version,reason}); await load(); } // ---- Order panel --------------------------------------------------------- function openPanel(id){openOrder=id;const order=board.orders.find(o=>o.id===id);$('drawer').hidden=false;renderPanel(order);renderBoard();} function closePanel(){openOrder=null;$('drawer').hidden=true;$('panel').replaceChildren();if(board)renderBoard();} $('scrim').onclick=closePanel; document.addEventListener('keydown',e=>{if(e.key==='Escape'&&openOrder)closePanel();}); function layoutPreview(production){ const ns='http://www.w3.org/2000/svg', w=Number(production.film_width_cm), h=Math.max(1,Number(production.height_cm)); const svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 '+w+' '+h);svg.setAttribute('role','img'); svg.setAttribute('aria-label','Montagem: '+production.placements.length+' peças em '+w+' × '+h+' cm'); const film=document.createElementNS(ns,'rect');film.setAttribute('width',w);film.setAttribute('height',h);film.setAttribute('fill','#0F1113');svg.append(film); const colors=['#2BB6D9','#E0508F','#E9C23A','#6FD19C','#FF8B2B','#A78BFA']; for(const p of production.placements){const r=document.createElementNS(ns,'rect'); r.setAttribute('x',p.x_cm);r.setAttribute('y',p.y_cm);r.setAttribute('width',p.width_cm);r.setAttribute('height',p.length_cm); r.setAttribute('fill',colors[p.source_index%colors.length]);r.setAttribute('fill-opacity','.55');svg.append(r);} return svg; } function renderPanel(order){ const token=++openToken, panel=$('panel');panel.dataset.order=order.id; const color=STAGE_COLORS[order.state]; const head=node('div',undefined,'panel-head'); const row=node('div',undefined,'row'); const stage=node('span',board.states[order.state],'stage');stage.style.setProperty('--c',color); const close=node('button',undefined,'close');close.type='button';close.setAttribute('aria-label','Fechar');close.append(icon(['M6 6l12 12M18 6L6 18']));close.onclick=closePanel; row.append(node('span','#'+order.number,'num'),stage,node('span','pago '+when(order.created_at),'faint'),close); const steps=node('ol',undefined,'steps');steps.setAttribute('aria-label','Etapas'); const at=FLOW.indexOf(order.state); for(const [i,s] of FLOW.entries()){const li=node('li',board.states[s]);li.style.setProperty('--c',STAGE_COLORS[s]); if(at>=0&&iitemsList.append(itemCard(order,item,index))); items.append(itemsList); const total=node('p',undefined,'muted');total.append('Total pago: ',node('b',money(s.total_cents)));items.append(total); const finals=node('section',undefined,'finals');finals.append(node('p','Arquivos finais','eyebrow'),node('p','Carregando…','faint')); const history=node('section');history.append(node('p','Histórico','eyebrow'),node('p','Carregando…','faint')); body.append(facts,items,finals,history); panel.replaceChildren(head,body); api('/orders/'+order.id+'/files').then(rows=>{if(token===openToken)finalsSection(order,rows,finals);}).catch(e=>say(e.message,true)); api('/orders/'+order.id+'/history').then(rows=>{if(token===openToken)historySection(order,rows,history);}).catch(e=>say(e.message,true)); } function moves(order){ const box=node('div');box.style.display='flex';box.style.flexDirection='column';box.style.gap='10px'; const row=node('div',undefined,'moves'); const reason=node('form',undefined,'reason');reason.hidden=true; for(const next of board.transitions[order.state]){ if(next==='cor'){ const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor'; b.onclick=()=>{reason.hidden=!reason.hidden;backForm.hidden=true;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b); }else{ const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b); } } // Undo a mistaken move: one stage back, internal reason, no customer message. const previous=board.back?.[order.state]; const backForm=node('form',undefined,'reason');backForm.hidden=true; if(previous){ const b=node('button','Voltar para '+board.states[previous],'btn ghost');b.type='button';b.dataset.back=previous; b.onclick=()=>{backForm.hidden=!backForm.hidden;reason.hidden=true;if(!backForm.hidden)backForm.querySelector('input').focus();}; row.append(b); const why=node('input');why.placeholder='Motivo';why.setAttribute('aria-label','Motivo do retorno');why.required=true;why.maxLength=1000; const go=node('button','Voltar para '+board.states[previous],'btn'); backForm.append(why,go); backForm.onsubmit=async e=>{e.preventDefault();go.disabled=true;try{await move(order,previous,why.value.trim());}catch(error){say(error.message,true);go.disabled=false;}}; } if(!row.children.length)row.append(node('span','Pedido concluído.','muted')); const text=node('textarea');text.placeholder='Motivo da correção';text.setAttribute('aria-label','Motivo da correção');text.maxLength=1000;text.required=true; const confirm=node('button','Enviar para correção','btn danger'); reason.append(text,confirm); reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}}; box.append(row,reason,backForm);return box; } const PRINT_TEXT={pending:'Na fila para gerar',rendering:'Gerando arquivo…',ready:'PDF gerado',manual:'Preparar à mão',failed:'Falhou ao gerar'}; function itemCard(order,item,index){ const row=(order.print_files||[]).find(r=>r.item_index===index); const box=node('div',undefined,'item'); if(row&&(row.status==='manual'||row.status==='failed'))box.classList.add('attention'); const preview=node('div',undefined,'preview'); if(item.production?.placements)preview.append(layoutPreview(item.production)); const body=node('div',undefined,'body'); const title=node('div',undefined,'title');title.append(node('b',PRODUCT[item.mode]),node('span',metres(item.billed_metres),'mono')); const spec=item.production; const line=spec?(spec.placements.length+' peças em '+spec.film_width_cm+' × '+spec.height_cm+' cm · nota '+item.grade):('nota '+item.grade); body.append(title,node('div',line,'line')); const file=node('div',undefined,'file'); if(row){ const tone=row.status==='ready'?'ok':row.status==='manual'||row.status==='failed'?'warn':'muted'; const label=node('span',PRINT_TEXT[row.status]+(row.status==='ready'&&row.name?' · '+row.name:'')+(row.status==='manual'&&row.detail.reason?': '+row.detail.reason:''),tone); file.append(tone==='ok'?icon(ICON_OK):tone==='warn'?icon(ICON_WARN):'',label); if(row.status==='ready'&&row.detail){body.append(file,node('div',[row.detail.film_width_cm+' × '+row.detail.height_cm+' cm', row.detail.min_dpi?'menor resolução '+row.detail.min_dpi+' DPI':'',row.detail.vector_sources?'PDF vetorial':''].filter(Boolean).join(' · '),'line'));} else body.append(file); }else body.append(node('div','Arquivo de impressão ainda não gerado','line')); const buttons=node('div',undefined,'buttons'); if(row?.status==='ready')buttons.append(button('Baixar PDF',download(row.upload_id))); item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost'))); if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost')); if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed')) buttons.append(button('Gerar novamente',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost')); body.append(buttons); box.append(preview,body);return box; } function finalsSection(order,revisions,section){ section.replaceChildren(node('p','Arquivos finais','eyebrow')); for(const file of revisions){ const rev=node('div',undefined,'rev'); rev.append(node('b',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name), node('span',(file.expired?'expirado':file.active?'atual':'substituído')+' · '+when(file.created_at)+' · '+file.note,'faint')); if(!file.expired){const b=button('Baixar',download(file.upload_id),'btn ghost');b.style.alignSelf='flex-start';rev.append(b);} section.append(rev); } if(!['rec','tra','cor'].includes(order.state)){if(!revisions.length)section.append(node('p','Nenhum arquivo final registrado.','faint'));return;} // A generated print file reproduces the approved layout: offer it first. Not // after a customer correction, which replaced the artwork it was made from. const corrected=revisions.some(file=>file.kind==='correction'); const form=node('form'); const inputs=order.snapshot.items.map((item,index)=>{ const slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]); const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index; const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready'); if(generated){ const use=node('input');use.type='checkbox';use.checked=true;use.dataset.useGenerated=index;input.required=false;input.hidden=true; use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;}; const choice=node('label',undefined,'check');choice.append(use,'Usar o PDF gerado ('+generated.name+')'); slot.append(choice);input.generated=()=>use.checked?generated.upload_id:null; } slot.append(input);form.append(slot);return input; }); const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000; const check=node('input');check.type='checkbox';check.required=true;check.dataset.confirm=''; const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos conferidos'); const submit=node('button','Aprovar arquivos finais','btn primary'); form.append(note,confirm,submit); form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{ const refs=[]; for(const [index,input] of inputs.entries()){ const generated=input.generated?.(); if(generated){refs.push({item_index:index,upload_id:generated});continue;} for(const file of input.files){ const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>say(text)}); refs.push({item_index:index,upload_id:uid}); } } await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value}); await load(); }catch(error){say(error.message,true);submit.disabled=false;}}; section.append(form); } function historySection(order,rows,section){ const list=node('ol',undefined,'timeline'); for(const r of [...rows].reverse()){ const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]); const text=node('div');text.append(node('b',r.back?'Voltou para '+board.states[r.to_state]:board.states[r.from_state]+' → '+board.states[r.to_state])); if(r.reason)text.append(node('span',' · '+r.reason,'muted')); text.append(node('small',when(r.created_at)+' · '+r.operator));li.append(text);list.append(li); } const first=node('li');first.style.setProperty('--c','var(--rec)'); const t=node('div');t.append(node('b','Pedido recebido e pago'),node('small',when(order.created_at)+' · '+(board.environment==='local'&&order.payment?.provider==='fake'?'pagamento de teste':'Site'))); first.append(t);list.append(first); section.replaceChildren(node('p','Histórico','eyebrow'),list); } // ---- Quotes -------------------------------------------------------------- function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);} let quoteRows=[], quoteTotal=0, quotePage=1, quoteSize=20, currentQuote=null, quoteLoading=0; async function loadQuotes(){ const token=++quoteLoading; const page=await api('/quotes?'+new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)})); if(token!==quoteLoading)return; quoteRows=page.quotes;quoteTotal=page.total; if(quotePage>1&&!quoteRows.length&"eTotal){quotePage=Math.ceil(quoteTotal/quoteSize);return loadQuotes();} renderQuotes(); } function renderQuotes(){ const kinds=[['pending','A revisar',board.pending_total],['approved','Aprovadas, aguardando pagamento',board.approved_total]]; $('quote-filters').replaceChildren(...kinds.map(([key,label,count])=>{ const b=node('button',label+' · '+count,'chip');b.type='button';b.setAttribute('aria-pressed',String(quoteKind===key)); b.onclick=()=>{quoteKind=key;quotePage=1;currentQuote=null;loadQuotes().catch(e=>say(e.message,true));};return b;})); if(!currentQuote||(!quoteRows.some(q=>q.id===currentQuote.id)&¤tQuote.status==='pending_review'))currentQuote=quoteRows[0]||null; else currentQuote=quoteRows.find(q=>q.id===currentQuote.id)||currentQuote; const list=quoteRows.map(q=>{ const b=node('button',undefined,'qitem');b.type='button';b.dataset.quotePick=q.id;b.setAttribute('aria-current',String(q.id===currentQuote?.id)); const left=node('div');left.append(node('b',q.draft.customer.mail), node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q)))); if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag')); else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag')); const right=node('div',undefined,'right'); right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at))); b.append(left,right);b.onclick=()=>{currentQuote=q;renderQuotes();};return b; }); if(!list.length)list.push(node('p',quoteKind==='pending'?'Nenhuma cotação para revisar.':'Nenhuma cotação aprovada aguardando pagamento.','faint')); if(quoteTotal>quoteSize||quotePage>1)list.push(pager({page:quotePage,size:quoteSize,total:quoteTotal,noun:'cotações',compact:true, onPage:n=>{quotePage=n;loadQuotes().catch(e=>say(e.message,true));}, onSize:n=>{quoteSize=n;quotePage=1;loadQuotes().catch(e=>say(e.message,true));}})); $('quote-list').replaceChildren(...list); $('reviews').replaceChildren(currentQuote?review(currentQuote):node('p','Selecione uma cotação.','note')); } function review(quote){ const card=node('article',undefined,'review');card.style.display='contents';card.dataset.quote=quote.id; const head=node('div',undefined,'head');const title=node('div'); title.append(node('b',quote.draft.customer.mail),node('div','Cotação '+quote.id.slice(0,8)+' · enviada '+ago(quote.created_at)+' · '+ (quote.draft.destination?'entrega em '+quote.draft.destination.city+'/'+quote.draft.destination.state:'retirada em Franca'),'muted')); head.append(title); const content=node('div',undefined,'content'); const foot=node('div',undefined,'foot'); if(quote.status==='pending_review'&"e.draft.items.some(item=>item.production?.version!==2)){ content.append(node('p','Montagem antiga. Solicite uma nova cotação ao cliente.','muted')); card.append(head,content);return card; } if(quote.status!=='pending_review'){ content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.': 'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted')); for(const item of quote.draft.items)content.append(quoteItem(item,null)); card.append(head,content);return card; } const form=node('form');form.style.display='contents'; const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields)); return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});}); const check=node('input');check.type='checkbox';check.required=true; const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos, metragem e nota conferidos'); const submit=node('button','Aprovar cotação','btn primary'); foot.append(confirm,submit); form.append(content,foot); form.onsubmit=async e=>{e.preventDefault();submit.disabled=true; try{const approved=await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())}); // Keep showing the quote just approved while its list refreshes. currentQuote={...quote,approved,status:'approved'};say('Cotação aprovada.');await load();} catch(error){say(error.message,true);submit.disabled=false;}}; card.append(head,form);return card; } function quoteItem(item,fields){ const box=node('div',undefined,'item'); const preview=node('div',undefined,'preview');preview.style.width='160px'; if(item.production?.placements)preview.append(layoutPreview(item.production)); const body=node('div',undefined,'body'); const title=node('div',undefined,'title'); const quality=item.quality_status==='ok'?'qualidade ok':item.quality_status==='warning'?'ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):'qualidade não verificada'; title.append(node('b',PRODUCT[item.mode]),node('span',quality+' · nota '+item.grade,item.quality_status==='ok'?'ok':'warn')); body.append(title); if(item.production)body.append(node('div',item.production.sources.length+' arquivo(s) · '+item.production.placements.length+' peças · '+ item.production.film_width_cm+' × '+item.production.height_cm+' cm','line')); if(fields){ const grid=node('div',undefined,'fields'); const asked=node('label','Metros do cliente');const a=node('input');a.value=item.metres;a.readOnly=true;asked.append(a); const m=node('label','Metros conferidos');fields.metres=node('input');Object.assign(fields.metres,{type:'number',min:'0.001',max:'12000',step:'any',value:item.metres,required:true});m.append(fields.metres); const g=node('label','Nota conferida');fields.grade=node('input');Object.assign(fields.grade,{type:'number',min:'0',max:'100',step:'1',value:item.grade,required:true});g.append(fields.grade); grid.append(asked,m,g);body.append(grid); } const buttons=node('div',undefined,'buttons'); item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost'))); if(item.production?.placements)buttons.append(button('Manifesto',()=>manifest(item.production),'btn ghost')); body.append(buttons);box.append(preview,body);return box; } // ---- Payments and integrations ------------------------------------------ const tinyResult=new URLSearchParams(location.search).get('tiny'); const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):''; if(tinyResult)history.replaceState(null,'',location.pathname); function tinyHeader(){ const t=board.tiny||{};const el=$('tiny'); if(!t.configured){el.textContent='';return;} const trouble=!t.connected||t.problem; el.textContent=!t.connected?'Tiny não conectado':t.problem?'Tiny: verificar conexão':'Tiny conectado'; el.style.setProperty('--dot',trouble?'var(--warn)':'var(--fin)'); } const TINY_PROBLEMS={refused:'O Tiny recusou a renovação da conexão. Conecte de novo.', expired:'A conexão com o Tiny expirou. Conecte de novo.', 'renewal-failing':'A última renovação falhou; o sistema tenta de novo sozinho.', expiring:'A conexão expira em breve e não está sendo renovada.'}; function tinyDetail(t){ if(!t.connected_by)return 'Conecte com uma conta do Tiny.'; const parts=[t.connected_by+' · conectado em '+when(t.connected_at),'renovado em '+when(t.renewed_at), t.offline?'sem expiração diária':t.expires_at?'válido até '+when(t.expires_at):'', 'envio de pedidos '+(t.orders_enabled?'ativo':'desativado')].filter(Boolean); const problem=TINY_PROBLEMS[t.problem]; return (problem?problem+(t.failed_at&&t.problem!=='expired'?' (falha em '+when(t.failed_at)+')':'')+' · ':'')+parts.join(' · '); } function integration(name,state,tone,text,actions=[]){ const box=node('div',undefined,'integration'); const head=node('div',undefined,'head');const dot=node('span',undefined,'dot'); const colors={ok:['var(--fin)','var(--ok)'],warn:['var(--warn)','var(--warn)'],off:['#5B6168','var(--muted)']}[tone]; dot.style.setProperty('--dot',colors[0]);const em=node('em',state);em.style.setProperty('--dotx',colors[1]); head.append(dot,node('b',name),em);box.append(head,node('p',text)); if(actions.length){const b=node('div',undefined,'buttons');b.append(...actions);box.append(b);} return box; } // The page bar under every list: page size, range, first/previous/page/next/last. const PAGE_SIZES=[10,20,50,100]; function pager({page,size,total,noun,onPage,onSize,compact=false}){ const pages=Math.max(1,Math.ceil(total/size)); const bar=node('div',undefined,'pager'+(compact?' compact':'')); const sizeLabel=node('label',undefined,'pager-size'); const select=node('select');select.setAttribute('aria-label','Itens por página'); for(const n of PAGE_SIZES){const o=node('option',String(n));o.value=String(n);o.selected=n===size;select.append(o);} select.onchange=()=>onSize(Number(select.value)); sizeLabel.append('Mostrar',select,'por página'); const nav=node('div',undefined,'pager-nav'); const from=total?(page-1)*size+1:0, to=Math.min(total,page*size); const go=(label,aria,target,disabled)=>{const b=node('button',label,'pg');b.type='button';b.setAttribute('aria-label',aria);b.disabled=disabled;b.onclick=()=>onPage(target);return b;}; const pageLabel=node('label',undefined,'pager-page'); const input=node('input');input.type='number';input.min='1';input.max=String(pages);input.value=String(page);input.setAttribute('aria-label','Página'); input.onchange=()=>onPage(Math.min(pages,Math.max(1,Math.round(Number(input.value))||1))); pageLabel.append(compact?'':'Página',input,'de '+pages); nav.append(go('«','Primeira página',1,page<=1),go('‹','Página anterior',page-1,page<=1),pageLabel, go('›','Próxima página',page+1,page>=pages),go('»','Última página',pages,page>=pages)); bar.append(sizeLabel,node('span',(compact?'':'Mostrando ')+from+' a '+to+' de '+total+(compact?'':' '+noun),'pager-range'),nav); return bar; } function chips(container,options,current,pick){ container.replaceChildren(...options.map(([key,label])=>{ const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(current===key)); b.onclick=()=>pick(key);return b;})); } function moreButton(container,has,fn){ if(!has)return; const b=button('Carregar mais',fn,'btn ghost');b.style.alignSelf='flex-start';b.style.marginTop='8px';container.append(b); } // Payments needing a person: paged, open or resolved (history). let issueState='open', issueRows=[], issueTotal=0, issuePage=1, issueSize=20, issueLoading=0; async function loadIssues(){ const token=++issueLoading; const page=await api('/payment-events?'+new URLSearchParams({state:issueState,limit:String(issueSize),offset:String((issuePage-1)*issueSize)})); if(token!==issueLoading)return; issueRows=page.issues;issueTotal=page.total; if(issuePage>1&&!issueRows.length&&issueTotal){issuePage=Math.ceil(issueTotal/issueSize);return loadIssues();} renderIssues(); } function renderIssues(){ const filters=node('div',undefined,'chips');filters.style.marginBottom='12px'; chips(filters,[['open','Abertos'],['resolved','Resolvidos'],['all','Todos']],issueState,key=>{issueState=key;issuePage=1;loadIssues().catch(e=>say(e.message,true));}); const list=node('div'); if(!issueRows.length)list.append(node('p',issueState==='resolved'?'Nenhum pagamento resolvido.':'Nenhum pagamento pendente.','faint')); for(const issue of issueRows){ const box=node('div',undefined,'issue'); if(issue.resolved_at)box.classList.add('done'); box.append(issue.resolved_at?icon(ICON_OK):icon(ICON_WARN));box.firstChild.style.color=issue.resolved_at?'var(--ok)':'var(--warn)'; const text=node('div',undefined,'text'); text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)), node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at))); if(issue.resolved_at){ text.append(node('span','Resolvido por '+issue.resolved_by+' em '+when(issue.resolved_at)+': '+issue.resolution)); box.append(text); }else{ const form=node('form');const note=node('input');note.placeholder='Resolução';note.setAttribute('aria-label','Resolução');note.required=true;note.minLength=3;note.maxLength=1000; const save=node('button','Registrar resolução','btn warn');form.append(note,save); form.onsubmit=async e=>{e.preventDefault();save.disabled=true; try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});say('Resolução registrada.');await load();} catch(error){say(error.message,true);save.disabled=false;}}; box.append(text,form); } list.append(box); } const bar=pager({page:issuePage,size:issueSize,total:issueTotal,noun:'pagamentos', onPage:n=>{issuePage=n;loadIssues().catch(e=>say(e.message,true));}, onSize:n=>{issueSize=n;issuePage=1;loadIssues().catch(e=>say(e.message,true));}}); $('payments').replaceChildren(filters,list,bar); } // The integration send log: filtered and paged on the server. let eventFilter={provider:'',status:'',event:'',order:''}, eventRows=[], eventTotal=0, eventPage=1, eventSize=20, eventLoading=0, eventTimer=null; async function loadEvents(){ const token=++eventLoading; const params=new URLSearchParams({limit:String(eventSize),offset:String((eventPage-1)*eventSize)}); for(const [key,value] of Object.entries(eventFilter))if(value)params.set(key,value); const page=await api('/events?'+params); if(token!==eventLoading)return; eventRows=page.events;eventTotal=page.total; if(eventPage>1&&!eventRows.length&&eventTotal){eventPage=Math.ceil(eventTotal/eventSize);return loadEvents();} renderEvents(); } function eventFilters(){ const bar=$('event-filters');if(bar.dataset.ready)return;bar.dataset.ready='1'; const select=(label,key,options)=>{ const field=node('label',undefined,'field');field.append(label); const s=node('select');for(const [value,text] of options){const o=node('option',text);o.value=value;s.append(o);} s.onchange=()=>{eventFilter[key]=s.value;eventPage=1;loadEvents().catch(e=>say(e.message,true));}; field.append(s);return field; }; const order=node('label',undefined,'field');order.append('Pedido'); const input=node('input');input.type='number';input.min='1';input.placeholder='Nº';input.setAttribute('inputmode','numeric'); input.oninput=()=>{clearTimeout(eventTimer);eventTimer=setTimeout(()=>{eventFilter.order=input.value.trim();eventPage=1;loadEvents().catch(e=>say(e.message,true));},350);}; order.append(input); bar.append( select('Destino','provider',[['','Todos'],['tiny','Tiny'],['whatsapp','WhatsApp']]), select('Situação','status',[['','Todas'],['delivered','Enviados'],['queued','Na fila'],['failing','Com erro']]), select('Evento','event',[['','Todos'],...Object.entries(EVENTS)]), order); } function eventResult(e){ const receipt=e.receipt||{}; if(e.delivered_at){ const situacao=receipt.situacao?' · '+receipt.situacao:''; if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number+situacao,'good']; if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)'+situacao,'good']; if(receipt.status==='status-updated')return ['Tiny nº '+receipt.tiny_number+situacao,'good']; if(receipt.status==='status-unchanged')return ['Tiny nº '+receipt.tiny_number+' já estava'+situacao.replace(' · ',' em '),'']; if(receipt.status==='not-applicable')return ['Não se aplica ao Tiny','']; return ['Registrado, sem envio','']; } if(e.last_error)return ['Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')','retry']; return ['Na fila','']; } function renderEvents(){ const rows=eventRows.map(e=>{ const tr=node('tr');const [result,cls]=eventResult(e); tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'), node('td',{tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider), node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls)); return tr; }); if(!rows.length){const tr=node('tr');const td=node('td','Nenhum envio encontrado.','faint');td.colSpan=5;tr.append(td);rows.push(tr);} $('events').replaceChildren(...rows); $('events-more').replaceChildren(pager({page:eventPage,size:eventSize,total:eventTotal,noun:'envios', onPage:n=>{eventPage=n;loadEvents().catch(e=>say(e.message,true));}, onSize:n=>{eventSize=n;eventPage=1;loadEvents().catch(e=>say(e.message,true));}})); } function renderIntegrations(){ const t=board.tiny||{};const cards=[]; if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Credenciais ausentes no servidor.')); else{ const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn'); const actions=[connect]; if(t.connected){ actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{}); say(r.ok?'Conexão com o Tiny ok: pedidos, contatos, os 4 produtos e a retirada conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));} cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado', t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions)); } const mp=board.providers?.payment; cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off', mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.')); cards.push(integration('Frete','Não configurado','off','Somente retirada.')); cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.')); $('integrations').replaceChildren(...cards); eventFilters(); } // ---- Wiring -------------------------------------------------------------- for(const b of document.querySelectorAll('[data-tab]'))b.onclick=()=>showTab(b.dataset.tab); $('search').oninput=e=>{search=e.target.value.trim().toLowerCase();renderBoard();if(search)showTab('board');}; $('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){say(error.message,true);}finally{$('password').value='';}}; $('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();}; $('refresh').onclick=load; load();