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

30 lines
15 KiB
React

import React,{useEffect,useMemo,useState,useRef} from 'react';
import {fiscalMapDownload} from './statement-download.js';
import {WorkshopStockOperation} from './workshop-stock-operation.js';
import {checkedObligations,obligationsPageCsv,obligationLabels,decisionLabels} from './fiscal-obligations-view.js';
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
export function FiscalObligationRows({data,onSelect,disabled}){return <div className="table-scroll"><table><thead><tr><th>Registo / versão</th><th>Condição aprovada actual</th><th>Prazo / referência</th><th>Responsável / evidência</th><th>Autor e fundamento da versão</th><th>Conferir</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.record_key}<small>v{r.version} · {decisionLabels[r.state]} · {r.data.operation}</small></td><td>{obligationLabels[r.effective?.administrative_status??'unapproved']}<small>Mapa interno #{r.data.form_id} · {r.data.period}</small>{!r.current_source_matches&&<strong>Origem alterada: decisão bloqueada.</strong>}</td><td>{r.effective?.due_on??'Prazo desconhecido'}<small>{r.days_overdue===null?'Atraso indeterminado':r.days_overdue+' dia(s) à referência '+data.reference_on}</small></td><td>{r.effective?'QA'+r.effective.responsible_id:'Por aprovar'}<small>{r.effective?.evidence??'Preparação não equivale a aprovação.'}</small>{r.effective?.delivery_evidence&&<small>SHA da evidência interna: {r.effective.delivery_evidence.export_sha256}. Entrega apenas simulada.</small>}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={disabled} onClick={()=>onSelect(r)}>Escolher este registo</button></td></tr>)}</tbody></table>{!data.records.data.length&&<p>Nenhum registo corresponde ao âmbito/filtros.</p>}</div>;}
export default function FiscalObligationsWorkspace({context,api}){
const initial={reference_on:'',period:'',status:'',due_state:'',due_from:'',due_to:'',per_page:'25'},[filters,setFilters]=useState(initial),[query,setQuery]=useState(null),[data,setData]=useState(null),[selected,setSelected]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=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 operation=useMemo(()=>new WorkshopStockOperation(api),[context.token]),locked=busy||operation.uncertain;
useEffect(()=>{if(!query)return;let active=true;setData(null);setBusy(true);setError('');api('/api/fiscal-obligations?'+qs(query)).then(r=>{if(active)setData(checkedObligations(r,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,query]);
const choose=r=>{setSelected(r);setReceipt(null);setNotice('');};
function start(op,review=false,newRecord=false){const target=newRecord?null:selected;if(newRecord)setSelected(null);const source=data.source,e=target?.effective;setPreview(null);setError('');setForm({action:review?'review':'prepare',record_key:target?.record_key??crypto.randomUUID(),version:target?.version??0,reason:'',data:review?{decision:''}:{operation:op,form_id:source.form_id,period:source.period,responsible_id:e?.responsible_id??'',due_on:e?.due_on??'',evidence:'',export_sha256:''},unknown:review?false:Boolean(e&&e.due_on===null)});}
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const{unknown,...input}=form;if(input.action==='prepare')input.data={...input.data,responsible_id:Number(input.data.responsible_id),due_on:unknown?null:input.data.due_on,export_sha256:input.data.operation==='delivery'?input.data.export_sha256:null};const key=crypto.randomUUID(),p=await api('/api/fiscal-obligations/preview','POST',input,undefined,key);setPreview({...p,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await operation.retry():await operation.send('/api/fiscal-obligations/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice('v'+out.version+' · '+decisionLabels[out.state]+(out.replayed?' · recibo recuperado':''));setSelected(null);setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!operation.uncertain)setPreview(null);}finally{setBusy(false);}}
async function downloadMap(){const token=context.token;setBusy(true);setError('');try{const result=await fiscalMapDownload({id:data.source.form_id,token,sha256:data.source.export_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-mapa-interno-IVA-QA.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setNotice('Mapa interno descarregado e SHA verificado; sem submissão.');}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
const exportPage=()=>{const url=URL.createObjectURL(new Blob(['\uFEFF'+obligationsPageCsv(data,query)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-obrigacoes-QA-pagina-'+data.records.current_page+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
async function recover(){setBusy(true);setError('');try{setReceipt(await api('/api/fiscal-obligations/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
return <section className="panel" data-sgf-operation-uncertain={form||preview||locked?'true':undefined}><p className="eyebrow">CONTABILIDADE</p><h1>Obrigações e prazos administrativos QA</h1><p>Prazo artificial e entrega apenas simulada ligados ao mapa interno revisto. Sem prazo legal, submissão à AT, apuramento ou pagamento. A referência classifica o prazo; não reconstrói um estado histórico.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery({...filters,page:1});}}><fieldset disabled={locked||Boolean(form)}><div className="form-grid"><label>Referência para atraso<input type="date" required value={filters.reference_on} onChange={e=>setFilters({...filters,reference_on:e.target.value})}/></label><label>Período da origem<input type="month" value={filters.period} onChange={e=>setFilters({...filters,period:e.target.value})}/></label><label>Estado administrativo<select value={filters.status} onChange={e=>setFilters({...filters,status:e.target.value})}><option value="">Todos</option>{Object.entries(obligationLabels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Situação do prazo<select value={filters.due_state} onChange={e=>setFilters({...filters,due_state:e.target.value})}><option value="">Todas</option><option value="unknown">Desconhecido</option><option value="overdue">Passou a data</option><option value="not_overdue">Data ainda não ultrapassada</option></select></label><label>Prazo desde<input type="date" required={Boolean(filters.due_to)} value={filters.due_from} onChange={e=>setFilters({...filters,due_from:e.target.value})}/></label><label>Prazo até<input type="date" required={Boolean(filters.due_from)} min={filters.due_from||undefined} value={filters.due_to} onChange={e=>setFilters({...filters,due_to:e.target.value})}/></label><label>Por página<select value={filters.per_page} onChange={e=>setFilters({...filters,per_page:e.target.value})}><option value="25">25</option><option value="100">100</option></select></label></div><button>Consultar</button></fieldset></form>
{data&&<>{data.blocked&&<p role="alert">{data.blocked} Pode consultar o histórico; não há origem válida para novas decisões.</p>}{data.source&&<p>Origem disponível para escolha: mapa interno #{data.source.form_id} v{data.source.form_version}, período{data.source.period}, {data.source.document_reference}. SHA da exportação: <code>{data.source.export_sha256}</code>. <button disabled={locked||Boolean(form)} onClick={downloadMap}>Consultar exportação interna existente</button></p>}{data.capabilities.prepare&&data.source&&!query.record_key&&<button disabled={locked||Boolean(form)} onClick={()=>start('establish',false,true)}>Nova obrigação deste mapa</button>}
<p>Página{data.records.current_page} de{data.records.last_page} · {data.records.total} registo(s). O estado temporal do prazo é independente da entrega/cancelamento.</p><FiscalObligationRows data={data} onSelect={choose} disabled={locked||Boolean(form)}/><button disabled={locked||Boolean(form)||data.records.current_page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={locked||Boolean(form)||data.records.current_page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button><button disabled={locked||Boolean(form)} onClick={exportPage}>Exportar apenas esta página CSV</button>{query.record_key&&<button disabled={locked||Boolean(form)} onClick={()=>{setSelected(null);setQuery({...filters,page:1});}}>Voltar à lista actual</button>}
{selected&&<section><h2>Registo escolhido: {selected.record_key} · v{selected.version}</h2><p>{decisionLabels[selected.state]} · proposta: {selected.data.operation}, data{selected.data.due_on??'desconhecida'}, responsável QA{selected.data.responsible_id} · {selected.data.evidence}</p><p>Impacto registado nesta versão: antes v{selected.impact.before.version}, {obligationLabels[selected.impact.before.effective?.administrative_status??'unapproved']}, prazo{selected.impact.before.effective?.due_on??'desconhecido'}; depois v{selected.impact.after.version}, {obligationLabels[selected.impact.after.effective?.administrative_status??'unapproved']}, prazo{selected.impact.after.effective?.due_on??'desconhecido'}.</p><button disabled={locked||Boolean(form)} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.source&&selected.current_source_matches&&<>{data.capabilities.prepare&&selected.effective?.administrative_status!=='cancelled'&&<>{selected.effective?<><button disabled={locked||Boolean(form)} onClick={()=>start('amend')}>Preparar correcção</button><button disabled={locked||Boolean(form)} onClick={()=>start('cancel')}>Preparar cancelamento</button>{selected.effective.administrative_status!=='delivery_simulated'&&<button disabled={locked||Boolean(form)} onClick={()=>start('delivery')}>Declarar entrega apenas simulada</button>}</>:<button disabled={locked||Boolean(form)} onClick={()=>start('establish')}>Nova preparação deste registo</button>}</>}{data.capabilities.review&&selected.state==='prepared'&&<button disabled={locked||Boolean(form)} onClick={()=>start('',true)}>Rever preparação alheia</button>}</>}</section>}</>}
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='prepare'?'Preparação individual':'Revisão distinta'} · versão observada {form.version}</h2>{form.action==='review'?<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>:<><p>Operação: {form.data.operation} · mapa#{form.data.form_id} · período{form.data.period}.</p><label>Responsável QA<select required disabled={['cancel','delivery'].includes(form.data.operation)} value={form.data.responsible_id} onChange={e=>setForm(f=>({...f,data:{...f.data,responsible_id:Number(e.target.value)}}))}><option value="">Escolher…</option>{[13,14,15,16,17].map(id=><option key={id} value={id}>QA{id}</option>)}</select></label><label><input type="checkbox" disabled={['cancel','delivery'].includes(form.data.operation)} checked={form.unknown} onChange={e=>setForm(f=>({...f,unknown:e.target.checked}))}/>Prazo explicitamente desconhecido</label><label>Prazo artificial<input type="date" min="2000-01-01" max="2100-12-31" required={!form.unknown} disabled={form.unknown||['cancel','delivery'].includes(form.data.operation)} value={form.data.due_on} onChange={e=>setForm(f=>({...f,data:{...f.data,due_on:e.target.value}}))}/></label><label>Referência textual da evidência<textarea required minLength={10} maxLength={2000} value={form.data.evidence} onChange={e=>setForm(f=>({...f,data:{...f.data,evidence:e.target.value}}))}/></label>{form.data.operation==='delivery'&&<label>SHA escolhido da exportação interna<input required pattern="[a-f0-9]{64}" value={form.data.export_sha256} onChange={e=>setForm(f=>({...f,data:{...f.data,export_sha256: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</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário e prévia</button></form>}
{preview&&<section><h2>Impacto antes da confirmação</h2><p>Antes: v{preview.impact.before.version}, {obligationLabels[preview.impact.before.effective?.administrative_status??'unapproved']}, prazo{preview.impact.before.effective?.due_on??'desconhecido'}. Depois da decisão: v{preview.impact.after.version}, {decisionLabels[preview.impact.after.state]}, {obligationLabels[preview.impact.after.effective?.administrative_status??'unapproved']}, prazo{preview.impact.after.effective?.due_on??'desconhecido'}.</p><p>Proposta: {preview.impact.after.proposal.operation}, prazo{preview.impact.after.proposal.due_on??'desconhecido'}, responsável QA{preview.impact.after.proposal.responsible_id}, {preview.impact.after.proposal.evidence}.</p><p>Fundamento: {form.reason}. Zero dívida/pagamentos/lançamentos; sem submissão oficial.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria: <code>{preview.key}</code></p></section>}
{operation.uncertain&&<><p role="alert">Resultado incerto. Conserve corpo, chave e decisão; não inicie outra.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
<h2>Recibo próprio</h2><label>Chave da operação<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 v{receipt.result.version} · {decisionLabels[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Nenhuma operação foi executada nesta consulta.</p>}
</section>;
}