34 lines
12 KiB
React
34 lines
12 KiB
React
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>;}
|