All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 3m3s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m51s
Kanban: tabs for production, quote review and payments/integrations; compact cards with products, metres, print-file status, delivery and time in stage; an order panel with stage progress, one main action, a correction reason in place, items with a preview drawn from the approved layout, final-file approval and the history as a timeline. Quote review gets a list and a pane; payment issues resolve in place; integrations show their real state, Tiny's connection with a read-only "Testar conexão", and a readable send log. The previous Kanban is kept in git tag ui-v1 and is no longer served. Production wording: the customer portal no longer says it is a local test environment outside the local stack; the checkout no longer tells customers to use the Kanban or shows internal stage codes; sign-in, session, quota and print-file messages are Portuguese and never say "local". A simulated freight price is refused outside the local stack until a real freight provider exists, so production only offers pickup. The browser suite drives the new tabs and panel and still checks the whole upload, quote, payment and production journey. Full CI sequence passes locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
517 lines
35 KiB
JavaScript
517 lines
35 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 extraQuotes={pending:[],approved:[]}, moreQuotes={pending:false,approved:false};
|
||
|
||
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;
|
||
}
|
||
function say(text,error=false){
|
||
for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);}
|
||
}
|
||
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');
|
||
extraQuotes={pending:[],approved:[]};
|
||
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
|
||
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
|
||
$('login').hidden=true;$('app').hidden=false;
|
||
say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice);
|
||
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));
|
||
$('tab-board').hidden=name!=='board';$('tab-quotes').hidden=name!=='quotes';$('tab-integrations').hidden=name!=='integrations';
|
||
}
|
||
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?.length?String(board.payment_issues.length):'';
|
||
tinyHeader();renderBoard();renderQuotes();renderIntegrations();
|
||
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>board.finished_shown?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));
|
||
column.ondragover=e=>{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 nesta etapa','late'));
|
||
c.onclick=()=>openPanel(order.id);
|
||
c.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);
|
||
return c;
|
||
}
|
||
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),node('span','CNPJ '+s.customer.cnpj+' · '+s.customer.zap));
|
||
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;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);
|
||
}
|
||
}
|
||
if(!row.children.length)row.append(node('span','Pedido concluído.','muted'));
|
||
const text=node('textarea');text.placeholder='O que o cliente precisa corrigir? (vai para o cliente)';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);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 de novo',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');
|
||
form.append(node('p','Aprove um conjunto final completo. Um novo conjunto substitui o anterior.','muted'));
|
||
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,'Conferi estes arquivos finais para impressão');
|
||
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',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 allQuotes(){return [...board.quotes,...extraQuotes.pending,...extraQuotes.approved];}
|
||
function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);}
|
||
async function loadMoreQuotes(kind){
|
||
const shown=allQuotes().filter(q=>kind==='pending'?!q.approved:!!q.approved);
|
||
const last=shown.at(-1);const params=new URLSearchParams({kind,limit:'50'});
|
||
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);}
|
||
const page=await api('/quotes?'+params);const known=new Set(allQuotes().map(q=>q.id));
|
||
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));moreQuotes[kind]=page.has_more;renderQuotes();
|
||
}
|
||
function renderQuotes(){
|
||
const quotes=allQuotes();
|
||
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;renderQuotes();};return b;}));
|
||
const visible=quotes.filter(q=>quoteKind==='pending'?q.status==='pending_review':q.status!=='pending_review');
|
||
if(!pickedQuote||!quotes.some(q=>q.id===pickedQuote))pickedQuote=visible[0]?.id||null;
|
||
const list=visible.map(q=>{
|
||
const b=node('button',undefined,'qitem');b.type='button';b.dataset.quotePick=q.id;b.setAttribute('aria-current',String(q.id===pickedQuote));
|
||
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=()=>{pickedQuote=q.id;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(moreQuotes[quoteKind])list.push(button('Carregar mais antigas',()=>loadMoreQuotes(quoteKind),'btn ghost'));
|
||
$('quote-list').replaceChildren(...list);
|
||
const quote=quotes.find(q=>q.id===pickedQuote);
|
||
$('reviews').replaceChildren(quote?review(quote):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','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.','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 o pagamento do cliente.','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,'Conferi arquivos, metragem (com repetições e montagem) e nota');
|
||
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{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});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;}
|
||
el.textContent=t.connected?'Tiny conectado':'Tiny não conectado';
|
||
el.style.setProperty('--dot',t.connected?'var(--fin)':'var(--warn)');
|
||
}
|
||
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;
|
||
}
|
||
function renderIntegrations(){
|
||
const issues=board.payment_issues||[];
|
||
const payments=[node('h2','Pagamentos que precisam de atenção')];
|
||
if(!issues.length)payments.push(node('p','Nenhum pagamento pendente de resolução.','faint'));
|
||
for(const issue of issues){
|
||
const box=node('div',undefined,'issue');box.append(icon(ICON_WARN));box.firstChild.style.color='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)));
|
||
const form=node('form');const note=node('input');note.placeholder='O que foi feito? (ex.: estornado em 25/09)';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});await load();}catch(error){say(error.message,true);save.disabled=false;}};
|
||
box.append(text,form);payments.push(box);
|
||
}
|
||
$('payments').replaceChildren(...payments);
|
||
|
||
const t=board.tiny||{};const cards=[];
|
||
if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Defina TINY_CLIENT_ID, TINY_CLIENT_SECRET e TINY_REDIRECT_URI 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){const result=node('p');
|
||
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
|
||
say(r.ok?'Tiny respondeu: leitura de pedidos e contatos ok.':'Tiny: pedidos '+r.results.pedidos+' · contatos '+r.results.contatos,!r.ok);}));}
|
||
cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn',
|
||
t.connected?'Por '+t.connected_by+' desde '+when(t.connected_at)+'. Envio de pedidos '+(t.orders_enabled?'ligado.':'desligado.'):
|
||
'Clique em Conectar e aprove o acesso com um login do Tiny.',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 no Site.':'Aguardando a conta e as chaves do Mercado Pago. O Site ainda não recebe pagamentos.'));
|
||
cards.push(integration('Frete','Não configurado','off','Aguardando a plataforma de frete. Por enquanto só retirada em Franca.'));
|
||
cards.push(integration('WhatsApp','Não configurado','off','As mensagens ficam registradas abaixo, sem envio ao cliente.'));
|
||
$('integrations').replaceChildren(...cards);
|
||
|
||
$('events').replaceChildren(...board.events.map(e=>{
|
||
const tr=node('tr');const receipt=e.receipt||{};let result, cls='';
|
||
if(e.delivered_at){
|
||
if(receipt.status==='created'){result='Criado no Tiny nº '+receipt.tiny_number;cls='good';}
|
||
else if(receipt.status==='already-created'){result='Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)';cls='good';}
|
||
else if(receipt.status==='not-applicable'){result='Não enviado (o Tiny recebe só a venda)';}
|
||
else result='Registrado, sem envio';
|
||
}else if(e.last_error){result='Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')';cls='retry';}
|
||
else result='Na fila';
|
||
const provider={tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider;
|
||
tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),node('td',provider),
|
||
node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls));
|
||
return tr;
|
||
}));
|
||
if(!board.events.length){const tr=node('tr');const td=node('td','Nenhum envio ainda.','faint');td.colSpan=5;tr.append(td);$('events').append(tr);}
|
||
}
|
||
|
||
// ---- 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();
|