Files
SGF/resources/js/InternalStatementsWorkspace.jsx
T
2026-11-11 09:00:17 +02:00

32 lines
16 KiB
React

import React,{useEffect,useMemo,useState,useRef} from 'react';
import {statementDownload} from './statement-download.js';
import {WorkshopStockOperation} from './workshop-stock-operation.js';
import {minorAmount} from './ledger-report-view.js';
import {checkedStatements,statementCategories,statementStates} from './internal-statements-view.js';
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
export function StatementGroups({summary}){const cards=(currency,c,categories)=><dl className="overview-definitions">{categories.map(category=>{const g=c.groups[category];return <div key={category}><dt>{statementCategories[category]} · {g.accounts} conta(s)</dt><dd>{g.presentation_minor===null?'Sem conta observada':minorAmount(g.presentation_minor)+' '+currency}</dd>{g.inverted_accounts>0&&<small>{g.inverted_accounts} saldo(s) invertido(s), sem usar valor absoluto.</small>}</div>;})}</dl>;return <section><h2>Rubricas internas e pendências</h2><p>Capital próprio: indeterminado. Modelo legal: não. As rubricas sem conta observada são distintas de um saldo zero observado.</p>{!Object.keys(summary.currencies).length&&<p>Sem contas com movimentos na fonte deste intervalo. Não se fabricam rubricas com saldo zero.</p>}{Object.entries(summary.currencies).map(([currency,c])=><section key={currency}><h3>{currency}</h3><h4>Balanço interno parcial</h4>{cards(currency,c,['asset','liability'])}<p>Capital e equação patrimonial completa: indeterminados. Balanço completo: não.</p><h4>Resultados internos parciais</h4>{cards(currency,c,['income','expense'])}<p>Subtotal das rubricas de resultados classificadas: {minorAmount(c.known_result_subtotal_minor)} {currency}. Não é resultado da empresa.</p><p>Resultado completo: {c.complete_result_minor===null?'Indeterminado por classificação pendente':minorAmount(c.complete_result_minor)+' '+currency+' no recorte QA'}.</p><h4>Contas por classificar</h4>{cards(currency,c,['unclassified'])}<p>{c.unclassified_accounts} conta(s) por classificar; o saldo conserva o sinal original.</p></section>)}</section>;}
export function StatementLines({rows,dates}){return <div className="table-scroll"><table><thead><tr><th>Conta / rubrica</th><th>Abertura líquida</th><th>Débitos do período</th><th>Créditos do período</th><th>Fecho líquido original</th><th>Origem</th></tr></thead><tbody>{rows.map(r=><tr key={r.account_id}><td>{r.code}<small>{r.label} · {statementCategories[r.category]}</small></td>{['opening_net_minor','debit_minor','credit_minor','closing_net_minor'].map(k=><td key={k}>{minorAmount(String(r[k]))} {r.currency}</td>)}<td><a href={'/general-ledger/account-ledger?'+new URLSearchParams({...dates,account_id:r.account_id,per_page:25})}>Abrir razão desta conta e datas</a><a href="/general-ledger">Conferir diário e origens</a><small>ID da conta:{r.account_id}</small></td></tr>)}</tbody></table></div>;}
export default function InternalStatementsWorkspace({api,context}){
const[query,setQuery]=useState({view:'history',page:1,per_page:25}),[data,setData]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[accounts,setAccounts]=useState(null),[optionQuery,setOptionQuery]=useState({view:'accounts',page:1,per_page:25,search:''}),[search,setSearch]=useState(''),[accountId,setAccountId]=useState(''),[category,setCategory]=useState(''),[receiptKey,setReceiptKey]=useState(''),[receipt,setReceipt]=useState(null);
const mounted=useRef(true),generation=useRef(context.token);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);
const command=useMemo(()=>new WorkshopStockOperation(api),[context.token]),locked=busy||command.uncertain;
useEffect(()=>{let active=true;setData(null);setBusy(true);setError('');api('/api/general-ledger/statements?'+qs(query)).then(r=>{if(active)setData(checkedStatements(r,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,query]);
useEffect(()=>{if(form?.kind!=='mapping'||form.action!=='prepare')return;let active=true;setAccounts(null);api('/api/general-ledger/statements?'+qs(optionQuery)).then(r=>{if(active)setAccounts(checkedStatements(r,optionQuery).records);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[context.token,optionQuery,form?.kind,form?.action]);
function start(kind,action){setPreview(null);setError('');setReceipt(null);const latest=kind==='mapping'?data.mapping_latest:data.result_latest;setForm({kind,action,version:latest?.version??0,reason:'',data:action==='review'?{decision:''}:kind==='mapping'?{entries:(data.mapping_approved?.data.entries??[]).map(e=>({account_id:e.account_id,category:e.category})),evidence:''}:{mapping_id:'',from_on:'',to_on:'',per_page:25,evidence:''}});setAccountId('');setCategory('');}
function addEntry(){const id=Number(accountId);if(!Number.isSafeInteger(id)||!category||!accounts?.data.some(a=>a.id===id))return;setForm(f=>({...f,data:{...f.data,entries:[...f.data.entries.filter(e=>e.account_id!==id),{account_id:id,category}]}}));setAccountId('');setCategory('');}
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input=structuredClone(form);if(input.kind==='result'&&input.action==='prepare'){input.data.mapping_id=Number(input.data.mapping_id);input.data.per_page=Number(input.data.per_page);}const key=crypto.randomUUID(),out=await api('/api/general-ledger/statements/preview','POST',input,undefined,key);setPreview({...out,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await command.retry():await command.send('/api/general-ledger/statements/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice(out.kind+' v'+out.version+' · '+statementStates[out.state]+(out.replayed?' · recibo recuperado':''));setForm(null);setPreview(null);setQuery({view:'history',page:1,per_page:query.per_page});}catch(e){setError(e.message);if(!command.uncertain)setPreview(null);}finally{setBusy(false);}}
async function recover(){setBusy(true);setError('');try{setReceipt(await api('/api/general-ledger/statements/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
async function download(){const token=context.token;setBusy(true);setError('');try{const result=await statementDownload({id:query.version_id,token,sha256:data.snapshot.csv_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([result.bytes],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-demonstracoes-internas-QA.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setNotice('CSV congelado descarregado e SHA verificado.');}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
const kindLabel=k=>k==='mapping'?'Correspondências':'Resultado interno';
return <section className="panel" data-sgf-operation-uncertain={form||preview||locked?'true':undefined}><p className="eyebrow">CONTABILIDADE</p><h1>Demonstrações internas parciais QA</h1><p>Balanço e resultados por correspondências explícitas revistas. Sem classificação automática de contas, capital fabricado ou modelo legal. Os lançamentos originais não são alterados.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
{data&&<><div className="actions"><button disabled={locked||Boolean(form)} onClick={()=>setQuery({view:'history',page:1,per_page:query.per_page})}>Histórico de versões</button>{data.capabilities.prepare&&<><button disabled={locked||Boolean(form)} onClick={()=>start('mapping','prepare')}>Preparar correspondências explícitas</button><button disabled={locked||Boolean(form)||!data.mapping_approved} onClick={()=>start('result','prepare')}>Preparar resultado com datas explícitas</button></>}{data.capabilities.review&&['mapping','result'].map(kind=>{const latest=kind==='mapping'?data.mapping_latest:data.result_latest;return latest?.state==='prepared'&&<button key={kind} disabled={locked||Boolean(form)} onClick={()=>start(kind,'review')}>Rever {kindLabel(kind).toLowerCase()} v{latest.version}</button>;})}</div>
{query.view==='result'&&data.snapshot?<><p>Resultado conservado da preparação v{data.snapshot.result_version}, {data.snapshot.frozen_at}, de{data.snapshot.summary.query.from_on} até{data.snapshot.summary.query.to_on}; correspondências v{data.snapshot.summary.mapping_version}. SHA:<code>{data.snapshot.csv_sha256}</code>.</p><p>{data.current_source_matches?'A fonte actual confere com este resultado conservado.':'A fonte/correspondência actual difere; este resultado permanece histórico e não foi recalculado.'}</p><StatementGroups summary={data.snapshot.summary}/><StatementLines rows={data.records.data} dates={data.snapshot.summary.query}/><button disabled={locked||Boolean(form)} onClick={download}>Descarregar CSV congelado desta revisão</button></>:<div className="table-scroll"><table><thead><tr><th>Tipo / versão / estado</th><th>Autor / data / fundamento</th><th>Dados conservados</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{kindLabel(r.kind)} v{r.version}<small>{statementStates[r.state]}</small></td><td>QA{r.host_actor_id} · {r.created_at}<small>{r.reason}</small></td><td>{r.kind==='mapping'?<details><summary>Ver todas as correspondências desta versão</summary><ul>{r.data.entries.map(e=><li key={e.account_id}>{e.account.code} · {statementCategories[e.category]}</li>)}</ul></details>:<><p>{r.data.summary.query.from_on} a{r.data.summary.query.to_on} · {r.data.summary.accounts} contas da fonte · {r.data.evidence}</p>{r.state==='approved'&&<button disabled={locked||Boolean(form)} onClick={()=>setQuery({view:'result',version_id:r.id,page:1,per_page:query.per_page})}>Abrir resultado revisto desta versão</button>}</>}</td></tr>)}</tbody></table></div>}
<p>Página{data.records.current_page} de{data.records.last_page} · {data.records.total} linha(s). Totais do resultado conservado abrangem todas as páginas da fonte; a tabela é paginada.</p><button disabled={locked||Boolean(form)||data.records.current_page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior</button><button disabled={locked||Boolean(form)||data.records.current_page>=data.records.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte</button><label>Por página<select disabled={locked||Boolean(form)} value={query.per_page} onChange={e=>setQuery(q=>({...q,per_page:Number(e.target.value),page:1}))}><option value="25">25</option><option value="100">100</option></select></label></>}
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='prepare'?'Preparação':'Revisão distinta'} · {kindLabel(form.kind)} · versão observada{form.version}</h2>{form.action==='review'?<><p>Confira os dados completos da preparação no histórico antes de escolher a decisão. A prévia apresenta os mesmos dados e o impacto.</p><label>Decisão<select required value={form.data.decision} onChange={e=>setForm(f=>({...f,data:{decision:e.target.value}}))}><option value="">Escolher…</option><option value="approved">Aprovar</option><option value="refused">Recusar</option></select></label></>:<>{form.kind==='mapping'?<><p>Não há rubrica automática para conta nova. As escolhas da revisão anterior, quando existem, são apenas a base explícita desta correcção.</p><label>Pesquisar código/designação<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>{setAccountId('');setOptionQuery(q=>({...q,search,page:1}));}}>Pesquisar contas</button><label>Conta observada<select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="">Escolher…</option>{accounts?.data.map(a=><option key={a.id} value={a.id}>{a.code} · {a.label}</option>)}</select></label><label>Rubrica explícita<select value={category} onChange={e=>setCategory(e.target.value)}><option value="">Escolher…</option>{Object.entries(statementCategories).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><button type="button" disabled={!accountId||!category||form.data.entries.length>=100} onClick={addEntry}>Adicionar/substituir esta correspondência</button>{accounts&&<><button type="button" disabled={accounts.current_page<=1} onClick={()=>{setAccountId('');setOptionQuery(q=>({...q,page:q.page-1}));}}>Contas anteriores</button><button type="button" disabled={accounts.current_page>=accounts.last_page} onClick={()=>{setAccountId('');setOptionQuery(q=>({...q,page:q.page+1}));}}>Contas seguintes</button></>}<ul>{form.data.entries.map(e=><li key={e.account_id}>Conta#{e.account_id} · {statementCategories[e.category]} <button type="button" onClick={()=>setForm(f=>({...f,data:{...f.data,entries:f.data.entries.filter(v=>v.account_id!==e.account_id)}}))}>Retirar desta proposta</button></li>)}</ul></>:<><label>Versão revista de correspondências<select required value={form.data.mapping_id} onChange={e=>setForm(f=>({...f,data:{...f.data,mapping_id:e.target.value}}))}><option value="">Escolher…</option>{data.mapping_approved&&<option value={data.mapping_approved.id}>Versão{data.mapping_approved.version}</option>}</select></label>{['from_on','to_on'].map(k=><label key={k}>{k==='from_on'?'Desde':'Até'}<input type="date" required value={form.data[k]} onChange={e=>setForm(f=>({...f,data:{...f.data,[k]:e.target.value}}))}/></label>)}<label>Página de leitura da fonte<select value={form.data.per_page} onChange={e=>setForm(f=>({...f,data:{...f.data,per_page:Number(e.target.value)}}))}><option value="25">25</option><option value="100">100</option></select></label></>}<label>Evidência/pressupostos<textarea required minLength={10} maxLength={2000} value={form.data.evidence} onChange={e=>setForm(f=>({...f,data:{...f.data,evidence:e.target.value}}))}/></label></>}<label>Fundamento individual<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm(f=>({...f,reason:e.target.value}))}/></label><button>Conferir impacto antes de confirmar</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário e prévia</button></form>}
{preview&&<section><h2>Impacto a confirmar</h2><p>Antes v{preview.impact.before.version} · {statementStates[preview.impact.before.state]??'Sem versão'}; depois v{preview.impact.after.version} · {statementStates[preview.impact.after.state]}. Zero lançamentos e contas alterados. Fundamento:{form.reason}.</p>{preview.impact.kind==='mapping'?<ul>{preview.impact.data.entries.map(e=><li key={e.account_id}>{e.account.code} · {statementCategories[e.category]}</li>)}</ul>:<><StatementGroups summary={preview.impact.data.summary}/><p>SHA da exportação congelada:{preview.impact.data.csv_sha256}</p></>}<button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria:<code>{preview.key}</code></p></section>}
{command.uncertain&&<><p role="alert">Resultado incerto. Conserve chave e decisão; não inicie outra operação.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
<h2>Recibo próprio</h2><label>Chave<input disabled={locked||Boolean(form)} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={locked||Boolean(form)||!receiptKey} onClick={recover}>Consultar recibo sem executar</button>{receipt&&<p>Recibo histórico {receipt.result.kind} v{receipt.result.version} · {statementStates[receipt.result.state]} · {receipt.created_at}. {receipt.reason}. Sem execução nesta consulta.</p>}
</section>;
}