feat: undo mistaken moves, numbered pagination, and quieter Kanban messages
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s
Moves: an order can go back one stage (BACK in app/runtime.py) with an internal reason, flagged in the history as movements.back. The customer is not notified and approved finals stay; "production started" and "ready" are now enqueued once per order, so undoing and redoing a move sends nothing twice. Dragging only goes forward and highlights the allowed column. Move errors are in Portuguese. Lists: the send log, payments (open, resolved as history, all) and quotes are paged on the server with a total, 20 rows by default (10/20/50/100), first/previous/page/next/last. The send log filters by destination, status, event and order. Older finished orders load on demand. The board no longer carries the send log or payment rows, only the open-payment count. Kanban: Pagamentos and Integrações are separate tabs; messages are brief, bottom notifications that clear themselves; wording is shorter. Full CI integration sequence passes locally, with new checks for undo, paging and filters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
294
web/kanban.js
294
web/kanban.js
@@ -29,7 +29,7 @@ function outcomeText(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};
|
||||
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';
|
||||
@@ -46,8 +46,13 @@ function button(text,fn,className='btn'){
|
||||
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){
|
||||
// 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');
|
||||
@@ -77,11 +82,11 @@ function manifest(production){
|
||||
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};
|
||||
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);
|
||||
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;}
|
||||
@@ -93,14 +98,19 @@ async function load(){
|
||||
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';
|
||||
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?.length?String(board.payment_issues.length):'';
|
||||
tinyHeader();renderBoard();renderQuotes();renderIntegrations();
|
||||
$('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();}
|
||||
}
|
||||
|
||||
@@ -144,12 +154,15 @@ function renderBoard(){
|
||||
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);
|
||||
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));
|
||||
column.ondragover=e=>{e.preventDefault();column.classList.add('target');};
|
||||
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'));
|
||||
@@ -171,11 +184,26 @@ function card(order){
|
||||
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.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);
|
||||
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();
|
||||
@@ -240,17 +268,29 @@ function moves(order){
|
||||
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);
|
||||
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='O que o cliente precisa corrigir? (vai para o cliente)';text.maxLength=1000;text.required=true;
|
||||
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);return box;
|
||||
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){
|
||||
@@ -278,7 +318,7 @@ function itemCard(order,item,index){
|
||||
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'));
|
||||
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;
|
||||
}
|
||||
@@ -296,7 +336,6 @@ function finalsSection(order,revisions,section){
|
||||
// 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;
|
||||
@@ -311,7 +350,7 @@ function finalsSection(order,revisions,section){
|
||||
});
|
||||
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 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{
|
||||
@@ -333,7 +372,7 @@ 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]));
|
||||
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);
|
||||
}
|
||||
@@ -345,38 +384,39 @@ function historySection(order,rows,section){
|
||||
|
||||
// ---- 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();
|
||||
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 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));
|
||||
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=()=>{pickedQuote=q.id;renderQuotes();};return b;
|
||||
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(moreQuotes[quoteKind])list.push(button('Carregar mais antigas',()=>loadMoreQuotes(quoteKind),'btn ghost'));
|
||||
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);
|
||||
const quote=quotes.find(q=>q.id===pickedQuote);
|
||||
$('reviews').replaceChildren(quote?review(quote):node('p','Selecione uma cotação.','note'));
|
||||
$('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;
|
||||
@@ -387,12 +427,12 @@ function review(quote){
|
||||
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'));
|
||||
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 o pagamento do cliente.','muted'));
|
||||
'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;
|
||||
}
|
||||
@@ -400,12 +440,14 @@ function review(quote){
|
||||
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 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{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}
|
||||
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;
|
||||
}
|
||||
@@ -453,57 +495,155 @@ function integration(name,state,tone,text,actions=[]){
|
||||
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)';
|
||||
// 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)));
|
||||
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);
|
||||
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);
|
||||
}
|
||||
$('payments').replaceChildren(...payments);
|
||||
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){
|
||||
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number,'good'];
|
||||
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)','good'];
|
||||
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','Defina TINY_CLIENT_ID, TINY_CLIENT_SECRET e TINY_REDIRECT_URI no servidor.'));
|
||||
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){const result=node('p');
|
||||
if(t.connected){
|
||||
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);}));}
|
||||
say(r.ok?'Conexão com o Tiny 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));
|
||||
t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'):
|
||||
'Conecte com uma conta 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.'));
|
||||
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);
|
||||
|
||||
$('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);}
|
||||
eventFilters();
|
||||
}
|
||||
|
||||
// ---- Wiring --------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user