Aceita fila administrativa de associacoes com checkpoint local

This commit is contained in:
2026-11-02 11:20:13 +02:00
parent fcf0ada227
commit 340f3bb5fa
15 changed files with 635 additions and 67 deletions
+26 -11
View File
@@ -1,13 +1,28 @@
import React,{useEffect,useState} from 'react';
import React,{useEffect,useRef,useState} from 'react';
const labels={clients:'Clientes',services:'Serviços',service_drafts:'Prestações em rascunho',billing_drafts:'Rascunhos de facturação'};
export default function ModuleRecordsWorkspace({context,can,api}) {
const [data,setData]=useState(null),[error,setError]=useState(''),[busy,setBusy]=useState(false);
async function load(){setData(await api('/api/module-records'));}
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
async function associate(e,area,id,version){
e.preventDefault();const form=e.currentTarget,values=Object.fromEntries(new FormData(form));setBusy(true);setError('');
try{await api('/api/module-records','POST',{area,id,version,...values});await load();form.reset();}catch(e){setError(e.message);}finally{setBusy(false);}
}
return <section className="panel"><h1>Associações de módulos</h1><p>Configuração geral da empresa, sujeita aos direitos de consulta originais. Esta vista permite localizar os dados existentes que ainda não têm associação. Não é uma consulta operacional do módulo activo.</p><p>A associação e a sua retirada geram eventos novos e auditados; o original permanece. Não transfere responsabilidade financeira, não cria uma cópia e não concede acesso. Para associar são necessários gestão de acessos, consulta e edição do registo, além de acesso ao módulo de destino.</p>{error&&<p role="alert">{error}</p>}{!data?<p>A carregar…</p>:<>{Object.entries(data.records).map(([area,records])=><section key={area}><h2>{labels[area]}</h2>{!records.length&&<p>Sem registos neste âmbito autorizado.</p>}{records.map(r=><article className="panel" key={r.id}><strong>#{r.id} · {r.name} · versão {r.version}</strong><p>{r.modules.length?`Associado a: ${r.modules.join(', ')}`:'Sem associação a módulo — pendente de conferência explícita.'}</p>{can('access.manage')&&can(`${area}.edit`,area.endsWith('_drafts')?context.unit?.id??null:null)&&<form onSubmit={e=>associate(e,area,r.id,r.version)}><label>Operação<select name="operation"><option value="associate">Associar</option><option value="remove">Retirar associação por novo evento</option></select></label><label>Módulo de destino<select name="module_id" required defaultValue=""><option value="">Escolher módulo autorizado</option>{data.modules.map(m=><option key={m.id} value={m.id}>{m.label}</option>)}</select></label><label>Motivo e fundamento da associação<input name="reason" required minLength={3} maxLength={500}/></label><button disabled={busy}>Registar decisão explícita</button></form>}</article>)}</section>)}</>}</section>;
const moduleName=(id,modules)=>modules.find(m=>m.id===id)?.label||id;
export function AssociationImpact({review,modules}) {
const names=values=>values.length?values.map(id=>moduleName(id,modules)).join(', '):'Sem associação';
return <section className="panel" aria-label="Impacto da decisão"><h2>Conferir antes de confirmar</h2><p>{review.record.name} · #{review.record.id} · versão {review.record.version}</p><p>Antes: {names(review.before)}</p><p>Depois: {names(review.after)}</p><p>Fundamento: {review.reason}</p><p>{review.impact}</p>{review.warnings?.map(w=><p key={w}>{w}</p>)}<h3>Registo e dependências conferidos</h3><ul>{review.dependencies.map(d=><li key={`${d.area}:${d.id}`}>{labels[d.area]} #{d.id} · versão {d.version} · {names(d.modules)}</li>)}</ul><h3>Histórico recente (até 20 eventos)</h3>{review.history.length?<ul>{review.history.map(h=>{const d=typeof h.details==='string'?JSON.parse(h.details):h.details;return <li key={h.id}>{h.created_at} · autor #{h.actor_id} · {h.action} · {d.reason||'Sem fundamento registado no evento antigo'}{d.before_modules&&<> · antes: {names(d.before_modules)} · depois: {names(d.after_modules)}</>}</li>;})}</ul>:<p>Sem eventos anteriores.</p>}</section>;
}
export default function ModuleRecordsWorkspace(props) { return <Queue key={props.context.token} {...props}/>; }
function Queue({context,can,api}) {
const areas=Object.keys(labels).filter(a=>can(`${a}.view`,a.endsWith('_drafts')?context.unit?.id??null:null)&&(!a.endsWith('_drafts')||context.unit));
const [area,setArea]=useState(areas[0]||'clients'),[state,setState]=useState('pending'),[search,setSearch]=useState(''),[query,setQuery]=useState(''),[page,setPage]=useState(1);
const [data,setData]=useState(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false),[review,setReview]=useState(null),[message,setMessage]=useState(''),[refresh,setRefresh]=useState(0);
const generation=useRef(0);
useEffect(()=>{const current=++generation.current;setLoading(true);setData(null);setReview(null);setError('');
const params=new URLSearchParams({area,state,search:query,page:String(page),per_page:'25'});
api(`/api/module-records?${params}`).then(result=>{if(current===generation.current)setData(result);}).catch(e=>{if(current===generation.current)setError(e.message);}).finally(()=>{if(current===generation.current)setLoading(false);});
return ()=>{generation.current++;};
},[context.token,area,state,query,page,refresh]);
async function preview(e,row){e.preventDefault();setBusy(true);setError('');setMessage('');setReview(null);const current=generation.current;
try{const result=await api('/api/module-records/preview','POST',{area,id:row.id,version:row.version,...Object.fromEntries(new FormData(e.currentTarget))});if(current===generation.current)setReview(result);}catch(e){setError(e.message);}finally{setBusy(false);}}
async function confirm(){setBusy(true);setError('');try{const result=await api('/api/module-records','POST',{review_token:review.review_token});setReview(null);setMessage(result.replayed?'Decisão já registada; repetição reconhecida sem duplicação.':'Decisão individual registada. Dependências preservadas.');setRefresh(n=>n+1);}catch(e){setError(`${e.message} Pode repetir a confirmação se a resposta se perdeu; se o servidor indicar mudança, cancele e faça nova conferência.`);}finally{setBusy(false);}}
function reset(set,value){set(value);setPage(1);setReview(null);}
return <section className="panel"><h1>Conferência de associações de módulos</h1><p>Opções gerais da empresa. Apenas registos do âmbito autorizado; os rascunhos pertencem à unidade activa: {context.unit?.name||'nenhuma seleccionada'}.</p><p>Cada decisão é individual e auditada. Não altera direitos, documentos ou responsabilidade financeira e não associa dependências automaticamente.</p>{error&&<p role="alert">{error}</p>}{message&&<p role="status">{message}</p>}
<fieldset disabled={busy}><legend>Filtros da fila</legend><label>Área<select value={area} onChange={e=>reset(setArea,e.target.value)}>{areas.map(a=><option key={a} value={a}>{labels[a]}</option>)}</select></label><label>Estado<select value={state} onChange={e=>reset(setState,e.target.value)}><option value="pending">Sem eventos de associação</option><option value="associated">Com associação activa</option><option value="removed">Todas as associações retiradas</option><option value="all">Todos</option></select></label><form onSubmit={e=>{e.preventDefault();reset(setQuery,search);}}><label>Nome do registo<input value={search} maxLength={100} onChange={e=>setSearch(e.target.value)}/></label><button>Pesquisar</button></form></fieldset>
{loading&&<p role="status">A carregar…</p>}{data&&<><p>{data.pagination.total} registos · página {data.pagination.page} de {data.pagination.last_page}</p><div className="actions"><button disabled={busy||page<=1} onClick={()=>setPage(p=>p-1)}>Anterior</button><button disabled={busy||page>=data.pagination.last_page} onClick={()=>setPage(p=>p+1)}>Seguinte</button></div>{(data.records[area]||[]).map(row=><article className="panel" key={row.id}><h2>#{row.id} · {row.name} · versão {row.version}</h2><p>Associações: {row.modules.length?row.modules.map(id=>moduleName(id,data.modules)).join(', '):'Sem associação activa — exige decisão explícita.'}</p><h3>Dependências</h3>{row.dependencies_restricted?<p>Conferência bloqueada: dependência fora do âmbito de consulta autorizado.</p>:row.dependencies.length?<ul>{row.dependencies.map(d=><li key={`${d.area}:${d.id}`}>{labels[d.area]} #{d.id} · versão {d.version} · {d.modules.map(id=>moduleName(id,data.modules)).join(', ')||'Sem associação'}</li>)}</ul>:<p>Sem dependências explícitas neste percurso.</p>}
{!row.dependencies_restricted&&can('access.manage')&&can(`${area}.edit`,area.endsWith('_drafts')?context.unit?.id??null:null)&&<form onSubmit={e=>preview(e,row)} onChange={()=>setReview(null)}><fieldset disabled={busy}><legend>Decisão individual</legend><label>Operação<select name="operation"><option value="associate">Associar</option><option value="remove">Retirar associação</option></select></label><label>Módulo de destino<select name="module_id" required defaultValue=""><option value="">Escolher módulo autorizado</option>{data.modules.map(m=><option key={m.id} value={m.id}>{m.label}</option>)}</select></label><label>Fundamento<textarea name="reason" required minLength={3} maxLength={500}/></label><button>Conferir impacto</button></fieldset></form>}</article>)}{!data.records[area]?.length&&<p>Sem registos para estes filtros.</p>}{review&&<><AssociationImpact review={review} modules={data.modules}/><div className="actions"><button disabled={busy} onClick={confirm}>Confirmar esta decisão individual</button><button disabled={busy} onClick={()=>setReview(null)}>Cancelar e voltar à conferência</button></div></>}</>}
</section>;
}