Files
SGF/public/sourcing-workspace.mjs
2026-11-04 09:37:20 +02:00

32 lines
13 KiB
JavaScript

import {rememberFocus} from './purchasing-focus.mjs';
import {evidence as renderEvidence,purchasingStatus} from './purchasing-presenter.mjs';
import {beginWorkspace,focusHref,appendProcessLinks} from './purchasing-focus.mjs';
import {createSupplierPicker} from './supplier-picker-model.mjs';
const root=document.querySelector('#sourcing'),status=document.querySelector('#status'),list=document.querySelector('#processes'),detail=document.querySelector('#process-detail');let page=1,last=1,generation=0,busy=false;
const node=(tag,text)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;return n;};const money=minor=>(Number(minor)/100).toFixed(2)+' MZN';
function impactNodes(impact){const rows=[node('h3',impact.operation)];if(impact.supplier)rows.push(node('p','Fornecedor: '+impact.supplier));if(impact.total_minor!==undefined)rows.push(node('p','Total: '+money(impact.total_minor)));for(const line of impact.lines||[])rows.push(node('p',`${line.description} · quantidade ${line.quantity} · preço unitário ${money(line.unit_price_minor)} · imposto ${money(line.tax_minor)} · total ${money(line.total_minor)}`));if(impact.quality!==undefined)rows.push(node('p',`Avaliação individual: qualidade ${impact.quality}, histórico ${impact.history}, pagamento ${impact.payment}.`));if(impact.comment)rows.push(node('p','Fundamento: '+impact.comment));if(impact.delivery_date)rows.push(node('p','Entrega: '+impact.delivery_date+' · '+impact.delivery_location));if(impact.required_approvals)rows.push(node('p','Aprovações exigidas: '+impact.required_approvals.map(r=>({department_head:'Responsável de departamento',procurement:'Compras',finance:'Finanças'})[r]||r).join(', ')));if(impact.decision)rows.push(node('p','Decisão a registar: '+(impact.decision==='approved'?'Aprovar':'Recusar')));rows.push(node('p','A operação só fica registada depois da confirmação e volta a validar a base actual. Não emite uma ordem, não movimenta stock e não paga.'));return rows;}
async function api(path,data){const r=await fetch('/api/purchasing-sourcing'+path,{method:data===undefined?'GET':'POST',headers:{Accept:'application/json','X-SGF-Context':root.dataset.context,'X-CSRF-TOKEN':root.dataset.csrf,...(data===undefined?{}:{'Content-Type':'application/json'})},body:data===undefined?undefined:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(Object.values(d.errors||{}).flat().join(' ')||d.message||'Não foi possível concluir.');return d;}
function field(parent,label,name,type='text'){const wrap=node('label',label),input=node(type==='textarea'?'textarea':'input');if(type!=='textarea')input.type=type;input.name=name;input.required=true;wrap.append(input);parent.append(wrap);return input;}
function select(parent,label,name,values){const wrap=node('label',label),input=node('select');input.name=name;input.required=true;input.append(new Option('Seleccione explicitamente',''));for(const [id,text]of values)input.append(new Option(text,String(id)));wrap.append(input);parent.append(wrap);return input;}
function pickSupplier(form,id,turn){
const box=node('fieldset'),legend=node('legend','Escolher fornecedor'),search=field(box,'Pesquisar nome ou NUIT','supplier_search','search'),find=node('button','Pesquisar fornecedores'),choice=select(box,'Fornecedor — selecção individual','supplier_id',[]),message=node('p'),previous=node('button','Fornecedores anteriores'),next=node('button','Fornecedores seguintes');
search.required=false;search.maxLength=100;message.setAttribute('role','status');for(const b of [find,previous,next])b.type='button';box.prepend(legend);box.append(find,message,previous,next);form.append(box);let current=1,lastPage=1,query='';
const picker=createSupplierPicker((q,p)=>api('/requests/'+id+'/suppliers?'+new URLSearchParams({search:q,page:String(p)})),state=>{
current=state.page;lastPage=state.last;choice.replaceChildren(new Option('Seleccione explicitamente',''));for(const s of state.items)choice.append(new Option(`${s.name} · NUIT ${s.nuit} · versão ${s.version}`,String(s.id)));choice.value=String(state.selected?.id??'');choice.disabled=state.loading||!state.items.length;
previous.disabled=state.loading||current<=1;next.disabled=state.loading||current>=lastPage;find.disabled=state.loading;
message.textContent=state.loading?'A pesquisar fornecedores…':state.error||`Página ${current} de ${lastPage} · ${state.total} fornecedores admissíveis. ${state.selected?'Seleccionado: '+state.selected.name:'Nenhum fornecedor escolhido.'}`;
form.dispatchEvent(new Event('input',{bubbles:true}));
},()=>turn===generation);
choice.onchange=()=>picker.choose(choice.value);const run=()=>{query=search.value;picker.search(query,1);};find.onclick=run;search.oninput=()=>picker.invalidate();search.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();run();}};previous.onclick=()=>{if(current>1)picker.search(query,current-1);};next.onclick=()=>{if(current<lastPage)picker.search(query,current+1);};picker.search();return picker;
}
async function load(){const turn=++generation;try{const d=await api('?page='+page);if(turn!==generation)return;list.replaceChildren();for(const r of d.data){const b=node('button',r.number+' · '+r.status);b.onclick=()=>open(r.id);list.append(b);}last=d.last_page;document.querySelector('#page').textContent=`Página ${page} de ${last}`;document.querySelector('#previous').disabled=page<=1;document.querySelector('#next').disabled=page>=last;}catch(e){status.textContent=e.message;}}
async function open(id){const turn=++generation;detail.replaceChildren();status.textContent='A consultar o processo…';try{const d=await api('/requests/'+id);if(turn!==generation)return;rememberFocus('sourcing',{request:id});status.textContent='';const panel=node('section');panel.append(node('h2',d.request.number),node('p',`Estado da requisição: ${d.request.status} · Regra: ${d.comparison.policy.minimum_proposals} propostas elegíveis e ${d.comparison.policy.minimum_evaluators} avaliações qualificadas por proposta.`));for(const line of d.request.items)panel.append(node('p',`${line.description} · ${line.quantity} ${line.unit}`));detail.append(panel);
const command=(title,action,build)=>{const form=node('form'),prepare=node('button','Conferir impacto'),confirm=node('button','Confirmar esta operação'),impact=node('section');form.append(node('h3',title));let revision=0,key=crypto.randomUUID(),preview=null;const read=build(form);prepare.type='submit';confirm.type='button';confirm.disabled=true;impact.hidden=true;form.oninput=()=>{revision++;key=crypto.randomUUID();preview=null;confirm.disabled=true;impact.hidden=true;};form.onsubmit=async event=>{event.preventDefault();if(action==='quote'&&!form.querySelector('[name=supplier_id]')?.value){status.textContent='Seleccione individualmente um fornecedor antes de conferir.';return;}if(busy)return;busy=true;prepare.disabled=true;const version=revision;try{const p=await api('/requests/'+id+'/preview',{operation_key:key,revision:d.revision,action,data:read()});if(turn!==generation||version!==revision)return;preview=p.preview;impact.replaceChildren(...impactNodes(p.impact));impact.hidden=false;confirm.disabled=false;}catch(e){status.textContent=e.message;}finally{busy=false;prepare.disabled=false;}};confirm.onclick=async()=>{if(busy||!preview)return;busy=true;confirm.disabled=true;try{await api('/requests/'+id+'/confirm',{preview});if(turn===generation)await open(id);}catch(e){status.textContent=e.message+' Se a resposta se perdeu, repita a mesma confirmação para consultar o resultado sem duplicar a operação.';confirm.disabled=false;}finally{busy=false;}};form.append(prepare,impact,confirm);const block=node('section');block.append(form);detail.append(block);};
if(d.capabilities.quote)command('Registar proposta de fornecedor','quote',form=>{const supplier=pickSupplier(form,id,turn),reference=field(form,'Referência da proposta','reference'),days=field(form,'Prazo de entrega em dias','delivery_days','number'),valid=field(form,'Validade','valid_until','date'),terms=field(form,'Condições de pagamento','payment_terms','textarea'),conflict=field(form,'Declaro ausência de conflito de interesses','no_conflict','checkbox');days.min='1';const lines=d.request.items.map(line=>{const block=node('article');block.append(node('h3',line.description),node('p',`${line.quantity} ${line.unit} · Preços em MZN, com ponto decimal.`));const price=field(block,'Preço unitário proposto','unit_price'),kind=select(block,'Tipo de desconto','discount_type',[['none','Sem desconto'],['percent','Percentagem'],['amount','Montante unitário']]),value=field(block,'Valor do desconto — 0 quando não existe','discount_value');form.append(block);return {line,price,kind,value};});return ()=>({supplier_id:Number(supplier.selected?.id),supplier_revision:supplier.selected?.selection_revision,reference:reference.value,delivery_days:Number(days.value),valid_until:valid.value,payment_terms:terms.value,no_conflict:conflict.checked,items:lines.map(v=>({requisition_item_id:v.line.id,unit_price:v.price.value,discount_type:v.kind.value,discount_value:v.value.value}))});});
const comparison=node('section');comparison.append(node('h2','Propostas e comparação'),node('p',d.comparison.ready?'Base de comparação completa. A escolha continua a ser individual.':'Comparação incompleta. As propostas ou avaliações em falta não são contadas como zero.'));for(const row of d.comparison.rows){const quote=d.quotes.find(q=>q.id===row.id),article=node('article');article.append(node('h3',quote.supplier.name+' · '+quote.reference),node('p',`Total: ${money(row.total_minor)} · Avaliações qualificadas: ${row.evaluation_count} · Pontuação: ${row.score===null?'por apurar':row.score}`));if(d.comparison.top_ids.includes(row.id))article.append(node('p','Proposta no topo da comparação — sem selecção automática'));for(const reason of row.ineligibility_reasons||[])article.append(node('p',reason));for(const e of quote.evaluations)article.append(node('p',`Avaliador #${e.evaluator_id}: qualidade ${e.quality}, histórico ${e.history}, pagamento ${e.payment} · ${e.comment}`));comparison.append(article);if(row.can_evaluate)command('Avaliar '+quote.supplier.name,'evaluate',form=>{const quality=field(form,'Qualidade — 0 a 10','quality'),history=field(form,'Histórico — 0 a 10','history'),payment=field(form,'Pagamento — 0 a 10','payment'),comment=field(form,'Fundamento individual','comment','textarea'),conflict=field(form,'Declaro ausência de conflito de interesses','no_conflict','checkbox');return ()=>({quotation_id:row.id,quality:quality.value,history:history.value,payment:payment.value,comment:comment.value,no_conflict:conflict.checked});});}detail.append(comparison);
if(d.capabilities.select)command('Propor adjudicação','select',form=>{const options=d.quotes.filter(q=>d.comparison.top_ids.includes(q.id)).map(q=>[q.id,q.supplier.name+' · '+money(q.total_minor)]),quote=select(form,'Escolha expressa entre propostas elegíveis do topo','quotation_id',options),reason=field(form,'Fundamento da escolha','justification','textarea'),date=field(form,'Data acordada de entrega','delivery_date','date'),place=field(form,'Local acordado de entrega','delivery_location');return ()=>({quotation_id:Number(quote.value),justification:reason.value,delivery_date:date.value,delivery_location:place.value});});
if(d.award){const award=node('section');award.append(node('h2','Adjudicação · '+d.award.status),node('p','Total proposto: '+money(d.award.total_minor)));for(const step of d.award.approvals)award.append(node('p',`${step.role} · ${step.decision} · responsável #${step.approver_id??'pendente'}`));const link=node('a','Continuar para ordens de compra');link.href=root.dataset.focusEnabled==='true'?focusHref('orders',{award:d.award.id}):'/purchasing/orders';award.append(link);detail.append(award);if(d.capabilities.decide)command('Decisão independente da adjudicação','decide',form=>{const decision=select(form,'Decisão','decision',[['approved','Aprovar'],['rejected','Recusar']]),comment=field(form,'Fundamento da decisão','comment','textarea');return ()=>({award_id:d.award.id,decision:decision.value,comment:comment.value});});}
const history=node('section');history.append(node('h2','Histórico recente'));for(const e of d.history)history.append(node('p',`${e.created_at} · ${e.action} · autor #${e.user_id}`));detail.append(history);const loadHistory=async current=>{try{const h=await api('/requests/'+id+'/history?page='+current);if(turn!==generation)return;history.replaceChildren(node('h2','Histórico do processo'));for(const e of h.data){const item=node('details');item.append(node('summary',e.created_at+' · '+e.action+' · autor #'+e.user_id),renderEvidence({anterior:e.before,posterior:e.after}));history.append(item);}const label=node('p','Página '+h.current_page+' de '+h.last_page);history.append(label);if(h.current_page>1){const b=node('button','Anterior');b.onclick=()=>loadHistory(h.current_page-1);history.append(b);}if(h.current_page<h.last_page){const b=node('button','Seguinte');b.onclick=()=>loadHistory(h.current_page+1);history.append(b);}}catch(e){status.textContent=e.message;}};loadHistory(1);
}catch(e){if(turn===generation)status.textContent=e.message;}}
document.querySelector('#previous').onclick=()=>{if(page>1){page--;load();}};document.querySelector('#next').onclick=()=>{if(page<last){page++;load();}};beginWorkspace({stage:'sourcing',root,load:load,open:f=>open(f.request),current:()=>generation,error:text=>{status.textContent=text;}});