Files
SGF/resources/js/PurchasingBudgetWorkspace.jsx
T
2026-10-29 18:43:29 +02:00

68 lines
11 KiB
React

import React, {useEffect, useRef, useState} from 'react';
export function budgetMoney(value) {
if (value === null || value === undefined) return 'Indisponível';
const n=BigInt(String(value)), sign=n<0n?'-':'', digits=(n<0n?-n:n).toString().padStart(3,'0');
return `${sign}${digits.slice(0,-2).replace(/\B(?=(\d{3})+(?!\d))/g,' ')}.${digits.slice(-2)} MZN`;
}
const kinds={reserve:'Reserva',award:'Ajuste de adjudicação',commit:'Compromisso',release:'Libertação'};
export default function PurchasingBudgetWorkspace({context,can,api,busy}) {
const [data,setData]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[working,setWorking]=useState(false),[selection,setSelection]=useState('');
const retry=useRef(null);
const activeContext=useRef(context.token);activeContext.current=context.token;
const allowed=r=>can(`purchasing_budget.${r}`,context.unit?.id??null);
const load=async()=>{const token=context.token;const next=await api('/api/purchasing-budget','GET',undefined,token);if(activeContext.current===token)setData(next);};
useEffect(()=>{setData(null);setSelection('');setError('');setNotice('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
const disabled=busy||working;
const budget=data?.budgets.find(b=>String(b.id)===selection)??data?.budgets[0];
const availableOrigins=data?.origins.filter(o=>o.cost_center_id===budget?.cost_center_id)??[];
const canMove=!!budget?.approved_by&&Number(budget.year)===data?.current_year&&availableOrigins.length>0;
async function send(action,input) {
const signature=JSON.stringify([action,input,context.token]);
if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};
setWorking(true);setError('');setNotice('');
try {const result=await api(`/api/purchasing-budget/${action}`,'POST',input,context.token,retry.current.key);if(activeContext.current!==context.token)return;retry.current=null;await load();if(action==='create')setSelection(String(result.id));setNotice(result.replayed?'Resposta recuperada; nenhum efeito duplicado.':'Operação sintética registada. Saldos actualizados pelo núcleo comum.');}
catch(e){setError(e.message);}finally{setWorking(false);}
}
const submit=(action,convert)=>event=>{event.preventDefault();send(action,convert(Object.fromEntries(new FormData(event.currentTarget))));};
const originOptions=availableOrigins.map(o=>{
const movements=data.entries.filter(e=>e.requisition_id===o.id).map(e=>e.kind);
const stage=movements.includes('release')?'Libertada':movements.includes('commit')?'Comprometida':movements.includes('award')?'Adjudicação ajustada':movements.includes('reserve')?'Reservada':'Sem movimentos';
return <option key={o.id} value={o.id}>{o.reference} · {stage} · centro #{o.cost_center_id}</option>;
});
const moneyInput=(name,label,value='')=><label>{label}<input name={name} inputMode="decimal" pattern="[0-9]{1,12}([.][0-9]{1,2})?" defaultValue={value} required placeholder="0.00" /></label>;
return <section><p className="eyebrow">DEMONSTRAÇÃO ORÇAMENTAL</p><h1>Compras — controlo orçamental</h1>
<p className="alert">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Dotações, reservas, compromissos e consumo usam o núcleo comum de Compras. Este percurso não é o orçamento industrial.</p>
<p>Empresa: <strong>{context.company.name}</strong> · Unidade: <strong>{context.unit?.name}</strong> · Moeda: MZN</p>
<p className="help">Prepare a dotação, entre como validador para a aprovar e use a conta de operação para os movimentos. Decisões exigem identidade distinta. Consumo aqui é apenas um efeito orçamental simulado, sem factura, obrigação ou pagamento.</p>
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="notice" role="status">{notice}</p>}
<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar saldos e histórico</button>
{!data?<p role="status">A carregar o percurso…</p>:<>
{allowed('create')&&<section className="panel"><h2>Preparar dotação</h2><form onSubmit={submit('create',d=>({...d,cost_center_id:Number(d.cost_center_id),year:Number(d.year),includes_tax:d.includes_tax==='true'}))}>
<fieldset disabled={disabled}><label>Centro de custo<select name="cost_center_id" required>{data.centres.map(c=><option key={c.id} value={c.id}>{c.code}</option>)}</select></label>
<label>Exercício<input name="year" type="number" min="2026" max="2100" defaultValue={data.current_year} required /></label>
<label>Natureza<select name="kind"><option value="operating">Corrente</option><option value="investment">Investimento</option></select></label>
<label>Base da dotação<select name="includes_tax"><option value="true">Com imposto indicado</option><option value="false">Sem imposto — usar subtotal</option></select></label>
{moneyInput('amount','Dotação inicial (MZN)','1000.00')}<label>Fundamento<textarea name="reason" required minLength={3} defaultValue="Dotação exclusivamente sintética para a equipa." /></label><button>Guardar proposta de dotação</button></fieldset>
</form></section>}
<section className="panel"><h2>Dotações e saldos</h2>{!budget?<p>Não há dotações neste contexto. A conta de preparação pode criar a primeira.</p>:<>
<label>Dotação<select value={String(budget.id)} onChange={e=>setSelection(e.target.value)}>{data.budgets.map(b=><option key={b.id} value={b.id}>#{b.id} · centro #{b.cost_center_id} · {b.year} · {b.includes_tax?'com imposto':'sem imposto'}</option>)}</select></label>
<p>Estado: <strong>{budget.approved_by?'Aprovada':'Pendente de aprovação'}</strong> · Preparada por {data.actors[budget.created_by]??`#${budget.created_by}`} {budget.approved_by&&`· Validada por ${data.actors[budget.approved_by]??`#${budget.approved_by}`}`}</p>
<dl className="home-context">{[['Dotação inicial',budget.amount_minor],['Reforço aprovado',budget.balance.reinforced_minor],['Total aprovado',budget.balance.approved_total_minor],['Reservado',budget.balance.reserved_minor],['Comprometido',budget.balance.committed_minor],['Consumido',budget.balance.consumed_minor],['Disponível',budget.balance.available_minor]].map(([label,value])=><div key={label}><dt>{label}</dt><dd>{budgetMoney(value)}</dd></div>)}</dl>
{allowed('approve')&&!budget.approved_by&&<button disabled={disabled} onClick={()=>send('approve',{budget_id:budget.id})}>Aprovar dotação por identidade distinta</button>}
</>}</section>
{(allowed('record')||allowed('consume'))&&!canMove&&<p role="status">Para operar, seleccione uma dotação aprovada do exercício actual com origem sintética no mesmo centro. O guião usa o centro ORC-DEMO.</p>}
{allowed('record')&&canMove&&<section className="panel"><h2>Movimentos da origem sintética</h2><p>Reserva → ajuste de adjudicação → compromisso. A libertação só está disponível antes do compromisso. Uma repetição não volta a movimentar o saldo.</p>
<form key={`movement-${budget.id}`} onSubmit={submit('movement',d=>({...d,origin_id:Number(d.origin_id)}))}><fieldset disabled={disabled}><label>Origem<select name="origin_id">{originOptions}</select></label><label>Movimento<select name="kind">{Object.entries(kinds).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label>{moneyInput('total','Total indicado (MZN)','300.00')}{moneyInput('subtotal','Subtotal sem imposto (MZN)','300.00')}<button>Registar movimento sintético</button></fieldset></form>
</section>}
{allowed('consume')&&canMove&&<section className="panel"><h2>Consumo orçamental simulado</h2><p>Requer compromisso anterior. A referência identifica uma simulação; não cria uma factura ou obrigação.</p><form key={`consume-${budget.id}`} onSubmit={submit('consume',d=>({...d,origin_id:Number(d.origin_id)}))}><fieldset disabled={disabled}><label>Origem<select name="origin_id">{originOptions}</select></label><label>Referência sintética<input name="reference" defaultValue="SIM-CONSUMO-001" required minLength={3} maxLength={100} /></label>{moneyInput('total','Total simulado (MZN)','100.00')}{moneyInput('subtotal','Subtotal simulado (MZN)','100.00')}<button>Registar consumo simulado</button></fieldset></form></section>}
{allowed('reinforcement.propose')&&budget?.approved_by&&<section className="panel"><h2>Propor reforço da dotação #{budget.id}</h2><form onSubmit={submit('reinforcement',d=>({...d,budget_id:budget.id}))}><fieldset disabled={disabled}><label>Referência<input name="reference" defaultValue="SIM-REFORCO-001" required maxLength={100} /></label>{moneyInput('amount','Montante do reforço (MZN)','200.00')}<label>Fundamento<textarea name="reason" required minLength={3} defaultValue="Reforço sintético para ensaio da equipa." /></label><button>Propor reforço</button></fieldset></form></section>}
<section className="panel"><h2>Reforços e decisões</h2>{data.reinforcements.length===0?<p>Sem propostas de reforço.</p>:data.reinforcements.map(r=><article key={r.id}><h3>{r.reference} · {budgetMoney(r.amount_minor)}</h3><p>Dotação #{r.budget_id} · {r.status} · Proponente: {data.actors[r.created_by]??r.created_by}</p><p>{r.reason}</p>{r.review_reason&&<p>Decisão: {r.review_reason}</p>}{allowed('reinforcement.review')&&r.status==='pending'&&<form onSubmit={submit('review',d=>({...d,reinforcement_id:r.id}))}><fieldset disabled={disabled}><label>Decisão<select name="decision"><option value="approved">Aprovar</option><option value="rejected">Recusar</option></select></label><label>Fundamento<textarea name="reason" required minLength={3} /></label><button>Registar decisão distinta</button></fieldset></form>}</article>)}</section>
<section className="panel"><h2>Movimentos rastreáveis</h2><div className="table-scroll"><table><thead><tr><th>Movimento</th><th>Dotação / origem</th><th>Actor</th><th>Reserva Δ</th><th>Compromisso Δ</th><th>Data</th></tr></thead><tbody>{data.entries.map(e=><tr key={e.id}><td>#{e.id} · {kinds[e.kind]??e.kind}</td><td>#{e.budget_id} / {data.origins.find(o=>o.id===e.requisition_id)?.reference??e.requisition_id}</td><td>{data.actors[e.created_by]??e.created_by}</td><td>{budgetMoney(e.reserved_minor)}</td><td>{budgetMoney(e.committed_minor)}</td><td>{e.created_at}</td></tr>)}</tbody></table></div>{!data.entries.length&&<p>Ainda não há movimentos.</p>}</section>
<section className="panel"><h2>Consumos e respectivas referências</h2>{data.consumption_sources.map(s=><p key={s.id}>{s.reference} · origem #{s.origin_id} · total {budgetMoney(s.total_minor)} · efeito #{data.consumptions.find(c=>c.invoice_id===s.id)?.id??'pendente'}</p>)}</section>
<section className="panel"><h2>Histórico de decisões e efeitos</h2>{data.history.map(h=><details key={h.id}><summary>#{h.id} · {h.action} · {data.actors[h.actor_id]??h.actor_id} · {h.created_at}</summary><pre>{JSON.stringify(typeof h.payload==='string'?JSON.parse(h.payload):h.payload,null,2)}</pre></details>)}</section>
</>}
</section>;
}