Accept isolated budget versus actual trial with documented capture limitation

This commit is contained in:
2026-10-28 17:07:37 +02:00
parent 4a94e8a054
commit 04870f012d
28 changed files with 774 additions and 9 deletions
@@ -0,0 +1,16 @@
import React,{useEffect,useRef,useState} from 'react';
const labels={material:'Material',labour:'Trabalho',allocated:'Indirectos imputados'};
const value=v=>v===null?'Indisponível':v;
export default function IndustrialBudgetsWorkspace({context,can,api,busy}){
const [data,setData]=useState(null),[lotId,setLotId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false);const retry=useRef(null);
const allowed=r=>can(r,context.unit?.id??null);const disabled=busy||working;
const load=async()=>setData(await api('/api/industrial-budgets'));
useEffect(()=>{setData(null);setLotId('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
const lot=data?.lots.find(l=>String(l.id)===lotId)??data?.lots[0];
async function send(action,input){setWorking(true);setError('');const path=`/api/industrial-budgets/lots/${lot.id}/${action}`,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);}}
return <section><p className="eyebrow">INDUSTRIAL-BUDGET-ACTUAL-SIM-v1</p><h2>Orçamento versus realizado</h2><div className="alert">ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Hipótese retrospectiva sintética; diferenças monetárias não demonstram causas ou eficiência.</div>{error&&<p role="alert" className="alert error">{error}</p>}{!data?<p>A carregar…</p>:data.blocked?<p>Operações reais bloqueadas.</p>:<><button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar versões explicitamente</button><label>Lote<select value={lot?.id??''} onChange={e=>setLotId(e.target.value)}>{data.lots.map(l=><option value={l.id} key={l.id}>{l.reference} · #{l.id}</option>)}</select></label>{lot?<><p>{lot.product.name} · {lot.period_start} a {lot.period_end} · 10 UN · MZN · Material, trabalho e indirectos imputados</p>
{allowed('industrial_budgets.prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget),last=lot.budgets.at(-1);send('proposals',{version:(last?.version??0)+1,previous_id:last?.id??null,dimensions:lot.dimensions,amounts:Object.fromEntries(Object.keys(labels).map(k=>[k,f.get(k)===''?null:f.get(k)])),evidence:f.get('evidence')});}}><h3>Preparar orçamento versionado</h3><p>{lot.budgets.length?`Sucessora explícita de #${lot.budgets.at(-1).id} v${lot.budgets.at(-1).version}. Referência histórica preservada.`:'Primeira proposta. Não é planeamento anterior comprovado.'}</p>{Object.entries(labels).map(([k,l])=><label key={k}>{l} previsto (MZN; vazio = desconhecido)<input name={k} inputMode="decimal" placeholder="Montante exacto ou vazio"/></label>)}<label>Fundamento / motivo da versão<input name="evidence" required minLength={3}/></label><button disabled={disabled}>Submeter orçamento</button></form>}
<h3>Orçamentos e decisões</h3>{lot.budgets.map(b=><article className="panel" key={b.id}><h3>Orçamento #{b.id} · v{b.version}</h3><p>{b.current?'Última versão':'HISTÓRICO — substituído explicitamente'} · {b.decision?`${b.decision.decision==='approved'?'Validado':'Recusado'} — ${b.decision.reason}`:'Aguarda validação distinta'}</p><p>{b.evidence}</p><ul>{Object.entries(labels).map(([k,l])=><li key={k}>{l}: {value(b.amounts[k])} MZN</li>)}</ul>{allowed('industrial_budgets.validate')&&b.current&&!b.decision&&<form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);send('decisions',{id:b.id,version:b.version,decision:f.get('decision'),reason:f.get('reason')});}}><label>Decisão<select name="decision"><option value="approved">Validar versão exacta</option><option value="refused">Recusar versão exacta</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar decisão do orçamento</button></form>}</article>)}
{allowed('industrial_budget_comparisons.prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget),b=lot.budgets.find(x=>String(x.id)===f.get('budget')),r=lot.results.find(x=>String(x.id)===f.get('result')),last=lot.comparisons.at(-1);send('comparisons',{version:(last?.version??0)+1,previous_id:last?.id??null,budget_id:b.id,budget_version:b.version,result_id:r.id,result_version:r.version});}}><h3>Comparar versões explícitas</h3><label>Orçamento aprovado<select name="budget" required defaultValue=""><option value="">Seleccionar ID e versão</option>{lot.budgets.filter(b=>b.current&&b.decision?.decision==='approved').map(b=><option key={b.id} value={b.id}>#{b.id} v{b.version}</option>)}</select></label><label>Realizado validado<select name="result" required defaultValue=""><option value="">Seleccionar ID e versão</option>{lot.results.filter(r=>r.current&&r.decision?.decision==='approved').map(r=><option key={r.id} value={r.id}>#{r.id} v{r.version} · {r.snapshot.incomplete?'800 conhecidos — incompleto':`${r.snapshot.known_cost_exact} MZN`}</option>)}</select></label><button disabled={disabled}>Comparar orçamento e realizado</button></form>}
<h3>Comparações preservadas</h3>{lot.comparisons.map(c=><article key={c.id} className="panel"><h3>Comparação #{c.id} · v{c.version}</h3><p>{c.current?'Versões actuais':'DESACTUALIZADA / HISTÓRICA — não substituída silenciosamente'}</p><p>Orçamento #{c.snapshot.budget.id} v{c.snapshot.budget.version} · Realizado #{c.snapshot.result.id} v{c.snapshot.result.version}</p><table><thead><tr><th>Componente</th><th>Previsto MZN</th><th>Realizado MZN</th><th>Desvio R − P</th><th>Percentagem</th></tr></thead><tbody>{Object.entries(labels).map(([k,l])=>{const r=c.snapshot.rows[k];return <tr key={k}><td>{l}</td><td>{value(r.planned)}</td><td>{value(r.actual)}</td><td>{value(r.delta)}</td><td>{r.percent===null?`Indisponível — ${r.percent_state==='zero_base'?'base zero':'valor desconhecido'}`:`${r.percent} %`}</td></tr>;})}</tbody></table><p><strong>{c.snapshot.incomplete?'COMPARAÇÃO INCOMPLETA — sem desvio total conclusivo':`Desvio total: ${c.snapshot.total_delta} MZN · ${c.snapshot.total_percent===null?'Percentagem indisponível — base zero':c.snapshot.total_percent+' %'}`}</strong></p><p>Realizado conhecido: {c.snapshot.actual_known} MZN</p><p>Reconciliação {c.snapshot.incomplete?'dos pares conhecidos':'total'}: {c.snapshot.paired_planned} + ({c.snapshot.paired_delta}) = {c.snapshot.paired_actual} MZN</p><p><strong>Residual separado: {c.snapshot.residual_separate} MZN por imputar — fora do desvio do lote</strong></p><p>Origens realizadas: {c.snapshot.actual_reconciliation.known_origins_exact} = {c.snapshot.actual_reconciliation.assigned_exact} atribuídos + {c.snapshot.actual_reconciliation.unassigned_exact} por imputar{c.snapshot.incomplete?' (cobertura parcial)':''}.</p></article>)}</>:<p>Sem lotes autorizados.</p>}</>}</section>;
}
+3 -1
View File
@@ -10,6 +10,7 @@ import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
@@ -55,7 +56,7 @@ function App() {
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('industrial_costs.view',context?.unit?.id ?? null) ? [['industrial_costs', 'Custos industriais de teste']] : []), ...(can('direct_costs.view',context?.unit?.id ?? null) ? [['direct_costs', 'Custos directos de teste']] : []), ...(can('workshop.view',context?.unit?.id ?? null) ? [['workshop', 'Oficina de teste']] : []), ...(can('service_receivables.view',context?.unit?.id ?? null) ? [['service_receivables', 'Recebimentos simulados']] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('industrial_budgets.view',context?.unit?.id ?? null) ? [['industrial_budgets', 'Orçamento versus realizado']] : []), ...(can('industrial_costs.view',context?.unit?.id ?? null) ? [['industrial_costs', 'Custos industriais de teste']] : []), ...(can('direct_costs.view',context?.unit?.id ?? null) ? [['direct_costs', 'Custos directos de teste']] : []), ...(can('workshop.view',context?.unit?.id ?? null) ? [['workshop', 'Oficina de teste']] : []), ...(can('service_receivables.view',context?.unit?.id ?? null) ? [['service_receivables', 'Recebimentos simulados']] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
@@ -67,6 +68,7 @@ function App() {
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'direct_costs' && <DirectCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'industrial_budgets' && <IndustrialBudgetsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'industrial_costs' && <IndustrialCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}