Files
SGF/resources/js/FixedAssetsWorkspace.jsx
2026-11-11 15:04:01 +02:00

39 lines
16 KiB
React

import React,{useEffect,useState,useRef} from 'react';
const kinds={asset:'Cadastro e custódia',plan:'Planos mensais',charge:'Quotas por competência',disposal:'Abate sem contraprestação'};
const fields={asset:{code:'Código QA',description:'Descrição',origin_reference:'Referência da abertura artificial',cost:'Custo MZN (desconhecido se vazio)',residual:'Residual MZN (desconhecido se vazio)',available_on:'Entrada em uso',location:'Localização interna',custodian:'Custódia interna'},plan:{asset_key:'Activo',life_months:'Vida artificial em meses',status:'Estado do plano'},charge:{asset_key:'Activo',plan_key:'Plano escolhido',month:'Competência'},disposal:{asset_key:'Activo',effect_on:'Data do abate'}};
const money=v=>v===null||v===undefined?'Indeterminado':`${BigInt(v)/100n},${String(BigInt(v)%100n).padStart(2,'0')} MZN`;
const label=r=>r?.data?.input?.code??r?.data?.input?.month??`${kinds[r?.kind]??'Registo'} · v${r?.version??''}`;
export default function FixedAssetsWorkspace({api,context,can,perform,busy}){
const[kind,setKind]=useState('asset'),[page,setPage]=useState(1),[size,setSize]=useState(25),[search,setSearch]=useState(''),[list,setList]=useState(null),[selected,setSelected]=useState(null),[form,setForm]=useState({}),[reason,setReason]=useState(''),[cut,setCut]=useState(null),[key,setKey]=useState(crypto.randomUUID()),[domain,setDomain]=useState(''),[result,setResult]=useState(null),[error,setError]=useState(''),[ledger,setLedger]=useState(null),[ledgerPage,setLedgerPage]=useState(1),[history,setHistory]=useState(false);
const reads=useRef({list:0,ledger:0});
const locked=busy||Boolean(cut);const unit=context.unit?.id;const chosenAsset=selected?(selected.kind==='asset'?selected.record_key:selected.data.input.asset_key):null;
async function load(){const q=new URLSearchParams({kind,page,per_page:size,...(search?{search}:{}),...(history&&selected?{record_key:selected.record_key}:{})});const n=++reads.current.list;const out=await api('/api/fixed-assets?'+q);if(n===reads.current.list){if(out.kind!==kind||out.records.per_page!==Number(size)||!Array.isArray(out.records.data)||out.records.data.length>size)throw Error('Resposta de consulta incompatível; actualize.');setList(out);}}
async function loadLedger(){const n=++reads.current.ledger;if(chosenAsset){const out=await api('/api/fixed-assets/ledger?'+new URLSearchParams({asset_key:chosenAsset,page:ledgerPage,per_page:25}));if(n===reads.current.ledger)setLedger(out);}else setLedger(null);}
useEffect(()=>{setList(null);load().catch(e=>setError(e.message));},[context.token,kind,page,size,search,history]);
useEffect(()=>{loadLedger().catch(e=>setError(e.message));},[chosenAsset,ledgerPage]);
function reset(k=kind){reads.current.list++;reads.current.ledger++;setKind(k);setSelected(null);setForm({operation:'establish',status:'active'});setHistory(false);setPage(1);setCut(null);setResult(null);setError('');setLedger(null);setKey(crypto.randomUUID());}
function choose(r){reads.current.ledger++;setLedger(null);setSelected(r);setForm({...((r.effective??r).data.input),operation:'amend'});setCut(null);setResult(null);setLedgerPage(1);setKey(crypto.randomUUID());}
async function preview(data,type='admin'){await perform(async()=>{setError('');setResult(null);setDomain(type);const out=await api('/api/fixed-assets'+(type==='ledger'?'/ledger':'')+'/preview','POST',data,context.token,key);setCut(out);},'Confira o impacto individual antes de confirmar.');}
async function confirm(){await perform(async()=>{try{const out=await api('/api/fixed-assets'+(domain==='ledger'?'/ledger':'')+'/confirm','POST',{preview:cut.preview});setResult({...out,command_key:key});setCut(null);setKey(crypto.randomUUID());await load();await loadLedger();setSelected(null);setForm({});}catch(e){setError(e.message);throw e;}},'Decisão registada e auditada.');}
async function recover(){await perform(async()=>{const out=await api(domain==='ledger'?'/api/general-ledger/receipts/'+key:'/api/fixed-assets/receipts/'+key);setResult(out.result??out);setCut(null);await load();await loadLedger();},'Comprovativo consultado sem repetir a operação.');}
const action=data=>preview({action:'effect-prepare',data},'ledger');
const source=selected?.effective??selected;const input=source?.data.input;const proposedAsset=source?.kind==='asset'?source.record_key:input?.asset_key;
return <section className="fiscal-workspace"><p className="eyebrow">CONTABILIDADE</p><h1>Activos e depreciações internas QA</h1><p className="fiscal-notice">Exercício artificial. Cadastro, plano, quota e lançamento exigem decisões próprias. Sem aquisição, pagamento ou tratamento fiscal legal presumidos.</p>{error&&<p role="alert">{error}</p>}
<div className="actions">{Object.entries(kinds).map(([k,n])=><button key={k} disabled={locked} aria-pressed={kind===k} onClick={()=>reset(k)}>{n}</button>)}</div>
<section className="panel"><h2>{kinds[kind]}</h2><label>Pesquisar<input type="search" disabled={locked} value={search} onChange={e=>{setSearch(e.target.value);setPage(1);}}/></label><label>Registos por página<select disabled={locked} value={size} onChange={e=>{setSize(Number(e.target.value));setPage(1);}}><option>25</option><option>100</option></select></label><button disabled={locked} onClick={()=>perform(load,'Lista actualizada.')}>Actualizar</button><button disabled={locked||!can('fixed_assets.prepare',unit)} onClick={()=>{reset();setForm({operation:'establish',status:'active'});}}>Novo registo</button>
{list?.records.data.map(r=><article key={r.id}><button disabled={locked} onClick={()=>choose(r)}>{label(r)} · v{r.version} · {r.state==='prepared'?'Aguarda revisão':r.state==='approved'?'Revisto':'Recusado'}</button><p>{r.data.input.description??r.data.input.evidence}</p>{r.effective&&<p>Versão efectiva: {r.effective.version}</p>}</article>)}
{list&&<nav aria-label="Páginas dos activos"><button disabled={locked||page<=1} onClick={()=>setPage(page-1)}>Anterior</button><span>Página {page} de {list.records.last_page} · {list.records.total} registos</span><button disabled={locked||page>=list.records.last_page} onClick={()=>setPage(page+1)}>Seguinte</button></nav>}</section>
{selected&&<section className="panel"><h2>Versão seleccionada</h2><p>{label(selected)} · versão {selected.version} · autor #{selected.actor_id} · {selected.created_at}</p><p>{selected.reason}</p><button disabled={locked} onClick={()=>{setHistory(!history);setPage(1);}}>{history?'Voltar aos registos actuais':'Consultar histórico desta origem'}</button><dl>{Object.entries(selected.data.input).filter(([k])=>fields[kind][k]).map(([k,v])=><React.Fragment key={k}><dt>{fields[kind][k]}</dt><dd>{v===null?'Indeterminado':String(v)}</dd></React.Fragment>)}</dl>{selected.data.calculation?.complete&&<p>Quota: {money(selected.data.calculation.quota_cents)} · líquido previsto: {money(selected.data.calculation.carrying_cents)}</p>}</section>}
{can('fixed_assets.prepare',unit)&&<section className="panel"><h2>{selected?'Preparar nova versão':'Preparar registo'}</h2><form onSubmit={e=>{e.preventDefault();const d={operation:form.operation??'establish',evidence:form.evidence??''};for(const f of Object.keys(fields[kind]))d[f]=form[f]===''||form[f]===undefined?null:f==='life_months'?Number(form[f]):form[f];const recordKey=selected?.record_key??crypto.randomUUID();preview({action:'prepare',kind,record_key:recordKey,version:selected?.version??0,reason,data:d});}}><fieldset disabled={locked}>
<label>Operação<select value={form.operation??'establish'} onChange={e=>setForm({...form,operation:e.target.value})}><option value="establish">Criar</option><option value="amend">Alterar explicitamente</option>{kind==='plan'&&<option value="correct_plan">Plano para correcção de quota contabilizada</option>}{kind!=='plan'&&<option value="cancel">Cancelar sem apagar (se admissível)</option>}</select></label>
{Object.entries(fields[kind]).map(([f,n])=><label key={f}>{n}{f==='status'?<select value={form[f]??'active'} onChange={e=>setForm({...form,[f]:e.target.value})}><option value="active">Activo</option><option value="suspended">Suspenso</option></select>:['asset_key','plan_key'].includes(f)?<ReferencePicker api={api} kind={f==='asset_key'?'asset':'plan'} value={form[f]??''} onChange={v=>setForm({...form,[f]:v})}/>:<input type={f==='month'?'month':f.endsWith('_on')?'date':f==='life_months'?'number':'text'} min={f==='life_months'?1:undefined} max={f==='life_months'?600:undefined} value={form[f]??''} onChange={e=>setForm({...form,[f]:e.target.value})}/>}</label>)}<label>Evidência<textarea required minLength={10} maxLength={1000} value={form.evidence??''} onChange={e=>setForm({...form,evidence:e.target.value})}/></label><label>Fundamento da decisão<textarea required minLength={10} maxLength={2000} value={reason} onChange={e=>setReason(e.target.value)}/></label><button>Conferir preparação</button></fieldset></form></section>}
{selected?.state==='prepared'&&can('fixed_assets.review',unit)&&<section className="panel"><h2>Revisão independente</h2><label>Fundamento<textarea disabled={locked} minLength={10} maxLength={2000} value={reason} onChange={e=>setReason(e.target.value)}/></label>{['approved','refused'].map(decision=><button key={decision} disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'review',kind,record_key:selected.record_key,version:selected.version,reason,data:{decision}})}>{decision==='approved'?'Conferir aprovação':'Conferir recusa'}</button>)}</section>}
{source?.state==='approved'&&source.data.status!=='cancelled'&&source.kind!=='plan'&&can('general_ledger.prepare',unit)&&<section className="panel"><h2>Origem revista → diário comum</h2><label>Fundamento contabilístico<textarea disabled={locked} minLength={10} value={reason} onChange={e=>setReason(e.target.value)}/></label><button disabled={locked||reason.trim().length<10} onClick={()=>{const type={asset:'assets.admitted',charge:'assets.depreciation',disposal:'assets.writeoff'}[source.kind];const date=source.kind==='asset'?input.available_on:source.kind==='charge'?new Date(Date.UTC(Number(input.month.slice(0,4)),Number(input.month.slice(5,7)),0)).toISOString().slice(0,10):input.effect_on;action({effect_type:type,source_id:source.id,journal_on:date,period_key:'QA-2026-INTEGRADO',rule_key:'QA-ASSET-'+({asset:'ADMISSION',charge:'DEPRECIATION',disposal:ledger?.balance.accumulated_minor===0?'WRITEOFF-NO-DEP':'WRITEOFF'}[source.kind]),reason});}}>Conferir proposta contabilística desta origem</button><p>Preparar não aplica o lançamento. O diário exige outra revisão e aplicação explícita.</p></section>}
{ledger&&<section className="panel"><h2>Diário do activo seleccionado</h2><p>Custo {money(ledger.balance.cost_minor)} · acumulado {money(ledger.balance.accumulated_minor)} · líquido {money(ledger.balance.carrying_minor)} · {ledger.balance.disposed?'Abatido':'Sem abate aplicado'}</p><a href="/general-ledger">Abrir diário, balancete, razão e período</a>{ledger.records.data.map(p=><article key={p.id}><h3>Proposta #{p.id} · {p.snapshot.source.effect_type}</h3><p>{p.snapshot.journal_on} · {money(p.snapshot.impact.debit_minor)} · {p.journal?'Lançamento #'+p.journal.id:p.review?'Revista':'Aguarda revisão'}</p><JournalLines rows={p.snapshot.lines}/>{!p.journal&&!p.snapshot.source.effect_type.endsWith('.reversal')&&!p.snapshot.source.effect_type.endsWith('.replacement')&&<><label>Fundamento<textarea disabled={locked} value={reason} onChange={e=>setReason(e.target.value)}/></label>{!p.review&&can('general_ledger.validate',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'validate',data:{proposal_id:p.id,revision:p.revision,decision:'approved',reason}},'ledger')}>Conferir revisão contabilística</button>}{p.review?.decision==='approved'&&can('general_ledger.post',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'post',data:{proposal_id:p.id,revision:p.revision,reason}},'ledger')}>Conferir aplicação no diário</button>}</>}{p.journal&&p.snapshot.source.effect_type==='assets.depreciation'&&can('general_ledger.correct_prepare',unit)&&<><label>Data explícita da correcção<input disabled={locked} type="date" value={form.correction_on??''} onChange={e=>setForm({...form,correction_on:e.target.value})}/></label><button disabled={locked||!form.correction_on||reason.trim().length<10} onClick={()=>preview({action:'correct-prepare',data:{journal_id:p.journal.id,journal_on:form.correction_on,period_key:'QA-2026-INTEGRADO',reason}},'ledger')}>Conferir estorno e substituição</button></>}</article>)}{ledger.corrections?.map(g=><article key={'group'+g.id}><h3>Par de correcção #{g.id}</h3><p>{g.applied?'Aplicado integralmente':g.approved?'Revisto distintamente':'Aguarda revisão distinta'}</p>{!g.applied&&<><label>Fundamento do par<textarea disabled={locked} value={reason} onChange={e=>setReason(e.target.value)}/></label>{!g.approved&&can('general_ledger.correct_validate',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'correct-validate',data:{correction_id:g.id,revision:g.revision,decision:'approved',reason}},'ledger')}>Conferir revisão do par</button>}{g.approved&&can('general_ledger.correct_post',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'correct-post',data:{correction_id:g.id,revision:g.revision,reason}},'ledger')}>Conferir aplicação indivisível do par</button>}</>}</article>)}<button disabled={locked||ledgerPage<=1} onClick={()=>setLedgerPage(ledgerPage-1)}>Anterior</button><span>Página {ledgerPage} de {ledger.records.last_page}</span><button disabled={locked||ledgerPage>=ledger.records.last_page} onClick={()=>setLedgerPage(ledgerPage+1)}>Seguinte</button></section>}
{cut&&<section className="panel fiscal-review"><h2>Impacto antes da confirmação</h2>{domain==='admin'?<><p>Versão {cut.impact.before.version} → {cut.impact.after.version} · {cut.impact.after.state}</p><p>{cut.impact.after.proposal.input.evidence}</p>{cut.impact.after.proposal.calculation?.complete&&<p>Quota {money(cut.impact.after.proposal.calculation.quota_cents)} · líquido previsto {money(cut.impact.after.proposal.calculation.carrying_cents)}</p>}<p>Esta decisão administrativa cria zero lançamentos e zero pagamentos.</p></>:<>{cut.impact.snapshots.map((s,i)=><article key={i}><h3>{s.source.effect_type}</h3><p>{s.journal_on} · {money(s.impact.debit_minor)}</p><JournalLines rows={s.lines}/></article>)}<p>Estado proposto: {cut.impact.state} · lançamentos nesta operação: {cut.impact.journal_count}</p></>}<button disabled={busy} onClick={confirm}>Confirmar só este impacto</button><button disabled={busy} onClick={()=>{setCut(null);setKey(crypto.randomUUID());}}>Voltar à conferência</button><button disabled={busy} onClick={recover}>Recuperar comprovativo desta operação</button></section>}
{result&&<section className="panel"><h2>Comprovativo da operação</h2><p>Estado: {result.state} · {result.version?'versão '+result.version:''} · {result.journal_id?'lançamento #'+result.journal_id:''}</p><p>Operação {result.command_key??key}. O histórico conserva autor, data, fundamento e estados.</p></section>}
</section>;
}
function JournalLines({rows}){return <table><thead><tr><th>Conta QA</th><th>Débito</th><th>Crédito</th></tr></thead><tbody>{rows.map((l,i)=><tr key={i}><td>{l.account_code}</td><td>{money(l.debit_minor)}</td><td>{money(l.credit_minor)}</td></tr>)}</tbody></table>;}
function ReferencePicker({api,kind,value,onChange}){const[q,setQ]=useState(''),[page,setPage]=useState(1),[data,setData]=useState(null),[error,setError]=useState('');useEffect(()=>{let live=true;api('/api/fixed-assets?'+new URLSearchParams({kind,search:q,page,per_page:25})).then(v=>{if(live){setData(v.records);setError('');}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[kind,q,page]);return <><input aria-label="Pesquisar referência" value={q} onChange={e=>{setQ(e.target.value);setPage(1);}}/><select value={value} onChange={e=>onChange(e.target.value)}><option value="">Escolher explicitamente</option>{value&&!data?.data.some(r=>r.record_key===value)&&<option value={value}>Escolha conservada · {value}</option>}{data?.data.filter(r=>r.effective&&r.effective.data.status!=='cancelled').map(r=><option key={r.record_key} value={r.record_key}>{label(r.effective)} · versão {r.effective.version}</option>)}</select>{error&&<span role="alert">{error}</span>}<button type="button" disabled={page<=1} onClick={()=>setPage(page-1)}>Anteriores</button><button type="button" disabled={!data||page>=data.last_page} onClick={()=>setPage(page+1)}>Seguintes</button></>;}