Files
2026-11-11 15:04:01 +02:00

204 lines
33 KiB
React

import GeographyLink from './GeographyLink.jsx';
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';
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
const ServiceFiscalWorkspace=React.lazy(()=>import('./ServiceFiscalWorkspace.jsx'));
import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
import PurchasingBudgetWorkspace from './PurchasingBudgetWorkspace.jsx';
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
import {navigationGroups} from './WorkspaceNavigation.jsx';
import ModuleNavigation, {moduleGroups,moduleFunctionAllowed} from './ModuleNavigation.jsx';
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'));
const FiscalObligationsWorkspace=React.lazy(()=>import('./FiscalObligationsWorkspace.jsx'));
const PayablePositionsWorkspace=React.lazy(()=>import('./PayablePositionsWorkspace.jsx'));
const ReceivablePositionsWorkspace=React.lazy(()=>import('./ReceivablePositionsWorkspace.jsx'));
const WorkspaceHome=React.lazy(()=>import('./WorkspaceHome.jsx'));
import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx';
import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.jsx';
import BillingMenuWorkspace from './BillingMenuWorkspace.jsx';
import {billingSections} from './billing-menu.js';
import WorkshopMenuWorkspace from './WorkshopMenuWorkspace.jsx';
import {workshopSections} from './workshop-menu.js';
import StockMenuWorkspace from './StockMenuWorkspace.jsx';
import {openStockReceipt} from './stock-receipt-trace.js';
import {stockSections} from './stock-menu.js';
import AccountingMenuWorkspace from './AccountingMenuWorkspace.jsx';
import {accountingSections} from './accounting-menu.js';
import PurchasingHome from './PurchasingHome.jsx';
import {presentationLanding} from './presentation-landing.js';
const CommercialQuotesWorkspace=React.lazy(()=>import('./CommercialQuotesWorkspace.jsx'));
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
function App() {
const [state, setState] = useState(null);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
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||document.querySelector('[data-sgf-operation-uncertain="true"]')){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||document.querySelector('[data-sgf-operation-uncertain="true"]')) {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 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('');
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);
});
const openModulePage=(module_id,id)=>transition(async()=>{
if(!moduleFunctionAllowed(context,allGroups,module_id,id)){setError('Função sem módulo ou permissão autorizados.');return;}
setError('');try{if(context.module_id!==module_id){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});mountedToken.current=next.context.token;currentToken.current=next.context.token;setState(next);}transitions.current.discard();setASelection(null);setPage(id);}catch(e){setError(e.message);}
});
useEffect(() => { if (!groups.some(g => g.items.some(i => i.id === page))) setPage('home'); }, [context?.token, JSON.stringify(context?.grants), page]);
const initialLanding=useRef(false);
useEffect(()=>{if(initialLanding.current||!context?.token)return;initialLanding.current=true;if(!context.single_installation)return;const url=new URL(window.location.href),start=url.searchParams.get('start');if(!start)return;const target=presentationLanding(context,groups,start);if(target)setPage(target);else setError('Entrada sem página ou permissão autorizadas. Escolha um percurso no módulo actual.');url.searchParams.delete('start');window.history.replaceState(null,'',url.pathname+url.search+url.hash);},[context?.token]);
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 ([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.';
const error=new Error(message);error.status=response.status;throw error;
}
if(token && token !== currentToken.current) throw new Error('Resposta de um contexto anterior ignorada.');
return result;
};
return method==='GET' ? execute() : transitions.current.command(execute);
}
const openStockReceiptOrigin = movement => transition(async()=>{
setError('');
try { await openStockReceipt({movement,context,active:()=>currentToken.current===context.token,
select:body=>api('/api/context','POST',body),
commit:next=>{mountedToken.current=next.context.token;currentToken.current=next.context.token;setState(next);},
go:href=>location.assign(href)});
}catch(e){setError(e.message);}
});
async function refresh() {
const response = await fetch('/api/session', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Não foi possível carregar a sessão.');
const next = await response.json(); setState(next);
if (next.notice) setNotice(next.notice);
}
useEffect(() => { refresh().catch(e => setError(e.message)); }, []);
async function perform(operation, message) {
setBusy(true); setError(''); setNotice('');
try { await operation(); await refresh(); if (message) setNotice(message); }
catch (e) { setError(e.message); }
finally { setBusy(false); }
}
async function select(companyId, unitId = null) {
const company = state.companies.find(c => c.id === Number(companyId));
if (!company) return;
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" 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}`}>
{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' && <React.Suspense fallback={<p role="status">A abrir início do módulo…</p>}><WorkspaceHome context={context} groups={groups} can={can} api={api} navigate={navigate} openPage={openModulePage}/></React.Suspense>}
{page==='stock_catalogue'&&context.stock?.catalogue===true&&<section className="panel"><h1>Artigos de Stock</h1><p>Pesquisar o catálogo, preparar um novo artigo e submeter a revisão distinta. Artigos anteriores permanecem só para consulta; aprovar não cria saldo inicial ou movimento.</p><a href="/stock/catalogue">Abrir cadastro, ficha e histórico de artigos</a></section>}
{page === 'module_records' && <ModuleRecordsWorkspace context={context} can={can} api={api}/>}
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} navigate={navigate} />}
{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.')} /><GeographyLink enabled={context.geography_enabled} kind="company" id={context.company.id}/>{!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>{context.unit&&<GeographyLink enabled={context.geography_enabled} kind="unit" id={context.unit.id}/>}{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) => navigate(area,{area,id})} />}
{billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&!(page==='billing_quotes'&&context.commercial_quotes_enabled===true)&&<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} api={api} openReceiptOrigin={openStockReceiptOrigin}/>}
{accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate} api={api}/>}
{page==='native_payables'&&<section className="panel"><h1>Financeiro · fornecedores</h1><p>Consultar a obrigação original de Compras, preparar factos de pagamento, conferir afectações e reversões por identidades distintas. Sem outra dívida ou execução de dinheiro real.</p><a href="/finance/payables">Abrir o percurso financeiro integrado</a></section>}
{page==='cash'&&<section className="panel"><h1>Financeiro · Caixa</h1>{context.cash_enabled?<><p>Abertura e contagem explícitas, classificação individual de um recebimento efectivo de teste, despesa fictícia e revisão distinta. Sem dinheiro real, ajuste automático ou reabertura.</p><a href="/finance/cash">Abrir Caixa QA, extracto e histórico</a></>:<p>O percurso de Caixa ainda não está disponibilizado neste contexto.</p>}</section>}
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} initialOrderId={aSelection?.area==='workshop'?aSelection.id:null} navigate={id=>navigate(id)} />}
{workshopSections.some(s=>s.id===page)&&page!=='workshop'&&<WorkshopMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null)=>navigate(area,{area,id})}/>}
{page === 'direct_costs' && <DirectCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'industrial_budgets' && <IndustrialBudgetsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'purchasing_budget' && <PurchasingBudgetWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'purchasing_home' && <PurchasingHome context={context} can={can} navigate={navigate}/>}
{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>}
{page==='fiscal_obligations'&&<React.Suspense fallback={<p>A abrir obrigações…</p>}><FiscalObligationsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
{['payable_positions','payable_aging'].includes(page)&&<React.Suspense fallback={<p>A abrir posições de fornecedores…</p>}><PayablePositionsWorkspace aging={page==='payable_aging'} key={context.token+':'+page} context={context} api={api}/></React.Suspense>}
{page === 'receivable_positions' && <React.Suspense fallback={<p>A abrir posições…</p>}><ReceivablePositionsWorkspace context={context} api={api} onOpen={id=>navigate('service_receivables',{area:'service_receivables',id})}/></React.Suspense>}
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} initialObligationId={aSelection?.area==='service_receivables'?aSelection.id:null}/>}
{page === 'service_invoice_setup' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page==='billing_quotes'&&context.commercial_quotes_enabled===true&&<React.Suspense fallback={<p role="status">A abrir orçamentos…</p>}><CommercialQuotesWorkspace key={context.token} api={api} context={context} can={can} navigate={navigate} actorId={state.user.id}/></React.Suspense>}
{page==='financial_indicators'&&<section className="panel"><h1>Critérios e indicadores financeiros QA</h1>{context.analysis_qa_enabled?<><p>Fontes conciliadas, critérios revistos e retratos explicáveis, sem rácios gerais ou recomendação de pagamento.</p><a href="/finance/indicators">Abrir critérios, indicadores e histórico</a></>:<p>Este percurso ainda não está disponibilizado neste contexto.</p>}</section>}
{page==='bank_reconciliation'&&<section className="panel"><h1>Conferência de extracto QA</h1>{context.bank_qa_enabled?<><p>Extracto artificial, correspondências individuais com o diário existente e revisão distinta. Sem pagamentos ou banco real.</p><a href="/finance/bank">Abrir extracto, conferência e histórico</a></>:<p>O percurso ainda não está disponibilizado neste contexto.</p>}</section>}
{page==='fiscal_rules'&&<section className="panel"><h1>Regras fiscais versionadas QA</h1>{context.fiscal_rules_qa_enabled?<><p>Fontes, parâmetros, revisão distinta e cálculo de ensaio por vigência. Sem activação legal ou atribuição automática a documentos.</p><a href="/fiscal/rules">Abrir catálogo, histórico e cálculo QA</a></>:<p>Este percurso ainda não está disponibilizado neste contexto.</p>}</section>}
{page==='fiscal_iva'&&<section className="panel"><h1>Livro e mapa interno de IVA QA</h1>{context.fiscal_qa_enabled?<><p>Escolha explícita da origem, revisão distinta e mapa interno. Sem apuramento completo da empresa ou modelo oficial.</p><a href="/fiscal/iva">Abrir livro, mapa e histórico</a></>:<p>Este percurso ainda não está disponibilizado no contexto.</p>}</section>}
{page === 'service_fiscal' && <React.Suspense fallback={<p>A abrir enquadramento fiscal…</p>}><ServiceFiscalWorkspace key={context.token} context={context} can={can} api={api} perform={perform} busy={busy} /></React.Suspense>}
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
</div>}
</main><footer>SGF TEMPO · {context.single_installation?'Apresentação local · Dados fictícios · Sem validade fiscal':'Âmbito de cada operação sujeito às autorizações e validações próprias'}</footer></div></div>;
}
function Login({ busy, onSubmit }) {
const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
return <form onSubmit={async e => { e.preventDefault(); await onSubmit(email, password); setPassword(''); }}><label>Email<input type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required /></label><label>Palavra-passe<input type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required /></label><button className="primary" disabled={busy} type="submit">{busy ? 'A entrar…' : 'Entrar'}</button></form>;
}
function NameForm({ label, initial, editable, busy, onSave, button = 'Guardar nome' }) {
const [name, setName] = useState(initial);
return <form className="name-form" onSubmit={e => { e.preventDefault(); onSave(name); }}><label>{label}<input value={name} onChange={e => setName(e.target.value)} readOnly={!editable} maxLength={255} required /></label>{editable && <button className="primary" disabled={busy || !name.trim()}>{button}</button>}</form>;
}
function AccessPanel({ context, can, api, perform, busy }) {
const [list, setList] = useState(null); const [loadError, setLoadError] = useState('');
const [email, setEmail] = useState(''); const [scope, setScope] = useState('company'); const [permission, setPermission] = useState('company.view'); const [reason, setReason] = useState('');
const unit = scope === 'company' ? null : Number(scope);
const options = (unit === null ? ['company.view', 'company.edit', 'unit.create', 'access.manage'] : ['unit.view', 'unit.edit']).filter(p => can(p, unit));
useEffect(() => { api('/api/accesses').then(setList).catch(e => setLoadError(e.message)); }, [context.token]);
async function change(grant) { await perform(async () => { await api(grant ? '/api/accesses' : '/api/accesses/revoke', 'POST', { email, permission, unit_id: unit, reason }, context.token); setList(await api('/api/accesses')); }, grant ? 'Concessão registada.' : 'Revogação registada.'); }
return <><section className="panel"><p className="eyebrow">AUTORIZAÇÕES EXPLÍCITAS</p><h1>Gestão de acessos</h1><p>Actua apenas nesta empresa e pode delegar apenas direitos que possui no mesmo âmbito. Os utilizadores são preparados pelo procedimento local.</p>{loadError && <p role="alert">{loadError}</p>}<form onSubmit={e => { e.preventDefault(); change(true); }}><label>Email exacto do utilizador<input type="email" value={email} onChange={e => setEmail(e.target.value)} required /></label><div className="form-grid"><label>Âmbito<select value={scope} onChange={e => { setScope(e.target.value); setPermission(e.target.value === 'company' ? 'company.view' : 'unit.view'); }}><option value="company">Empresa activa</option>{context.units.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}</select></label><label>Permissão<select value={permission} onChange={e => setPermission(e.target.value)}>{options.map(p => <option key={p} value={p}>{labels[p]}</option>)}</select></label></div><label>Motivo da alteração<textarea value={reason} onChange={e => setReason(e.target.value)} maxLength={500} required /></label><p className="help">Não escreva palavras-passe ou outros segredos no motivo. A revogação de consulta da empresa remove também os direitos dependentes, apenas se tiver autoridade sobre todos eles. Revogar a sua própria gestão pode exigir recuperação assistida de acessos.</p><div className="actions"><button className="primary" disabled={busy || !options.includes(permission)}>Conceder acesso</button><button type="button" disabled={busy || !email || !reason || !options.includes(permission)} onClick={() => change(false)}>Revogar acesso</button></div></form></section><section className="panel"><h2>Acessos nesta empresa</h2>{list ? <div className="table-scroll"><table><thead><tr><th>Utilizador</th><th>Permissão</th><th>Âmbito</th></tr></thead><tbody>{list.grants.map(g => { const member = list.members.find(m => m.id === g.user_id); return <tr key={g.id}><td>{member?.name}<small>{member?.email}</small></td><td>{labels[g.permission]}</td><td>{g.unit_id ? context.units.find(u => u.id === g.unit_id)?.name || 'Unidade autorizada' : 'Empresa'}</td></tr>; })}</tbody></table></div> : <p>A carregar acessos…</p>}</section></>;
}
createRoot(document.getElementById('app')).render(<App />);