Files
SGF/resources/js/DirectCostsWorkspace.jsx
2026-10-27 12:26:52 +02:00

33 lines
7.7 KiB
React

import React, {useEffect,useRef,useState} from 'react';
const decisionLabel={approved:'Validado',refused:'Recusado'};
const money=v=>v===null?'Pendente':`${v} MZN`;
export default function DirectCostsWorkspace({context,can,api,busy}) {
const [data,setData]=useState(null),[error,setError]=useState(''),[working,setWorking]=useState(false),[orderId,setOrderId]=useState('');
const [selection,setSelection]=useState({}),[sourceId,setSourceId]=useState('');const retry=useRef(null);
const allowed=r=>can(`direct_costs.${r}`,context.unit?.id??null);
const load=async()=>{const d=await api('/api/direct-costs');setData(d);return d;};
useEffect(()=>{setSelection({});load().catch(e=>setError(e.message));},[context.token]);
const order=data?.orders.find(o=>String(o.id)===orderId)??data?.orders[0];
async function send(path,input){setWorking(true);setError('');const signature=JSON.stringify([path,input]);if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};
try{await api(path,'POST',input,context.token,retry.current.key);retry.current=null;await load();}catch(e){setError(e.message);}finally{setWorking(false);}}
const disabled=busy||working;
const latest=order?.calculations.at(-1);
const source=order?.sources.find(s=>String(s.id)===sourceId)??order?.sources[0];
const decide=(kind,row,decision,reason)=>send(`/api/direct-costs/orders/${order.id}/decisions`,{kind,id:row.id,version:row.version,decision,reason});
return <section className="workspace"><p className="eyebrow">ENSAIO DE CUSTO DIRECTO</p><h2>Custos directos da ordem</h2><div className="alert">ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Custo directo conhecido; não é custo completo ou margem. Sem stock, IVA, lançamentos ou facturação.</div>
{error&&<p role="alert" className="alert error">{error}</p>}{!data?<p>A carregar…</p>:data.blocked?<p>O custeio permanece bloqueado nesta instância. Use apenas a demonstração isolada.</p>:<>
<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar versões explicitamente</button>
<label>Ordem de teste<select value={order?.id??''} onChange={e=>{setOrderId(e.target.value);setSelection({});setSourceId('');}}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>
{!order?<p>Sem ordens no âmbito autorizado.</p>:<>
<section className="panel"><h3>Origens técnicas verificadas</h3><p>Referências aos apontamentos existentes. A validação de custos não modifica nem verifica a execução técnica.</p><ul>{order.sources.map(s=><li key={s.id}>#{s.id} v{s.version} · {s.origin} · {s.quantity} {s.unit} · {s.kind==='time'?'Trabalho efectivo':'Consumo efectivo'}</li>)}</ul></section>
{allowed('prepare')&&source&&<form className="panel" onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);const previous=order.rates.filter(r=>r.identifier===f.get('identifier')).at(-1);send(`/api/direct-costs/orders/${order.id}/rates`,{entry_id:source.id,entry_version:source.version,identifier:f.get('identifier'),version:(previous?.version??0)+1,previous_id:previous?.id??null,currency:'MZN',unit:source.kind==='time'?'H':source.unit,amount:f.get('amount'),evidence:f.get('evidence')});}}><h3>Preparar taxa sintética versionada</h3>
<label>Origem da taxa<select value={source.id} onChange={e=>setSourceId(e.target.value)}>{order.sources.map(s=><option key={s.id} value={s.id}>#{s.id} · {s.kind==='time'?'Tempo':'Material'} v{s.version}</option>)}</select></label>
<p>Âmbito: esta ordem e versão da origem; MZN/{source.kind==='time'?'H':source.unit}. Zero é uma taxa explícita; campo vazio não vale zero.</p>
<label>Identificador da taxa<input name="identifier" required minLength={3} placeholder="TEST-MATERIAL ou TEST-TRABALHO"/></label><label>Taxa de custo exacta<input name="amount" required inputMode="decimal" placeholder="50.00"/></label><label>Origem / evidência da taxa<input name="evidence" required minLength={3} placeholder="Pressuposto sintético identificado"/></label><button disabled={disabled}>Propor taxa</button></form>}
<section className="panel"><h3>Taxas e decisões</h3>{order.rates.map(r=><article key={r.id} className="panel"><strong>#{r.id} · {r.identifier} v{r.version}</strong><p>Origem #{r.entry_id} v{r.entry_version} · {r.amount} {r.currency}/{r.unit}</p><p>{r.evidence}</p><p>{r.current?'Última versão da taxa':'Versão anterior'} · {r.decision?decisionLabel[r.decision.decision]:'Aguarda validação'}{r.decision&&` — ${r.decision.reason}`}</p>{allowed('validate')&&r.current&&!r.decision&&<DecisionForm disabled={disabled} onDecision={(d,reason)=>decide('rate',r,d,reason)}/>}</article>)}</section>
{allowed('prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const lines=order.sources.map(s=>{const r=order.rates.find(r=>String(r.id)===String(selection[s.id]??''));return {entry_id:s.id,entry_version:s.version,rate_id:r?.id??null,rate_version:r?.version??null};});send(`/api/direct-costs/orders/${order.id}/calculations`,{currency:'MZN',version:(latest?.version??0)+1,previous_id:latest?.id??null,lines});}}><h3>Preparar cálculo com taxas explícitas</h3><p>Escolher cada versão. Nenhuma taxa é escolhida automaticamente, mesmo que seja a única ou a mais recente.</p>{order.sources.map(s=><label key={s.id}>Origem #{s.id} v{s.version}<select value={selection[s.id]??''} onChange={e=>setSelection({...selection,[s.id]:e.target.value})}><option value="">Taxa em falta — manter pendência</option>{order.rates.filter(r=>r.entry_id===s.id&&r.entry_version===s.version&&r.current&&r.decision?.decision==='approved').map(r=><option key={r.id} value={r.id}>#{r.id} · {r.identifier} v{r.version} · {r.amount} MZN/{r.unit}</option>)}</select></label>)}<button disabled={disabled}>Calcular custo directo conhecido</button></form>}
<section className="panel"><h3>Histórico de cálculos</h3>{order.calculations.map(c=><article key={c.id} className="panel"><h3>Cálculo #{c.id} · v{c.version}</h3><p>{c.current?'Dependências actuais':'DESACTUALIZADO / HISTÓRICO — não usar como actual'} · {c.decision?decisionLabel[c.decision.decision]:'Aguarda validação'}{c.decision&&` — ${c.decision.reason}`}</p><p>{c.snapshot.label}</p><table><thead><tr><th>Origem / versão</th><th>Quantidade exacta</th><th>Taxa / versão</th><th>Custo exacto</th></tr></thead><tbody>{c.snapshot.lines.map(l=><tr key={l.entry_id}><td>#{l.entry_id} v{l.entry_version}</td><td>{l.quantity} {l.source_unit}{l.kind==='time'&&` ÷ 60 = ${l.cost_quantity_exact} H`}</td><td>{l.rate_id===null?'Em falta':`#${l.rate_id} v${l.rate_version} · ${l.rate_amount} MZN/${l.rate_unit}`}</td><td>{money(l.cost_exact)}</td></tr>)}</tbody></table><p><strong>Subtotal directo conhecido: {money(c.snapshot.known_subtotal_exact)}</strong></p><p>{c.snapshot.incomplete?'TOTAL INCOMPLETO — taxas em falta':'Perímetro directo seleccionado valorizado; custo completo não apurado.'}</p><ul>{c.snapshot.pending.map(p=><li key={p}>{p}</li>)}</ul><small>{c.snapshot.policy} · {c.snapshot.scope}</small>{allowed('validate')&&c.current&&!c.decision&&<DecisionForm disabled={disabled} onDecision={(d,reason)=>decide('calculation',c,d,reason)}/>}</article>)}</section>
</> }</> }</section>;
}
function DecisionForm({disabled,onDecision}) {return <form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);onDecision(f.get('decision'),f.get('reason'));}}><label>Decisão<select name="decision"><option value="approved">Validar esta versão</option><option value="refused">Recusar esta versão</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar decisão de custos</button></form>;}