This commit is contained in:
2026-11-02 08:06:45 +02:00
parent e4f520092e
commit 2a4169162b
108 changed files with 3793 additions and 88 deletions
+10
View File
@@ -48,3 +48,13 @@ th{background:#f8fafc;color:#475569}th,td{border-color:var(--tempo-line);line-he
.login-layout>aside p{color:#cbd5e1}.login-layout>aside small{color:#94a3b8}
.login-layout>aside .brand{color:white}.login-card .eyebrow{color:var(--tempo-accent-strong)}
@media(prefers-reduced-motion:reduce){button{transition:none}}
/* Modular shell: general options and the active module have separate landmarks. */
.module-navigation{position:sticky;top:0;z-index:30;background:#fff;border-bottom:1px solid #d9e1e8}
.module-topbar,.module-menu{display:flex;align-items:center;gap:1rem;padding:.7rem 1.5rem;flex-wrap:wrap}
.module-topbar{justify-content:space-between}.module-topbar nav{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.module-topbar label{display:flex;align-items:center;gap:.7rem;margin:0}.module-topbar select{min-width:180px}
.module-menu{background:#f0f4f7}.module-menu>strong{color:#164f4b}.module-menu details{position:relative}
.module-menu summary{cursor:pointer;padding:.5rem}.module-menu details>div{position:absolute;top:100%;left:0;background:#fff;box-shadow:0 6px 24px #1232;min-width:240px;max-height:65vh;overflow:auto;padding:.5rem}
.module-menu details button{display:block;width:100%;text-align:left;margin:.2rem 0}.module-menu [aria-current=page]{background:#e1efed;font-weight:700}
.context-transition-backdrop{position:fixed;inset:0;background:#10233299;z-index:100;display:grid;place-items:center;padding:1rem}.context-transition-dialog{max-width:580px;max-height:90vh;overflow:auto}
@media(max-width:700px){.module-topbar,.module-menu{padding:.6rem}.module-topbar nav{width:100%}.module-menu details>div{position:fixed;left:1rem;right:1rem;top:auto;min-width:0}.module-topbar .topbar-account{display:flex}}
+19
View File
@@ -0,0 +1,19 @@
import React,{useEffect,useRef} from 'react';
export default function ContextTransitionDialog({busy,error,onSave,onDiscard,onCancel}) {
const root=useRef(null);
useEffect(()=>{
const previous=document.activeElement;
root.current?.querySelector('button')?.focus();
return ()=>previous?.isConnected&&previous.focus();
},[]);
function keyboard(e){
if(e.key==='Escape'&&!busy){e.preventDefault();onCancel();}
if(e.key!=='Tab')return;
const controls=[...root.current.querySelectorAll('button:not(:disabled)')];
if(!controls.length){e.preventDefault();return;}
if(e.shiftKey&&document.activeElement===controls[0]){e.preventDefault();controls.at(-1).focus();}
else if(!e.shiftKey&&document.activeElement===controls.at(-1)){e.preventDefault();controls[0].focus();}
}
return <div className="context-transition-backdrop"><section ref={root} className="panel context-transition-dialog" role="dialog" aria-modal="true" aria-labelledby="context-transition-title" onKeyDown={keyboard}><h2 id="context-transition-title">Alterações por guardar</h2><p>Guarde as alterações, descarte-as ou cancele a mudança de contexto. Guardar usa o formulário e as validações actuais.</p>{error&&<p role="alert">{error}</p>}<div className="actions"><button disabled={busy} onClick={onSave}>Guardar e continuar</button><button disabled={busy} onClick={onDiscard}>Descartar e continuar</button><button disabled={busy} onClick={onCancel}>Cancelar</button></div></section></div>;
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
const sections = {
commercial:['services','billing','billing_support'], purchasing:['purchasing'], stock:['stock'],
workshop:['workshop'], finance:['receivables','costs'], accounting:['accounting'],
};
export function moduleGroups(groups, module) {
if(!sections[module])return [];
const own=groups.filter(g => sections[module].includes(g.id));
if(module==='commercial')return own;
const shared=groups.flatMap(g=>g.items).filter(i=>['clients','services','service_drafts','billing_drafts'].includes(i.id));
return shared.length?[...own,{id:'shared_records',label:'Cadastros e rascunhos do módulo',items:shared}]:own;
}
export default function ModuleNavigation({groups, context, page, navigate, changeModule, user, busy, logout}) {
const active=context?.modules?.find(m=>m.id===context.module_id);
const own=moduleGroups(groups,active?.id);
return <div className="module-navigation">
<div className="module-topbar"><button className="topbar-brand" onClick={()=>navigate('home')}>TEMPO <span>SGF</span></button>
<nav aria-label="Menu principal"><label>Módulo activo<select aria-label="Módulo activo" value={active?.id||''} disabled={busy} onChange={e=>changeModule(e.target.value||null)}><option value="">Escolher módulo</option>{context?.modules?.map(m=><option key={m.id} value={m.id}>{m.label}{m.development?' · desenvolvimento':''}</option>)}</select></label></nav>
<nav aria-label="Opções gerais"><button aria-current={page==='home'?'page':undefined} onClick={()=>navigate('home')}>Início</button>{groups.find(g=>g.id==='admin')?.items.map(i=><button key={i.id} aria-current={page===i.id?'page':undefined} onClick={()=>navigate(i.id)}>{i.label}</button>)}</nav>
<div className="topbar-account"><strong>{user.name}</strong><button disabled={busy} onClick={logout}>Sair</button></div>
</div>
<nav className="module-menu" aria-label={active?`Navegação de ${active.label}`:'Navegação do módulo'}><strong>{active?.label||'Nenhum módulo seleccionado'}</strong>{own.map(g=><details key={g.id}><summary>{g.label}</summary><div>{g.items.map(i=><button key={i.id} aria-current={page===i.id?'page':undefined} onClick={()=>navigate(i.id)}>{i.label}{i.informational?' · Em preparação':''}</button>)}</div></details>)}{active&&!own.length&&<span>Sem funcionalidades autorizadas neste contexto.</span>}</nav>
</div>;
}
+13
View File
@@ -0,0 +1,13 @@
import React,{useEffect,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>;
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function navigationGroups(can, 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})),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('access','Acessos','access.manage')]},
{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);
}
+40 -9
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import '../css/app.css';
import '../css/tempo-theme.css';
@@ -14,7 +14,11 @@ import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
import PurchasingBudgetWorkspace from './PurchasingBudgetWorkspace.jsx';
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.jsx';
import {navigationGroups} from './WorkspaceNavigation.jsx';
import ModuleNavigation, {moduleGroups} from './ModuleNavigation.jsx';
import {ContextTransition} from './context-transition.js';
import ModuleRecordsWorkspace from './ModuleRecordsWorkspace.jsx';
import ContextTransitionDialog from './ContextTransitionDialog.jsx';
import WorkspaceHome from './WorkspaceHome.jsx';
import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx';
import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.jsx';
@@ -37,22 +41,47 @@ function App() {
const [busy, setBusy] = useState(false);
const [page, setPage] = useState('home');
const [aSelection, setASelection] = useState(null);
const transitions = useRef(new ContextTransition());
const [pendingTransition, setPendingTransition] = useState(null);
const [transitionBusy, setTransitionBusy] = useState(false);
const context = state?.context;
const currentToken = useRef(context?.token); currentToken.current = context?.token;
const mountedToken = useRef(context?.token);
useEffect(() => { if(mountedToken.current !== context?.token) { transitions.current.discard(); setASelection(null); setPage('home'); mountedToken.current=context?.token; } }, [context?.token]);
useEffect(() => { const leave=e=>{transitions.current.prune(); if(transitions.current.dirty.size||transitions.current.pending){e.preventDefault();e.returnValue='';}}; window.addEventListener('beforeunload',leave); return ()=>window.removeEventListener('beforeunload',leave); }, []);
function transition(action) { transitions.current.prune(); if(transitions.current.pending||busy||transitionBusy) {setError('Aguarde a conclusão da operação antes de mudar de contexto.');return;} if(transitions.current.dirty.size) setPendingTransition(()=>action); else action(); }
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 groups = navigationGroups(can, context?.unit?.id ?? null);
const navigate = (id,selection=null) => { setASelection(selection); setPage(id); setError(''); };
const allGroups = navigationGroups(can, context?.unit?.id ?? null);
const groups = [...allGroups.filter(g=>['start','admin'].includes(g.id)), ...moduleGroups(allGroups,context?.module_id)];
const navigate = (id,selection=null) => transition(async () => {
setError('');
if(!groups.some(g=>g.items.some(i=>i.id===id))) {
const target=context?.modules?.find(m=>moduleGroups(allGroups,m.id).some(g=>g.items.some(i=>i.id===id)));
if(!target){setError('Destino sem módulo ou permissão autorizados.');return;}
try {
const next=await api('/api/context','POST',{tenant_id:context.company.tenant_id,company_id:context.company.id,unit_id:context.unit?.id??null,module_id:target.id});
mountedToken.current=next.context.token;currentToken.current=next.context.token;setState(next);
} catch(e){setError(e.message);return;}
}
transitions.current.discard();setASelection(selection);setPage(id);
});
useEffect(() => { if (!groups.some(g => g.items.some(i => i.id === page))) setPage('home'); }, [context?.token, JSON.stringify(context?.grants), page]);
async function api(path, method = 'GET', body, token = context?.token, commandKey = null) {
const execute = async () => {
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '', ...(commandKey ? { 'Idempotency-Key': commandKey } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401) { await refresh(); }
if ([401,403,409].includes(response.status)) { await refresh(); }
const message = response.status === 419 ? 'A sessão expirou. Actualize a página e entre novamente.' : Object.values(result.errors || {}).flat().join(' ') || result.message || 'Não foi possível concluir a operação.';
throw new Error(message);
}
if(token && token !== currentToken.current) throw new Error('Resposta de um contexto anterior ignorada.');
return result;
};
return method==='GET' ? execute() : transitions.current.command(execute);
}
async function refresh() {
const response = await fetch('/api/session', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
@@ -70,21 +99,23 @@ function App() {
async function select(companyId, unitId = null) {
const company = state.companies.find(c => c.id === Number(companyId));
if (!company) return;
await perform(() => api('/api/context', 'POST', { tenant_id: company.tenant_id, company_id: company.id, unit_id: unitId }), 'Contexto alterado. Os formulários foram recarregados.');
transition(() => perform(() => api('/api/context', 'POST', { tenant_id: company.tenant_id, company_id: company.id, unit_id: unitId }), 'Contexto alterado. Seleccione o módulo.'));
}
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"><WorkspaceNavigation key={context?.token ?? "no-context"} groups={groups} page={page} navigate={navigate} user={state.user} busy={busy} logout={() => perform(() => api("/api/logout", "POST"), "Sessão terminada.")} /><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')&&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} changeModule={module_id=>transition(async()=>{await perform(()=>api('/api/context','POST',{tenant_id:context.company.tenant_id,company_id:context.company.id,unit_id:context.unit?.id??null,module_id}),'Módulo alterado.');})} 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}>
{!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}`}>
{page !== 'home' && page !== 'module_records' && context.module_id && <p className="help" role="status">Cadastros e rascunhos: apenas associações explícitas ao módulo activo. Dados anteriores sem associação estão preservados e pendentes de conferência pelo responsável. {can('access.manage')&&<button onClick={()=>navigate('module_records')}>Associações de módulos</button>}</p>}
{page === 'home' && <WorkspaceHome context={context} />}
{page === 'module_records' && <ModuleRecordsWorkspace context={context} can={can} api={api}/>}
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'workshop_corrections' && <WorkshopCorrectionsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => navigate(area,{area,id})} />}
{billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&<BillingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null,create=false)=>navigate(area,{area,id,create})}/>}
{stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
{accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
+32
View File
@@ -0,0 +1,32 @@
// A single coordinator for page, module, company and unit transitions.
// Saving uses the existing form and its existing command/validation path.
export class ContextTransition {
constructor() { this.dirty = new Set(); this.pending = 0; this.saved = 0; this.failed = 0; this.versions = new WeakMap(); this.submitting = null; }
changed(form) { if (form) { this.dirty.add(form); this.versions.set(form,(this.versions.get(form)||0)+1); } }
prune() { for (const form of this.dirty) if (!form.isConnected) this.dirty.delete(form); }
async command(operation) {
this.pending++;
const form=this.submitting, version=form&&this.versions.get(form);
try { const result = await operation(); this.saved++; if(form&&version===this.versions.get(form))this.dirty.delete(form); return result; }
catch (e) { this.failed++; throw e; }
finally { this.pending--; }
}
async save() {
this.prune();
for (const form of [...this.dirty]) {
if (!form.requestSubmit) throw new Error('Este editor exige gravação explícita no próprio percurso. Cancele a mudança, guarde e tente novamente.');
if (!form.reportValidity()) throw new Error('Corrija os campos antes de mudar de contexto.');
const saved = this.saved, failed = this.failed;
const version=this.versions.get(form);
this.submitting=form;
form.requestSubmit();
// React handlers start commands synchronously or after a short validation step.
await new Promise(resolve => setTimeout(resolve, 100));
while (this.pending) await new Promise(resolve => setTimeout(resolve, 40));
if (this.failed !== failed || this.saved === saved) throw new Error('Não foi possível guardar. O contexto foi mantido.');
if(version!==this.versions.get(form))throw new Error('Existem alterações posteriores à gravação. O contexto foi mantido.');
this.dirty.delete(form);
}
}
discard() { this.dirty.clear(); }
}