Some checks failed
Build and deploy / Validate source (push) Successful in 12s
Build and deploy / Integration suite on a real stack (push) Failing after 2m36s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Has been skipped
Sheets of several GB are the normal order. The upload limit is now 5 GB. ClamAV scans files up to 2 GB; a larger file is released only when its first bytes match the format its name claims, and a disguised file is refused. The Site grades a sheet over 150 MB from the pixel size in its PNG, JPEG or WebP header without decoding it, and reads large PDFs in ranges. The worker never opens a source over 300 MB: a finished sheet placed whole becomes its own print file, which the Kanban offers to approve as the final, and anything else goes to hand preparation. Files start uploading as they enter the cart, with progress in the summary, and each part renews the reservation so slow uploads do not expire. Quotas grow to 50 GB per customer and 500 GB in total; the Swarm config for ClamAV is renamed because a deployed config cannot change in place. Verified locally with a 386 MB and a 1.8 GB PNG (scanned, paid, original as print file), a 2.3 GB PNG (format check) and a disguised 2.3 GB file (refused). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
698 lines
46 KiB
JavaScript
698 lines
46 KiB
JavaScript
/* 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';
|
||
}
|
||
// The customer's WhatsApp opens the conversation, for messages the system does
|
||
// not send (a correction, a question about the artwork).
|
||
function whatsappLink(zap){
|
||
const digits=String(zap||'').replace(/\D/g,'');
|
||
if(digits.length<10)return null;
|
||
return 'https://wa.me/'+(digits.length<=11?'55':'')+digits;
|
||
}
|
||
function contato(customer){
|
||
const line=node('span','CNPJ '+customer.cnpj+' · ');
|
||
const href=whatsappLink(customer.zap);
|
||
if(!href){line.append(customer.zap||'');return line;}
|
||
const d=String(customer.zap).replace(/\D/g,'').replace(/^55(?=\d{10,11}$)/,'');
|
||
const shown=d.length===11?'('+d.slice(0,2)+') '+d.slice(2,7)+'-'+d.slice(7):d.length===10?'('+d.slice(0,2)+') '+d.slice(2,6)+'-'+d.slice(6):customer.zap;
|
||
const a=node('a',shown,'zap');a.href=href;a.target='_blank';a.rel='noopener noreferrer';
|
||
a.title='Abrir conversa no WhatsApp';
|
||
line.append(a);return line;
|
||
}
|
||
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_at<b.created_at?-1:1);
|
||
const oldest=shown[0];const params=new URLSearchParams({limit:'50'});
|
||
if(oldest){params.set('before_created_at',oldest.created_at);params.set('before_id',oldest.id);}
|
||
const page=await api('/orders/finished?'+params);
|
||
const known=new Set(board.orders.map(o=>o.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&&i<at)li.classList.add('done');if(i===at)li.classList.add('now');steps.append(li);}
|
||
head.append(row,steps,moves(order));
|
||
|
||
const body=node('div',undefined,'panel-body');
|
||
const s=order.snapshot, dest=s.destination;
|
||
const facts=node('section',undefined,'facts');
|
||
const who=node('div');who.append(node('p','Cliente','eyebrow'),node('b',s.customer.mail),contato(s.customer));
|
||
const where=node('div');where.append(node('p','Entrega','eyebrow'),node('b',dest?dest.recipient:'Retirada em Franca'),
|
||
node('span',dest?dest.street+', '+dest.number+(dest.complement?' '+dest.complement:'')+' · '+dest.district+' · '+dest.city+'/'+dest.state+' · CEP '+dest.postal_code:'Cliente retira na fábrica'));
|
||
facts.append(who,where);
|
||
const items=node('section');items.append(node('p','Itens e arquivos de impressão','eyebrow'));
|
||
const itemsList=node('div');itemsList.style.display='flex';itemsList.style.flexDirection='column';itemsList.style.gap='10px';
|
||
s.items.forEach((item,index)=>itemsList.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 original=row.status==='ready'&&row.detail?.source==='original';
|
||
const label=node('span',(original?'Arquivo grande: o original é o arquivo de impressão':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&&!original){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'&&row.detail?.source!=='original')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,(generated.detail?.source==='original'?'Usar o original como arquivo final (':'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'));
|
||
if(q.auto_approved)left.append(node('span','Aprovada automaticamente'));
|
||
else if(q.review_reason)left.append(node('span','Revisão manual: '+q.review_reason,'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.'));
|
||
const fr=board.providers?.freight||{};
|
||
const kg=v=>String(v).replace('.',',')+' kg';
|
||
cards.push(fr.provider==='jadlog'
|
||
? integration('Frete','Ativo','ok','Entrega pela Jadlog e retirada em Franca. Pacote: '+kg(fr.base_kg)+
|
||
' + '+kg(fr.per_metre_kg)+' por metro · prazo da Jadlog + '+fr.production_days+' dia(s) de produção.')
|
||
: 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();
|