This commit is contained in:
2026-10-29 22:42:37 +02:00
parent 78e89a51d2
commit 53ff46b0a2
52 changed files with 2082 additions and 79 deletions
+63 -13
View File
@@ -1,26 +1,76 @@
import React, {useRef, useState} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {visibleBillingSections} from './billing-menu.js';
import {visibleWorkshopSections} from './workshop-menu.js';
import {visibleStockSections} from './stock-menu.js';
import {visibleAccountingSections} from './accounting-menu.js';
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:'purchasing',label:'Compras',items:[item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit)]},
{id:'purchasing',label:'Compras',items:[{id:'purchasing_home',label:'Área de Compras',visible:can('company.view')},item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit)]},
{id:'stock',label:'Stock',status:'Em preparação',items:visibleStockSections(can).map(s=>({id:s.id,label:s.label,informational:true,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:'billing',label:'Facturação',items:visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))},
{id:'billing_support',label:'Configuração documental',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('service_invoice_setup','Séries e emissão 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),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento','workshop_corrections.view',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:[item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
{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')]},
].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></>;
export function navigationTopics(groups) {
const layout=[
{id:'start',label:'Início',groups:['start']},
{id:'commercial',label:'Comercial',groups:['services','billing']},
{id:'purchasing',label:'Compras',groups:['purchasing']},
{id:'stock',label:'Stock',groups:['stock']},
{id:'workshop',label:'Oficinas',groups:['workshop']},
{id:'finance',label:'Financeiro',groups:['receivables','accounting','costs']},
{id:'settings',label:'Configuração',groups:['billing_support','admin']},
];
const known=new Set(layout.flatMap(t=>t.groups));
return [...layout.map(t=>({...t,sections:t.groups.map(id=>groups.find(g=>g.id===id)).filter(Boolean)})),
...groups.filter(g=>!known.has(g.id)).map(g=>({id:g.id,label:g.label,sections:[g]}))]
.filter(t=>t.sections.some(g=>g.items.length));
}
export default function WorkspaceNavigation({groups,page,navigate,user,busy,logout}) {
const [open,setOpen]=useState(null);const root=useRef(null);const triggers=useRef({});
const topics=navigationTopics(groups);
useEffect(()=>{
const outside=e=>{if(root.current&&!root.current.contains(e.target))setOpen(null);};
const escape=e=>{if(e.key==='Escape'&&open){e.preventDefault();setOpen(null);triggers.current[open]?.focus();}};
document.addEventListener('pointerdown',outside);document.addEventListener('keydown',escape);
return ()=>{document.removeEventListener('pointerdown',outside);document.removeEventListener('keydown',escape);};
},[open]);
useEffect(()=>{setOpen(null);},[page,JSON.stringify(groups)]);
function choose(id){navigate(id);setOpen(null);}
function focusItem(e,topic){
if(!['ArrowDown','ArrowUp','Home','End'].includes(e.key))return;
const items=[...root.current.querySelectorAll(`#topic-${topic} .topic-item`)];
if(!items.length)return;e.preventDefault();const index=items.indexOf(e.target);
const next=e.key==='Home'?0:e.key==='End'?items.length-1:e.key==='ArrowDown'?(index+1)%items.length:(index-1+items.length)%items.length;
items[next].focus();
}
function openWithKeyboard(e,id){
if(e.key==='ArrowDown'||e.key==='ArrowUp'){
e.preventDefault();setOpen(id);
requestAnimationFrame(()=>{const items=root.current?.querySelectorAll(`#topic-${id} .topic-item`);(e.key==='ArrowUp'?items?.[items.length-1]:items?.[0])?.focus();});
}
}
return <div className="workspace-topbar" ref={root} onBlur={e=>{if(e.relatedTarget&&!e.currentTarget.contains(e.relatedTarget))setOpen(null);}}>
<a className="topbar-brand" href="/">TEMPO <span>SGF</span></a>
<nav className="topic-navigation" aria-label="Navegação principal por assunto">{topics.map((t,index)=>{
const active=t.sections.some(g=>g.items.some(i=>i.id===page));
if(t.id==='start')return <button className={`topic-trigger ${active?'topic-active':''}`} aria-current={active?'page':undefined} key={t.id} onClick={()=>choose('home')}>Início</button>;
return <div className={`navigation-topic ${index>=4?'topic-align-right':''}`} key={t.id}>
<button className={`topic-trigger ${active?'topic-active':''}`} ref={el=>{triggers.current[t.id]=el;}} aria-expanded={open===t.id} aria-controls={`topic-${t.id}`} onClick={()=>setOpen(open===t.id?null:t.id)} onKeyDown={e=>openWithKeyboard(e,t.id)}>{t.label}<span aria-hidden="true"> ▾</span>{active&&<span className="sr-only"> — área actual</span>}</button>
<div id={`topic-${t.id}`} className="topic-dropdown" hidden={open!==t.id} onKeyDown={e=>focusItem(e,t.id)}><div className="topic-sections">{t.sections.map(g=><section key={g.id}><h2>{g.label}{g.status&&<small>{g.status}</small>}</h2>{g.items.map(i=><button className={`topic-item ${page===i.id?'topic-item-active':''}`} aria-current={page===i.id?'page':undefined} key={i.id} onClick={()=>choose(i.id)}>{i.label}{i.informational&&<small>Em preparação</small>}</button>)}</section>)}</div></div>
</div>;
})}</nav>
<div className="topbar-account"><div><strong>{user.name}</strong><small>{user.email}</small></div><button disabled={busy} onClick={logout}>Sair</button></div>
</div>;
}