import React, { useEffect, useState, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import '../css/app.css'; import '../css/tempo-theme.css'; import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx'; import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx'; import { DocumentPreviewConfiguration } from './DocumentPreview.jsx'; import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx'; import ServiceFiscalWorkspace from './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} 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'; 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 {stockSections} from './stock-menu.js'; import AccountingMenuWorkspace from './AccountingMenuWorkspace.jsx'; import {accountingSections} from './accounting-menu.js'; import PurchasingHome from './PurchasingHome.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){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 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 ([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' } }); 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

SGF TEMPO

{error || 'A carregar…'}

; if (state.setup_pending) return

SGF TEMPO · PREPARAÇÃO LOCAL

Falta concluir a primeira conta

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.

Concluir no terminal do VS Code

Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1

Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.

{error &&

{error}

}
; if (!state.user) return

BEM-VINDO AO SGF TEMPO

Entrar na aplicação

Use a conta preparada pelo procedimento local autorizado.

{error &&
{error}
} perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} />

Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.

; return
{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;});}}}>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&&resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}
CONTEXTO DE TRABALHO{context?.company.name || 'Seleccione uma empresa'}{context?.unit?.name || 'Sem unidade seleccionada'}
Ambiente local
Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.
{error &&
{error}
}{notice &&
{notice}
}

Apenas os contextos com acesso explícito são apresentados.

{!state.companies.length ?

Sem empresas autorizadas

A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.

: !context ?

Escolha onde vai trabalhar

Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.

:
{page !== 'home' && page !== 'module_records' && context.module_id &&

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')&&}

} {page === 'home' && } {page === 'module_records' && } {page === 'workshop_billing' && } {page === 'workshop_corrections' && } {page === 'company' &&

DADOS BÁSICOS

Empresa

perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') &&

O seu acesso permite consulta. A edição exige uma concessão própria.

}
} {page === 'units' && <>

DADOS BÁSICOS

Unidades autorizadas

{!context.units.length &&

Não tem consulta concedida a nenhuma unidade desta empresa.

}
    {context.units.map(u =>
  • {u.name}
  • )}
{context.unit && perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}
{can('unit.create') &&

Criar unidade

A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.

perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} />
}} {serviceAreas[page] && navigate(area,{area,id})} />} {billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&navigate(area,{area,id,create})}/>} {stockSections.some(s=>s.id===page)&&} {accountingSections.some(s=>s.id===page)&&} {page === 'preview_model' && } {page === 'documents' && } {page === 'workshop' && navigate(id)} />} {workshopSections.some(s=>s.id===page)&&page!=='workshop'&&navigate(area,{area,id})}/>} {page === 'direct_costs' && } {page === 'industrial_budgets' && } {page === 'purchasing_budget' && } {page === 'purchasing_home' && } {page === 'supplier_payables' &&

Obrigações a fornecedores

Percurso sintético ligado ao núcleo comum. Preparação, validação distinta e consulta, sem pagamentos ou vencimento presumido.

Abrir preparação e consulta de obrigações
} {page === 'supplier_payments' &&

Pagamentos e afectações a fornecedores

Ensaio sintético: preparação e confirmação distintas, afectação explícita e saldo canónico. Não executa transferências bancárias.

Abrir pagamentos e afectações sintéticas
} {page === 'industrial_costs' && } {page === 'service_receivables' && } {page === 'service_invoice_setup' && } {page === 'service_fiscal' && } {page === 'catalogue_accounting' && } {page === 'access' && (can('access.manage') ? :

Acesso não autorizado

A gestão de acessos exige uma concessão própria.

)}
}
; } function Login({ busy, onSubmit }) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); return
{ e.preventDefault(); await onSubmit(email, password); setPassword(''); }}>
; } function NameForm({ label, initial, editable, busy, onSave, button = 'Guardar nome' }) { const [name, setName] = useState(initial); return
{ e.preventDefault(); onSave(name); }}>{editable && }
; } 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 <>

AUTORIZAÇÕES EXPLÍCITAS

Gestão de acessos

Actua apenas nesta empresa e pode delegar apenas direitos que possui no mesmo âmbito. Os utilizadores são preparados pelo procedimento local.

{loadError &&

{loadError}

}
{ e.preventDefault(); change(true); }}>