Some checks failed
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Failing after 10m30s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Five items that needed no decisions. Indexes: the schema indexed only uploads(owner), so the worker's once-a-second outbox poll scanned a table that only grows, and every per-customer and per-order lookup did the same. Ten indexes now follow queries the application actually issues, and no more, since each one is paid for on every write. The outbox and live uploads use partial indexes so they stay the size of the backlog rather than of all history. Confirmed against the database that the planner chooses them. Board: /api/operator/board returned every order ever created. Finished orders are terminal, so they were pure growth. It now returns everything still in progress however old, plus a window of recent finished ones and the true finished total, and the Kanban column says "50 de 213" rather than letting the count read as an all-time figure. An operator cannot lose a card they could act on. Dependencies: the root requirements.txt was the prototype's, pinned by wildcard, listing packages this system does not use, next to the hash-locked lock file. Deleted. pip was pinned as a runtime dependency, which installed a package manager into the read-only production image; nothing depended on it, so it is gone from both the direct list and the lock, and the base image's pip performs the hash-enforced install. Retention copy: the Site told customers their artwork was kept 90 days with 12 months of history, and invited them to reorder without uploading again. Files are kept 30 days. The copy now matches the policy and drops the promise the system cannot keep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
9.0 KiB
JavaScript
113 lines
9.0 KiB
JavaScript
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
|
|
const $ = id=>document.getElementById(id);
|
|
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
|
|
sessionStorage.removeItem('dtf-operator');
|
|
let board;
|
|
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
|
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;}
|
|
function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
|
async function api(path,body){
|
|
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
|
|
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
|
|
}
|
|
function files(container,items){
|
|
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
|
|
const result=await api('/uploads/'+uid+'/download');
|
|
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
|
}));
|
|
}
|
|
async function load(){
|
|
try{
|
|
board=await api('/board');$('login').hidden=true;
|
|
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
|
|
render();
|
|
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
|
}
|
|
function render(){
|
|
$('reviews').replaceChildren();
|
|
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial'));
|
|
for(const quote of board.quotes){
|
|
const card=node('article',undefined,'review');
|
|
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
|
|
if(quote.status!=='pending_review'){
|
|
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.'));
|
|
}else{
|
|
const form=node('form');
|
|
const edits=quote.draft.items.map((item,index)=>{
|
|
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
|
|
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
|
|
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
|
|
const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row);
|
|
return ()=>({...item,metres:metres.value,grade:Number(grade.value)});
|
|
});
|
|
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label);
|
|
const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit);
|
|
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
|
card.append(form);
|
|
}
|
|
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
|
|
}
|
|
$('kan').replaceChildren();
|
|
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
|
|
Object.entries(board.states).forEach(([state,title],index)=>{
|
|
const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]);
|
|
const orders=board.orders.filter(o=>o.state===state);
|
|
// Finished orders are a recent window, not the whole history: say so rather
|
|
// than let the count read as an all-time total.
|
|
const count=state==='fin'&&board.finished_total>orders.length
|
|
? orders.length+' de '+board.finished_total : String(orders.length);
|
|
column.append(node('h2',title+' · '+count));
|
|
for(const order of orders){
|
|
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
|
|
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
|
for(const item of order.snapshot.items)card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
|
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
|
|
const artwork=node('div');
|
|
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
|
|
actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');}));
|
|
for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next)));
|
|
card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card);
|
|
}
|
|
column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo');
|
|
column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}};
|
|
$('kan').append(column);
|
|
});
|
|
$('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.';
|
|
}
|
|
async function move(order,state){
|
|
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
|
|
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
|
|
}
|
|
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
|
|
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
|
|
$('refresh').onclick=load;
|
|
load();
|
|
|
|
async function artworkPanel(order,container){
|
|
container.replaceChildren();
|
|
const revisions=await api('/orders/'+order.id+'/files');
|
|
for(const file of revisions){
|
|
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
|
|
row.append(node('p',file.note));
|
|
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
|
|
container.append(row);
|
|
}
|
|
if(!['rec','tra','cor'].includes(order.state))return;
|
|
const form=node('form');
|
|
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
|
|
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
|
|
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
|
|
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
|
|
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
|
|
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
|
const refs=[];
|
|
for(const [index,input] of inputs.entries())for(const file of input.files){
|
|
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
|
|
refs.push({item_index:index,upload_id:uid});
|
|
}
|
|
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
|
|
await load();
|
|
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
|
container.append(form);
|
|
}
|