Record explicit workshop acceptance and preserve subsequent synthetic work without accepting receivables. Keep 080000/070000 unapplied, real workshop grants pending and operational issuance blocks intact. Exclude local credentials and real-data preservation reports.
54 lines
13 KiB
React
54 lines
13 KiB
React
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
const titles = { obligation:'Obrigação de teste', receipt:'Recebimento proposto', observation:'Observação simulada', correction:'Correcção proposta' };
|
|
const actions = { reverse_allocation:'Reverter afectação integral', reverse_reconcile:'Desfazer conferência', reverse_receipt:'Reverter recebimento integral', reduction:'Aplicar redução sintética', due:'Corrigir vencimento' };
|
|
const effects = { open:'Obrigação aberta', receipt_confirm:'Recebimento confirmado', refused:'Proposta recusada', allocate:'Afectação aplicada', reconcile:'Reconciliado em simulação', divergence:'Divergência preservada', reverse_allocation:'Afectação revertida', reverse_reconcile:'Conferência desfeita', reverse_receipt:'Recebimento revertido', reduction:'Redução sintética aplicada', due:'Vencimento corrigido' };
|
|
const money = v => String(v ?? '').replace('.', ',');
|
|
export default function ServiceReceivablesWorkspace({context, can, api, busy}) {
|
|
const [data,setData]=useState(null), [error,setError]=useState(''), [working,setWorking]=useState(false), [obId,setObId]=useState(''), [proof,setProof]=useState(null);
|
|
const attempt=useRef(null);
|
|
const proposalIntent=useRef(null);
|
|
const allowed = r => can(`service_receivables.${r}`,context.unit?.id ?? null);
|
|
const load = async () => { const next=await api('/api/service-receivables'); setData(next); return next; };
|
|
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
|
|
const ob=data?.obligations.find(o=>String(o.id)===String(obId)) ?? data?.obligations[0];
|
|
const record=id=>data?.records.find(r=>r.id===Number(id));
|
|
const origin=ob && record(ob.id);
|
|
const receipts=ob ? Object.values(ob.receipts) : [];
|
|
const disabled=busy||working;
|
|
async function send(path,input) {
|
|
setError('');setWorking(true);
|
|
const canonical=JSON.stringify([path,input]);
|
|
if(attempt.current?.canonical!==canonical) attempt.current={canonical,key:crypto.randomUUID()};
|
|
try { await api(`/api/service-receivables${path}`,'POST',input,context.token,attempt.current.key); await load(); attempt.current=null; proposalIntent.current=null; }
|
|
catch(e) { setError(e.message); }
|
|
finally {setWorking(false);}
|
|
}
|
|
function propose(kind,values) {
|
|
const invoice=kind==='obligation'?Number(values.invoice_id):origin.invoice_id;
|
|
const signature=JSON.stringify([kind,invoice,values]);
|
|
if(proposalIntent.current?.signature!==signature) proposalIntent.current={signature,id:crypto.randomUUID()};
|
|
return send('/proposals',{kind,invoice_id:invoice,currency:'MZN',source:'TEST-MANUAL',external_id:values.external_id || proposalIntent.current.id,revision:kind==='obligation'?0:ob.revision,...(kind==='obligation'?{}:{obligation_id:ob.id}),...values});
|
|
}
|
|
const pending = r => !ob?.effects.some(e=>e.record_id===r.id && e.kind!=='allocate');
|
|
return <>
|
|
<section className="panel"><p className="eyebrow">SGF-RECEIVABLES-SIM-v1</p><h1>Contas a receber — simulação</h1><div className="alert error"><strong>TESTE — SEM VALIDADE FISCAL</strong><p>SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO</p></div><p>Liquidar um documento e conferir um recebimento são operações distintas. Sem recibo fiscal, PDF novo, banco ou contabilidade.</p>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar dados explicitamente</button><p className="help">A vista conserva a revisão apresentada. Alterações entretanto feitas por outro utilizador provocam recusa e exigem actualização; os formulários não substituem valores silenciosamente.</p></section>
|
|
{!data ? <p>A carregar…</p> : data.blocked ? <section className="panel"><h2>Percurso real indisponível</h2><p>Este incremento só funciona na instância isolada de testes. As permissões não retiram o bloqueio.</p></section> : <>
|
|
{allowed('prepare')&&<section className="panel"><h2>Preparar abertura explícita</h2><SmallForm submit="Propor obrigação" disabled={disabled} onSubmit={v=>propose('obligation',{invoice_id:Number(v.invoice_id),due_date:v.due_date||null})}><label>Documento exacto<select name="invoice_id" required>{data.documents.map(d=><option key={d.id} value={d.id}>#{d.id} · {d.series_code}/{d.number}</option>)}</select></label><label>Vencimento conhecido (opcional)<input type="date" name="due_date"/></label></SmallForm></section>}
|
|
<section className="panel"><h2>Obrigações e confirmação</h2>{data.obligations.length===0&&<p>Nenhuma obrigação aberta ou proposta. Documentos e rascunhos não criam dívida automaticamente.</p>}<label>Obrigação<select value={ob?.id||''} onChange={e=>{setObId(e.target.value);setProof(null);}}>{data.obligations.map(o=><option key={o.id} value={o.id}>#{o.id} · {record(o.id)?.external_id} · {o.state==='proposed'?'Aguarda confirmação':o.state==='refused'?'Recusada':o.state==='settled'?'Liquidada':'Em aberto'}</option>)}</select></label>{ob&&<><p>Documento #{origin.invoice_id} · Cliente #{origin.client_id} · MZN · Revisão {ob.revision} · Corte {ob.cut_date}</p><div className="summary-grid">{[['Original',ob.original],['Redução sintética',ob.reduced],['Recebido confirmado',ob.received],['Afectado',ob.allocated],['Por afectar',ob.available],[ob.opened?'Saldo por receber':'Valor proposto — obrigação não aberta',ob.balance]].map(([label,value])=><section key={label}><span>{label}</span><h2>{money(value)} MZN</h2></section>)}</div><p>Vencimento: {ob.due_date||'por definir'} · {({not_open:'Obrigação ainda não aberta',unknown:'Pendência — atraso não determinado',overdue:'Saldo vencido',not_due:'Ainda não vencido',settled:'Liquidada'})[ob.due_state]}</p>{pending(origin)&&allowed('confirm')&&<Decision record={origin} disabled={disabled} decide={decision=>send(`/${origin.id}/decision`,{version:origin.version,revision:ob.revision,decision,reason:'Decisão explícita sobre proposta sintética.'})}/>}</>}</section>
|
|
{ob&&ob.opened&&<>
|
|
{allowed('prepare')&&<section className="panel"><h2>Registar recebimento proposto</h2><SmallForm submit="Propor recebimento" disabled={disabled} onSubmit={v=>propose('receipt',v)}><label>Valor recebido (MZN; ponto decimal)<input name="amount" inputMode="decimal" placeholder="400.00" required/></label><label>Data declarada<input name="date" type="date" required/></label><label>Identificador do acontecimento sintético<input name="external_id" required/></label><label>Evidência sintética<textarea name="evidence" required/></label></SmallForm></section>}
|
|
<section className="panel"><h2>Recebimentos e afectações</h2>{receipts.map(rc=>{const r=record(rc.id); return <article className="panel" key={rc.id}><h3>Recebimento #{rc.id} · {r.external_id}</h3><p>{({proposed:'Aguarda confirmação',confirmed:'Confirmado em teste',refused:'Recusado',reversed:'Revertido'})[rc.state]} · Recebido {money(rc.amount)} · Afectado {money(rc.allocated)} · Por afectar {money(rc.available)} MZN</p><p>{rc.reconciled?'Reconciliado em simulação':'Sem reconciliação simulada confirmada'}</p><button onClick={()=>setProof(rc.id)}>Consultar comprovativo interno</button>{pending(r)&&allowed('confirm')&&<Decision record={r} disabled={disabled} decide={decision=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision,reason:'Confirmação de evidência exclusivamente sintética.'})}/ >}{rc.state==='confirmed'&&allowed('allocate')&&<SmallForm submit="Afectar explicitamente" disabled={disabled} onSubmit={v=>send(`/${rc.id}/allocate`,{amount:v.amount,version:r.version,revision:ob.revision})}><label>Montante a afectar a este documento<input name="amount" inputMode="decimal" required/></label></SmallForm>}</article>;})}</section>
|
|
{proof&&<section className="panel"><h2>Comprovativo interno #{proof}</h2><strong>TESTE — SEM VALIDADE FISCAL</strong><p>SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO</p><p>Documento #{origin.invoice_id} · Recebimento #{proof} · Revisão {ob.revision} · Corte {ob.cut_date}</p><p>Evidência: {record(proof)?.data.evidence} · Data declarada: {record(proof)?.data.date}</p><p>Recebido {money(ob.receipts[proof]?.amount)} · Afectado {money(ob.receipts[proof]?.allocated)} · Por afectar {money(ob.receipts[proof]?.available)} MZN</p><p>Estado actual: {ob.receipts[proof]?.state}. Consulta interna, sem emissão ou entrega de recibo fiscal.</p>{ob.effects.filter(e=>e.receipt_id===proof||e.record_id===proof).map(e=><p key={e.id}>Efeito #{e.id} · {effects[e.kind]} · {money(e.amount)} MZN · Autor #{e.actor_id}</p>)}</section>}
|
|
{allowed('prepare')&&<section className="panel"><h2>Preparar observação de fonte simulada</h2><SmallForm submit="Guardar observação sintética" disabled={disabled} onSubmit={v=>propose('observation',{...v,receipt_id:Number(v.receipt_id)})}><label>Recebimento exacto<select name="receipt_id">{receipts.filter(r=>r.state==='confirmed').map(r=><option key={r.id} value={r.id}>#{r.id} · {record(r.id)?.external_id}</option>)}</select></label><label>Valor observado (MZN)<input name="amount" required/></label><label>Data da observação<input name="date" type="date" required/></label><label>ID da observação sintética<input name="external_id" required/></label><label>Evidência artificial<textarea name="evidence" required/></label></SmallForm></section>}
|
|
<section className="panel"><h2>Conferência das observações</h2>{data.records.filter(r=>r.kind==='observation'&&r.obligation_id===ob.id).map(r=><article key={r.id}><h3>Observação #{r.id} → Recebimento #{r.receipt_id}</h3><p>{r.external_id} · {money(r.amount)} MZN · {r.data.date}</p>{pending(r)&&allowed('reconcile')&&<button disabled={disabled} onClick={()=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision:'approved',reason:'Conferência explícita da observação simulada.'})}>Conferir correspondência exacta</button>}</article>)}</section>
|
|
{allowed('correction_request')&&<section className="panel"><h2>Propor correcção de teste</h2><SmallForm submit="Submeter correcção para validação" disabled={disabled} onSubmit={v=>{const d={action:v.action,reason:v.reason}; if(v.action==='reduction')Object.assign(d,{amount:v.amount,document_ref:v.document_ref,line:'1'}); else if(v.action==='due')d.due_date=v.due_date||null; else if(v.action==='reverse_receipt')d.receipt_id=Number(v.receipt_id); else d.target_effect_id=Number(v.target_effect_id); return propose('correction',d);}}><label>Operação<select name="action">{Object.entries(actions).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select></label><label>Efeito exacto a reverter<select name="target_effect_id">{ob.effects.filter(e=>['allocate','reconcile'].includes(e.kind)).map(e=><option key={e.id} value={e.id}>#{e.id} · {effects[e.kind]} · {money(e.amount)}</option>)}</select></label><label>Recebimento para reversão integral<select name="receipt_id">{receipts.map(r=><option key={r.id} value={r.id}>#{r.id}</option>)}</select></label><label>Valor da redução (se aplicável)<input name="amount" placeholder="100.00"/></label><label>Documento redutor sintético (se aplicável)<input name="document_ref" placeholder="TEST-REDUCTION-..."/></label><label>Novo vencimento (se aplicável)<input name="due_date" type="date"/></label><label>Motivo explícito<textarea name="reason" required/></label><p className="help">Apenas os campos da operação escolhida serão enviados. Redução refere a linha 1 do documento original.</p></SmallForm></section>}
|
|
<section className="panel"><h2>Propostas de correcção</h2>{data.records.filter(r=>r.kind==='correction'&&r.obligation_id===ob.id).map(r=><article key={r.id}><h3>#{r.id} · {actions[r.data.action]}</h3><p>{r.data.reason} · Revisão proposta {r.data.revision} · {r.data.document_ref}</p>{pending(r)&&allowed('correction_approve')&&<Decision record={r} disabled={disabled} decide={decision=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision,reason:'Decisão explícita da correcção operacional sintética.'})}/>}</article>)}</section>
|
|
<section className="panel"><h2>Histórico imutável</h2>{ob.effects.map(e=><p key={e.id}>#{e.id} · {effects[e.kind]} · {money(e.amount)} MZN · Autor #{e.actor_id} · {e.created_at}{e.target_effect_id&&` · Reverte efeito #${e.target_effect_id}`}</p>)}</section>
|
|
</>}
|
|
</>}
|
|
</>;
|
|
}
|
|
function SmallForm({children,onSubmit,submit,disabled}) {return <form onSubmit={e=>{e.preventDefault();onSubmit(Object.fromEntries(new FormData(e.currentTarget)));}}>{children}<button className="primary" disabled={disabled}>{submit}</button></form>;}
|
|
function Decision({disabled,decide}) {return <div className="actions"><button className="primary" disabled={disabled} onClick={()=>decide('approved')}>Confirmar proposta</button><button disabled={disabled} onClick={()=>decide('refused')}>Recusar proposta</button></div>;}
|