Files
SGF/resources/js/IndustrialCostsWorkspace.jsx
T
2026-10-27 13:57:09 +02:00

34 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React,{useEffect,useRef,useState} from 'react';
const names={material:'Consumo material',labour:'Trabalho efectivo',pool:'Origem do conjunto indirecto',machine:'Utilização de máquina',production:'Produção boa concluída',material_rate:'Taxa de material',labour_rate:'Taxa de trabalho',criterion:'Critério de imputação'};
const units={material:'KG',labour:'MIN',pool:'MZN',machine:'HM',production:'UN',material_rate:'KG',labour_rate:'H'};
const money=v=>v===null?'Indisponível':`${v} MZN`;
export default function IndustrialCostsWorkspace({context,can,api,busy}){
const [data,setData]=useState(null),[error,setError]=useState(''),[lotId,setLotId]=useState(''),[kind,setKind]=useState('criterion'),[selection,setSelection]=useState({}),[working,setWorking]=useState(false);const retry=useRef(null);
const allowed=r=>can(`industrial_costs.${r}`,context.unit?.id??null);const disabled=busy||working;
const load=async()=>{const r=await api('/api/industrial-costs');setData(r);};useEffect(()=>{setSelection({});load().catch(e=>setError(e.message));},[context.token]);
const lot=data?.lots.find(l=>String(l.id)===lotId)??data?.lots[0];const latest=lot?.results.at(-1);const lastInput=lot?.inputs.filter(i=>i.kind===kind).at(-1);
const options=k=>lot?.inputs.filter(i=>i.kind===k&&i.current&&i.decision?.decision==='approved')??[];
const reference=v=>{const r=lot.inputs.find(i=>String(i.id)===String(v));return r?{id:r.id,version:r.version}:null;};
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 decide=(type,row,decision,reason)=>send(`/api/industrial-costs/lots/${lot.id}/decisions`,{type,id:row.id,version:row.version,decision,reason});
const inputForm=e=>{e.preventDefault();const f=new FormData(e.currentTarget);let d;
if(kind==='criterion'){const p=reference(f.get('pool')),m=reference(f.get('machine'));if(!p||!m){setError('Escolher versões explícitas do conjunto e utilização.');return;}d={pool_id:p.id,pool_version:p.version,machine_id:m.id,machine_version:m.version,capacity:f.get('capacity'),driver_unit:'HM',hypothesis:'synthetic_only'};}
else if(kind.endsWith('_rate')){const source=reference(f.get('source'));if(!source){setError('Escolher origem exacta.');return;}d={source_id:source.id,source_version:source.version,amount:f.get('amount'),currency:'MZN',unit:units[kind]};}
else {d={quantity:f.get('quantity'),unit:units[kind],event_date:f.get('event_date')};if(kind==='production')d.completed=f.get('completed')==='on';}
send(`/api/industrial-costs/lots/${lot.id}/inputs`,{kind,source_ref:lastInput?.source_ref??f.get('source_ref'),version:(lastInput?.version??0)+1,previous_id:lastInput?.id??null,data:d,evidence:f.get('evidence')});};
return <section className="workspace"><p className="eyebrow">INDUSTRIAL-COST-SIM-v1</p><h2>Ensaio de custos industriais</h2><div className="alert">ENSAIO INDUSTRIAL — SEM UTILIZAÇÃO OPERACIONAL. Critério sintético; não configura método contabilístico real. Sem stock, facturação, margens ou lançamentos.</div>{error&&<p role="alert" className="alert error">{error}</p>}
{!data?<p>A carregar…</p>:data.blocked?<p>Custeio industrial real bloqueado nesta instância.</p>:<><button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar versões explicitamente</button>
{allowed('prepare')&&<details className="panel"><summary>Abrir lote sintético e produto próprios</summary><form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);send('/api/industrial-costs/lots',Object.fromEntries([...f.entries(),['currency','MZN']]));}}>{[['reference','Referência do lote'],['product_reference','Referência do produto'],['product_name','Nome do produto'],['centre','Centro sintético']].map(([n,l])=><label key={n}>{l}<input name={n} required minLength={3}/></label>)}<label>Início do período<input type="date" name="period_start" required/></label><label>Fim do período<input type="date" name="period_end" required/></label><p>MZN explícito; produto em UN. Não é ordem de oficina.</p><button disabled={disabled}>Abrir lote de teste</button></form></details>}
<label>Lote de teste<select value={lot?.id??''} onChange={e=>{setLotId(e.target.value);setSelection({});}}>{data.lots.map(l=><option key={l.id} value={l.id}>{l.reference} · #{l.id}</option>)}</select></label>
{!lot?<p>Sem lotes no âmbito autorizado.</p>:<><p>{lot.product.name} · {lot.centre} · {lot.period_start} a {lot.period_end} · {lot.currency}</p>
{allowed('prepare')&&<form className="panel" onSubmit={inputForm}><h3>Preparar origem, taxa ou critério versionado</h3><label>Tipo de proposta<select value={kind} onChange={e=>setKind(e.target.value)}>{Object.entries(names).map(([k,n])=><option key={k} value={k}>{n}</option>)}</select></label><p>{lastInput?`Nova versão ${(lastInput.version)+1}, anterior #${lastInput.id}; origem ${lastInput.source_ref}.`:'Nova origem sintética identificada, sem duplicar outra origem.'}</p>{!lastInput&&<label>Identidade da origem<input name="source_ref" placeholder="IND-TEST-CRITERIO" required pattern="IND-TEST-[A-Za-z0-9_-]+"/></label>}
{kind==='criterion'?<><SourceSelect label="Conjunto indirecto exacto" name="pool" rows={options('pool')}/><SourceSelect label="Utilização exacta da máquina" name="machine" rows={options('machine')}/><label>Capacidade sintética em HM<input name="capacity" required placeholder="10"/></label><p>Hipótese apenas do ensaio; denominador explícito. Não redistribuir residual.</p></>:kind.endsWith('_rate')?<><SourceSelect label="Origem exacta da taxa" name="source" rows={options(kind.replace('_rate',''))}/><label>Taxa exacta MZN/{units[kind]}<input name="amount" required inputMode="decimal"/></label></>:<><label>Quantidade exacta em {units[kind]}<input name="quantity" required inputMode="decimal"/></label><label>Data do acontecimento sintético<input type="date" name="event_date" required min={lot.period_start} max={lot.period_end}/></label>{kind==='production'&&<label><input type="checkbox" name="completed" required/>Lote fisicamente concluído neste ensaio</label>}</>}
<label>Evidência / pressuposto<input name="evidence" required minLength={3}/></label><button disabled={disabled}>Propor versão industrial</button></form>}
<section className="panel"><h3>Origens, taxas e critério</h3>{lot.inputs.map(i=><article className="panel" key={i.id}><strong>#{i.id} · {names[i.kind]} v{i.version}</strong><p>{i.source_ref} · {i.current?'Última versão':'Histórico'} · {i.decision?`${i.decision.decision==='approved'?'Validado':'Recusado'} — ${i.decision.reason}`:'Aguarda validação'}</p><dl>{Object.entries(i.data).map(([k,v])=><React.Fragment key={k}><dt>{({quantity:'Quantidade',unit:'Unidade',event_date:'Data',amount:'Taxa',currency:'Moeda',capacity:'Capacidade',driver_unit:'Unidade da base',hypothesis:'Hipótese',source_id:'Origem #',source_version:'Versão da origem',pool_id:'Conjunto #',pool_version:'Versão do conjunto',machine_id:'Utilização #',machine_version:'Versão da utilização',completed:'Concluído'})[k]??k}</dt><dd>{v===true?'Sim':v==='synthetic_only'?'Apenas ensaio':String(v)}</dd></React.Fragment>)}</dl><p>{i.evidence}</p>{allowed('validate')&&i.current&&!i.decision&&<DecisionForm disabled={disabled} onDecision={(d,r)=>decide('input',i,d,r)}/>}</article>)}</section>
{allowed('prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const refs=Object.fromEntries(Object.keys(names).map(k=>[k,reference(selection[k]??'')]));send(`/api/industrial-costs/lots/${lot.id}/calculations`,{currency:'MZN',version:(latest?.version??0)+1,previous_id:latest?.id??null,refs});}}><h3>Seleccionar versões para cálculo</h3><p>Selecção explícita; taxa material ausente mantém pendência. As outras referências são necessárias.</p>{Object.entries(names).map(([k,n])=><label key={k}>{n}<select value={selection[k]??''} required={k!=='material_rate'} onChange={e=>setSelection({...selection,[k]:e.target.value})}><option value="">{k==='material_rate'?'Sem taxa — total incompleto':'Escolher versão validada'}</option>{options(k).map(i=><option key={i.id} value={i.id}>#{i.id} v{i.version} · {i.source_ref}</option>)}</select></label>)}<button disabled={disabled}>Calcular lote sintético</button></form>}
<section className="panel"><h3>Resultados e residual</h3>{lot.results.map(r=><article key={r.id} className="panel"><h3>Resultado #{r.id} · v{r.version}</h3><p>{r.current?'Dependências actuais':'DESACTUALIZADO / HISTÓRICO — não usar como actual'} · {r.decision?`${r.decision.decision==='approved'?'Validado':'Recusado'} — ${r.decision.reason}`:'Aguarda validação'}</p><p>{r.snapshot.hypothesis}</p><table><thead><tr><th>Componente</th><th>Composição exacta</th><th>Custo no lote</th></tr></thead><tbody><tr><td>Material</td><td>{r.snapshot.origins.material.data.quantity} KG × {r.snapshot.origins.material_rate?.data.amount??'taxa em falta'}</td><td>{money(r.snapshot.material_exact)}</td></tr><tr><td>Trabalho</td><td>{r.snapshot.origins.labour.data.quantity} MIN ÷ 60 = {r.snapshot.labour_hours_exact} H × {r.snapshot.origins.labour_rate.data.amount}</td><td>{money(r.snapshot.labour_exact)}</td></tr><tr><td>Indirectos imputados</td><td>{r.snapshot.pool_exact} ÷ {r.snapshot.capacity_hm} HM = {r.snapshot.indirect_rate_exact} MZN/HM; × {r.snapshot.used_hm} HM</td><td>{money(r.snapshot.allocated_exact)}</td></tr></tbody></table><p><strong>Custo atribuído conhecido: {money(r.snapshot.known_cost_exact)}</strong></p><p>{r.snapshot.incomplete?'TOTAL INCOMPLETO — material pendente':'Total do perímetro sintético: '+money(r.snapshot.selected_total_exact)}</p><p>{r.snapshot.good_units} UN boas · Custo unitário do cenário: {r.snapshot.unit_cost_exact===null?'Indisponível':money(r.snapshot.unit_cost_exact)+'/UN'}</p><p><strong>Residual separado: {money(r.snapshot.residual_exact)} por imputar</strong></p><p>Conjunto: {r.snapshot.pool_exact} = {r.snapshot.allocated_exact} imputados + {r.snapshot.residual_exact} por imputar.</p><p>Reconciliação {r.snapshot.reconciliation.coverage==='partial_known_only'?'PARCIAL — só valores conhecidos':'do perímetro'}: {r.snapshot.reconciliation.known_origins_exact} = {r.snapshot.reconciliation.assigned_exact} atribuídos + {r.snapshot.reconciliation.unassigned_exact} por imputar.</p><ul>{r.snapshot.pending.map(p=><li key={p}>{p}</li>)}</ul><details><summary>Referências exactas utilizadas</summary><ul>{Object.entries(r.snapshot.refs).map(([k,v])=><li key={k}>{names[k]}: {v?`#${v.id} v${v.version}`:'Ausente'}</li>)}</ul></details>{allowed('validate')&&r.current&&!r.decision&&<DecisionForm disabled={disabled} onDecision={(d,reason)=>decide('result',r,d,reason)}/>}</article>)}</section>
</>}</>}</section>;
}
function SourceSelect({label,name,rows}){return <label>{label}<select name={name} required defaultValue=""><option value="">Escolher versão validada</option>{rows.map(i=><option key={i.id} value={i.id}>#{i.id} v{i.version} · {i.source_ref}</option>)}</select></label>;}
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 industrial<select name="decision"><option value="approved">Validar esta versão</option><option value="refused">Recusar esta versão</option></select></label><label>Fundamento industrial<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar decisão industrial</button></form>;}