Preserve grouped navigation and Tempo presentation with visual review limitations

This commit is contained in:
2026-10-28 17:43:56 +02:00
parent 04870f012d
commit 090b7da336
10 changed files with 271 additions and 2 deletions
+4
View File
@@ -1,5 +1,9 @@
# SGF Tempo — instruções locais
- Preservação autorizada em 28/10/2026: registar avaliação técnica da página inicial/menu e criar checkpoint local sem credenciais/dados privados; não presumir revisão visual ou aceitação visual. Encerramento do orçamento permanece em `04870f0` com limitações explícitas. Próximo passo autorizado apenas de especificação: oficina → rascunho de facturação; definir facturabilidade, revisão, preços e duplicações antes de implementar. `110000`, sete concessões e utilização real permanecem por autorizar.
- Autorização posterior de 28/10/2026: implementar página inicial e navegação agrupada após a proposta `docs/HOME_NAVIGATION_PROPOSAL.md`. Ver `docs/HOME_NAVIGATION_DELIVERY.md`. Preservar `04870f0` e anteriores, direitos por contexto e bloqueios. Sem novos acessos, módulos financeiros, contagens/indicadores fictícios ou uso real; fontes agregadas de pendências não implementadas. Prova visual permanece separada, sem presumir aceitação ou checkpoint. `110000` e sete concessões continuam por autorizar.
- Aceitação específica de 28/10/2026: utilizador aceitou o ensaio de orçamento versus realizado com limitação das capturas documentada e autorizou checkpoint exclusivamente local, sem credenciais/dados privados. Ver `docs/INDUSTRIAL_BUDGET_ACCEPTANCE.md`. Não transformar provas pendentes em realizadas; automação Edge bloqueada e capturas não recebidas permanecem limitações. Substitui apenas a pendência de aceitação/encerramento. `110000`, sete concessões e utilização real continuam por autorizar; sem aplicação ou publicação. Próxima prioridade: página inicial e menus agrupados, preservando entregas e direitos existentes.
- Autorização de 28/10/2026: implementar o ensaio de orçamento versus realizado da secção 16 de `docs/INDUSTRIAL_COST_ACCOUNTING_SCOPE.md`, só sintético em `sgf_tempo_test`, preservando `cbdf2b4`, `4a94e8a` e alterações posteriores. Orçamento aprovado como hipótese: 900 material + 600 trabalho + 300 indirectos imputados; comparar versão validada de 1000/500/300, 10 UN, MZN, mesmo produto/lote/período, com desvios +100/−100/0 e total 0; residual 300 separado. Osmane prepara; Samira valida orçamento exacto por identidade distinta e consulta comparação. Direitos próprios, sem herança ou concessões reais. Sem taxa material: 800 conhecidos, comparação incompleta, nunca poupança. Preservar versões/origens e bloqueios SQL/aplicação. Migração `110000` e sete concessões apenas propostas, NÃO aplicar em desenvolvimento. Sem dependências novas, stock, lançamentos, facturação, publicação ou aceitação presumida; checkpoint de encerramento só após aceitação. Ver `docs/INDUSTRIAL_BUDGET_DELIVERY.md` para estado e provas.
+2
View File
@@ -121,6 +121,8 @@ O primeiro núcleo de cálculo de serviço e a política operacional de truncage
O menu **Classificação contabilística**, disponível após actualizar a sessão com os acessos especificamente concedidos, permite preparar e submeter versões PGC-NIRF, validar/recusar sem editar e consultar classificação por data. Categoria e excepções são empresariais; operações e respectivos históricos continuam limitados às unidades autorizadas. As tabelas reais começam vazias, sem contas de movimento ou regras aprovadas por omissão. Consultar [utilização, provas e limites da classificação](docs/CATALOGUE_ACCOUNTING_DELIVERY.md).
## Ensaio isolado de custo directo
Página inicial e menus agrupados: [alterações, verificações e limites visuais](docs/HOME_NAVIGATION_DELIVERY.md). Os atalhos dependem dos acessos existentes; não apresentam contagens de pendências ou indicadores financeiros simulados.
O ensaio de orçamento versus realizado está descrito em [INDUSTRIAL_BUDGET_DELIVERY.md](docs/INDUSTRIAL_BUDGET_DELIVERY.md), com percurso, estado das provas e propostas separadas. Continua limitado a `sgf_tempo_test`; a instalação normal e os acessos novos não são aplicados por arrasto.
O percurso `WORKSHOP-DIRECT-COST-SIM-v1` e as instruções de prova estão em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md). Reutiliza apontamentos verificados sem duplicar consumos; não activa custeio real. Migração `090000` e quatro concessões continuam propostas, não aplicadas.
+35
View File
@@ -0,0 +1,35 @@
# Página inicial e menus agrupados
## Avaliação técnica e preservação local — 28/10/2026
A pedido do utilizador, preservar esta implementação num checkpoint exclusivamente local. Avaliação do agente: organização coerente com os destinos existentes, grupos filtrados por contexto, distinção entre consulta/preparação/validação, ausência de indicadores fictícios e identidade visual alinhada com as folhas públicas do InvesteMoz. Build, renderização por perfil e entrega HTTP aprovados. Esta é avaliação técnica do código e dos resultados automatizados, não aprovação visual pessoal do utilizador. Capturas e verificação visual/interactiva no Edge continuam indisponíveis; o checkpoint não as transforma em provas realizadas. Nenhum segredo ou dado privado incluído, sem push/publicação. Encerramento do ensaio de orçamento conferido em `04870f0` e `docs/INDUSTRIAL_BUDGET_ACCEPTANCE.md`, mantendo expressamente as limitações originais.
## Compatibilização visual com InvesteMoz — 28/10/2026
Por pedido posterior do utilizador, acrescentada `resources/css/tempo-theme.css`, importada depois dos estilos base do SGF. Referência conferida exclusivamente em leitura nas folhas de apresentação públicas `resources/css/app.css` e `resources/css/tempo-header.css` do portal-investidor: cabeçalho #090909, destaque #ed1c24, cinzentos neutros, superfície branca, bordas #e2e8f0 e raio de 10px. Não se acedeu a configurações privadas nem se alteraram ficheiros, dados ou serviços do InvesteMoz.
Aplicados ao SGF: navegação em carvão, marcador activo vermelho, botões principais vermelhos, cabeçalho/conteúdo claros, cartões neutros, hierarquia de títulos, campos e tabelas alinhados com a mesma linguagem visual. Grupos e destinos permanecem os autorizados, sem copiar menu público, marcas ou conteúdos do portal. Verde/âmbar/erro mantêm significado de estados, distinguindo-os da cor de marca. Instrument Sans é só preferência local, com fallback de sistema; nenhum tipo de letra ou recurso externo foi descarregado. Compilação aprovada com Vite 8.3.5 e diff sem erros. Sem nova prova visual no Edge: compatibilização fundamentada em CSS, com revisão visual manual ainda pendente.
Verificação HTTP posterior: na instância `8002`, diagnóstico confirmou `browser-testing`/`sgf_tempo_test`; página inicial, JavaScript e CSS responderam 200. O HTML referencia o ficheiro JavaScript do manifesto actual e o CSS servido contém o destaque #ed1c24. Esta prova confirma a entrega dos ficheiros compilados, sem comprovar o aspecto visual ou a interacção no navegador.
28/10/2026. Implementação autorizada após [mapa e esboço](HOME_NAVIGATION_PROPOSAL.md). Preservado o ensaio aceite em `04870f0`, checkpoints anteriores e limites financeiros. Sem migrações, concessões, novas dependências ou activação real. Sem aceitação presumida ou checkpoint desta alteração.
## Alterações
Barra lateral com oito grupos: Início, Clientes e serviços, Facturação, Recebimentos, Oficinas, Custos e orçamento, Contabilidade e fiscalidade e Administração. Só aparecem grupos com destinos permitidos. Grupo da página actual sempre aberto, opção activa destacada com marcador e `aria-current`; restantes grupos expansíveis com `aria-expanded`. Destinos reaproveitam os ecrãs existentes, sem criar módulos de equipamentos, afectações ou séries independentes. Apresentação do rascunho permanece em Facturação. Empresa/unidade continuam em contexto explícito; mudança de acessos que retire o destino devolve à página inicial quando a sessão actualizada recebe essas concessões.
Página inicial com atalhos de preparação/acompanhamento e validação conforme os direitos, nunca conforme nomes/emails. Consulta mantém atalhos de leitura. As acções abrem os percursos existentes: não criam decisões ou alteram dados no painel inicial. Não há contagens, montantes, gráficos ou listas de tarefas inventados. Consulta agregada de pendências ainda não implementada: mensagem explícita explica que os estados são consultados no respectivo percurso. Consulta não confere preparação/validação.
Cabeçalho fixo com empresa/unidade, ambiente local e aviso de operações reais bloqueadas. Os avisos específicos dos módulos permanecem. Em ecrã pequeno, botão Menu abre um diálogo nativo com fecho por botão/Escape e gestão de foco do navegador. Menu mantém conta actual e saída. Tabelas directamente contidas em painéis têm scroll próprio; contexto e atalhos adaptam-se a uma coluna. Cores base preservadas.
## Verificações e limites
- `scripts/sgf.ps1 npm run build`: aprovado, Vite 8.3.5, sem alteração de dependências.
- `scripts/sgf.ps1 npm exec --offline -c 'node scripts/home-navigation-check.mjs'`: verificações de renderização aprovadas para preparador, validador, leitor, outra unidade e ausência de permissões. Não usam base de dados ou navegador.
- `git diff --check`: aprovado.
- Não se repetiu a bateria financeira: alteração limitada à apresentação, sem mudanças no servidor, cálculos ou base. A prova manual industrial anterior permanece preservada.
- **Sem validação visual nova no Edge ou capturas:** automação continua bloqueada; não foi repetida. Renderização e build não provam comportamento visual ou interacção real do diálogo móvel. Revisão manual a efectuar no navegador.
Ficheiros: `resources/js/WorkspaceNavigation.jsx`, `resources/js/WorkspaceHome.jsx`, integração em `resources/js/app.jsx`, estilos em `resources/css/app.css`, verificação `scripts/home-navigation-check.mjs` e documentação. Não foram aplicadas `110000` ou sete concessões por consequência desta alteração.
Para rever: actualizar `http://127.0.0.1:8002/` enquanto a instância de prova SGF estiver activa, entrar pelas contas sintéticas já preparadas e abrir Início. Conferir grupos de acordo com concessões, preparação versus validação, permanência do grupo actual aberto e menu em janela estreita, Escape e retorno de foco. Não enviar credenciais em capturas. Se a instância estiver parada, seguir o procedimento de retoma de prova isolada documentado, sem executar testes destrutivos sobre a fixture manual.
+60
View File
@@ -0,0 +1,60 @@
# Página inicial e navegação agrupada — proposta
28/10/2026. Primeiro passo de desenho solicitado pelo utilizador; **sem alteração da aplicação**. Preservar o ensaio aceite no checkpoint `04870f0` e anteriores. A migração `110000`, sete concessões e utilização real continuam por autorizar. Este mapa não cria funcionalidades, direitos ou acesso operacional.
## Base actual conferida no código
`resources/js/app.jsx` apresenta uma lista lateral plana, filtrada pelas concessões do contexto, e destaca a página seleccionada. A página inicial tem saudação e dois cartões: empresa e unidade. O cabeçalho mostra o contexto, com selectores de empresa/unidade antes do conteúdo. Os módulos de ensaio apresentam avisos próprios; não há hoje uma lista agregada de tarefas pendentes na página inicial. Esta descrição é conferência de fonte, não captura visual da aplicação em execução.
Captura actual da página inicial/menu ainda não disponível. A automação do Edge permaneceu bloqueada e não deve ser repetida ou contornada. Para ajustar o desenho visual ao ecrã real, a captura manual deve mostrar a página inicial e o menu, sem credenciais; dados pessoais podem ser ocultados. O protótipo abaixo é um esboço proposto, nunca uma captura.
## Mapa proposto e correspondência com o existente
| Grupo | Destinos existentes a reaproveitar | Limites e subdivisões futuras |
| --- | --- | --- |
| Início | Área inicial | Contexto, atalhos permitidos e pendências apenas onde existam dados/consulta autorizada |
| Clientes e serviços | Clientes (`clients`), Catálogo de serviços (`services`), Prestações em rascunho (`service_drafts`) | Manter nomes que distingam rascunho de operação confirmada |
| Facturação | Preparação documental (`documents`), Rascunhos de facturação (`billing_drafts`), Facturas de teste (`service_invoices`), Apresentação do rascunho (`preview_model`) | Documentos/séries pertencem ao percurso existente de facturas de teste; não criar emissão real ou entrada independente sem suporte |
| Recebimentos | Recebimentos simulados (`service_receivables`) | Afectações/reconciliação mantêm-se dentro do percurso de teste existente; sem novas páginas vazias ou ligação bancária |
| Oficinas | Oficina de teste (`workshop`) | Ordens, tarefas e apontamentos continuam no módulo existente; equipamentos só como destino futuro se houver funcionalidade autorizada, nunca botão fictício |
| Custos e orçamento | Custos directos de teste (`direct_costs`), Custos industriais de teste (`industrial_costs`), Orçamento versus realizado (`industrial_budgets`) | Lotes pertencem ao módulo industrial; orçamento/comparação partilham o ecrã actual. Não dividir páginas sem necessidade confirmada |
| Contabilidade e fiscalidade | Classificação contabilística (`catalogue_accounting`), Perfil fiscal do serviço (`service_fiscal`) | Preservar validações e bloqueios fiscais; sem lançamentos |
| Administração | Empresa (`company`), Unidades (`units`), Acessos (`access`) | Apresentação do rascunho pode ficar aqui em vez de Facturação, mas nunca duplicada; configurações adicionais só se existentes e autorizadas |
O grupo só aparece se houver pelo menos um destino permitido. Destino e acção mantêm as verificações existentes; nome/email não atribuem perfil. As designações Osmane/Samira exemplificam distribuições já concedidas, sem associação nominativa no código. Consulta não implica preparação, validação ou administração.
## Esboço da página inicial — estrutura, sem dados inventados
```text
┌────────────────────────┬─────────────────────────────────────────────┐
│ SGF TEMPO │ Empresa seleccionada · Unidade seleccionada │
│ │ Alterar contexto │
│ ● Início ├─────────────────────────────────────────────┤
│ ▸ Clientes e serviços │ Ambiente / avisos de ensaio e bloqueios │
│ ▸ Facturação ├─────────────────────────────────────────────┤
│ ▸ Recebimentos │ Início │
│ ▸ Oficinas │ Trabalho no contexto seleccionado │
│ ▸ Custos e orçamento │ │
│ ▸ Contab. e fiscalidade│ A preparar e acompanhar | A validar │
│ ▸ Administração │ Apenas acções permitidas; estados reais │
│ │ │
│ Conta actual │ Atalhos permitidos │
│ Sair │ Clientes · Rascunhos · Consultas autorizadas │
└────────────────────────┴─────────────────────────────────────────────┘
```
Em destino interno, o grupo dessa página permanece aberto, com opção activa destacada por texto e marcador além da cor. Cabeçalhos expansíveis são botões de teclado com `aria-expanded`; destino activo usa `aria-current`. Abrir um grupo não altera o contexto nem concede acesso.
Para o preparador, dar prioridade a «A preparar e acompanhar», com atalhos para as operações que já pode iniciar e consulta do estado das suas propostas. Para a validadora, dar prioridade a «A validar», com ligações a versões exactas pendentes e acesso de consulta. Quem tiver ambos os tipos de poderes vê ambas as secções, sem poder validar a própria proposta. Não criar uma decisão directamente no cartão inicial: encaminhar para o percurso de revisão existente.
**Pendências:** ainda é necessário definir quais os módulos que fornecem consulta segura e actualizada de pendências. Não inferir contagens no cliente, consultar módulos sem autorização nem tratar erro/indisponibilidade como zero. Onde não houver fonte, mostrar apenas atalhos; não apresentar listas ou números simulados. Distinguir «Sem pendências» (consulta bem-sucedida), «Consulta indisponível» e «Escolha uma unidade». Rótulos do esboço não afirmam que haja tarefas concretas.
## Ecrãs pequenos, estados e contexto
Em ecrã pequeno, menu em painel recolhível por botão «Menu», fechado por Escape, foco devolvido ao botão e destino activo identificável. Empresa/unidade e aviso relevante permanecem visíveis no cabeçalho; selectores podem abrir por «Alterar contexto». Conteúdo em coluna única, alvos de toque confortáveis e tabelas com scroll horizontal próprio, sem esconder montantes/estados ou provocar scroll de toda a página.
Estados com rótulo textual consistente: «Rascunho», «Aguarda validação», «Validado», «Recusado», «Histórico/desactualizado», «Incompleto» e «Operação bloqueada». Verde discreto para validação concluída, âmbar para pendência/incompletude, neutro para histórico e vermelho para erro; cor nunca é a única informação. Preservar cores base do SGF; melhorar espaçamento, alinhamento de montantes e leitura de tabelas. Não introduzir indicadores financeiros, saldos, receitas, margens ou gráficos fictícios.
## Próxima revisão do desenho
Comparar este mapa/esboço com uma captura manual da página inicial e menu actuais. Confirmar onde fica «Apresentação do rascunho» e a primeira selecção de fontes reais de pendências. Só depois alterar a aplicação, preservando permissões, separação empresa/unidade e avisos. Nenhuma nova implementação ou validação visual é declarada por este documento.
+33
View File
@@ -14,3 +14,36 @@
.invoice-test-document{margin:24px 0}.invoice-test-document>p{white-space:pre-wrap;margin:8px 0;line-height:1.6}.invoice-test-document>h3{border-top:1px solid #dce6e1;padding-top:18px;margin-top:26px;color:#176b55}.invoice-test-document form{margin-top:28px;border-top:1px solid #dce6e1;padding-top:24px}.invoice-pdf-link{display:inline-block;padding:12px 18px;background:#176b55;color:#fff;border-radius:7px;text-decoration:none;font-weight:650}
.invoice-test-workspace>.fiscal-notice{position:sticky;top:0;z-index:20}
/* Grouped workspace navigation: permissions remain owned by the existing modules. */
.grouped-sidebar{width:270px;position:sticky;top:0;height:100dvh;overflow-y:auto}
.grouped-sidebar .sidebar-label{margin-top:30px}
.navigation-group{margin:0 0 5px}
.sidebar nav .navigation-heading{display:flex;justify-content:space-between;gap:12px;width:100%;color:#f5fbf7;font-size:.86rem;padding:12px 10px}
.sidebar nav .navigation-item{display:block;width:100%;padding:11px 12px 11px 24px;font-size:.83rem;line-height:1.5;border-left:3px solid transparent;border-radius:0 7px 7px 0}
.sidebar nav .navigation-item.active{border-left-color:#bfe4cf;background:#275b4b;color:white;font-weight:750}
.sidebar button:focus-visible{outline:2px solid #cbe8dc;outline-offset:2px}
.workspace>header{position:sticky;top:0;z-index:25;flex-wrap:wrap;box-shadow:0 2px 8px #123f3508}
.workspace>header>.workspace-safety{flex-basis:100%;margin:4px 0 0}
.workspace-safety{padding:12px 16px;background:#fff6df;border:1px solid #e6d4a2;color:#6e551c;border-radius:8px;margin-bottom:20px;font-size:.85rem;line-height:1.6}
.home-work-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}
.home-work-grid .panel{margin-bottom:20px}
.home-work-grid h2,.home-context h2{margin-top:0}
.home-shortcuts{display:flex;flex-wrap:wrap;gap:10px}
.home-shortcuts button{line-height:1.5;text-align:left}
.home-notice{background:#fff6df;color:#6e551c}
.home-context dl{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:24px 0}
.home-context dt{font-size:.8rem;color:#5d716d;margin-bottom:6px}.home-context dd{margin:0;font-weight:600}
.panel:has(>table){overflow-x:auto}.panel>table{min-width:540px}td,dd{overflow-wrap:anywhere}
.mobile-menu-toggle,.navigation-drawer{display:none}
@media(max-width:800px){
.grouped-sidebar{display:none}.shell{display:block}.workspace>header{padding:20px 18px 20px 92px;min-height:94px}
.mobile-menu-toggle{display:block;position:fixed;top:23px;left:14px;z-index:30;padding:10px}
.navigation-drawer[open]{display:block;width:min(90vw,340px);height:100dvh;max-height:100dvh;max-width:100vw;margin:0;padding:0;border:0;background:#123f35;color:#f5fbf7;overflow-y:auto}
.navigation-drawer::backdrop{background:#102c26aa}.navigation-drawer .sidebar{display:flex;width:100%;min-height:calc(100dvh - 60px);padding:20px}
.navigation-drawer .sidebar nav{display:flex;flex-direction:column;overflow:visible;white-space:normal}.navigation-drawer .sidebar-label{display:block;margin-top:24px}
.navigation-drawer .sidebar-bottom{display:block;padding-top:28px}.drawer-close{margin:12px 16px 0;background:#275b4b;color:white;border-color:#568475}
.navigation-drawer .sidebar-bottom strong,.navigation-drawer .sidebar-bottom small,.navigation-drawer .brand span{display:block}
.workspace main{padding:20px 16px}.home-context dl{grid-template-columns:1fr}.workspace header .local-badge{white-space:normal;text-align:center}
.home-shortcuts{display:grid;grid-template-columns:1fr}.context-card{grid-template-columns:1fr}.workspace header strong{overflow-wrap:anywhere}.workspace header .local-badge{display:block}
}
+50
View File
@@ -0,0 +1,50 @@
/* SGF presentation aligned with the public InvesteMoz/Tempo stylesheet.
No external fonts, assets, services or application dependencies. */
:root{
--tempo-ink:#171717;--tempo-muted:#64748b;--tempo-line:#e2e8f0;
--tempo-canvas:#f8fafc;--tempo-paper:#fff;--tempo-dark:#090909;
--tempo-accent:#ed1c24;--tempo-accent-strong:#b91c1c;--tempo-accent-soft:#fef2f2;
font-family:'Instrument Sans','Segoe UI',system-ui,sans-serif;
color:var(--tempo-ink);background:var(--tempo-canvas);
}
button{color:var(--tempo-ink);border-color:var(--tempo-line);min-height:42px;border-radius:8px;transition:background-color .15s,border-color .15s}
button:hover{background:#f1f5f9;border-color:#cbd5e1}
button.primary,.invoice-pdf-link{background:var(--tempo-accent);border-color:var(--tempo-accent);color:white}
button.primary:hover,.invoice-pdf-link:hover{background:var(--tempo-accent-strong);border-color:var(--tempo-accent-strong)}
input,select,textarea{color:var(--tempo-ink);border-color:#cbd5e1;border-radius:8px}
input[readonly]{background:#f8fafc}
input:focus,select:focus,textarea:focus,button:focus-visible,a:focus-visible{outline:3px solid #ed1c2440;outline-offset:2px}
.muted,.help,footer{color:var(--tempo-muted)}
.eyebrow{color:#64748b;letter-spacing:.12em}
.workspace :is(h1,h2,h3,h4){color:var(--tempo-ink)}
.sidebar,.navigation-drawer[open]{background:var(--tempo-dark);color:white}
.sidebar{border-right:1px solid #ffffff14}
.sidebar .brand{letter-spacing:.09em;border-left:3px solid var(--tempo-accent);padding-left:12px}
.sidebar-label{color:#94a3b8}
.sidebar nav .navigation-heading{color:#e2e8f0;font-weight:600}
.sidebar nav .navigation-heading:hover{background:#ffffff0d}
.sidebar nav .navigation-item{color:#cbd5e1}
.sidebar nav .navigation-item:hover{background:#ffffff0d;color:white}
.sidebar nav .navigation-item.active{background:#ffffff10;color:white;border-left-color:var(--tempo-accent)}
.sidebar-bottom{border-top:1px solid #ffffff1a;margin-top:24px;padding-top:22px}
.sidebar-bottom small{color:#94a3b8}.sidebar-bottom button{border-color:#ffffff30;color:#e2e8f0}
.sidebar-bottom button:hover{background:#ffffff0d}.sidebar button:focus-visible{outline-color:#fb7185}
.workspace>header{background:white;border-bottom:1px solid var(--tempo-line);box-shadow:0 2px 8px #0f172a05;gap:12px}
.workspace header .muted{color:var(--tempo-muted)}
.local-badge{color:#475569;border-color:var(--tempo-line);background:#f8fafc}
.workspace-safety,.home-notice{background:#fffbeb;border-color:#fde68a;color:#854d0e}
.context-card{background:white;border-color:var(--tempo-line);border-radius:10px;margin-bottom:28px;box-shadow:0 1px 2px #0f172a03}
.context-card p{color:var(--tempo-muted)}
.panel{background:var(--tempo-paper);border-color:var(--tempo-line);border-radius:10px;box-shadow:0 1px 3px #0f172a04}
.page-title{margin:24px 0}.page-title h1{letter-spacing:-.035em}
.home-work-grid .panel{border-top:3px solid #171717}
.home-shortcuts{gap:10px}.home-shortcuts button{background:#fff;border-color:var(--tempo-line);font-weight:500}
.home-shortcuts button:hover{background:var(--tempo-accent-soft);border-color:#fecaca;color:var(--tempo-accent-strong)}
.home-context dt{color:var(--tempo-muted)}
th{background:#f8fafc;color:#475569}th,td{border-color:var(--tempo-line);line-height:1.6}td small{color:var(--tempo-muted)}
.unit-list li{border-color:var(--tempo-line)}
.drawer-close{background:#262626;color:white;border-color:#525252}
.login-layout>aside{background:var(--tempo-dark);border-top:5px solid var(--tempo-accent)}
.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}}
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
export default function WorkspaceHome({context,can,groups,navigate}) {
const unit=context.unit?.id??null;
const available=groups.flatMap(g=>g.items);
const links=(rules)=>rules.filter(([,permission,scope])=>can(permission,scope)).map(([id,,,label])=>({id,label:label??available.find(i=>i.id===id)?.label})).filter(i=>available.some(a=>a.id===i.id));
const prepare=links([
['clients','clients.create',null],['services','services.create',null],['service_drafts','service_drafts.create',unit],['billing_drafts','billing_drafts.create',unit],
['documents','issuer_documents.edit',null],['documents','client_documents.edit',null],['documents','draft_documents.edit',unit],
['catalogue_accounting','catalogue_accounting.propose',null],['service_fiscal','service_fiscal.propose',null],
['workshop','workshop.open',unit],['direct_costs','direct_costs.prepare',unit],['industrial_costs','industrial_costs.prepare',unit],['industrial_budgets','industrial_budgets.prepare',unit],
]);
const validate=links([
['catalogue_accounting','catalogue_accounting.approve',null],['service_fiscal','service_fiscal.approve',null],['service_invoices','invoice_series.approve',null],
['service_receivables','service_receivables.confirm',unit],['service_receivables','service_receivables.correction_approve',unit],['workshop','workshop.verify',unit],['direct_costs','direct_costs.validate',unit],['industrial_costs','industrial_costs.validate',unit],['industrial_budgets','industrial_budgets.validate',unit],
]);
function actions(items){return <div className="home-shortcuts">{[...new Map(items.map(i=>[i.id,i])).values()].map(i=><button key={i.id} onClick={()=>navigate(i.id)}>{i.label}<span aria-hidden="true"> →</span></button>)}</div>;}
return <><div className="page-title"><p className="eyebrow">O SEU ESPAÇO DE TRABALHO</p><h1>Início</h1><p className="muted">Escolha o percurso no contexto seleccionado. Cada acção mantém a sua autorização própria.</p></div>
{!context.unit&&<p className="alert home-notice" role="status">Seleccione uma unidade para consultar os percursos de trabalho dessa unidade.</p>}
<div className="home-work-grid">{prepare.length>0&&<section className="panel"><h2>A preparar e acompanhar</h2><p>Abra o percurso para preparar propostas e acompanhar o estado das versões.</p>{actions(prepare)}</section>}{validate.length>0&&<section className="panel"><h2>A validar</h2><p>Consulte as versões no respectivo percurso antes de tomar uma decisão. Não é permitida a auto-validação.</p>{actions(validate)}</section>}</div>
<section className="panel"><h2>Consultas e atalhos permitidos</h2>{actions(available.filter(i=>!['home','company','units','access'].includes(i.id)))}</section>
<p className="help">Esta página não agrega nem conta tarefas pendentes. Abra cada percurso para consultar os estados actuais.</p>
<section className="panel home-context"><h2>Contexto actual</h2><dl><div><dt>Empresa</dt><dd>{context.company.name}</dd></div><div><dt>Unidade</dt><dd>{context.unit?.name??'Sem unidade seleccionada'}</dd></div></dl><div className="actions"><button onClick={()=>navigate('company')}>Consultar empresa</button><button onClick={()=>navigate('units')}>Ver unidades autorizadas</button></div></section>
</>;
}
+25
View File
@@ -0,0 +1,25 @@
import React, {useRef, useState} from 'react';
export function navigationGroups(can, unit) {
const item=(id,label,permission,scope=null)=>({id,label,visible:can(permission,scope)});
return [
{id:'start',label:'Início',items:[{id:'home',label:'Página inicial',visible:true}]},
{id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]},
{id:'billing',label:'Facturação',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit),item('service_invoices','Documentos e séries de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]},
{id:'receivables',label:'Recebimentos',items:[item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit)]},
{id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.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:[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')]},
].map(g=>({...g,items:g.items.filter(i=>i.visible)})).filter(g=>g.items.length);
}
export default function WorkspaceNavigation({groups,page,navigate,user,busy,logout}) {
const [expanded,setExpanded]=useState({});const drawer=useRef(null);
function choose(id){navigate(id);drawer.current?.close();}
function contents(prefix){return <><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav aria-label="Navegação principal">{groups.map(g=>{
const active=g.items.some(i=>i.id===page),open=active||!!expanded[g.id];
return <section className="navigation-group" key={g.id}><button className="navigation-heading" aria-expanded={open} aria-controls={`${prefix}-${g.id}`} onClick={()=>setExpanded(s=>({...s,[g.id]:!open}))}><span>{g.label}</span><span aria-hidden="true">{open?'−':'+'}</span></button><div id={`${prefix}-${g.id}`} hidden={!open}>{g.items.map(i=><button key={i.id} className={`navigation-item ${page===i.id?'active':''}`} aria-current={page===i.id?'page':undefined} onClick={()=>choose(i.id)}>{i.label}</button>)}</div></section>;
})}</nav><div className="sidebar-bottom"><strong>{user.name}</strong><small>{user.email}</small><button disabled={busy} onClick={logout}>Sair da aplicação</button></div></>;}
return <><button className="mobile-menu-toggle" onClick={()=>drawer.current.showModal()} aria-haspopup="dialog">Menu</button><aside className="sidebar grouped-sidebar">{contents('desktop-nav')}</aside><dialog className="navigation-drawer" ref={drawer} aria-label="Menu de navegação"><button className="drawer-close" onClick={()=>drawer.current.close()} autoFocus>Fechar menu</button><div className="sidebar">{contents('mobile-nav')}</div></dialog></>;
}
+9 -2
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } 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';
@@ -12,6 +13,8 @@ import WorkshopWorkspace from './WorkshopWorkspace.jsx';
import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.jsx';
import WorkspaceHome from './WorkspaceHome.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' };
@@ -25,6 +28,10 @@ function App() {
const context = state?.context;
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 => { setPage(id); setError(''); };
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 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(() => ({}));
@@ -56,11 +63,11 @@ function App() {
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"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('industrial_budgets.view',context?.unit?.id ?? null) ? [['industrial_budgets', 'Orçamento versus realizado']] : []), ...(can('industrial_costs.view',context?.unit?.id ?? null) ? [['industrial_costs', 'Custos industriais de teste']] : []), ...(can('direct_costs.view',context?.unit?.id ?? null) ? [['direct_costs', 'Custos directos de teste']] : []), ...(can('workshop.view',context?.unit?.id ?? null) ? [['workshop', 'Oficina de teste']] : []), ...(can('service_receivables.view',context?.unit?.id ?? null) ? [['service_receivables', 'Recebimentos simulados']] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><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></header><main>
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>
{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 === 'home' && <><div className="page-title"><p className="eyebrow">ÁREA INICIAL</p><h1>Bem-vindo, {state.user.name.split(' ')[0]}.</h1><p className="muted">O contexto permanece visível e cada operação é validada pelo servidor.</p></div><div className="summary-grid"><section className="panel"><span className="eyebrow">EMPRESA ACTIVA</span><h2>{context.company.name}</h2><p>Cliente contratante #{context.company.tenant_id} · Empresa #{context.company.id}</p><button onClick={() => setPage('company')}>Consultar empresa</button></section><section className="panel"><span className="eyebrow">UNIDADE ACTIVA</span><h2>{context.unit?.name || 'Ainda não seleccionada'}</h2><p>{context.units.length} unidade(s) com consulta autorizada</p><button onClick={() => setPage('units')}>Ver unidades</button></section></div></>}
{page === 'home' && <WorkspaceHome context={context} can={can} groups={groups} navigate={navigate} />}
{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} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
+28
View File
@@ -0,0 +1,28 @@
// Render-only checks: no browser, database, credentials or additional dependencies.
import assert from 'node:assert/strict';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
try {
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const {default:Home}=await server.ssrLoadModule('/resources/js/WorkspaceHome.jsx');
const context={company:{name:'Empresa sintética'},unit:{id:7,name:'Unidade sintética'}};
const check=(grants)=>{
const can=(permission,unit=null)=>grants.some(g=>g[0]===permission&&g[1]===unit);
const groups=navigationGroups(can,7);
return {groups,html:renderToStaticMarkup(React.createElement(Home,{context,can,groups,navigate:()=>{}}))};
};
const preparer=check([['company.view',null],['industrial_budgets.view',7],['industrial_budgets.prepare',7]]);
assert(preparer.html.includes('A preparar e acompanhar'));assert(!preparer.html.includes('<h2>A validar</h2>'));
assert(!preparer.groups.some(g=>g.id==='billing'));assert(!preparer.groups.flatMap(g=>g.items).some(i=>i.id==='access'));
const reviewer=check([['company.view',null],['industrial_budgets.view',7],['industrial_budgets.validate',7]]);
assert(reviewer.html.includes('<h2>A validar</h2>'));assert(!reviewer.html.includes('<h2>A preparar e acompanhar</h2>'));
const otherUnit=check([['company.view',null],['industrial_budgets.view',99],['industrial_budgets.prepare',99]]);
assert(!otherUnit.groups.some(g=>g.id==='costs'));
const noAccess=check([]);assert.deepEqual(noAccess.groups.map(g=>g.id),['start']);
const reader=check([['company.view',null],['industrial_budgets.view',7]]);
assert(!reader.html.includes('<h2>A validar</h2>'));assert(!reader.html.includes('<h2>A preparar e acompanhar</h2>'));
assert(reader.html.includes('não agrega nem conta tarefas pendentes'));
console.log('Home/navigation render checks passed: preparer, reviewer, reader, other unit and absent permissions. No database or browser used.');
} finally {await server.close();}