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

36 lines
17 KiB
React

import React,{useEffect,useMemo,useRef,useState}from'react';
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
const PersonnelSourcePicker=React.lazy(()=>import('./PersonnelWorkspace.jsx').then(m=>({default:m.PersonnelSourcePicker})));
import{remunerationDownload}from'./statement-download.js';
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
const states={prepared:'Preparado — sem aprovação',approved:'Revisto e aprovado',refused:'Recusado'},kinds={worker:'Cadastro fictício',rules:'Regras artificiais',payroll:'Processamento interno'};
export const remunerationMoney=c=>c===null||c===undefined?'Indeterminado':new Intl.NumberFormat('pt-MZ',{minimumFractionDigits:2,maximumFractionDigits:2}).format(Number(c)/100)+' MZN';
export function checkedRemunerations(data,q){if(data.policy!=='SGF-REMUNERATIONS-QA-v1'||data.synthetic_only!==true||data.legal_payroll_validated!==false||data.kind!==q.kind||data.records?.current_page!==q.page||!Array.isArray(data.records?.data)||data.records.data.length>Number(q.per_page)||data.records.data.some(r=>r.kind!==q.kind||(q.record_key&&r.record_key!==q.record_key)))throw Error('Resposta de outro âmbito/página. Consulte novamente.');return data;}
function SourcePicker({kind,api,context,value,onChange,disabled}){
const[query,setQuery]=useState({kind,page:1,per_page:25}),[search,setSearch]=useState(''),[data,setData]=useState(null),[error,setError]=useState('');
useEffect(()=>{let active=true;setData(null);api('/api/remunerations?'+qs(query)).then(d=>{if(active)setData(checkedRemunerations(d,query));}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[context.token,query]);
return <fieldset disabled={disabled}><legend>{kind==='worker'?'Escolher colaborador fictício revisto':'Escolher regras revistas'}</legend><label>Pesquisar fonte<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>setQuery(q=>({...q,search,page:1}))}>Pesquisar {kinds[kind]}</button>{error&&<p role="alert">{error}</p>}<label>Fonte explícita<select required value={value} onChange={e=>onChange(e.target.value)}><option value="">Escolher…</option>{value&&!data?.records.data.some(r=>r.record_key===value)&&<option value={value}>Escolha anterior: {value}</option>}{data?.records.data.filter(r=>r.effective&&(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.label} · 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 — {kinds[kind]}</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 — {kinds[kind]}</button></>}</fieldset>;
}
function Calculation({value}){if(!value)return null;return <section><h3>Cálculo artificial explicável</h3><p>Bruto{remunerationMoney(value.gross_cents)} · descontos{remunerationMoney(value.deductions_cents)} · líquido interno{remunerationMoney(value.net_cents)} · custo interno{remunerationMoney(value.employer_cost_cents)}.</p><p>{value.complete?'Parâmetros do exercício completos.':'Parâmetros em falta: aprovação completa bloqueada.'} Sem validação legal de IRPS/INSS.</p><div className="table-scroll"><table><thead><tr><th>Rubrica QA</th><th>Percentagem artificial</th><th>Valor documental</th><th>Exacto / diferença em centavos</th></tr></thead><tbody>{Object.entries(value.rubrics).map(([k,r])=><tr key={k}><td>{{worker_rate:'Desconto do trabalhador',employer_rate:'Encargo patronal',retention_rate:'Retenção artificial'}[k]}</td><td>{r.rate??'Em falta'}</td><td>{remunerationMoney(r.cents)}</td><td>{r.exact??'Indeterminado'} / {r.difference??'Indeterminado'}</td></tr>)}</tbody></table></div></section>;}
export default function RemunerationsWorkspace({api,context}){
const[query,setQuery]=useState({kind:'worker',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 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,...(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 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>}</>}
{selected&&<section><h2>Registo escolhido · v{selected.version}</h2><pre>{JSON.stringify(selected.data.input,null,2)}</pre><Calculation value={selected.data.calculation}/><p>Estado anterior: v{selected.impact.before.version} · {selected.impact.before.state??'Sem registo'}. Estado posterior: v{selected.impact.after.version} · {states[selected.state]}.</p><button disabled={protectedForm} 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={protectedForm} onClick={()=>start(selected.effective?'amend':'establish')}>Preparar {selected.effective?'correcção':'nova versão'}</button>{query.kind==='payroll'&&selected.effective&&<button disabled={protectedForm} onClick={()=>start('cancel')}>Preparar cancelamento interno</button>}</>}{!query.record_key&&data.capabilities.review&&selected.state==='prepared'&&<button disabled={protectedForm} onClick={()=>start('',true)}>Rever preparação alheia</button>}{selected.state==='approved'&&selected.kind==='payroll'&&<button disabled={protectedForm} onClick={()=>download(selected)}>Descarregar mapa conservado desta versão</button>}</section>}
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='review'?'Revisão distinta':'Preparação individual'} · {kinds[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}. A versão e as fontes serão conferidas outra vez na confirmação.</p>{form.kind==='worker'?<>{textInput('code','Código pseudónimo QA','text',{pattern:'QA-[A-Z0-9-]{2,60}',readOnly:form.version>0})}<label>Estado<select required value={form.data.status} onChange={e=>change('status',e.target.value)}><option value="active">Activo</option><option value="suspended">Suspenso</option></select></label></>:form.kind==='rules'?<>{textInput('label','Designação das regras')}{textInput('valid_from','Vigência desde','date')}{textInput('valid_to','Vigência até','date')}{['worker_rate','employer_rate','retention_rate'].map((key,i)=>textInput(key,['Desconto trabalhador % artificial','Encargo patronal % artificial','Retenção % artificial'][i],'number',{required:false,min:0,max:100,step:'0.0001'}))}{textInput('source','Fonte do exercício QA (não legal)')}</>:<><SourcePicker kind="worker" api={api} context={context} value={form.data.worker_key} onChange={v=>change('worker_key',v)} disabled={locked||form.version>0}/><SourcePicker kind="rules" api={api} context={context} value={form.data.rules_key} onChange={v=>change('rules_key',v)} disabled={locked||form.data.operation==='cancel'}/>{context.hr_personnel_enabled&&<React.Suspense fallback={<p>A abrir contratos…</p>}><PersonnelSourcePicker kind="contract" workerKey={form.data.worker_key} api={api} context={context} optional value={form.data.contract_key??''} onChange={key=>change('contract_key',key)} disabled={locked||form.data.operation==='cancel'}/><p>Ligação opcional explícita. Introduza o bruto conforme o contrato revisto; não recalcula versões anteriores.</p></React.Suspense>}{textInput('period','Período artificial','month',{readOnly:form.version>0})}{textInput('gross','Bruto MZN (duas casas decimais)','text',{pattern:'[0-9]{1,9}\\.[0-9]{2}',readOnly:form.data.operation==='cancel'})}</>}</>}<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}, {preview.impact.before.state??'Sem registo'}; depois v{preview.impact.after.version}, {states[preview.impact.after.state]} · {preview.impact.after.proposal.status}.</p><pre>{JSON.stringify(preview.impact.after.proposal.input,null,2)}</pre><Calculation value={preview.impact.after.proposal.calculation}/><p>Fundamento: {form.reason}. Sem pagamento/dívida/lançamentos ou cálculo legal.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria: <code>{preview.key}</code></p></section>}{operation.uncertain&&<><p role="alert">Resultado incerto. Conserve esta decisão e chave.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
<h2>Recibo técnico próprio</h2><label>Chave<input disabled={protectedForm} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={protectedForm||!receiptKey} onClick={recover}>Consultar recibo sem executar</button>{receipt&&<p>v{receipt.result.version} · {states[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Recibo de decisão, sem pagamento.</p>}
</section>;
}