feat: Kanban shows each order's artwork, filters quotes and searches orders and quotes
All checks were successful
Build and deploy / Validate source (push) Successful in 1m25s
Build and deploy / Integration suite on a real stack (push) Successful in 3m16s
Build and deploy / Secret scan and release gate (push) Successful in 13s
Build and deploy / Publish images (push) Successful in 1m29s
All checks were successful
Build and deploy / Validate source (push) Successful in 1m25s
Build and deploy / Integration suite on a real stack (push) Successful in 3m16s
Build and deploy / Secret scan and release gate (push) Successful in 13s
Build and deploy / Publish images (push) Successful in 1m29s
The customer's browser sends the small picture it already makes of each file (at most 300 KB, WebP/JPEG/PNG read from the bytes, own uploads only); it goes when the file's bytes go. Board cards, quote rows, the order panel and the quote detail show it, with the layout drawing as a second view and as the fallback for files without a picture. Cotações gets a customer filter (e-mail, CNPJ, WhatsApp) and product, layout and resolution-warning chips, with the page bar always shown. The board adds Entrega, Retirada and stalled-order chips. The top search now covers orders in any stage and unpaid quotes; CNPJ and phone match by digits only when nothing but digits and punctuation was typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
175
web/kanban.js
175
web/kanban.js
@@ -27,8 +27,13 @@ function outcomeText(outcome){
|
||||
}
|
||||
|
||||
let board=null;
|
||||
let tab='board', filter='all', search='', openOrder=null, openToken=0;
|
||||
let tab='board', filter='all', openOrder=null, openToken=0;
|
||||
let quoteKind='pending', pickedQuote=null;
|
||||
let quoteQuery='', quoteProduct='', quoteLayout='', quoteFlag='', quoteTimer=null;
|
||||
// Uploads that have an artwork picture (the customer's browser made it). Every
|
||||
// list the API returns says which, so only pictures that exist are asked for.
|
||||
const THUMBS=new Set();
|
||||
const absorb=r=>{for(const id of r?.thumbnails||[])THUMBS.add(String(id));return r;};
|
||||
let olderFinished=[], finishedMore=null;
|
||||
|
||||
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
@@ -102,11 +107,52 @@ function manifest(production){
|
||||
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}
|
||||
|
||||
// ---- Artwork pictures -------------------------------------------------------
|
||||
|
||||
// The picture of one upload, or null when the browser could not make one (a
|
||||
// CDR, a PSD, an order from before pictures existed).
|
||||
function artPicture(uid,alt=''){
|
||||
if(!uid||!THUMBS.has(String(uid)))return null;
|
||||
const img=node('img');img.src='/api/operator/uploads/'+uid+'/thumbnail';img.alt=alt;img.loading='lazy';img.decoding='async';
|
||||
img.onerror=()=>{THUMBS.delete(String(uid));img.remove();};
|
||||
return img;
|
||||
}
|
||||
// A small box with the first picture of these items; without one, the layout
|
||||
// drawing, as before pictures existed.
|
||||
function artBox(items,className){
|
||||
const box=node('div',undefined,'art '+className);
|
||||
const uid=items.flatMap(i=>i.uploads||[]).find(u=>THUMBS.has(String(u)));
|
||||
const pic=artPicture(uid);
|
||||
if(pic){box.append(pic);return box;}
|
||||
const item=items.find(i=>i.production?.placements);
|
||||
if(item){box.classList.add('layout');box.append(layoutPreview(item.production));}
|
||||
else{box.classList.add('none');box.append(node('span','sem prévia','noart'));}
|
||||
return box;
|
||||
}
|
||||
// An item's artwork (each file's picture) and its layout on the film, as two views.
|
||||
function itemPreview(item){
|
||||
const wrap=node('div',undefined,'preview');
|
||||
const pics=(item.uploads||[]).map((uid,i)=>artPicture(uid,'Arte '+(i+1))).filter(Boolean);
|
||||
const layout=item.production?.placements?layoutPreview(item.production):null;
|
||||
if(!pics.length){if(layout)wrap.append(layout);return wrap;}
|
||||
wrap.classList.add('with-art');
|
||||
const art=node('div',undefined,'view art');art.append(...pics);
|
||||
if(!layout){wrap.append(art);return wrap;}
|
||||
const film=node('div',undefined,'view layout');film.append(layout);film.hidden=true;
|
||||
const tabs=node('div',undefined,'ptabs');
|
||||
const pick=(show)=>{art.hidden=show!=='art';film.hidden=show!=='film';
|
||||
for(const b of tabs.children)b.setAttribute('aria-pressed',String(b.dataset.view===show));};
|
||||
for(const [view,label] of [['art','Arte'],['film','Montagem no filme']]){
|
||||
const b=node('button',label);b.type='button';b.dataset.view=view;b.setAttribute('aria-pressed',String(view==='art'));
|
||||
b.onclick=e=>{e.stopPropagation();pick(view);};tabs.append(b);}
|
||||
wrap.append(tabs,art,film);return wrap;
|
||||
}
|
||||
|
||||
// ---- Loading and tabs ------------------------------------------------------
|
||||
|
||||
async function load(){
|
||||
try{
|
||||
board=await api('/board');
|
||||
board=absorb(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;
|
||||
@@ -153,19 +199,20 @@ function printState(order){
|
||||
return {text:items>1&&ready?ready+' de '+items+' pronto':'Preparar à mão',tone:'warn'};
|
||||
return {text:'Sem arquivo gerado',tone:'warn'};
|
||||
}
|
||||
const stalled=order=>['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000;
|
||||
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==='entrega'&&!order.snapshot.destination)return false;
|
||||
if(filter==='retirada'&&order.snapshot.destination)return false;
|
||||
if(filter==='parados'&&!stalled(order))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));
|
||||
return true;
|
||||
}
|
||||
function renderBoard(){
|
||||
const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['manual','Preparar à mão']];
|
||||
const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['entrega','Entrega'],['retirada','Retirada'],
|
||||
['parados','Parados há '+LATE_HOURS+' h'],['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;}));
|
||||
@@ -184,7 +231,7 @@ function renderBoard(){
|
||||
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);
|
||||
if(state==='fin'&&finishedMore&&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');};
|
||||
@@ -210,9 +257,8 @@ function card(order){
|
||||
const dest=order.snapshot.destination;
|
||||
const where=node('span',dest?'Entrega · '+dest.state:'Retirada','where');where.prepend(icon(dest?ICON_TRUCK:ICON_PIN));
|
||||
foot.append(status,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.append(artBox(order.snapshot.items,'card-art'),top,node('div',order.snapshot.customer.mail,'cust'),tags,foot);
|
||||
if(stalled(order))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);
|
||||
@@ -227,7 +273,7 @@ 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 page=absorb(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();
|
||||
@@ -326,8 +372,7 @@ 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 preview=itemPreview(item);
|
||||
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;
|
||||
@@ -419,7 +464,9 @@ function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Numbe
|
||||
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)}));
|
||||
const params=new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)});
|
||||
for(const [key,value] of [['q',quoteQuery],['product',quoteProduct],['layout',quoteLayout],['flag',quoteFlag]])if(value)params.set(key,value);
|
||||
const page=absorb(await api('/quotes?'+params));
|
||||
if(token!==quoteLoading)return;
|
||||
quoteRows=page.quotes;quoteTotal=page.total;
|
||||
if(quotePage>1&&!quoteRows.length&"eTotal){quotePage=Math.ceil(quoteTotal/quoteSize);return loadQuotes();}
|
||||
@@ -430,11 +477,20 @@ function renderQuotes(){
|
||||
$('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;}));
|
||||
// Product, layout and warning: each chip toggles; the two of a pair exclude each other.
|
||||
const toggles=[['product','textil','Têxtil'],['product','uv','UV'],['layout','folha','Folha montada'],
|
||||
['layout','avulsa','Artes avulsas'],['flag','ressalva','Com ressalva']];
|
||||
const value={product:quoteProduct,layout:quoteLayout,flag:quoteFlag};
|
||||
$('quote-chips').replaceChildren(...toggles.map(([group,key,label])=>{
|
||||
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(value[group]===key));
|
||||
b.onclick=()=>{const next=value[group]===key?'':key;
|
||||
if(group==='product')quoteProduct=next;else if(group==='layout')quoteLayout=next;else quoteFlag=next;
|
||||
quotePage=1;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),
|
||||
const left=node('div',undefined,'qmain');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'));
|
||||
@@ -442,10 +498,12 @@ function renderQuotes(){
|
||||
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;
|
||||
b.append(artBox(q.draft.items,'qart'),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,
|
||||
const filtered=quoteQuery||quoteProduct||quoteLayout||quoteFlag;
|
||||
if(!list.length)list.push(node('p',filtered?'Nenhuma cotação com esses filtros.':
|
||||
quoteKind==='pending'?'Nenhuma cotação para revisar.':'Nenhuma cotação aprovada aguardando pagamento.','faint'));
|
||||
if(quoteTotal)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);
|
||||
@@ -496,8 +554,8 @@ function review(quote){
|
||||
}
|
||||
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 preview=itemPreview(item);
|
||||
if(!preview.classList.contains('with-art'))preview.style.width='160px';
|
||||
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';
|
||||
@@ -729,7 +787,78 @@ function renderIntegrations(){
|
||||
// ---- 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');};
|
||||
$('quote-q').oninput=e=>{clearTimeout(quoteTimer);quoteTimer=setTimeout(()=>{
|
||||
quoteQuery=e.target.value.trim();quotePage=1;loadQuotes().catch(err=>say(err.message,true));},300);};
|
||||
|
||||
// ---- Search: orders (any stage) and unpaid quotes ---------------------------
|
||||
|
||||
let searchTimer=null, searchToken=0, searchHits=[], searchAt=-1;
|
||||
function closeSearch(){$('search-results').hidden=true;$('search').setAttribute('aria-expanded','false');searchAt=-1;}
|
||||
async function runSearch(text){
|
||||
const token=++searchToken;
|
||||
const result=absorb(await api('/search?'+new URLSearchParams({q:text})));
|
||||
if(token!==searchToken||$('search').value.trim()!==text)return;
|
||||
const box=$('search-results');searchHits=[];searchAt=-1;
|
||||
const parts=[];
|
||||
for(const [title,rows,make] of [['Pedidos',result.orders,orderHit],['Cotações',result.quotes,quoteHit]]){
|
||||
if(!rows.length)continue;
|
||||
parts.push(node('div',title+' · '+rows.length,'rgroup'));
|
||||
for(const row of rows){const hit=make(row);hit.el.id='hit-'+searchHits.length;searchHits.push(hit);parts.push(hit.el);}
|
||||
}
|
||||
if(!searchHits.length)parts.push(node('p','Nada encontrado para “'+text+'”.','rempty'));
|
||||
parts.push(node('div','↑ ↓ para escolher · Enter abre · Esc fecha · busca por e-mail, CNPJ, WhatsApp ou nº do pedido','rhelp'));
|
||||
box.replaceChildren(...parts);box.hidden=false;$('search').setAttribute('aria-expanded','true');
|
||||
}
|
||||
function hitRow(items,title,sub,meta,badge,color,open){
|
||||
const el=node('button',undefined,'rhit');el.type='button';el.setAttribute('role','option');el.setAttribute('aria-selected','false');
|
||||
const text=node('div',undefined,'rtext');const line=node('div');line.append(node('b',title),node('span',sub));
|
||||
text.append(line,node('span',meta,'rmeta'));
|
||||
const tag=node('span',badge,'rbadge');if(color)tag.style.setProperty('--c',color);
|
||||
el.append(artBox(items,'rart'),text,tag);
|
||||
el.onmousedown=e=>e.preventDefault();
|
||||
el.onclick=()=>{closeSearch();open();};
|
||||
return {el,open};
|
||||
}
|
||||
function orderHit(order){
|
||||
const s=order.snapshot;
|
||||
return hitRow(s.items,'#'+order.number,s.customer.mail,
|
||||
s.items.map(i=>SHORT[i.mode]+' · '+metres(i.billed_metres)).join(' + ')+' · '+(s.destination?'Entrega':'Retirada')+' · pago '+when(order.created_at),
|
||||
board.states[order.state],STAGE_COLORS[order.state],()=>{
|
||||
// An older finished order is not on the board yet: it joins, like "Carregar mais".
|
||||
if(!board.orders.some(o=>o.id===order.id)){olderFinished.push(order);board.orders.push(order);}
|
||||
showTab('board');openPanel(order.id);});
|
||||
}
|
||||
function quoteHit(q){
|
||||
return hitRow(q.draft.items,q.approved?money(q.approved.total_cents):'Cotação '+q.id.slice(0,8),q.draft.customer.mail,
|
||||
q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))+' · '+ago(q.created_at),
|
||||
q.approved?'Aguardando pagamento':'A revisar',q.approved?'':'var(--accent)',()=>{
|
||||
// Opens Cotações filtered to this customer, with this quote selected.
|
||||
quoteKind=q.approved?'approved':'pending';quoteQuery=q.draft.customer.mail;$('quote-q').value=quoteQuery;
|
||||
quoteProduct=quoteLayout=quoteFlag='';quotePage=1;currentQuote=q;showTab('quotes');});
|
||||
}
|
||||
$('search').oninput=e=>{
|
||||
const text=e.target.value.trim();clearTimeout(searchTimer);
|
||||
if(text.length<2){searchToken++;closeSearch();return;}
|
||||
searchTimer=setTimeout(()=>runSearch(text).catch(err=>say(err.message,true)),250);
|
||||
};
|
||||
$('search').onkeydown=e=>{
|
||||
if(e.key==='Escape'){e.stopPropagation();closeSearch();return;}
|
||||
if($('search-results').hidden||!searchHits.length)return;
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'){
|
||||
e.preventDefault();searchAt=(searchAt+(e.key==='ArrowDown'?1:-1)+searchHits.length)%searchHits.length;
|
||||
searchHits.forEach((h,i)=>h.el.setAttribute('aria-selected',String(i===searchAt)));
|
||||
$('search').setAttribute('aria-activedescendant',searchHits[searchAt].el.id);searchHits[searchAt].el.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
if(e.key==='Enter'){e.preventDefault();const hit=searchHits[Math.max(0,searchAt)];closeSearch();hit.open();}
|
||||
};
|
||||
$('search').onblur=()=>setTimeout(closeSearch,150);
|
||||
$('search').onfocus=()=>{if($('search').value.trim().length>=2&&searchHits.length){$('search-results').hidden=false;$('search').setAttribute('aria-expanded','true');}};
|
||||
// "/" jumps to the search, as the field shows, unless typing somewhere else.
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key!=='/'||e.ctrlKey||e.metaKey||e.altKey)return;
|
||||
const t=e.target;if(t.closest&&t.closest('input,textarea,select,[contenteditable="true"]'))return;
|
||||
if($('app').hidden)return;e.preventDefault();$('search').focus();
|
||||
});
|
||||
$('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;
|
||||
|
||||
Reference in New Issue
Block a user