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:
@@ -118,6 +118,22 @@
|
||||
}
|
||||
return e.promise;
|
||||
}
|
||||
// The Kanban shows each order's artwork from the small picture this page
|
||||
// already made of it (site-upload.js). Never blocks the order: a picture that
|
||||
// fails to go leaves the Kanban with the layout drawing it shows today.
|
||||
const fotosEnviadas=new Set();
|
||||
function enviaFoto(uid, foto) {
|
||||
if (!uid || !foto || fotosEnviadas.has(uid)) return;
|
||||
fotosEnviadas.add(uid);
|
||||
try {
|
||||
const [cabecalho, dados] = foto.split(',');
|
||||
const bin = atob(dados), bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
fetch('/api/uploads/'+uid+'/thumbnail', {method:'PUT', body:bytes,
|
||||
headers:{'Content-Type':cabecalho.slice(5).split(';')[0]}})
|
||||
.then(r=>{ if (!r.ok) fotosEnviadas.delete(uid); }, ()=>fotosEnviadas.delete(uid));
|
||||
} catch (e) { fotosEnviadas.delete(uid); }
|
||||
}
|
||||
// A failed upload is shown and waits for the customer, never retried behind
|
||||
// their back: a file the check refused would fail again on every change.
|
||||
const falhas=new Map();
|
||||
@@ -265,7 +281,10 @@
|
||||
for (const item of cart) {
|
||||
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
|
||||
const uploads=[];
|
||||
for (const file of item.localFiles) uploads.push(await upload(file));
|
||||
for (const [index,file] of item.localFiles.entries()) {
|
||||
uploads.push(await upload(file));
|
||||
enviaFoto(uploads[index], item.fotos?.[index]);
|
||||
}
|
||||
if (item.production?.version!==2 || item.production.sources?.length!==uploads.length)
|
||||
throw new Error('A montagem deste item precisa ser refeita antes da cotação.');
|
||||
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads,
|
||||
|
||||
@@ -51,8 +51,25 @@ input[type=file]{font-size:14px;color:var(--muted);max-width:100%}
|
||||
.tabs button{display:flex;align-items:center;gap:8px;padding:0 14px;border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);font-weight:600;font-size:14.5px}
|
||||
.tabs button[aria-selected=true]{color:var(--tx);font-weight:700;border-bottom-color:var(--accent)}
|
||||
.tabs .count{font-family:var(--mono);font-size:13.5px;color:var(--muted)}
|
||||
.search{display:flex;align-items:center;gap:8px;width:300px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint);margin-left:auto}
|
||||
.searchbox{position:relative;margin-left:auto}
|
||||
.search{display:flex;align-items:center;gap:8px;width:380px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint)}
|
||||
.search:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,139,43,.18)}
|
||||
.search input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none}
|
||||
.search kbd{font:inherit;font-size:12px;padding:0 6px;border:1px solid var(--line3);border-radius:5px;color:var(--faint)}
|
||||
.search:focus-within kbd{display:none}
|
||||
/* The search results: orders and unpaid quotes, grouped. */
|
||||
.results{position:absolute;top:calc(100% + 8px);right:0;width:580px;max-height:min(70vh,580px);overflow:auto;z-index:8;padding:6px 0;border:1px solid var(--line3);border-radius:12px;background:var(--card);box-shadow:0 18px 48px rgba(0,0,0,.5)}
|
||||
.rgroup{padding:8px 14px 4px;font-size:12.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--faint)}
|
||||
.rhit{display:flex;align-items:center;gap:12px;width:100%;padding:8px 14px;border:0;background:transparent;text-align:left}
|
||||
.rhit:hover,.rhit[aria-selected=true]{background:rgba(255,139,43,.08)}
|
||||
.rtext{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||
.rtext div{display:flex;gap:8px;align-items:baseline;min-width:0}
|
||||
.rtext b{font-size:14px;flex-shrink:0}
|
||||
.rtext div span{font-size:13.5px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rmeta{font-size:13px;color:var(--muted)}
|
||||
.rbadge{flex-shrink:0;font-size:12.5px;font-weight:700;padding:2px 9px;border-radius:999px;background:color-mix(in srgb,var(--c,#8B929A) 18%,transparent);color:var(--c,var(--tx2))}
|
||||
.rempty{margin:0;padding:14px;font-size:14px;color:var(--muted)}
|
||||
.rhelp{margin-top:6px;padding:8px 14px 4px;border-top:1px solid var(--line);font-size:12.5px;color:var(--faint)}
|
||||
.tinydot{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted);white-space:nowrap}
|
||||
.tinydot::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--dot,#5B6168)}
|
||||
.tinydot:empty{display:none}
|
||||
@@ -137,7 +154,14 @@ main{padding:0 24px 24px}
|
||||
#tab-quotes{padding-top:18px}
|
||||
.split{display:flex;gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;min-height:calc(100vh - 140px)}
|
||||
.split .list{width:460px;flex-shrink:0;border-right:1px solid var(--line);padding:16px;display:flex;flex-direction:column;gap:12px;background:#111316}
|
||||
.qitem{text-align:left;display:flex;justify-content:space-between;gap:12px;padding:14px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
|
||||
.qitem{text-align:left;display:flex;align-items:center;gap:12px;padding:12px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
|
||||
.qitem .qmain{flex:1;min-width:0}
|
||||
.qitem .qmain>span{display:block}
|
||||
.qsearch{display:flex;align-items:center;gap:8px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint)}
|
||||
.qsearch:focus-within{border-color:var(--accent)}
|
||||
.qsearch input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none}
|
||||
.chips.small{gap:6px}
|
||||
.chips.small .chip{height:30px;padding:0 10px;font-size:13px}
|
||||
.qitem[aria-current=true]{border-color:var(--accent);background:#1F1A15}
|
||||
.qitem b{display:block;font-size:14.5px;overflow-wrap:anywhere}
|
||||
.qitem span{font-size:13.5px;color:var(--muted)}
|
||||
@@ -196,7 +220,9 @@ table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:to
|
||||
|
||||
@media (max-width:900px){
|
||||
.top{flex-wrap:wrap;height:auto;padding:10px 16px;gap:10px}
|
||||
.search{order:5;width:100%;margin-left:0}
|
||||
.searchbox{order:5;width:100%;margin-left:0}
|
||||
.search{width:100%}
|
||||
.results{width:100%}
|
||||
main{padding:0 16px 16px}
|
||||
.split{flex-direction:column}
|
||||
.split .list{width:auto;border-right:0;border-bottom:1px solid var(--line)}
|
||||
@@ -207,3 +233,28 @@ table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:to
|
||||
.btn.with-icon{display:inline-flex;align-items:center;gap:6px}
|
||||
.card .foot .where{display:inline-flex;align-items:center;gap:4px}
|
||||
a.zap{display:inline-flex;align-items:center;gap:4px;vertical-align:bottom}
|
||||
|
||||
/* Artwork pictures, made by the customer's browser. The checkerboard shows
|
||||
what is transparent; without a picture, the layout drawing stands in. */
|
||||
.art{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;flex-shrink:0;border-radius:8px;border:1px solid var(--line2);
|
||||
background-color:#2A2F35;background-image:conic-gradient(#323840 25%,transparent 0 50%,#323840 0 75%,transparent 0);background-size:12px 12px}
|
||||
.art img{display:block;max-width:100%;max-height:100%;object-fit:contain}
|
||||
.art.layout{background:var(--card2);padding:4px}
|
||||
.art.layout svg{display:block;height:100%;width:auto;max-width:100%}
|
||||
.art.none{background:var(--card2)}
|
||||
.art .noart{font-size:12px;font-weight:700;color:var(--faint)}
|
||||
.card .card-art{width:100%;height:96px}
|
||||
.qart{width:56px;height:56px}
|
||||
.rart{width:44px;height:44px;background-size:8px 8px}
|
||||
.item .preview.with-art{width:220px;padding:8px;flex-direction:column;align-items:stretch;gap:8px}
|
||||
.pane .item .preview.with-art{width:320px}
|
||||
.ptabs{display:flex;gap:4px}
|
||||
.ptabs button{height:28px;padding:0 10px;border-radius:7px;border:0;background:transparent;color:var(--muted);font-size:12.5px;font-weight:600}
|
||||
.ptabs button[aria-pressed=true]{background:var(--card);color:var(--tx);font-weight:700}
|
||||
.preview .view{height:220px;border-radius:8px;display:flex;gap:6px;align-items:center;justify-content:center;overflow:hidden}
|
||||
.pane .preview .view{height:300px}
|
||||
.preview .view.art{padding:6px;border:1px solid var(--line2);
|
||||
background-color:#2A2F35;background-image:conic-gradient(#323840 25%,transparent 0 50%,#323840 0 75%,transparent 0);background-size:14px 14px}
|
||||
.preview .view.art img{flex:1;min-width:0;max-height:100%;object-fit:contain}
|
||||
.preview .view.layout{padding:6px;background:var(--bg)}
|
||||
.item .preview .view.layout svg{height:100%;width:auto;max-width:100%}
|
||||
|
||||
@@ -22,10 +22,14 @@
|
||||
<button type="button" data-tab="payments" aria-selected="false">Pagamentos <span class="badge warn" id="badge-payments"></span></button>
|
||||
<button type="button" data-tab="integrations" aria-selected="false">Integrações</button>
|
||||
</nav>
|
||||
<label class="search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
|
||||
<input id="search" type="search" placeholder="Buscar pedido, cliente ou CNPJ" aria-label="Buscar pedido, cliente ou CNPJ">
|
||||
</label>
|
||||
<div class="searchbox">
|
||||
<label class="search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
|
||||
<input id="search" type="search" placeholder="Buscar em pedidos e cotações: cliente, CNPJ, nº" aria-label="Buscar em pedidos e cotações" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="search-results">
|
||||
<kbd>/</kbd>
|
||||
</label>
|
||||
<div id="search-results" class="results" role="listbox" aria-label="Resultados da busca" hidden></div>
|
||||
</div>
|
||||
<span id="tiny" class="tinydot"></span>
|
||||
<span id="who" class="who"></span>
|
||||
<button class="btn ghost" id="refresh" type="button">Atualizar</button>
|
||||
@@ -42,6 +46,11 @@
|
||||
<div class="list">
|
||||
<h1>Cotações</h1>
|
||||
<div class="chips" id="quote-filters"></div>
|
||||
<label class="qsearch">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
|
||||
<input id="quote-q" type="search" placeholder="Filtrar por e-mail, CNPJ ou WhatsApp" aria-label="Filtrar cotações por cliente" autocomplete="off">
|
||||
</label>
|
||||
<div class="chips small" id="quote-chips"></div>
|
||||
<div id="quote-list" style="display:flex;flex-direction:column;gap:8px"></div>
|
||||
</div>
|
||||
<div class="pane" id="reviews"></div>
|
||||
|
||||
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;
|
||||
|
||||
@@ -127,6 +127,7 @@ function carrinho(){
|
||||
itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
|
||||
localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
|
||||
mini:(ehFolha()?folhas:artes).map(x=>x.miniSrc).find(Boolean)||null,
|
||||
fotos:(ehFolha()?folhas:artes).map(x=>x.fotoSrc||null),
|
||||
ext:extDe(((ehFolha()?folhas:artes)[0]||{f:{name:''}}).f.name),
|
||||
arquivos:(ehFolha()?folhas:artes).length,
|
||||
production:{version:2,film_width_cm:larguraFilme(),sources,...layout},
|
||||
|
||||
@@ -357,10 +357,11 @@ function textoProgresso(x){
|
||||
return x.pct<50?'lendo o arquivo…':x.pct<70?'medindo a folha…':'conferindo a arte…';
|
||||
}
|
||||
// The file card's thumbnail (52 x 96 px, drawn at twice that for sharp screens).
|
||||
function miniatura(fonte){
|
||||
// At 480 x 720 it is the picture the Kanban shows of the order (checkout.js).
|
||||
function miniatura(fonte, maxW=104, maxH=192){
|
||||
const w=fonte.naturalWidth||fonte.width, h=fonte.naturalHeight||fonte.height;
|
||||
if(!(w>0 && h>0)) return null;
|
||||
const s=Math.min(1,104/w,192/h), cv=document.createElement('canvas');
|
||||
const s=Math.min(1,maxW/w,maxH/h), cv=document.createElement('canvas');
|
||||
cv.width=Math.max(1,Math.round(w*s)); cv.height=Math.max(1,Math.round(h*s));
|
||||
cv.getContext('2d').drawImage(fonte,0,0,cv.width,cv.height);
|
||||
return cv.toDataURL('image/webp',0.8);
|
||||
|
||||
@@ -73,7 +73,7 @@ function sel(fs){
|
||||
if(pct-mostrado>=1){ mostrado=pct; x.pct=pct; pintaProgressoFolha(x); }
|
||||
}).then(cv=>{
|
||||
if(cv && folhas.includes(x)){
|
||||
x.previewSrc=cv.toDataURL('image/webp',0.85); x.miniSrc=miniatura(cv);
|
||||
x.previewSrc=cv.toDataURL('image/webp',0.85); x.miniSrc=miniatura(cv); x.fotoSrc=miniatura(cv,480,720);
|
||||
try{
|
||||
const an=analisarFolha(cv, md.larg);
|
||||
x.an={...an, dpi:md.dpiFolha, fonteDpi:'arquivo', grande:false};
|
||||
@@ -82,7 +82,7 @@ function sel(fs){
|
||||
}).catch(()=>{}).finally(()=>{ x.pct=null; pintaFolha(); });
|
||||
}else if(md && RENDERIZA.test(x.f.name)){
|
||||
carregarImagem(x.f).then(img=>{
|
||||
if(img){ x.previewSrc=img.src; x.miniSrc=miniatura(img); try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; }
|
||||
if(img){ x.previewSrc=img.src; x.miniSrc=miniatura(img); x.fotoSrc=miniatura(img,480,720); try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; }
|
||||
catch(e){} }
|
||||
x.pct=null; pintaFolha();
|
||||
});
|
||||
@@ -98,7 +98,7 @@ function sel(fs){
|
||||
: '');
|
||||
}
|
||||
if(r && r.tela){
|
||||
x.miniSrc=miniatura(r.tela);
|
||||
x.miniSrc=miniatura(r.tela); x.fotoSrc=miniatura(r.tela,480,720);
|
||||
try{
|
||||
x.an=analisarFolha(r.tela, md.larg);
|
||||
// vetor puro não tem resolução · nota máxima, e está certo
|
||||
@@ -140,13 +140,13 @@ function medir(a){
|
||||
if(pct-mostrado>=1){ mostrado=pct; a.lendo=pct; pintaProgressoArte(a); }
|
||||
}).catch(()=>null);
|
||||
a.lendo=null;
|
||||
if(cv && artes.includes(a)){ a.src=cv.toDataURL('image/webp',0.9); a.miniSrc=miniatura(cv); }
|
||||
if(cv && artes.includes(a)){ a.src=cv.toDataURL('image/webp',0.9); a.miniSrc=miniatura(cv); a.fotoSrc=miniatura(cv,480,720); }
|
||||
else if(!cv) a.decodeError=true;
|
||||
return a;
|
||||
}).catch(()=>{ a.decodeError=true; return a; });
|
||||
}
|
||||
return carregarImagem(a.f).then(img=>{
|
||||
if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; a.miniSrc=miniatura(img); }
|
||||
if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; a.miniSrc=miniatura(img); a.fotoSrc=miniatura(img,480,720); }
|
||||
else a.decodeError=true;
|
||||
return a;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user