commit melhorias1

This commit is contained in:
2026-11-11 15:04:01 +02:00
parent 1d11a5e57b
commit cd9897df05
145 changed files with 10950 additions and 84 deletions
+1
View File
@@ -0,0 +1 @@
.multi-invoice-layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:20px;align-items:start}.multi-invoice-totals{position:sticky;top:12px;margin-top:0}.multi-invoice-totals dl{display:grid;grid-template-columns:1fr auto;gap:12px}.multi-invoice-totals dd{margin:0;text-align:right;font-variant-numeric:tabular-nums}.multi-total{font-size:1.35rem;font-weight:700}.multi-line-fields{display:grid;grid-template-columns:minmax(160px,2fr) 90px minmax(100px,1fr) minmax(120px,1fr) auto;gap:12px;align-items:end}.multi-reference{min-width:0;margin:12px 0}.multi-reference .actions{display:flex;flex-wrap:wrap;gap:8px}.multi-reference select{width:100%;max-width:100%}.multi-invoice-workspace fieldset{min-width:0}.multi-invoice-workspace nav{display:flex;gap:12px;align-items:center;margin-top:14px}.multi-invoice-workspace .multi-line-fields button{margin-bottom:0}.multi-invoice-workspace .multi-invoice-totals{overflow-wrap:anywhere}@media(max-width:1000px){.multi-invoice-layout{grid-template-columns:minmax(0,1fr)}.multi-invoice-totals{position:static}.multi-line-fields{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.multi-line-fields{grid-template-columns:minmax(0,1fr)}}
+38
View File
@@ -0,0 +1,38 @@
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></>;}
@@ -0,0 +1,49 @@
import React,{useEffect,useMemo,useRef,useState}from'react';
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
const base='/api/multiline-invoices',policy='SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1';
const fresh=()=>({draft_id:null,version:0,source_version:0,preparation_version:0,name:'',client_id:'',client_name:'',currency:'MZN',price_mode:'',service_date:'',period_start:'',period_end:'',proposed_due_date:'',payment_terms:'',lines:[{service_id:'',service_name:'',description:'',quantity:'',unit_price:'',unit:''}],reason:''});
export function checkedInvoiceOptions(v,kind,page,size){if(v.kind!==kind||v.records?.current_page!==page||v.records.per_page!==size||!Array.isArray(v.records.data)||v.records.data.length>size)throw Error('Resposta de outra consulta/página. Actualize.');return v.records;}
const money=v=>v===null||v===undefined?'A conferir':`${String(v).replace('.',',')} MZN`;
export function InvoiceTotals({result,arithmetic=null,mode}){return <aside className="multi-invoice-totals panel" aria-label="Totais do documento"><h2>Totais do cálculo seleccionado</h2><dl><dt>Base documental</dt><dd>{money(result?.base_document)}</dd><dt>Imposto documental</dt><dd>{money(result?.tax_document)}</dd><dt>Total</dt><dd className="multi-total">{money(result?.total)}</dd></dl>{!result&&<p>{arithmetic!==null?`${mode==='included'?'Bruto':'Base aritmética'} proposta: ${arithmetic} MZN. `:''}Escolha e confira a regra aplicável; imposto desconhecido não é zero.</p>}{result&&<p>Valores documentais das linhas somados. {result.price_mode==='gross_included'?'Preço com imposto incluído.':'Preço sem imposto; imposto acrescido.'}</p>}</aside>;}
export function InvoiceReferencePicker({api,kind,value,label,onChange,disabled=false,draftId=null,filter}){
const[q,setQ]=useState({page:1,search:''}),[search,setSearch]=useState(''),[data,setData]=useState(null),[error,setError]=useState('');useEffect(()=>{let live=true;if(disabled)return;setData(null);api(base+'/options?'+new URLSearchParams({kind,page:q.page,per_page:25,...(q.search?{search:q.search}:{}),...(draftId?{draft_id:draftId}:{})})).then(v=>{if(live){setData(checkedInvoiceOptions(v,kind,q.page,25));setError('');}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[q,kind,draftId,disabled]);const rows=(data?.data??[]).filter(filter??(()=>true));return <fieldset disabled={disabled} className="multi-reference"><legend>{label}</legend><div className="actions" data-sgf-read-filters="true"><input aria-label={'Pesquisar '+label} value={search} onChange={e=>setSearch(e.target.value)}/><button type="button" onClick={()=>setQ({page:1,search})}>Pesquisar</button></div><select aria-label={label} value={value??''} onChange={e=>onChange(e.target.value,rows.find(r=>String(r.id)===e.target.value))}><option value="">Escolher explicitamente…</option>{value&&!rows.some(r=>String(r.id)===String(value))&&<option value={value}>Escolha conservada #{value}</option>}{rows.map(r=><option key={r.id} value={r.id}>{r.name??r.identifier??r.rule_key} {r.valid_from?`· ${r.valid_from} a ${r.valid_until??'por definir'}`:''}{r.rule_version?' · v'+r.rule_version:r.version?' · v'+r.version:''}</option>)}</select>{error&&<p role="alert">{error}</p>}{data&&<div className="actions"><button type="button" disabled={q.page<=1} onClick={()=>setQ({...q,page:q.page-1})}>Anteriores</button><span>Página {q.page}/{data.last_page}</span><button type="button" disabled={q.page>=data.last_page} onClick={()=>setQ({...q,page:q.page+1})}>Seguintes</button></div>}</fieldset>;
}
export default function MultiLineInvoiceWorkspace({api,context,can,navigate,markClean=()=>{},actor}){
const[query,setQuery]=useState({page:1,per_page:25,search:''}),[search,setSearch]=useState(''),[list,setList]=useState(null),[frame,setFrame]=useState(null),[form,setForm]=useState(fresh),[dirty,setDirty]=useState(false),[intentDirty,setIntentDirty]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[cut,setCut]=useState(null),[key,setKey]=useState(crypto.randomUUID()),[selectedRule,setSelectedRule]=useState(''),[calculations,setCalculations]=useState([]),[selectedCalc,setSelectedCalc]=useState(null),[temporals,setTemporals]=useState([]),[selectedTemporal,setSelectedTemporal]=useState(null),[series,setSeries]=useState(''),[decisionReason,setDecisionReason]=useState(''),[windowDates,setWindowDates]=useState({from:'',to:''}),[receipt,setReceipt]=useState(null);
const op=useMemo(()=>new WorkshopStockOperation(api),[context.token]),formRef=useRef(null),rootRef=useRef(null),reads=useRef({list:0,editor:0,extras:0});const unit=context.unit?.id,locked=busy||op.uncertain||Boolean(cut),edit=can('service_drafts.'+(frame?'edit':'create'),unit)&&can('billing_drafts.'+(frame?'edit':'create'),unit)&&can('draft_documents.edit',unit)&&!frame?.invoice;
const protect=locked||dirty||intentDirty;
const scopedResult=frame?.invoice?.totals??(!dirty&&frame&&selectedCalc&&selectedCalc.draft_version===frame?.billing.version&&selectedCalc?.preparation_version===frame?.preparation?.version?selectedCalc.result:null);
async function load(){const n=++reads.current.list;const v=await api(base+'/options?'+new URLSearchParams({kind:'drafts',...query}));if(n===reads.current.list)setList(checkedInvoiceOptions(v,'drafts',query.page,query.per_page));}
async function extras(id){const n=++reads.current.extras;const paths=[api(base+'/options?kind=calculations&draft_id='+id+'&page=1&per_page=100'),can('service_invoices.view',unit)?api(base+'/options?kind=temporals&draft_id='+id+'&page=1&per_page=100'):Promise.resolve(null)];const results=await Promise.all(paths);if(n!==reads.current.extras)return;setCalculations(checkedInvoiceOptions(results[0],'calculations',1,100).data);if(results[1])setTemporals(checkedInvoiceOptions(results[1],'temporals',1,100).data);return{calculations:results[0].records.data,temporals:results[1]?.records.data??[]};}
async function open(id){const n=++reads.current.editor;setError('');const v=await api(base+'/drafts/'+id);if(n!==reads.current.editor)return;if(v.policy!==policy||v.billing.id!==id)throw Error('Resposta de outra origem.');const p=v.preparation?.data??{};setFrame(v);setForm({draft_id:id,version:v.billing.version,source_version:v.source.version,preparation_version:v.preparation?.version??0,name:v.billing.name,client_id:v.billing.client_id,client_name:v.billing.data.client_name,currency:v.billing.data.currency??'',price_mode:p.price_mode==='undefined'?'':p.price_mode??'',service_date:p.service_date??'',period_start:p.period_start??'',period_end:p.period_end??'',proposed_due_date:p.proposed_due_date??'',payment_terms:p.payment_terms??'',lines:v.billing.data.lines.map((l,i)=>({...l,unit:p.units?.[i]??''})),reason:''});setDirty(false);setIntentDirty(false);setDecisionReason('');setSelectedRule('');setSeries('');setCut(null);setSelectedCalc(null);setSelectedTemporal(null);setReceipt(null);setKey(crypto.randomUUID());setWindowDates({from:v.server_date??'',to:v.server_date??''});markClean(rootRef.current);await extras(id);}
useEffect(()=>{load().catch(e=>setError(e.message));},[query,context.token]);
async function work(fn){if(busy)return;setBusy(true);setError('');try{await fn();}catch(e){setError(e.message);}finally{setBusy(false);}}
function change(k,v){setForm(f=>({...f,[k]:v}));setDirty(true);setCut(null);setSelectedCalc(null);setSelectedTemporal(null);setReceipt(null);}
function line(i,k,v){setForm(f=>({...f,lines:f.lines.map((l,n)=>n===i?{...l,[k]:v}:l)}));setDirty(true);setSelectedCalc(null);setSelectedTemporal(null);setReceipt(null);}
function reset(){reads.current.editor++;reads.current.extras++;setFrame(null);setForm(fresh());setDirty(false);setIntentDirty(false);setDecisionReason('');setSelectedRule('');setCut(null);setSelectedCalc(null);setSelectedTemporal(null);setCalculations([]);setTemporals([]);setReceipt(null);setSeries('');setKey(crypto.randomUUID());markClean(rootRef.current);}
async function previewDraft(e){e.preventDefault();await work(async()=>{const data={...form};delete data.client_name;data.price_mode=data.price_mode||'undefined';for(const f of['period_start','period_end'])data[f]=data[f]||null;data.client_id=Number(data.client_id);data.lines=data.lines.map(({service_id,description,quantity,unit_price,unit})=>({service_id:Number(service_id),description:description||null,quantity:quantity===''?null:quantity,unit_price:unit_price===''?null:unit_price,unit:unit||null}));for(const k of['service_date','proposed_due_date','payment_terms'])data[k]=data[k]||null;const v=await api(base+'/drafts/preview','POST',data,context.token,key);setCut({domain:'drafts',...v});});}
async function previewCalculation(){await work(async()=>{const body={draft_id:frame.billing.id,draft_version:frame.billing.version,preparation_version:frame.preparation.version,rule_event_id:Number(selectedRule),reason:decisionReason};const v=await api(base+'/calculations/preview','POST',body,context.token,key);setCut({domain:'calculations',...v});});}
async function complete(result,domain){result={...result,command_key:op.operation?.key};setIntentDirty(false);setReceipt(result);setCut(null);setKey(crypto.randomUUID());if(domain==='drafts'){await open(result.id);setReceipt(result);await load();setNotice('Rascunho e preparação guardados atomicamente; sem emissão.');}else if(domain==='calculations'){await extras(frame.billing.id);if(result.draft_id!==frame.billing.id)throw Error('Comprovativo de outro rascunho.');setSelectedCalc(result);setNotice('Cálculo preservado. A emissão ainda exige revisão temporal e confirmação.');}else if(domain==='issue'){const id=frame.billing.id;await open(id);setReceipt(result);setNotice('Documento de teste emitido uma vez. Recebível e Contabilidade mantêm as suas decisões.');}else{const next=await extras(frame.billing.id);setSelectedTemporal(next?.temporals.find(t=>t.id===result.id)??null);setNotice('Decisão temporal registada. Seleccione explicitamente a versão actual.');}markClean(rootRef.current);}
async function confirm(){await work(async()=>{const c=cut;if(c.domain==='issue'){const result=await op.send(base+'/drafts/'+frame.billing.id+'/issue',c.body,key);await complete(result,'issue');}else{const result=await op.send(base+'/'+c.domain+'/confirm',{preview:c.preview},key);await complete(result,c.domain);}});}
async function recover(){await work(async()=>{const result=await op.retry();const domain=op.operation.path.endsWith('/issue')?'issue':op.operation.path.includes('/drafts/confirm')?'drafts':op.operation.path.includes('/calculations/confirm')?'calculations':'temporal';await complete(result,domain);});}
async function prepareTemporal(){await work(async()=>{const data={calculation_policy:policy,common_calculation_id:selectedCalc.id,common_rule_event_id:selectedCalc.rule_event_id??Number(selectedRule),draft_id:frame.billing.id,draft_version:frame.billing.version,preparation_version:frame.preparation.version,valid_from:windowDates.from,valid_until:windowDates.to,period_start:form.period_start,period_end:form.period_end,issue_window_start:windowDates.from,issue_window_end:windowDates.to,event:'on_issue_within_deadline',readiness_basis:decisionReason,evidence:decisionReason};const body={kind:'temporal',identifier:selectedTemporal?.identifier??'MULTI-DRAFT-'+frame.billing.id,...(selectedTemporal?{previous_id:selectedTemporal.id}:{}),data};const result=await op.send(base+'/temporals',body,crypto.randomUUID());await complete(result,'temporal');});}
async function reviewTemporal(decision){await work(async()=>{const result=await op.send(base+'/temporals/'+selectedTemporal.id+'/decision',{version:selectedTemporal.version,decision,reason:decisionReason},crypto.randomUUID());await complete(result,'temporal');});}
async function readiness(){await work(async()=>{const r=await api(base+'/drafts/'+frame.billing.id+'/readiness','POST',{series_id:Number(series),rule_id:selectedTemporal.id});if(!r.ready){setError(r.payload.blocks.join(' '));return;}setKey(crypto.randomUUID());setCut({domain:'issue',impact:r.payload,body:{series_id:Number(series),rule_id:selectedTemporal.id,review_hash:r.review_hash}});});}
return <section className="multi-invoice-workspace" ref={rootRef} data-sgf-operation-uncertain={protect?'true':undefined}><p className="eyebrow">COMERCIAL · FACTURAÇÃO</p><h1>Documento com várias linhas</h1><p className="fiscal-notice"><strong>TESTE — SEM VALIDADE FISCAL.</strong> Guardar, calcular, rever e emitir são decisões distintas. Sem recebimento ou lançamento automático.</p>{error&&<p role="alert" className="alert error">{error}</p>}{notice&&<p role="status">{notice}</p>}
<section className="panel"><h2>Rascunhos deste percurso</h2><form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setQuery({...query,page:1,search});}}><div className="actions"><label>Pesquisar documento<input disabled={locked} type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Por página<select disabled={locked} value={query.per_page} onChange={e=>setQuery({...query,page:1,per_page:Number(e.target.value)})}><option>25</option><option>100</option></select></label><button disabled={locked}>Pesquisar</button><button type="button" disabled={locked||dirty||intentDirty} onClick={reset}>Novo documento</button></div></form>{list?.data.map(d=><button key={d.id} disabled={locked||dirty||intentDirty} onClick={()=>work(()=>open(d.id))}>{d.name} · v{d.version}</button>)}{list&&<nav aria-label="Páginas dos documentos"><button disabled={locked||query.page<=1} onClick={()=>setQuery({...query,page:query.page-1})}>Anterior</button><span>Página {list.current_page}/{list.last_page}</span><button disabled={locked||query.page>=list.last_page} onClick={()=>setQuery({...query,page:query.page+1})}>Seguinte</button></nav>}</section>
<div className="multi-invoice-layout"><div><form ref={formRef} onSubmit={previewDraft}><fieldset disabled={locked||!edit}><legend>{frame?'Rascunho #'+frame.billing.id+' · versão '+frame.billing.version:'Novo rascunho'}</legend><section className="panel"><h2>Cabeçalho</h2><div className="form-grid"><label>Designação<input required value={form.name} onChange={e=>change('name',e.target.value)} maxLength={255}/></label><label>Moeda<select required value={form.currency} onChange={e=>change('currency',e.target.value)}><option value="MZN">MZN</option></select></label><label>Modalidade de preço<select value={form.price_mode} onChange={e=>change('price_mode',e.target.value)}><option value="">Escolher…</option><option value="added">Sem imposto, acrescido</option><option value="included">Com imposto incluído</option></select></label></div><InvoiceReferencePicker api={api} kind="clients" value={form.client_id} label="Cliente" disabled={locked||!edit} onChange={(v,r)=>{change('client_id',v);setForm(f=>({...f,client_name:r?.name??''}));}}/>{frame?.parties?.client&&String(form.client_id)===String(frame.billing.client_id)&&<p>NUIT: {frame.parties.client.data.nuit??'Por preencher'} · Morada: {frame.parties.client.data.address??'Por preencher'}</p>}<div className="form-grid">{[['service_date','Data do serviço'],['period_start','Período desde'],['period_end','Período até'],['proposed_due_date','Vencimento proposto']].map(([f,l])=><label key={f}>{l}<input type="date" value={form[f]} onChange={e=>change(f,e.target.value)}/></label>)}</div><label>Condições de pagamento<textarea value={form.payment_terms} onChange={e=>change('payment_terms',e.target.value)} maxLength={1000}/></label></section>
<section className="panel"><h2>Linhas do documento</h2>{form.lines.map((l,i)=><fieldset key={i}><legend>Linha {i+1}</legend><InvoiceReferencePicker api={api} kind="services" value={l.service_id} label={'Serviço da linha '+(i+1)} disabled={locked||!edit} onChange={(v,r)=>{line(i,'service_id',v);setForm(f=>({...f,lines:f.lines.map((x,n)=>n===i?{...x,service_name:r?.name??''}:x)}));}}/><div className="multi-line-fields"><label>Descrição<input value={l.description??''} onChange={e=>line(i,'description',e.target.value)} maxLength={2000}/></label><label>Unidade<input value={l.unit??''} onChange={e=>line(i,'unit',e.target.value)} maxLength={80}/></label><label>Quantidade<input inputMode="decimal" value={l.quantity??''} onChange={e=>line(i,'quantity',e.target.value)}/></label><label>Preço {form.price_mode==='included'?'com imposto':'sem imposto'}<input inputMode="decimal" value={l.unit_price??''} onChange={e=>line(i,'unit_price',e.target.value)}/></label><button type="button" disabled={form.lines.length<=1} onClick={()=>change('lines',form.lines.filter((_,n)=>n!==i))}>Retirar linha</button></div></fieldset>)}<button type="button" disabled={form.lines.length>=25} onClick={()=>change('lines',[...form.lines,{service_id:'',service_name:'',description:'',quantity:'',unit_price:'',unit:''}])}>Adicionar linha</button><p>{form.lines.length} de25 linhas. Preços e quantidades em decimais exactos, com ponto.</p><label>Fundamento da preparação<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>change('reason',e.target.value)}/></label><div className="actions"><button className="primary">Conferir rascunho e preparação</button><button type="button" onClick={()=>{if(frame)work(()=>open(frame.billing.id));else reset();}}>Descartar alterações locais</button></div></section></fieldset></form>{!edit&&<p className="help">Consulta da origem. A alteração exige os direitos próprios de preparação; documentos emitidos conservam a fotografia.</p>}
{frame?.invoice&&<section className="panel"><h2>{frame.invoice.series_code} / {frame.invoice.number}</h2><a href={'/api/service-invoices/'+frame.invoice.id+'/pdf'}>Descarregar o PDF preservado</a><button disabled={locked} onClick={()=>work(async()=>{const d=await api('/api/service-invoices/'+frame.invoice.id);setReceipt({...d,type:'invoice'});})}>Consultar fotografia e histórico</button>{can('service_receivables.view',unit)&&<button disabled={locked} onClick={()=>navigate('service_receivables')}>Continuar no Financeiro</button>}{can('general_ledger.view',unit)&&<button disabled={locked} onClick={()=>navigate('accounting_panel')}>Consultar o diário</button>}</section>}
{intentDirty&&!cut&&!op.uncertain&&<button disabled={busy} onClick={()=>{setDecisionReason('');setSelectedRule('');setSelectedCalc(null);setSelectedTemporal(null);setSeries('');setIntentDirty(false);if(!dirty)markClean(rootRef.current);}}>Descartar conferência local</button>}
{frame&&!dirty&&!frame.invoice&&<section className="panel"><h2>Cálculo e conferência temporal</h2><p>O preparador fiscal conserva um cálculo vinculado a estas linhas. A regra temporal exige outro revisor antes da emissão.</p>{can('fiscal_rules.prepare',unit)&&<><InvoiceReferencePicker api={api} kind="rules" value={selectedRule} label="Regra QA revista e vigente" disabled={locked} onChange={v=>{setSelectedRule(v);setIntentDirty(true);}}/><label>Fundamento fiscal/documental<textarea disabled={locked} minLength={10} value={decisionReason} onChange={e=>{setDecisionReason(e.target.value);setIntentDirty(true);}}/></label><button disabled={locked||!selectedRule||decisionReason.trim().length<10} onClick={previewCalculation}>Conferir cálculo destas linhas</button></>}
<InvoiceReferencePicker api={api} kind="calculations" value={selectedCalc?.id??''} draftId={frame.billing.id} label="Cálculo preservado destas linhas" disabled={locked} onChange={(v,r)=>{setSelectedCalc(r??null);setIntentDirty(true);}}/>{selectedCalc&&<CalculationLines result={selectedCalc.result}/>}
{can('service_fiscal.propose')&&selectedCalc&&<><div className="form-grid"><label>Janela fundamentada desde<input disabled={locked} type="date" value={windowDates.from} onChange={e=>{setWindowDates({...windowDates,from:e.target.value});setIntentDirty(true);}}/></label><label>Janela fundamentada até<input disabled={locked} type="date" value={windowDates.to} onChange={e=>{setWindowDates({...windowDates,to:e.target.value});setIntentDirty(true);}}/></label></div><label>Fundamento da prontidão<textarea disabled={locked} value={decisionReason} minLength={10} onChange={e=>{setDecisionReason(e.target.value);setIntentDirty(true);}}/></label><button disabled={locked||!windowDates.from||!windowDates.to||decisionReason.trim().length<10} onClick={prepareTemporal}>{selectedTemporal?'Preparar nova versão temporal':'Preparar regra temporal desta origem'}</button></>}
<InvoiceReferencePicker api={api} kind="temporals" value={selectedTemporal?.id??''} draftId={frame.billing.id} label="Versão temporal" disabled={locked} onChange={(v,r)=>{setSelectedTemporal(r??null);setIntentDirty(true);}}/>{selectedTemporal&&!selectedTemporal.decision&&can('service_fiscal.approve')&&selectedTemporal.actor_id!==actor&&<><label>Fundamento independente<textarea disabled={locked} value={decisionReason} minLength={10} onChange={e=>{setDecisionReason(e.target.value);setIntentDirty(true);}}/></label>{['approved','refused'].map(d=><button key={d} disabled={locked||decisionReason.trim().length<10} onClick={()=>reviewTemporal(d)}>{d==='approved'?'Aprovar versão temporal':'Recusar versão temporal'}</button>)}</>}
{can('service_invoices.issue',unit)&&<><InvoiceReferencePicker api={api} kind="series" value={series} label="Série de teste revista" disabled={locked} filter={r=>r.decision?.decision==='approved'&&r.data.state==='open'} onChange={v=>{setSeries(v);setIntentDirty(true);}}/><button disabled={locked||!series||selectedTemporal?.decision?.decision!=='approved'} onClick={readiness}>Conferir prontidão e impacto de emissão</button></>}{can('service_invoices.view',unit)&&<button disabled={locked} onClick={()=>navigate('service_invoice_setup')}>Configuração de séries e emissão de teste</button>}</section>}
</div><InvoiceTotals result={scopedResult} mode={form.price_mode}/></div>
{cut&&<section className="panel fiscal-review"><h2>Impacto antes da confirmação</h2>{cut.domain==='drafts'?<><p>{cut.impact.after.name} · {cut.impact.after.client} · {cut.impact.after.lines.length} linhas.</p><p>Montante aritmético proposto: {cut.impact.after.arithmetic_amount??'Indeterminado'} MZN. Sem documento emitido, recebível ou lançamento.</p></>:cut.domain==='calculations'?<CalculationLines result={cut.impact.result}/>:<><p>Cliente: {cut.impact.document.client.name} · Série: {cut.impact.series_code} · Data prevista: {cut.impact.expected_date}</p><InvoiceTotals result={cut.impact.calculation_entry.snapshot.result.common_document_result}/><p>A confirmação atribui um número e fixa o PDF de teste. Recebível e diário exigem decisões posteriores.</p></>}<button className="primary" disabled={busy||op.uncertain} onClick={confirm}>{cut.domain==='issue'?'Confirmar emissão só de teste':'Confirmar este impacto'}</button><button disabled={busy||op.uncertain} onClick={()=>{setCut(null);if(dirty)formRef.current?.dispatchEvent?.(new Event('input',{bubbles:true}));setKey(crypto.randomUUID());}}>Voltar à conferência</button></section>}
{op.uncertain&&<section className="panel" role="alert"><h2>Resultado por recuperar</h2><p>Conserve a mesma operação. A recuperação usa a mesma chave e o mesmo pedido.</p><button disabled={busy} onClick={recover}>Recuperar a mesma operação</button></section>}
{receipt&&<section className="panel"><h2>Comprovativo e evidências</h2><p>Registo #{receipt.id} · {receipt.number?'número '+receipt.number:''} · {receipt.version?'versão '+receipt.version:''}</p>{receipt.document_lines?.map(([k,v],i)=>k==='title'?<h3 key={i}>{v}</h3>:k==='section'?<h4 key={i}>{v}</h4>:<p key={i}>{v}</p>)}{receipt.command_key&&<details><summary>Identificador para recuperar o comprovativo</summary><p>{receipt.command_key}</p></details>}<p>Autor, versões, decisão e fotografia permanecem no histórico da origem.</p></section>}
</section>;
}
export function CalculationLines({result:r}){if(!r)return null;return <><InvoiceTotals result={r}/><details><summary>Conferir valores por linha e diferenças</summary><div className="table-scroll"><table><thead><tr><th>Linha</th><th>Base documental</th><th>Imposto</th><th>Total</th><th>Exactos e diferenças</th></tr></thead><tbody>{r.lines.map(l=><tr key={l.line}><td>{l.line}</td><td>{money(l.base_document)}</td><td>{money(l.tax_document)}</td><td>{money(l.total_document)}</td><td>Base {l.base_exact_fraction} · imposto {l.tax_exact_fraction}<br/>Diferenças {l.base_difference_fraction} / {l.tax_difference_fraction}</td></tr>)}</tbody></table></div></details></>;}
+6 -6
View File
@@ -1,6 +1,6 @@
import React,{useEffect,useMemo,useRef,useState}from'react';
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
import{personnelDownload}from'./statement-download.js';
import{personnelDownload,personnelReadableDownload}from'./statement-download.js';
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
export const personnelKinds={contract:'Contratos',day:'Planos e assiduidade',entitlement:'Condições de férias',leave:'Pedidos e gozo de férias',report:'Conferência do período'};
const labels={prepared:'Preparado — por rever',approved:'Revisto e aprovado',refused:'Recusado',active:'Activo',terminated:'Terminado',cancelled:'Cancelado',reserved:'Reservado',taken:'Gozado'};
@@ -10,8 +10,8 @@ export function PersonnelSourcePicker({kind,workerKey,api,context,value,onChange
const[query,setQuery]=useState({page:1,per_page:25,search:''}),[search,setSearch]=useState(''),[data,setData]=useState(null),[error,setError]=useState('');useEffect(()=>{let active=true;setData(null);setError('');if(kind!=='worker'&&!workerKey)return;const q={...query,kind:kind==='worker'?'worker':kind,...(workerKey?{worker_key:workerKey}:{})},path=kind==='worker'?'/api/remunerations':'/api/hr-personnel';api(path+'?'+qs(q)).then(r=>{if(active)setData(kind==='worker'?r:checkedPersonnel(r,q));}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[query,workerKey,context.token,kind]);const records=data?.records.data??[];
return <fieldset disabled={disabled}><legend>{kind==='worker'?'Colaborador fictício revisto':personnelKinds[kind]+' — fonte revista'}</legend><label>Pesquisar fonte<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>setQuery({page:1,per_page:25,search})}>Pesquisar {kind==='worker'?'colaborador':personnelKinds[kind]}</button>{error&&<p role="alert">{error}</p>}<label>Escolha explícita<select required={!optional} value={value} onChange={e=>{const r=records.find(r=>r.record_key===e.target.value);onChange(e.target.value,r?.effective??r);}}><option value="">{optional?'Sem ligação contratual escolhida':'Escolher…'}</option>{value&&!records.some(r=>r.record_key===value)&&<option value={value}>Fonte escolhida anteriormente</option>}{records.filter(r=>r.effective&&r.effective.data.status!=='cancelled'&&(kind!=='worker'||r.effective.data.status==='active')).map(r=><option key={r.record_key} value={r.record_key}>{r.effective.data.input.code??r.effective.data.input.reference??r.effective.data.input.date??(r.effective.data.input.valid_from+' a '+r.effective.data.input.valid_to)} · v{r.effective.version}</option>)}</select></label>{data&&<><button type="button" disabled={disabled||query.page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior — fontes</button><span>Página{data.records.current_page}/{data.records.last_page}</span><button type="button" disabled={disabled||query.page>=data.records.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte — fontes</button></>}</fieldset>;
}
function Intervals({label,value,onChange,disabled,nullable=false}){return <fieldset disabled={disabled}><legend>{label}</legend>{nullable&&<label><input type="checkbox" checked={value===null} onChange={e=>onChange(e.target.checked?null:[])}/>Informação ainda desconhecida</label>}{value!==null&&<>{value.map((r,i)=><div className="form-grid" key={i}><label>Desde<input required type="time" value={clock(r.from)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,from:minutes(e.target.value)}:x))}/></label><label>Até<input required type={r.to===1440?'text':'time'} value={clock(r.to)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,to:minutes(e.target.value)}:x))}/></label><button type="button" onClick={()=>onChange(value.filter((_,j)=>i!==j))}>Remover intervalo</button></div>)}<button type="button" disabled={disabled||value.length>=8} onClick={()=>onChange([...value,{from:480,to:720}])}>Adicionar intervalo</button>{value.length===0&&<p>Conjunto explicitamente vazio. Não equivale a informação desconhecida.</p>}</>}</fieldset>;}
export function PersonnelResult({kind,data}){const i=data.input,r=data.result;return <section><h3>{personnelKinds[kind]} · {labels[data.status]}</h3><p>{data.dependencies.worker.code} · {i.reference??i.date??i.period??(i.valid_from+' a '+i.valid_to)}.</p>{kind==='contract'&&<dl className="overview-definitions"><dt>Modalidade interna</dt><dd>{{fixed:'Prazo explícito',open:'Sem termo declarado',unknown_end:'Fim desconhecido'}[i.mode]}</dd><dt>Vigência</dt><dd>{i.valid_from} a {i.valid_to??'Sem fim confirmado'}</dd><dt>Bruto mensal declarado</dt><dd>{i.gross} MZN</dd><dt>Dependentes revistos</dt><dd>{r.dependent_records}</dd></dl>}{kind==='day'&&<dl className="overview-definitions"><dt>Previsto</dt><dd>{duration(r.planned_minutes)}</dd><dt>Declarado</dt><dd>{duration(r.declared_minutes)}</dd><dt>Férias conferidas</dt><dd>{duration(r.leave_minutes)}</dd><dt>Por conferir</dt><dd>{duration(r.unaccounted_minutes)}</dd><dt>Fora do plano, sem autorização de horas extra</dt><dd>{duration(r.outside_plan_minutes)}</dd></dl>}{['entitlement','leave'].includes(kind)&&<><p>{kind==='leave'?'Pedido: '+duration(r.minutes)+'. ':''}Saldo do exercício{duration((r.balance_after??r).available_minutes)}, reservado{duration((r.balance_after??r).reserved_minutes)}, gozado{duration((r.balance_after??r).taken_minutes)}.</p><p>Minutos de orçamento fictício; sem direito anual legal presumido.</p></>}{kind==='report'&&<><p>{r.known_days} dias com registos; {r.known_conferred_days} conferidos; {r.other_calendar_days_unknown} outros dias sem cobertura comprovada. Mês completo: não demonstrado.</p><div className="table-scroll"><table><thead><tr><th>Dia / versão</th><th>Previsto</th><th>Declarado</th><th>Férias</th><th>Por conferir</th></tr></thead><tbody>{r.days.map(d=><tr key={d.record_key}><td>{d.calculation.date} · v{d.version}</td><td>{duration(d.calculation.planned_minutes)}</td><td>{duration(d.calculation.declared_minutes)}</td><td>{duration(d.calculation.leave_minutes)}</td><td>{duration(d.calculation.unaccounted_minutes)}</td></tr>)}</tbody></table></div>{r.leave_balances.map(b=><p key={b.entitlement_key}>Condição v{b.entitlement_version}: disponível{duration(b.available_minutes)}, reservado{duration(b.reserved_minutes)}, gozado{duration(b.taken_minutes)}.</p>)}{r.payroll.map(p=><p key={p.record_key}>Processamento v{p.version}: {p.contract_link_proven?'Tem contrato escolhido na origem.':'Sem ligação contratual histórica comprovada.'} O processamento não foi recalculado.</p>)}</>}<p>Evidência: {i.evidence}. Sem salário, dívida ou pagamento automático.</p></section>;}
function Intervals({label,value,onChange,disabled,nullable=false}){return <fieldset disabled={disabled}><legend>{label}</legend>{nullable&&<label><input type="checkbox" checked={value===null} onChange={e=>onChange(e.target.checked?null:[])}/>Informação ainda desconhecida</label>}{value!==null&&<>{value.map((r,i)=><div className="form-grid" key={i}><label>Desde<input required type="time" value={clock(r.from)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,from:minutes(e.target.value)}:x))}/></label><label><input type="checkbox" checked={r.to===1440} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,to:e.target.checked?1440:1020}:x))}/>Fim à meia-noite deste dia</label><label>Até<input readOnly={r.to===1440} required type={r.to===1440?'text':'time'} value={clock(r.to)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,to:minutes(e.target.value)}:x))}/></label><button type="button" onClick={()=>onChange(value.filter((_,j)=>i!==j))}>Remover intervalo</button></div>)}<button type="button" disabled={disabled||value.length>=8} onClick={()=>onChange([...value,{from:480,to:720}])}>Adicionar intervalo</button>{value.length===0&&<p>Conjunto explicitamente vazio. Não equivale a informação desconhecida.</p>}</>}</fieldset>;}
export function PersonnelResult({kind,data}){const i=data.input,r=data.result;return <section><h3>{personnelKinds[kind]} · {labels[data.status]}</h3><p>{data.dependencies.worker.code} · {i.reference??i.date??i.period??(i.valid_from+' a '+i.valid_to)}.</p>{kind==='contract'&&<dl className="overview-definitions"><dt>Modalidade interna</dt><dd>{{fixed:'Prazo explícito',open:'Sem termo declarado',unknown_end:'Fim desconhecido'}[i.mode]}</dd><dt>Vigência</dt><dd>{i.valid_from} a {i.valid_to??'Sem fim confirmado'}</dd><dt>Bruto mensal declarado</dt><dd>{i.gross} MZN</dd><dt>Dependentes revistos</dt><dd>{r.dependent_records}</dd></dl>}{kind==='day'&&<dl className="overview-definitions"><dt>Previsto</dt><dd>{duration(r.planned_minutes)}</dd><dt>Declarado</dt><dd>{duration(r.declared_minutes)}</dd><dt>Férias conferidas</dt><dd>{duration(r.leave_minutes)}</dd><dt>Por conferir</dt><dd>{duration(r.unaccounted_minutes)}</dd><dt>Fora do plano, sem autorização de horas extra</dt><dd>{duration(r.outside_plan_minutes)}</dd></dl>}{['entitlement','leave'].includes(kind)&&<><p>{kind==='leave'?'Pedido: '+duration(r.minutes)+'. ':''}Saldo no resultado desta versão{duration((r.balance_after??r).available_minutes)}, reservado{duration((r.balance_after??r).reserved_minutes)}, gozado{duration((r.balance_after??r).taken_minutes)}.</p><p>Minutos de orçamento fictício; sem direito anual legal presumido.</p></>}{kind==='report'&&<><p>{r.known_days} dias com registos; {r.known_conferred_days} conferidos; {r.other_calendar_days_unknown} outros dias sem cobertura comprovada. Mês completo: não demonstrado.</p><div className="table-scroll"><table><thead><tr><th>Dia / versão</th><th>Previsto</th><th>Declarado</th><th>Férias</th><th>Por conferir</th></tr></thead><tbody>{r.days.map(d=><tr key={d.record_key}><td>{d.calculation.date} · v{d.version}</td><td>{duration(d.calculation.planned_minutes)}</td><td>{duration(d.calculation.declared_minutes)}</td><td>{duration(d.calculation.leave_minutes)}</td><td>{duration(d.calculation.unaccounted_minutes)}</td></tr>)}</tbody></table></div>{r.leave_balances.map(b=><p key={b.entitlement_key}>Condição v{b.entitlement_version}: disponível{duration(b.available_minutes)}, reservado{duration(b.reserved_minutes)}, gozado{duration(b.taken_minutes)}.</p>)}{r.payroll.map(p=><p key={p.record_key}>Processamento v{p.version}: {p.contract_link_proven?'Tem contrato escolhido na origem.':'Sem ligação contratual histórica comprovada.'} O processamento não foi recalculado.</p>)}</>}{data.refusal_conference&&<p>Recusa conferida com as fontes actuais{data.refusal_conference.original_source_changed?' alteradas desde a preparação':''}. A proposta não foi aplicada.{data.refusal_conference.current_balance&&<> Saldo efectivo conferido: disponível{duration(data.refusal_conference.current_balance.available_minutes)}, reservado{duration(data.refusal_conference.current_balance.reserved_minutes)}.</>}</p>}<p>Evidência: {i.evidence}. Sem salário, dívida ou pagamento automático.</p></section>;}
export default function PersonnelWorkspace({api,context,initialKind='contract'}){
const[worker,setWorker]=useState(null),[query,setQuery]=useState({kind:initialKind,page:1,per_page:25}),[filters,setFilters]=useState({search:'',period:'',state:''}),[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 op=useMemo(()=>new WorkshopStockOperation(api),[context.token]),generation=useRef(context.token),mounted=useRef(true);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);const locked=busy||op.uncertain,protect=locked||Boolean(form);
useEffect(()=>{if(!query.worker_key)return;let active=true;setBusy(true);setError('');setData(null);api('/api/hr-personnel?'+qs(query)).then(r=>{if(active)setData(checkedPersonnel(r,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,query]);
@@ -19,12 +19,12 @@ export default function PersonnelWorkspace({api,context,initialKind='contract'})
const change=(k,v)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[k]:v}}));};
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const body={...form,data:{...form.data}};if(body.kind==='contract'&&body.action==='prepare'&&body.data.mode!=='fixed')body.data.valid_to=null;const key=crypto.randomUUID(),out=await api('/api/hr-personnel/preview','POST',body,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 op.retry():await op.send('/api/hr-personnel/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice(`v${out.version} · ${labels[out.state]}${out.replayed?' · recibo recuperado':''}`);setSelected(null);setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!op.uncertain)setPreview(null);}finally{setBusy(false);}}
async function download(row){const token=context.token;setBusy(true);setError('');try{const out=await personnelDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='sgf-rh-interno-'+row.kind+'-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
async function download(row,readable=false){const token=context.token;setBusy(true);setError('');try{const guard=()=>mounted.current&&generation.current===token;const out=readable?await personnelReadableDownload({id:row.id,token,sourceSha256:row.data.artifact_sha256,isCurrent:guard}):await personnelDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:guard});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=(readable?'sgf-rh-resumo-legivel-':'sgf-rh-interno-')+row.kind+'-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
const field=(k,label,type='text',extra={})=><label key={k}>{label}<input required type={type} value={form.data[k]??''} onChange={e=>change(k,e.target.value)} {...extra}/></label>,source=(kind,key)=> <PersonnelSourcePicker kind={kind} workerKey={query.worker_key} context={context} api={api} value={form.data[key]} onChange={v=>change(key,v)} disabled={locked||Boolean(preview)||form.version>0}/>;
return <section className="panel" data-sgf-operation-uncertain={protect?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Contratos, assiduidade e férias QA</h1><p>Fontes e decisões individuais, com revisão distinta. Registos internos fictícios; sem calendário laboral legal presumido nem alteração automática de remunerações.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<PersonnelSourcePicker kind="worker" api={api} context={context} value={query.worker_key??''} onChange={(key,row)=>{setWorker(row??null);setSelected(null);setData(null);setQuery({kind:query.kind,page:1,per_page:25,...(key?{worker_key:key}:{})});}} disabled={protect}/>{worker&&<p>Ficha de {worker.data.input.code}, cadastro revisto v{worker.version}. Consultas abaixo mantêm esta pessoa e o contexto.</p>}<nav aria-label="Funções de pessoal">{Object.entries(personnelKinds).map(([kind,label])=><button key={kind} disabled={protect||!query.worker_key} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,worker_key:query.worker_key,page:1,per_page:25});}}>{label}</button>)}</nav>{!query.worker_key&&<p>Escolha explicitamente um colaborador revisto para consultar ou preparar.</p>}
return <section className="panel" data-sgf-operation-uncertain={protect?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Contratos, assiduidade e férias QA</h1><p>Fontes e decisões individuais, com revisão distinta. Registos internos fictícios; sem calendário laboral legal presumido nem alteração automática de remunerações. Resultados preparados são propostas; reserva e gozo só ficam efectivos após revisão alheia.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<PersonnelSourcePicker kind="worker" api={api} context={context} value={query.worker_key??''} onChange={(key,row)=>{setWorker(row??null);setSelected(null);setData(null);setQuery({kind:query.kind,page:1,per_page:25,...(key?{worker_key:key}:{})});}} disabled={protect}/>{worker&&<p>Ficha de {worker.data.input.code}, cadastro revisto v{worker.version}. Consultas abaixo mantêm esta pessoa e o contexto.</p>}<nav aria-label="Funções de pessoal">{Object.entries(personnelKinds).map(([kind,label])=><button key={kind} disabled={protect||!query.worker_key} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,worker_key:query.worker_key,page:1,per_page:25});}}>{label}</button>)}</nav>{!query.worker_key&&<p>Escolha explicitamente um colaborador revisto para consultar ou preparar.</p>}
{query.worker_key&&<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery(q=>({kind:q.kind,worker_key:q.worker_key,per_page:q.per_page,...filters,page:1}));}}><fieldset disabled={protect}><label>Pesquisar referência/código<input value={filters.search} onChange={e=>setFilters(f=>({...f,search:e.target.value}))}/></label>{['day','leave','report'].includes(query.kind)&&<label>Período<input type="month" value={filters.period} onChange={e=>setFilters(f=>({...f,period:e.target.value}))}/></label>}<label>Estado<select value={filters.state} onChange={e=>setFilters(f=>({...f,state:e.target.value}))}><option value="">Todos</option>{['prepared','approved','refused'].map(s=><option key={s} value={s}>{labels[s]}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>{setSelected(null);setQuery(q=>({...q,per_page:Number(e.target.value),page:1}));}}><option value="25">25</option><option value="100">100</option></select></label><button>Consultar</button></fieldset></form>}
{data&&<>{data.capabilities.prepare&&!query.record_key&&<button disabled={protect} onClick={()=>{setSelected(null);start('establish',false,true);}}>Novo registo — {personnelKinds[query.kind]}</button>}<p>Página{data.records.current_page}/{data.records.last_page} · {data.records.total} registo/versão(s).</p><div className="table-scroll"><table><thead><tr><th>Referência/dia/período</th><th>Versão e decisão</th><th>Estado revisto actual</th><th>Autor e fundamento</th><th>Escolha</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.data.input.reference??r.data.input.date??r.data.input.period??(r.data.input.valid_from+' a '+r.data.input.valid_to)}</td><td>v{r.version} · {labels[r.state]}<small>{r.data.input.operation}</small></td><td>{r.effective?labels[r.effective.data.status]:'Sem aprovação'}{r.effective&&<small>v{r.effective.version}</small>}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={protect} onClick={()=>{setSelected(r);setReceipt(null);}}>Escolher este registo</button></td></tr>)}</tbody></table></div>{!data.records.data.length&&<p>Sem registos neste âmbito/filtro.</p>}<button disabled={protect||query.page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={protect||query.page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button>{query.record_key&&<button disabled={protect} onClick={()=>{setSelected(null);setQuery({kind:query.kind,worker_key:query.worker_key,page:1,per_page:25});}}>Voltar à lista actual</button>}</>}
{selected&&<section><h2>Registo escolhido · v{selected.version} · {labels[selected.state]}</h2><PersonnelResult kind={selected.kind} data={selected.data}/><p>Antes v{selected.impact.before.version}, {labels[selected.impact.before.state]??'Sem registo'}; depois v{selected.impact.after.version}, {labels[selected.state]}.</p><button disabled={protect} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.capabilities.prepare&&selected.effective?.data.status!=='cancelled'&&<><button disabled={protect} onClick={()=>start(selected.effective?'amend':'establish')}>Preparar {selected.effective?'correcção':'nova versão'}</button>{selected.effective&&<button disabled={protect} onClick={()=>start('cancel')}>Preparar cancelamento</button>}{selected.kind==='contract'&&selected.effective&&<button disabled={protect} onClick={()=>start('terminate')}>Preparar terminação</button>}{selected.kind==='leave'&&selected.effective?.data.status==='reserved'&&<button disabled={protect} onClick={()=>start('take')}>Declarar gozo sem automatismo</button>}</>}{!query.record_key&&data.capabilities.review&&selected.state==='prepared'&&<button disabled={protect} onClick={()=>start('',true)}>Rever preparação alheia</button>}{selected.state==='approved'&&<button disabled={protect} onClick={()=>download(selected)}>Descarregar versão conservada</button>}</section>}
{selected&&<section><h2>Registo escolhido · v{selected.version} · {labels[selected.state]}</h2><PersonnelResult kind={selected.kind} data={selected.data}/>{selected.current_balance&&<p><strong>Saldo efectivo nesta consulta:</strong> disponível{duration(selected.current_balance.available_minutes)}, reservado{duration(selected.current_balance.reserved_minutes)}, gozado{duration(selected.current_balance.taken_minutes)}. A versão conservada mantém os valores da sua decisão.</p>}<p>Antes v{selected.impact.before.version}, {labels[selected.impact.before.state]??'Sem registo'}; depois v{selected.impact.after.version}, {labels[selected.state]}.</p><button disabled={protect} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.capabilities.prepare&&selected.effective?.data.status!=='cancelled'&&<><button disabled={protect} onClick={()=>start(selected.effective?'amend':'establish')}>Preparar {selected.effective?'correcção':'nova versão'}</button>{selected.effective&&<button disabled={protect} onClick={()=>start('cancel')}>Preparar cancelamento</button>}{selected.kind==='contract'&&selected.effective&&<button disabled={protect} onClick={()=>start('terminate')}>Preparar terminação</button>}{selected.kind==='leave'&&selected.effective?.data.status==='reserved'&&<button disabled={protect} onClick={()=>start('take')}>Declarar gozo sem automatismo</button>}</>}{!query.record_key&&data.capabilities.review&&selected.state==='prepared'&&<button disabled={protect} onClick={()=>start('',true)}>Rever preparação alheia</button>}{selected.state==='approved'&&<><button disabled={protect} onClick={()=>download(selected,true)}>Descarregar resumo legível desta versão</button><button disabled={protect} onClick={()=>download(selected)}>Descarregar CSV original conservado</button><p>O resumo deriva apenas desta versão revista; mantém o SHA de referência do original e identifica o seu próprio formato.</p></>}</section>}
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='review'?'Revisão distinta':'Preparação individual'} · {personnelKinds[form.kind]} · v{form.version}</h2>{form.action==='review'?<label>Decisão<select required value={form.data.decision} onChange={e=>change('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}. Pessoa e fontes são revalidadas na confirmação.</p><fieldset disabled={['cancel','take'].includes(form.data.operation)}>{form.kind==='contract'?<>{field('reference','Referência interna QA','text',{readOnly:form.version>0,pattern:'QA-[A-Z0-9-]{2,60}'})}<label>Modalidade interna<select value={form.data.mode} onChange={e=>change('mode',e.target.value)}><option value="fixed">Prazo explícito</option><option value="open">Sem termo declarado</option><option value="unknown_end">Fim desconhecido — vigência incompleta</option></select></label>{field('valid_from','Início','date',{readOnly:form.version>0})}{field('valid_to','Fim explícito','date',{required:form.data.mode==='fixed',disabled:form.data.mode!=='fixed'})}{field('gross','Bruto mensal MZN','text',{pattern:'[0-9]{1,9}\\.[0-9]{2}',readOnly:form.data.operation==='terminate'})}</>:form.kind==='report'?field('period','Período da conferência','month',{readOnly:form.version>0}):<>{source('contract','contract_key')}{form.kind==='entitlement'?<>{field('valid_from','Vigência desde','date')}{field('valid_to','Vigência até','date')}<label><input type="checkbox" checked={form.data.minutes===null} onChange={e=>change('minutes',e.target.checked?null:0)}/>Saldo inicial explicitamente desconhecido</label><label>Orçamento QA em minutos<input type="number" min="0" max="527040" required={form.data.minutes!==null} disabled={form.data.minutes===null} value={form.data.minutes??''} onChange={e=>change('minutes',Number(e.target.value))}/></label></>:<>{form.kind==='leave'&&<>{source('entitlement','entitlement_key')}{source('day','day_key')}</>}{field('date','Dia local','date',{readOnly:form.version>0})}</>}</>}</fieldset>{['day','leave'].includes(form.kind)&&field('reference_on','Referência da declaração (não futura)','date')}{form.kind==='day'&&<><Intervals label="Intervalos previstos" value={form.data.planned} nullable onChange={v=>change('planned',v)} disabled={locked||Boolean(preview)||form.data.operation==='cancel'}/><Intervals label="Intervalos declarados" value={form.data.actual} nullable onChange={v=>change('actual',v)} disabled={locked||Boolean(preview)||form.data.operation==='cancel'}/><label><input type="checkbox" checked={form.data.leave_conferred} disabled={form.data.operation==='cancel'} onChange={e=>change('leave_conferred',e.target.checked)}/>Conferir explicitamente férias efectivas deste dia</label></>}{form.kind==='leave'&&<Intervals label="Intervalos do pedido" value={form.data.intervals} onChange={v=>change('intervals',v)} disabled={locked||Boolean(preview)||['cancel','take'].includes(form.data.operation)}/>}<label>Referência da evidência<textarea required minLength={10} maxLength={1000} value={form.data.evidence} onChange={e=>change('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</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}, {labels[preview.impact.before.state]??'Sem registo'}; depois v{preview.impact.after.version}, {labels[preview.impact.after.state]}.</p><PersonnelResult kind={form.kind} data={preview.impact.after.proposal}/><p>Fundamento: {form.reason}. Sem alteração salarial, dívida, pagamento ou lançamento.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria: <code>{preview.key}</code></p></section>}{op.uncertain&&<><p role="alert">Resultado incerto. Conserve corpo/chave e não inicie outra decisão.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
<h2>Recibo técnico próprio</h2><label>Chave<input disabled={protect} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={protect||!receiptKey} onClick={async()=>{setBusy(true);setError('');try{setReceipt(await api('/api/hr-personnel/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}}>Consultar recibo sem executar</button>{receipt&&<p>v{receipt.result.version} · {labels[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Não foi executada outra operação.</p>}
+2 -2
View File
@@ -17,13 +17,13 @@ export default function RemunerationsWorkspace({api,context}){
const operation=useMemo(()=>new WorkshopStockOperation(api),[context.token]),mounted=useRef(true),generation=useRef(context.token);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);const locked=busy||operation.uncertain,protectedForm=locked||Boolean(form);
useEffect(()=>{let active=true;setBusy(true);setData(null);setError('');api('/api/remunerations?'+qs(query)).then(d=>{if(active)setData(checkedRemunerations(d,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[query,context.token]);
function start(operationName='establish',review=false,newRecord=false){const row=newRecord?null:selected,source=row?.effective?.data.input??row?.data.input,kind=query.kind;const defaults=kind==='worker'?{code:'QA-',status:'active'}:kind==='rules'?{label:'',valid_from:'',valid_to:'',worker_rate:'',employer_rate:'',retention_rate:'',source:''}:{worker_key:'',rules_key:'',period:'',gross:''};setPreview(null);setError('');setForm({kind,action:review?'review':'prepare',record_key:row?.record_key??crypto.randomUUID(),version:row?.version??0,reason:'',data:review?{decision:''}:{...(source??defaults),operation:operationName}});}
const change=(key,value)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[key]:value}}));};
const change=(key,value)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[key]:value,...(key==='worker_key'&&f.data.contract_key?{contract_key:''}:{})}}));};
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input={...form,data:{...form.data}};if(!input.data.contract_key)delete input.data.contract_key;if(input.kind==='rules'&&input.action==='prepare')for(const k of['worker_rate','employer_rate','retention_rate'])if(input.data[k]==='')input.data[k]=null;const key=crypto.randomUUID(),p=await api('/api/remunerations/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/remunerations/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice('Decisão v'+out.version+' · '+states[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 download(row){const token=context.token;setBusy(true);setError('');try{const out=await remunerationDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='sgf-remuneracao-interna-QA-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
async function recover(){setBusy(true);setError('');try{setReceipt(await api('/api/remunerations/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
const textInput=(key,label,type='text',extra={})=><label key={key}>{label}<input required type={type} value={form.data[key]??''} onChange={e=>change(key,e.target.value)} {...extra}/></label>;
return <section className="panel" data-sgf-operation-uncertain={protectedForm?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Remunerações internas QA</h1><p>Área autónoma, com acessos próprios. Cadastro e regras fictícios, processamento e revisão distinta. Sem salário/dívida/pagamento/lançamento, recibo legal ou submissão. Contratos, assiduidade, férias e conformidade legal continuam pendentes.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
return <section className="panel" data-sgf-operation-uncertain={protectedForm?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Remunerações internas QA</h1><p>Área autónoma, com acessos próprios. Cadastro e regras fictícios, processamento e revisão distinta. Sem salário/dívida/pagamento/lançamento, recibo legal ou submissão. Contratos, assiduidade e férias estão na função própria de RH. A conformidade legal continua pendente.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
<nav aria-label="Funções de Recursos Humanos">{Object.entries(kinds).map(([kind,label])=><button key={kind} disabled={protectedForm} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,page:1,per_page:25});}}>{label}</button>)}</nav>
<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery(q=>({kind:q.kind,per_page:q.per_page,...filters,page:1}));}}><fieldset disabled={protectedForm}><label>Pesquisa por código / regras<input value={filters.search} onChange={e=>setFilters(f=>({...f,search:e.target.value}))}/></label>{query.kind==='payroll'&&<label>Período<input type="month" value={filters.period} onChange={e=>setFilters(f=>({...f,period:e.target.value}))}/></label>}<label>Estado<select value={filters.state} onChange={e=>setFilters(f=>({...f,state:e.target.value}))}><option value="">Todos</option>{Object.entries(states).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>{setSelected(null);setQuery(q=>({...q,per_page:Number(e.target.value),page:1}));}}><option value="25">25</option><option value="100">100</option></select></label><button>Consultar</button></fieldset></form>
{data&&<>{data.capabilities.prepare&&!query.record_key&&<button disabled={protectedForm} onClick={()=>{setSelected(null);start('establish',false,true);}}>Novo: {kinds[query.kind]}</button>}<p>Página{data.records.current_page}/{data.records.last_page} · {data.records.total} versão/registo(s).</p><div className="table-scroll"><table><thead><tr><th>Registo / período</th><th>Versão e decisão</th><th>Resultado revisto actual</th><th>Autor / fundamento</th><th>Escolha</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.data.input.code??r.data.input.label??r.data.dependencies.worker?.data.input.code}<small>{r.data.input.period??''} · {r.record_key}</small></td><td>v{r.version} · {states[r.state]} · {r.data.input.operation}</td><td>{r.effective?<>v{r.effective.version} · {r.effective.data.status}{r.effective.data.calculation&&<small>Líquido interno{remunerationMoney(r.effective.data.calculation.net_cents)}</small>}</>:'Sem aprovação'}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={protectedForm} onClick={()=>{setSelected(r);setReceipt(null);}}>Escolher este registo</button></td></tr>)}</tbody></table></div>{!data.records.data.length&&<p>Sem registos neste âmbito/filtro.</p>}<button disabled={protectedForm||query.page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={protectedForm||query.page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button>{query.record_key&&<button disabled={protectedForm} onClick={()=>{setSelected(null);setQuery({kind:query.kind,page:1,per_page:25});}}>Voltar à lista actual</button>}{query.kind==='payroll'&&!query.record_key&&<p>Somente aprovados activos desta página: {data.page_totals.approved_active_on_page}; bruto{remunerationMoney(data.page_totals.gross_cents)}, líquido{remunerationMoney(data.page_totals.net_cents)}, custo{remunerationMoney(data.page_totals.employer_cost_cents)}. Não são totais do universo nem salários pagos.</p>}</>}
+2 -2
View File
@@ -11,13 +11,13 @@ export function navigationGroups(can, unit, capabilities={}) {
{id:'purchasing',label:'Compras',items:[{id:'purchasing_home',label:'Área de Compras',visible:can('company.view')},item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit),item('supplier_payments','Pagamentos e afectações — ensaio','supplier_payables.view',unit)]},
{id:'stock',label:'Stock',status:'Em preparação',items:[...visibleStockSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.stockCatalogue?[item('stock_catalogue','Artigos e revisão do catálogo','stock.view',unit)]:[])]},
{id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]},
{id:'billing',label:'Facturação',items:visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))},
{id:'billing',label:'Facturação',items:[...(capabilities.multilineInvoices?[item('multiline_invoices','Documento com várias linhas QA','billing_drafts.view',unit)]:[]),...visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))]},
{id:'billing_support',label:'Configuração documental',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('service_invoice_setup','Séries e emissão de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]},
{id:'treasury',label:'Fornecedores',items:[...(capabilities.payablePositions?[item('payable_positions','Posições de fornecedores','supplier_payables.view',unit),item('payable_aging','Mapa de antiguidade — fornecedores','supplier_payables.view',unit)]:[]),item('native_payables','Obrigações, pagamentos e afectações','supplier_payables.view',unit)]},
{id:'receivables',label:'Recebimentos e Caixa',items:[item('receivable_positions','Posições de clientes e vencimentos','service_receivables.view',unit),item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit),item('financial_indicators','Critérios e indicadores QA','financial_analysis.view',unit),item('bank_reconciliation','Conferência de extracto QA','bank_reconciliation.view',unit),item('cash','Caixa — abertura, despesas e fecho QA','cash.view',unit)]},
{id:'workshop',label:'Oficinas',items:[...visibleWorkshopSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true})),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento comercial','workshop_corrections.view',unit)]},
{id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]},
{id:'accounting',label:'Contabilidade e fiscalidade',items:[...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.internalStatements?[item('internal_statements','Demonstrações internas parciais QA','general_ledger.view',unit)]:[]),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view'),item('fiscal_iva','Livro e mapa interno de IVA QA','fiscal_assessments.view',unit),item('fiscal_rules','Regras fiscais versionadas QA','fiscal_rules.view',unit),...(capabilities.fiscalObligations?[item('fiscal_obligations','Obrigações e prazos administrativos QA','fiscal_obligations.view',unit)]:[])]},
{id:'accounting',label:'Contabilidade e fiscalidade',items:[...(capabilities.fixedAssets?[item('fixed_assets','Activos e depreciações internas QA','fixed_assets.view',unit)]:[]),...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.internalStatements?[item('internal_statements','Demonstrações internas parciais QA','general_ledger.view',unit)]:[]),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view'),item('fiscal_iva','Livro e mapa interno de IVA QA','fiscal_assessments.view',unit),item('fiscal_rules','Regras fiscais versionadas QA','fiscal_rules.view',unit),...(capabilities.fiscalObligations?[item('fiscal_obligations','Obrigações e prazos administrativos QA','fiscal_obligations.view',unit)]:[])]},
...(capabilities.remunerations?[{id:'human_resources',label:'Recursos Humanos',items:[item('remunerations','Cadastro e remunerações internas QA','human_resources.remunerations.view',unit),...(capabilities.personnel?[{id:'hr_personnel',label:'Contratos, assiduidade e férias',visible:['contracts','attendance','leave'].every(a=>can('human_resources.'+a+'.view',unit))}]:[])]}]:[]),
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('module_records','Associações de módulos','access.manage'),item('access','Acessos','access.manage')]},
].map(g=>({...g,items:g.items.filter(i=>i.visible)})).filter(g=>g.items.length);
+7 -2
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import '../css/app.css';
import '../css/tempo-theme.css';
import '../css/multiline-invoice.css';
import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
@@ -20,6 +21,8 @@ import ModuleNavigation, {moduleGroups,moduleFunctionAllowed} from './ModuleNavi
import {ContextTransition} from './context-transition.js';
import ModuleRecordsWorkspace from './ModuleRecordsWorkspace.jsx';
import ContextTransitionDialog from './ContextTransitionDialog.jsx';
const MultiLineInvoiceWorkspace=React.lazy(()=>import('./MultiLineInvoiceWorkspace.jsx'));
const FixedAssetsWorkspace=React.lazy(()=>import('./FixedAssetsWorkspace.jsx'));
const PersonnelWorkspace=React.lazy(()=>import('./PersonnelWorkspace.jsx'));
const RemunerationsWorkspace=React.lazy(()=>import('./RemunerationsWorkspace.jsx'));
const InternalStatementsWorkspace=React.lazy(()=>import('./InternalStatementsWorkspace.jsx'));
@@ -63,7 +66,7 @@ function App() {
async function resolveTransition(save) { setTransitionBusy(true);setError(''); try { if(save) await transitions.current.save(); else transitions.current.discard(); const action=pendingTransition;setPendingTransition(null); await action(); } catch(e){setError(e.message);} finally{setTransitionBusy(false);} }
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
const allGroups = navigationGroups(can, context?.unit?.id ?? null,{personnel:context?.hr_personnel_enabled===true,remunerations:context?.remunerations_enabled===true,stockCatalogue:context?.stock?.catalogue===true,payablePositions:context?.single_installation===true,fiscalObligations:context?.fiscal_obligations_enabled===true,internalStatements:context?.internal_statements_enabled===true});
const allGroups = navigationGroups(can, context?.unit?.id ?? null,{multilineInvoices:context?.multiline_invoices_enabled===true,fixedAssets:context?.fixed_assets_enabled===true,personnel:context?.hr_personnel_enabled===true,remunerations:context?.remunerations_enabled===true,stockCatalogue:context?.stock?.catalogue===true,payablePositions:context?.single_installation===true,fiscalObligations:context?.fiscal_obligations_enabled===true,internalStatements:context?.internal_statements_enabled===true});
const groups = [...allGroups.filter(g=>['start','admin'].includes(g.id)), ...moduleGroups(allGroups,context?.module_id)];
const navigate = (id,selection=null) => transition(async () => {
setError('');
@@ -128,7 +131,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" onSubmitCapture={e=>{transitions.current.submitting=e.target;}} onInputCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onChangeCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onClickCapture={e=>{transitions.current.submitting=e.target.closest('form')||e.target.closest('section');const a=e.target.closest('a[href]');if(a&&a.origin===location.origin){e.preventDefault();transition(()=>{location.href=a.href;});}}}><ModuleNavigation groups={allGroups} context={context} page={page} navigate={navigate} openPage={openModulePage} user={state.user} busy={busy||transitionBusy} logout={()=>transition(()=>perform(()=>api('/api/logout','POST'),'Sessão terminada.'))} />{pendingTransition&&<ContextTransitionDialog busy={transitionBusy} error={error} onSave={()=>resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}<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><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></header><main>
return <div className="shell" onSubmitCapture={e=>{transitions.current.submitting=e.target;}} onInputCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&!e.target.closest('[data-sgf-read-filters="true"]')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onChangeCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&!e.target.closest('[data-sgf-read-filters="true"]')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onClickCapture={e=>{transitions.current.submitting=e.target.closest('form')||e.target.closest('section');const a=e.target.closest('a[href]');if(a&&a.origin===location.origin){e.preventDefault();transition(()=>{location.href=a.href;});}}}><ModuleNavigation groups={allGroups} context={context} page={page} navigate={navigate} openPage={openModulePage} user={state.user} busy={busy||transitionBusy} logout={()=>transition(()=>perform(()=>api('/api/logout','POST'),'Sessão terminada.'))} />{pendingTransition&&<ContextTransitionDialog busy={transitionBusy} error={error} onSave={()=>resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}<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><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></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}:${page}`}>
@@ -157,6 +160,8 @@ function App() {
{page === 'supplier_payables' && <section className="panel"><h1>Obrigações a fornecedores</h1><p>Percurso sintético ligado ao núcleo comum. Preparação, validação distinta e consulta, sem pagamentos ou vencimento presumido.</p>{context.single_installation?<button onClick={()=>navigate('native_payables')}>Escolher Financeiro e abrir a obrigação original</button>:<a href="/supplier-payables">Abrir preparação e consulta de obrigações</a>}</section>}
{page === 'supplier_payments' && <section className="panel"><h1>Pagamentos e afectações a fornecedores</h1><p>Ensaio sintético: preparação e confirmação distintas, afectação explícita e saldo canónico. Não executa transferências bancárias.</p>{context.single_installation?<button onClick={()=>navigate('native_payables')}>Escolher Financeiro e continuar pagamentos e afectações</button>:<a href="/supplier-payments">Abrir pagamentos e afectações sintéticas</a>}</section>}
{page === 'industrial_costs' && <IndustrialCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page==='multiline_invoices'&&<React.Suspense fallback={<p>A abrir facturação…</p>}><MultiLineInvoiceWorkspace key={context.token} context={context} api={api} can={can} navigate={navigate} actor={state.user.id} markClean={element=>{if(element)for(const changed of transitions.current.dirty)if(changed===element||element.contains?.(changed))transitions.current.dirty.delete(changed);}}/></React.Suspense>}
{page==='fixed_assets'&&<React.Suspense fallback={<p>A abrir activos…</p>}><FixedAssetsWorkspace key={context.token} context={context} can={can} api={api} perform={perform} busy={busy}/></React.Suspense>}
{page==='internal_statements'&&<React.Suspense fallback={<p>A abrir demonstrações internas…</p>}><InternalStatementsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
{page==='hr_personnel'&&<React.Suspense fallback={<p>A abrir contratos, assiduidade e férias…</p>}><PersonnelWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
{page==='remunerations'&&<React.Suspense fallback={<p>A abrir Recursos Humanos…</p>}><RemunerationsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
+9
View File
@@ -14,3 +14,12 @@ export const fiscalMapDownload=args=>verifiedCsvDownload({...args,pathForId:id=>
export const remunerationDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/remunerations/exports/'+id});
export const personnelDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/hr-personnel/exports/'+id});
/** Readable view is derived; the original artifact SHA and render format are checked separately. */
export async function personnelReadableDownload({id,token,sourceSha256,isCurrent,fetcher=fetch}){
if(!Number.isSafeInteger(Number(id))||Number(id)<1||!token||!/^[a-f0-9]{64}$/.test(sourceSha256))throw Error('Versão ou fonte indisponível. Consulte novamente.');
const check=()=>{if(!isCurrent())throw Error('O contexto mudou. O resumo anterior foi ignorado.');};check();
const r=await fetcher('/api/hr-personnel/readable/'+Number(id),{credentials:'same-origin',headers:{Accept:'text/csv','X-SGF-Context':token}});check();
if(!r.ok){const problem=await r.json().catch(()=>({})),e=Error(problem.message||'Não foi possível consultar o resumo desta versão.');e.status=r.status;throw e;}
const expected=r.headers.get('x-content-sha256');if(!/^text\/csv(?:;|$)/i.test(r.headers.get('content-type')??'')||r.headers.get('x-source-sha256')!==sourceSha256||r.headers.get('x-sgf-read-format')!=='SGF-HR-READABLE-CSV-v1'||!/^[a-f0-9]{64}$/.test(expected??''))throw Error('O resumo não corresponde à versão conservada.');
const bytes=await r.arrayBuffer();check();const digest=await crypto.subtle.digest('SHA-256',bytes);check();const actual=[...new Uint8Array(digest)].map(v=>v.toString(16).padStart(2,'0')).join('');if(actual!==expected)throw Error('O resumo não confere com o SHA da resposta.');return{bytes,sha256:actual,sourceSha256};
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Read existing, authorized pages. No cross-module queries, writes or invented aggregates.
export const moduleGuides={
human_resources:{label:'Recursos Humanos',purpose:'Acompanhar cadastro fictício e remunerações internas com identidade e direitos próprios.',steps:['Cadastro revisto','Regras artificiais revistas','Processamento individual','Revisão distinta','Mapa conservado'],evidence:'Cada versão conserva fonte, autor, fundamento e resultado explicável; a exportação confere por SHA.',boundary:'Processamento interno QA não é salário pago nem cálculo legal IRPS/INSS. Contratos, assiduidade e férias continuam pendentes.',next:'A ligação salarial a Financeiro e Contabilidade ainda não cria dívida, pagamentos ou lançamentos.'},
human_resources:{label:'Recursos Humanos',purpose:'Acompanhar cadastro, contratos, assiduidade, férias e remunerações internas com identidade e direitos próprios.',steps:['Cadastro e contrato revistos','Plano e declaração de tempos','Reserva e gozo de férias','Conferência do período','Remunerações e mapas conservados'],evidence:'Cada versão conserva fonte, autor, fundamento e resultado explicável; a exportação confere por SHA.',boundary:'Processamento interno QA não é salário pago nem cálculo legal IRPS/INSS. O calendário legal e os modelos oficiais permanecem pendentes; dias sem registos não são faltas presumidas.',next:'A ligação salarial a Financeiro e Contabilidade ainda não cria dívida, pagamentos ou lançamentos.'},
commercial:{label:'Comercial',purpose:'Acompanhar clientes, propostas, documentos e a passagem para recebimentos.',steps:['Cliente e serviço','Orçamento e aceitação','Rascunho e revisão','Documento de teste','Recebível e afectação'],evidence:'Número, cliente, versão, emissão e PDF identificam o documento. O recibo conserva a afectação de origem.',boundary:'Orçamento e rascunho não são receita. O saldo de uma factura pode aparecer em vários documentos ligados; não deve ser somado.',next:'A cobrança é acompanhada no Financeiro, conservando a factura de origem.'},
purchasing:{label:'Compras',purpose:'Acompanhar a aquisição desde a necessidade até à recepção e obrigação reconhecida.',steps:['Requisição','Propostas e avaliação','Adjudicação e ordem','Recepção e factura','Obrigação reconhecida'],evidence:'O dossiê mantém decisões, propostas, documentos recebidos, factura conferida e a origem da obrigação.',boundary:'Receber material não comprova pagamento. Uma factura pendente não reserva nem confirma automaticamente uma obrigação.',next:'A recepção alimenta o mesmo Stock; a obrigação segue para conferência financeira individual.'},
stock:{label:'Stock',purpose:'Conhecer o físico por artigo e armazém e rastrear entradas, saídas, trânsito e contagens.',steps:['Artigo e armazém','Recepção ou saída','Transferência e recepção','Lotes e contagem','Conferência da valorização'],evidence:'Cada movimento conserva quantidade, unidade, armazém e origem. Lotes e trânsito têm decisões próprias.',boundary:'Quantidades de unidades diferentes não são somadas. Físico, reservado, bloqueado e disponível não são equivalentes.',next:'Consumos e devoluções da Oficina mantêm a entrega original; valorização é conferida na Contabilidade.'},