All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from the Kanban; the callback is authorised by a single-use state, because Tiny's cross-site redirect does not carry the SameSite=Strict operator cookie. Tokens are kept in provider_tokens, the refresh token rotates under a row lock, and the worker keeps the connection alive while order creation is off. Orders find or create the customer's contact by CNPJ, then POST /pedidos with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the customer's recent orders for that number first. The product settings avoid a _FILE suffix, which the secrets loader reads as a secret file path. Production passes the application credentials through but keeps TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a supervised test. compose.providers.yaml gives the local API and worker an internet route for provider testing; the default local stack still has none. Verified with the full CI integration sequence locally, including the new tiny_oauth_test against the real database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
16 KiB
JavaScript
249 lines
16 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;
|
||
let extraQuotes={pending:[],approved:[]};
|
||
let moreQuotes={pending:false,approved:false};
|
||
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();
|
||
}));
|
||
}
|
||
function productionLines(container,item){
|
||
if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;}
|
||
container.append(node('p','Especificação v'+item.production.version+' · qualidade '+item.quality_status+
|
||
(item.quality_status==='warning'?' · ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):''),'meta'));
|
||
if(item.production.placements){
|
||
container.append(node('p',item.production.placements.length+' peças posicionadas em '+
|
||
item.production.film_width_cm+' × '+item.production.height_cm+' cm de filme','meta'));
|
||
const download=node('button','Baixar manifesto da montagem');
|
||
download.type='button';
|
||
download.onclick=()=>{
|
||
const url=URL.createObjectURL(new Blob([JSON.stringify(item.production,null,2)],{type:'application/json'}));
|
||
const link=node('a');link.href=url;
|
||
link.download='dtf-layout-'+item.production.sources[0].upload_id.slice(0,8)+'.json';
|
||
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||
};
|
||
container.append(download);
|
||
}
|
||
item.production.sources.forEach((source,index)=>container.append(node('p',
|
||
(index+1)+'. '+source.kind+' · '+source.copies+' × '+source.width_cm+' × '+source.length_cm+
|
||
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
|
||
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
|
||
}
|
||
const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto',
|
||
manual:'preparar à mão',failed:'falhou ao gerar'};
|
||
function download(uid){return async()=>{
|
||
const result=await api('/uploads/'+uid+'/download');
|
||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||
};}
|
||
function printFiles(card,order){
|
||
const rows=order.print_files||[];
|
||
const box=node('div',undefined,'print');
|
||
box.append(node('b','Arquivo de impressão'));
|
||
order.snapshot.items.forEach((item,index)=>{
|
||
const row=rows.find(r=>r.item_index===index);
|
||
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
|
||
if(row?.status==='ready'){
|
||
line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+
|
||
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'');
|
||
line.append(action('Baixar PDF',download(row.upload_id)));
|
||
}
|
||
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));
|
||
box.append(line);
|
||
});
|
||
const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index);
|
||
return !row||row.status==='manual'||row.status==='failed';});
|
||
if(retry&&['rec','tra'].includes(order.state))
|
||
box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();}));
|
||
card.append(box);
|
||
}
|
||
function paymentIssues(){
|
||
$('payments').replaceChildren();
|
||
if(!board.payment_issues?.length)return;
|
||
$('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length));
|
||
$('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+
|
||
'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta'));
|
||
for(const issue of board.payment_issues){
|
||
const card=node('article',undefined,'review');
|
||
card.append(node('b',issue.provider+' · evento '+issue.event_id),
|
||
node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+
|
||
(issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'),
|
||
node('p',issue.outcome));
|
||
card.append(action('Registrar resolução',async()=>{
|
||
const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return;
|
||
await api('/payment-events/'+issue.id+'/resolve',{note});await load();
|
||
}));
|
||
$('payments').append(card);
|
||
}
|
||
}
|
||
async function load(){
|
||
try{
|
||
board=await api('/board');
|
||
extraQuotes={pending:[],approved:[]};
|
||
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
|
||
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
|
||
$('login').hidden=true;
|
||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice;
|
||
render();
|
||
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
||
}
|
||
async function loadMoreQuotes(kind){
|
||
const shown=[...board.quotes.filter(q=>kind==='pending'?!q.approved:!!q.approved),...extraQuotes[kind]];
|
||
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(shown.map(q=>q.id));
|
||
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));
|
||
moreQuotes[kind]=page.has_more;
|
||
render();
|
||
}
|
||
// The one-time authorisation of this system in the client's Tiny. Shown only
|
||
// when the Tiny application is configured on the server.
|
||
function tinyStatus(){
|
||
const box=$('tiny');box.replaceChildren();
|
||
const tiny=board.tiny||{};
|
||
if(!tiny.configured)return;
|
||
box.append(node('span',tiny.connected
|
||
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado')
|
||
: 'Tiny não conectado'));
|
||
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{
|
||
const result=await api('/tiny/connect',{});location.href=result.url;
|
||
}));
|
||
}
|
||
const tinyResult=new URLSearchParams(location.search).get('tiny');
|
||
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
|
||
if(tinyResult)history.replaceState(null,'',location.pathname);
|
||
function render(){
|
||
tinyStatus();
|
||
paymentIssues();
|
||
$('reviews').replaceChildren();
|
||
if(board.pending_total || board.approved_total)
|
||
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
|
||
board.approved_total+' aprovadas sem pedido'));
|
||
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){
|
||
const card=node('article',undefined,'review');
|
||
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
|
||
if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){
|
||
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.'));
|
||
}else 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+' '));
|
||
productionLines(row,item);
|
||
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);
|
||
}
|
||
for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'],
|
||
['approved','Carregar cotações aprovadas mais antigas']]){
|
||
if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind)));
|
||
}
|
||
$('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+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||
const to=order.snapshot.destination;
|
||
card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+
|
||
' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta'));
|
||
for(const item of order.snapshot.items){
|
||
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
||
productionLines(card,item);
|
||
}
|
||
printFiles(card,order);
|
||
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,download(file.upload_id)));
|
||
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.'));
|
||
// A generated print file reproduces the approved layout; offer it first, and
|
||
// keep the upload for items that need hand preparation. Not after a
|
||
// correction: the file was made from artwork the customer has replaced.
|
||
const corrected=revisions.some(file=>file.kind==='correction');
|
||
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%';
|
||
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
|
||
if(generated){
|
||
const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true;
|
||
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
|
||
const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block';
|
||
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
|
||
}
|
||
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()){
|
||
const generated=input.generated?.();
|
||
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
|
||
for(const file of input.files){
|
||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('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);
|
||
}
|