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
+13
View File
@@ -47,3 +47,16 @@
.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}
}
/* Facturação: document lists and filters, kept separate from financial engines. */
.billing-section-tabs{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 20px}.billing-section-tabs [aria-current="page"]{background:#125a4b;color:white;border-color:#125a4b}.billing-toolbar{margin-bottom:14px}.billing-filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0 18px}.billing-filters label:first-child{grid-column:1/-1}.billing-number{font-variant-numeric:tabular-nums;white-space:nowrap}.billing-document-list th{white-space:nowrap}.billing-document-detail{overflow-wrap:anywhere}.billing-menu-workspace .fiscal-notice{display:block}.billing-menu-workspace .fiscal-notice strong{margin-right:8px}@media(max-width:560px){.billing-section-tabs button{flex:1 1 140px}.billing-filters{grid-template-columns:1fr}.billing-toolbar button{width:100%}}
/* Stock information menu. No operational balance controls until a source is connected. */
.stock-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}.stock-tabs [aria-current="page"]{background:#125a4b;color:white;border-color:#125a4b}.stock-route-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.stock-route-grid button{text-align:left;padding:18px}.stock-route-grid span{display:block;font-size:.8rem;font-weight:400;margin-top:8px;line-height:1.5}.stock-definitions{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:18px}.stock-definitions div{padding:16px;background:#f3f7f4;border-radius:8px}.stock-definitions dt{font-weight:700}.stock-definitions dd{margin:8px 0 0;line-height:1.6}.stock-unavailable{font-weight:600}.stock-menu-workspace li{line-height:1.65;margin-bottom:8px}@media(max-width:560px){.stock-tabs button{flex:1 1 130px}}
.navigation-status{display:block;font-size:.65rem;font-weight:400;color:#c5dace;margin-top:3px}.shortcut-status{display:block;font-size:.7rem;font-weight:400;color:#5d716d;margin-top:4px}
/* Accounting navigation and report definitions, without accounting balances. */
.accounting-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}.accounting-tabs [aria-current="page"],.accounting-report-list [aria-pressed="true"]{background:#125a4b;color:white;border-color:#125a4b}.accounting-routes,.accounting-report-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}.accounting-routes button,.accounting-report-list button{text-align:left;padding:16px}.accounting-routes span,.accounting-report-list small{display:block;font-size:.8rem;font-weight:400;line-height:1.6;margin-top:8px}.accounting-report-detail{margin-top:24px;padding:18px;border-left:4px solid #4f8775;background:#f3f7f4;overflow-wrap:anywhere}@media(max-width:560px){.accounting-tabs button{flex:1 1 140px}}
/* Horizontal subject navigation. Dropdowns retain every permitted destination. */
.shell{display:block}.workspace-topbar{position:sticky;top:0;z-index:70;display:flex;align-items:center;flex-wrap:wrap;gap:12px 20px;padding:12px 24px;background:var(--tempo-dark,#090909);border-top:4px solid var(--tempo-accent,#ed1c24);color:#fff;box-shadow:0 3px 12px #0002}.topbar-brand{color:#fff;text-decoration:none;font-weight:750;letter-spacing:.1em;white-space:nowrap}.topbar-brand span{font-size:.72rem;letter-spacing:.18em;color:#cbd5e1}.topic-navigation{position:relative;display:flex;align-items:center;flex-wrap:wrap;gap:4px;flex:1}.navigation-topic{position:static}.workspace-topbar .topic-trigger{background:transparent;border:1px solid transparent;color:#e2e8f0;font-size:1rem;padding:10px 12px;white-space:nowrap}.workspace-topbar .topic-trigger:hover,.workspace-topbar .topic-trigger[aria-expanded="true"]{background:#262626;color:#fff}.workspace-topbar .topic-trigger.topic-active{border-bottom-color:var(--tempo-accent,#ed1c24);color:#fff;background:#262626}.topic-dropdown{position:absolute;top:calc(100% + 8px);left:0;width:min(680px,calc(100vw - 32px));max-height:70dvh;overflow-y:auto;padding:18px;background:white;color:var(--tempo-ink,#171717);border:1px solid var(--tempo-line,#e2e8f0);border-radius:10px;box-shadow:0 16px 42px #0003}.topic-dropdown[hidden]{display:none}.topic-align-right .topic-dropdown{right:0;left:auto}.topic-sections{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr));gap:18px}.topic-sections h2{font-size:.88rem;text-transform:uppercase;letter-spacing:.05em;margin:0 0 8px;padding:0 10px 8px;border-bottom:1px solid #e2e8f0}.topic-sections h2 small{display:block;font-size:.65rem;font-weight:400;text-transform:none;letter-spacing:0;color:#64748b;margin-top:4px}.workspace-topbar .topic-item{display:block;width:100%;text-align:left;border:0;border-radius:6px;font-size:.95rem;font-weight:500;padding:10px;color:var(--tempo-ink,#171717);background:white;line-height:1.5}.workspace-topbar .topic-item:hover{background:var(--tempo-accent-soft,#fef2f2)}.workspace-topbar .topic-item-active{background:var(--tempo-accent-soft,#fef2f2);border-left:3px solid var(--tempo-accent,#ed1c24);font-weight:700}.topic-item small{display:block;font-size:.75rem;font-weight:400;color:#64748b}.topbar-account{display:flex;align-items:center;gap:12px;margin-left:auto}.topbar-account strong,.topbar-account small{display:block;max-width:190px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.topbar-account strong{font-size:.8rem}.topbar-account small{font-size:.7rem;color:#cbd5e1}.topbar-account button{background:#262626;color:white;border-color:#525252;font-size:.8rem}.workspace>header{position:static;padding:22px 32px;min-height:0}.workspace main{max-width:1500px;margin:0 auto}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}@media(max-width:1100px){.topic-navigation{order:3;flex-basis:100%;position:relative}.navigation-topic{position:static}.topic-dropdown,.topic-align-right .topic-dropdown{top:calc(100% + 8px);left:0;right:auto;width:min(680px,calc(100vw - 32px))}}@media(max-width:560px){.workspace-topbar{padding:10px 14px;gap:8px}.topic-navigation{gap:3px}.workspace-topbar .topic-trigger{font-size:.9rem;padding:9px 8px}.topic-dropdown,.topic-align-right .topic-dropdown{width:calc(100vw - 28px);max-height:60dvh;padding:14px}.topic-sections{grid-template-columns:1fr}.topbar-account strong,.topbar-account small{max-width:130px}.topbar-account small{display:none}.workspace>header{padding:18px}.workspace main{padding:18px}.workspace header .local-badge{display:block}}
+30
View File
@@ -0,0 +1,30 @@
import React,{useEffect,useState} from 'react';
import {accountingSections,visibleAccountingSections,accountingFunctions,accountingReports,filterAccountingReports} from './accounting-menu.js';
export default function AccountingMenuWorkspace({section,context,can,navigate}) {
const [search,setSearch]=useState(''),[family,setFamily]=useState(''),[selected,setSelected]=useState(null);
useEffect(()=>{setSearch('');setFamily('');setSelected(null);},[context.token]);
if(!visibleAccountingSections(can).length)return <section className="panel"><h1>Contabilidade</h1><p>Sem contexto autorizado para esta consulta.</p></section>;
const active=accountingSections.find(s=>s.id===section);if(!active)return null;
const unit=context.unit?.id??null;
const action=f=>f.target&&(!f.unit||unit!==null)&&can(f.permission,f.unit?unit:null)?<button onClick={()=>navigate(f.target)}>{f.action}</button>:null;
const functions=accountingFunctions.filter(f=>f.section===section);
const report=accountingReports.find(r=>r.id===selected);
return <div className="accounting-menu-workspace"><div className="page-title"><p className="eyebrow">CONTABILIDADE</p><h1>{active.label}</h1><p>{context.company.name}{context.unit?` / ${context.unit.name}`:''}</p></div>
<nav className="accounting-tabs" aria-label="Submenus de contabilidade">{visibleAccountingSections(can).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
<p className="alert home-notice">Navegação em preparação. Classificação contabilística, documentos, obrigações, liquidações e lançamentos têm responsabilidades distintas. Este menu não gera lançamentos ou declarações fiscais.</p>
{section==='accounting_panel'&&<>
<section className="panel"><h2>Trabalho contabilístico no contexto seleccionado</h2><p>Ainda não há fonte agregada de documentos por validar, contas por reconciliar ou estado do fecho. A indisponibilidade não é apresentada como zero pendências.</p><div className="accounting-routes">{accountingSections.filter(s=>s.id!==section).map(s=><button key={s.id} onClick={()=>navigate(s.id)}>{s.label}<span>Consultar percursos e disponibilidade →</span></button>)}</div></section>
<section className="panel"><h2>Percursos já disponíveis</h2><div className="actions">{can('catalogue_accounting.view')&&<button onClick={()=>navigate('catalogue_accounting')}>Classificação contabilística</button>}{can('service_fiscal.view')&&<button onClick={()=>navigate('service_fiscal')}>Perfis fiscais do serviço</button>}{unit!==null&&can('service_receivables.view',unit)&&<button onClick={()=>navigate('service_receivables')}>Recebimentos e reconciliação de teste</button>}</div><p>Estas ligações conservam os direitos e os limites dos respectivos módulos; não representam uma contabilidade geral já integrada.</p></section>
</>}
{functions.map(f=><section className="panel" key={f.id}><h2>{f.label}</h2><p>{f.description}</p><p><strong>Disponibilidade:</strong> {f.status}</p>{action(f)}</section>)}
{section==='accounting_daily'&&<section className="panel"><h2>Origem documental</h2><p>Documento recebido → preparação → classificação → conferência → validação → reconciliação → relatório → fecho. As etapas que ainda não têm implementação não são executadas por este menu.</p><div className="actions">{unit!==null&&can('billing_drafts.view',unit)&&<button onClick={()=>navigate('billing_drafts')}>Abrir rascunhos de facturação existentes</button>}{unit!==null&&can('supplier_payables.view',unit)&&<button onClick={()=>navigate('supplier_payables')}>Abrir obrigações a fornecedores de teste</button>}</div><p>Uma obrigação com principal reconhecido e vencimento desconhecido continua sem classificação automática de atraso. Preparar ou aprovar um pagamento não comprova execução bancária.</p></section>}
{section==='accounting_analysis'&&<section className="panel"><h2>Catálogo de relatórios previstos</h2><p>Catálogo para orientar a implementação. Não contém relatórios gerados, saldos, dados salariais ou bancários.</p><div className="form-grid"><label>Pesquisar relatório<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Área<select value={family} onChange={e=>setFamily(e.target.value)}><option value="">Todas as áreas</option>{[...new Set(accountingReports.map(r=>r.family))].map(f=><option key={f}>{f}</option>)}</select></label></div>
<div className="accounting-report-list">{filterAccountingReports(search,family).map(r=><button key={r.id} aria-pressed={r.id===selected} onClick={()=>setSelected(r.id)}>{r.label}<small>{r.family} · Por implementar</small></button>)}</div>{!filterAccountingReports(search,family).length&&<p>Nenhuma definição de relatório corresponde à pesquisa. Isto não é uma consulta de movimentos.</p>}
{report&&<article className="accounting-report-detail"><h3>{report.label}</h3><p>{report.description}</p><p role="status"><strong>Relatório indisponível.</strong> Falta a fonte contabilística/auxiliar validada e a configuração aplicável. Não há geração, impressão ou exportação nesta página.</p><p>Filtros previstos: empresa, período ou data de referência, moeda e dimensões efectivamente aplicáveis. Os resultados e as exportações terão as mesmas restrições de acesso, com ligação aos documentos de origem.</p></article>}
<p>Não se misturam empresas, períodos ou moedas sem base consistente. Rascunhos, movimentos validados e previsões não são somados como se fossem equivalentes. As demonstrações serão reconciliadas com razão e balancete.</p>
</section>}
{section==='accounting_configuration'&&<section className="panel"><h2>Fiscalidade separada da entrada e da classificação</h2><p>O menu não adopta regras fiscais a partir de referências comerciais ou da comparação com outros produtos. Os perfis fiscais existentes mantêm as suas versões, decisões e bloqueios próprios.</p>{can('service_fiscal.view')&&<button onClick={()=>navigate('service_fiscal')}>Abrir perfis fiscais existentes</button>}</section>}
<section className="panel"><h2>Funções e responsabilidade</h2><p>Gestão acompanha e aprova dentro das suas alçadas; Contabilidade prepara, confere e valida conforme mandato; Tesouraria prepara operações e reconcilia dentro do seu âmbito. Estes nomes descrevem a organização proposta, não atribuem permissões à conta.</p><p>Preparação, aprovação, execução e revisão permanecem distintas. Administração técnica não substitui autorização financeira. Dados bancários, salários, custos e auditoria exigem consulta própria; não são carregados neste mapa.</p><p>Correcções e estornos preservam o original, com motivo e autorização. Nenhum modelo de relatório, menu ou comprovativo activa um cálculo, pagamento, fecho ou obrigação fiscal.</p></section>
</div>;
}
+48
View File
@@ -0,0 +1,48 @@
import React,{useEffect,useState} from 'react';
import {billingSections,visibleBillingSections,filterBillingRows,loadBillingRows} from './billing-menu.js';
const emptyFilters={search:'',type:'',currency:'',series:'',state:'',author:'',from:'',to:''};
const value=v=>v===null||v===undefined||v===''?'Por determinar':String(v);
export function BillingDocumentList({rows,filters,onFilter,onClear,onOpen}) {
const filtered=filterBillingRows(rows,filters);
const options=field=>[...new Set(rows.map(r=>r[field]).filter(v=>v!==null&&v!==undefined&&v!==''))].sort();
return <section className="panel billing-document-list"><h2>Lista de documentos</h2><div className="billing-filters">
<label>Pesquisar número, cliente ou NUIT<input type="search" value={filters.search} onChange={e=>onFilter('search',e.target.value)}/></label>
<label>Tipo<select value={filters.type} onChange={e=>onFilter('type',e.target.value)}><option value="">Todos os tipos disponíveis</option>{options('type').map(t=><option value={t} key={t}>{t==='invoice'?'Factura de teste':'Rascunho'}</option>)}</select></label>
{['currency','series','state','author'].map(field=><label key={field}>{{currency:'Moeda',series:'Série',state:'Estado documental',author:'Autor (identificador registado)'}[field]}<select value={filters[field]} onChange={e=>onFilter(field,e.target.value)}><option value="">Todos</option>{options(field).map(v=><option key={v} value={v}>{v}</option>)}</select></label>)}
<label>Emissão desde<input type="date" value={filters.from} onChange={e=>onFilter('from',e.target.value)}/></label><label>Emissão até<input type="date" value={filters.to} onChange={e=>onFilter('to',e.target.value)}/></label>
</div><button onClick={onClear}>Limpar filtros</button>{filters.from&&filters.to&&filters.from>filters.to?<p role="alert">A data inicial deve ser anterior ou igual à data final.</p>:<>
<p role="status">{filtered.length} documento(s) nesta consulta.</p><div className="table-scroll"><table><thead><tr><th>Número / referência</th><th>Tipo</th><th>Cliente</th><th>Emissão</th><th>Vencimento</th><th>Total</th><th>Moeda</th><th>Saldo por receber</th><th>Estados</th><th>Acções</th></tr></thead><tbody>{filtered.map(r=><tr key={r.key}><td>{r.number}{r.name&&<small>{r.name}</small>}{r.version&&<small>Versão {r.version}</small>}</td><td>{r.typeLabel}</td><td>{value(r.client)}{r.nuit&&<small>NUIT {r.nuit}</small>}</td><td>{r.type==='draft'?'Não emitido':value(r.issuedDate)}</td><td>{r.type==='draft'?'Não aplicável':value(r.dueDate)}</td><td className="billing-number">{r.type==='draft'?'Provisório — por determinar':value(r.total)}{r.base!==undefined&&r.base!==null&&<small>Base aritmética: {r.base} (não é total fiscal)</small>}</td><td>{value(r.currency)}</td><td>{r.type==='draft'?'Não aplicável':'Consultar percurso financeiro'}</td><td>{r.state}<small>Pagamento: {r.payment}</small></td><td><button onClick={()=>onOpen(r)}>Abrir</button></td></tr>)}</tbody></table></div>{!filtered.length&&<p>Nenhum documento corresponde aos filtros nas fontes disponíveis.</p>}</>}
<p className="help">Valores documentais preservados, sem recálculo. Não são somadas moedas diferentes. Ausência de saldo ou vencimento não significa zero, factura paga ou atraso. Filtros de emissão excluem rascunhos e datas desconhecidas.</p>
</section>;
}
export default function BillingMenuWorkspace({section,context,can,api,navigate}) {
const unit=context.unit?.id??null;
const [result,setResult]=useState(null),[loading,setLoading]=useState(false),[reload,setReload]=useState(0),[filters,setFilters]=useState(emptyFilters),[selected,setSelected]=useState(null);
const active=billingSections.find(s=>s.id===section);
useEffect(()=>{setSelected(null);},[section]);
useEffect(()=>{
let current=true;setResult(null);setSelected(null);
if(unit===null||active?.pending)return ()=>{current=false;};
setLoading(true);
loadBillingRows(api,can,unit).then(r=>{if(current){setResult(r);setLoading(false);}});
return ()=>{current=false;};
},[context.token,section,reload]);
if(!active)return null;
const newDraft=()=>navigate('billing_drafts',null,true);
return <div className="billing-menu-workspace"><div className="page-title"><p className="eyebrow">FACTURAÇÃO</p><h1>{active.label}</h1><p>Documentos comerciais no contexto de {context.company.name}{context.unit?` / ${context.unit.name}`:''}.</p></div>
<nav className="billing-section-tabs" aria-label="Submenus de facturação">{visibleBillingSections(can,unit).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
<p className="fiscal-notice"><strong>Demonstração — sem validade fiscal.</strong> A entrada e a consulta não habilitam emissão real. Documento, dívida e recebimento são factos distintos.</p>
{!context.unit&&<section className="panel"><h2>Seleccione uma unidade</h2><p>Escolha uma unidade autorizada para consultar os seus documentos.</p></section>}
{active.pending?<section className="panel"><h2>Capacidade ainda não disponível</h2><p>{active.pending}</p>{section==='billing_receipts'&&can('service_receivables.view',unit)&&<button onClick={()=>navigate('service_receivables')}>Abrir recebimentos e afectações de teste</button>}{section==='billing_credit_notes'&&can('service_invoices.view',unit)&&<button onClick={()=>navigate('service_invoice_setup')}>Consultar facturas e pedidos de correcção existentes</button>}<p>Este submenu identifica o âmbito previsto; não contém documentos simulados nem executa efeitos financeiros.</p></section>:context.unit&&<>
<div className="actions billing-toolbar">{can('billing_drafts.view',unit)&&can('billing_drafts.create',unit)&&<button className="primary" onClick={newDraft}>Novo documento — preparar rascunho</button>}{can('service_invoices.view',unit)&&<button onClick={()=>navigate('service_invoice_setup')}>Séries e emissão de teste</button>}<button disabled={loading} onClick={()=>setReload(r=>r+1)}>Actualizar lista</button></div>
<p className="help">A criação reutiliza uma prestação existente e não emite automaticamente. Guardar rascunho, confirmar internamente, emitir em teste e receber mantêm os seus circuitos próprios.</p>
{loading&&<p role="status">A carregar documentos autorizados…</p>}
{result?.sources.map(s=>s.error?<p className="alert error" role="alert" key={s.label}>{s.label}: consulta indisponível. {s.error} Use Actualizar lista para tentar novamente.</p>:s.notice?<p className="alert home-notice" key={s.label}>{s.label}: {s.notice}</p>:null)}
{result&&!loading&&(result.sources.some(s=>!s.error&&!s.notice)?<BillingDocumentList rows={result.rows.filter(r=>section!=='service_invoices'||r.type==='invoice')} filters={filters} onFilter={(k,v)=>setFilters(f=>({...f,[k]:v}))} onClear={()=>setFilters(emptyFilters)} onOpen={r=>r.type==='draft'?navigate('billing_drafts',r.id):setSelected(r)}/>:<p>Não há uma fonte documental disponível para esta consulta. Não é possível concluir que existam zero documentos.</p>)}
{selected&&<article className="panel billing-document-detail"><h2>{selected.number}</h2><p>Factura de teste — fotografia preservada, sem edição directa.</p><button onClick={()=>setSelected(null)}>Fechar ficha</button>{selected.detail.document_lines.map(([kind,text],i)=>kind==='title'?<h3 key={i}>{text}</h3>:kind==='section'?<h4 key={i}>{text}</h4>:<p key={i}>{text}</p>)}<a href={`/api/service-invoices/${selected.id}/pdf`}>Descarregar PDF de teste</a><h3>Pedidos de correcção</h3>{selected.detail.corrections.length?selected.detail.corrections.map(c=><p key={c.id}>#{c.id}: {c.reason} · {c.created_at} · Pendente, sem efeito fiscal.</p>):<p>Nenhum pedido registado.</p>}<p className="help">Enviar ao cliente, duplicar documentos, exportar selecções e emitir notas de crédito ainda não estão disponíveis neste menu. Não existe emissão ou rectificação em massa.</p></article>}
</>}
</div>;
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
export default function PurchasingHome({context,can,navigate}) {
const unit=context.unit?.id??null;
const budget=unit!==null&&can('purchasing_budget.view',unit);
const payables=unit!==null&&can('supplier_payables.view',unit);
return <><div className="page-title"><p className="eyebrow">COMPRAS</p><h1>Área de Compras</h1><p>{context.company.name}{context.unit?` / ${context.unit.name}`:''}</p></div>
<section className="panel"><h2>Aquisição, recepção e obrigação</h2><p>Compras acompanha as aquisições e os seus documentos. A recepção de mercadoria, o consumo, a obrigação ao fornecedor e o pagamento são factos distintos; cada efeito mantém um único responsável.</p><p>Compras e Stock têm entradas separadas. Separar a navegação não remove os percursos existentes nem concede acesso a operações de outra empresa ou composição.</p></section>
<section className="panel"><h2>Percursos disponíveis neste contexto</h2>{!context.unit&&<p>Seleccione uma unidade autorizada para verificar os percursos de trabalho.</p>}<div className="actions">{budget&&<button onClick={()=>navigate('purchasing_budget')}>Controlo orçamental</button>}{payables&&<button onClick={()=>navigate('supplier_payables')}>Obrigações a fornecedores</button>}</div>{!budget&&!payables&&<p>Esta composição ainda não apresenta percursos operacionais de Compras autorizados para a unidade actual. As demonstrações próprias de orçamento e obrigações conservam os seus dados e acessos; não foram eliminadas.</p>}</section>
<section className="panel"><h2>Compras autónomo e ERP</h2><p>O módulo autónomo conserva requisições, fornecedores, cotações, adjudicação, ordens e recepções. Esses percursos ainda não estão incorporados integralmente nesta interface SGF. Não se apresenta esta entrada como substituto funcional dessa aplicação.</p><p>Orçamento e obrigações reutilizam o núcleo comum. A entrada neste mapa não permite pagamentos, movimentos de stock ou emissão fiscal.</p></section>
</>;
}
+6 -3
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react';
import DocumentPreview from './DocumentPreview.jsx';
import {visibleBillingSections} from './billing-menu.js';
export const serviceAreas = { clients: 'Clientes', services: 'Serviços', service_drafts: 'Prestações em rascunho', billing_drafts: 'Rascunhos de facturação' };
const actions = { view: 'Consultar', create: 'Criar', edit: 'Editar', manage: 'Gerir concessões' };
const isDraft = area => area === 'service_drafts' || area === 'billing_drafts';
const newLine = () => ({ service_id: '', quantity: '', unit_price: '', description: '' });
export default function ServicesWorkspace({ area, context, can, api, perform, busy, initialId, navigate }) {
export default function ServicesWorkspace({ area, context, can, api, perform, busy, initialId, initialCreate=false, navigate }) {
const unit = isDraft(area) ? context.unit?.id ?? null : null;
const allowed = action => can(`${area}.${action}`, unit);
const [records, setRecords] = useState([]);
@@ -18,11 +19,12 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
const [access, setAccess] = useState(false);
const [reviewConfirmation, setReviewConfirmation] = useState(false);
const [previewMode, setPreviewMode] = useState(null);
const [search, setSearch] = useState('');
const path = `/api/a/${area}`;
async function load() { const result = await api(path); setRecords(result.records); }
async function open(id) { setError(''); setReviewConfirmation(false); try { setSelected(await api(`${path}/${id}`)); setForm(null); } catch (e) { setError(e.message); } }
useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); }, [area, context.token]);
useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); else if(initialCreate&&allowed('create')) start(); }, [area, context.token]);
async function start(record = null) {
setError(''); setAccess(false);
@@ -71,11 +73,12 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
if (!allowed('view')) return <section className="panel"><h1>{serviceAreas[area]}</h1><p>Sem consulta autorizada neste contexto.</p></section>;
if (previewMode && selected) return <DocumentPreview id={selected.id} historical={previewMode === 'history'} context={context} api={api} onClose={() => setPreviewMode(null)} />;
return <>
{area==='billing_drafts'&&<nav className="billing-section-tabs" aria-label="Submenus de facturação">{visibleBillingSections(can,unit).map(s=><button key={s.id} aria-current={s.id===area?'page':undefined} onClick={()=>{if(s.id!==area)navigate(s.id);}}>{s.label}</button>)}</nav>}
<div className="a-heading"><div><p className="eyebrow">SERVIÇOS · PREPARAÇÃO E CONFIRMAÇÃO INTERNA</p><h1>{serviceAreas[area]}</h1><p className="muted">{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}</p></div><div className="actions">{allowed('create') && <button className="primary" disabled={busy} onClick={() => start()}>Novo registo</button>}{allowed('manage') && <button onClick={() => { setAccess(!access); setForm(null); }}>Gerir acessos desta área</button>}</div></div>
{error && <div role="alert" className="alert error">{error}</div>}
{isDraft(area) && <div className="a-draft-notice">{area === 'billing_drafts' ? 'Rascunhos e confirmação interna — sem validade fiscal. ' : 'Apenas rascunhos. '}Sem emissão fiscal, confirmação de serviço ou lançamentos contabilísticos.</div>}
{access ? <AreaAccess area={area} context={context} api={api} perform={perform} busy={busy} /> : <>
<div className="a-grid"><section className="panel a-list"><h2>Registos</h2>{!records.length && <p className="muted">Ainda não há registos neste âmbito.</p>}{records.map(record => <button key={record.id} className={selected?.id === record.id ? 'chosen' : ''} onClick={() => open(record.id)}><strong>{record.name}</strong><small>#{record.id} · Versão {record.version}{record.internal_confirmation ? ' · ' + record.internal_confirmation.label : isDraft(area) ? ' · Rascunho' : ''}</small></button>)}</section><section className="panel a-detail">
<div className="a-grid"><section className="panel a-list"><h2>Registos</h2>{area==='billing_drafts'&&<label>Pesquisar referência, designação ou cliente<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label>}{!records.length && <p className="muted">Ainda não há registos neste âmbito.</p>}{records.filter(record=>area!=='billing_drafts'||[record.name,record.id,record.data?.client_name].some(v=>String(v??'').toLocaleLowerCase('pt').includes(search.trim().toLocaleLowerCase('pt')))).map(record => <button key={record.id} className={selected?.id === record.id ? 'chosen' : ''} onClick={() => open(record.id)}><strong>{record.name}</strong><small>#{record.id} · Versão {record.version}{record.internal_confirmation ? ' · ' + record.internal_confirmation.label : isDraft(area) ? ' · Rascunho' : ''}</small></button>)}</section><section className="panel a-detail">
{form ? <form onSubmit={save}><h2>{selected ? 'Editar' : 'Criar'} {isDraft(area) ? 'rascunho' : 'cadastro'}</h2><label>{area === 'clients' ? 'Nome do cliente' : area === 'services' ? 'Nome do serviço' : 'Designação do rascunho (opcional)'}<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} maxLength={255} required={!isDraft(area)} /></label>
{area !== 'clients' && <label>Descrição (opcional)<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} maxLength={2000} /></label>}
{area === 'billing_drafts' && !selected ? <><label>Prestação de origem<select value={form.source_id} onChange={e => setForm({ ...form, source_id: e.target.value })} required><option value="">Seleccionar prestação em rascunho</option>{references.source.map(record => <option key={record.id} value={record.id}>{record.name} · versão {record.version}</option>)}</select></label><p className="help">Cliente, linhas e moeda serão reutilizados da prestação, sem redigitação. Poderá editar o rascunho depois de o preparar.</p></> : isDraft(area) && <>
+35
View File
@@ -0,0 +1,35 @@
import React,{useEffect,useState} from 'react';
import {stockSections,visibleStockSections,stockOperations,availableStockReports} from './stock-menu.js';
export default function StockMenuWorkspace({section,context,can,navigate}) {
const [reportId,setReportId]=useState('balances');
useEffect(()=>{setReportId('balances');},[context.token]);
if(!can('company.view'))return <section className="panel"><h1>Stock</h1><p>Seleccione uma empresa autorizada.</p></section>;
const active=stockSections.find(s=>s.id===section);
if(!active)return null;
const operation=stockOperations[section];
// This SGF composition has no stock source, feature configuration or cost permission yet.
// Do not substitute workshop rights, company rights or purchasing roles for stock rights.
const reports=availableStockReports({},false),report=reports.find(r=>r.id===reportId)??reports[0];
const tabs=<nav className="stock-tabs" aria-label="Submenus de stock">{visibleStockSections(can).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>;
return <div className="stock-menu-workspace"><div className="page-title"><p className="eyebrow">STOCK</p><h1>{active.label}</h1><p>{context.company.name}{context.unit?` / ${context.unit.name}`:''}</p></div>{tabs}
<div className="alert home-notice" role="status"><strong>Menu em preparação — fonte de stock ainda não ligada ao SGF.</strong><p>Os saldos e movimentos de Compras permanecem na sua aplicação. Aqui não são apresentados como zero, importados ou alterados. Os apontamentos da oficina não são existências de armazém.</p></div>
{section==='stock_panel'&&<>
<section className="panel"><h2>Situação actual</h2><p>Não há indicadores de stock disponíveis nesta composição. Não é possível determinar produtos abaixo do mínimo, entradas/saídas, valor do stock ou mercadoria em trânsito.</p><p>O painel sinalizará situações actuais; os relatórios permitirão investigar os movimentos e o período. Nenhum indicador é calculado a partir de exemplos ilustrativos.</p></section>
<section className="panel"><h2>Escolher percurso</h2><div className="stock-route-grid">{stockSections.filter(s=>s.id!=='stock_panel').map(s=><button key={s.id} onClick={()=>navigate(s.id)}>{s.label}<span>Consultar o âmbito e o estado de disponibilidade →</span></button>)}</div></section>
</>}
{section==='stock_balances'&&<>
<section className="panel"><h2>Consulta de existências indisponível</h2><p>Falta a fonte autorizada de produtos, armazéns e saldos desta empresa. Não há uma lista vazia de existências nem um saldo editável.</p><dl className="stock-definitions"><div><dt>Físico</dt><dd>Quantidade efectivamente registada no armazém, incluindo reservado e bloqueado.</dd></div><div><dt>Reservado</dt><dd>Quantidade comprometida com documentos próprios, quando o controlo estiver activo.</dd></div><div><dt>Bloqueado</dt><dd>Quantidade indisponível por condição ou qualidade, sem sobreposição com reservas.</dd></div><div><dt>Disponível</dt><dd>Físico menos reservado e bloqueado. Mercadoria em trânsito não está disponível no destino.</dd></div></dl><p>Se uma dessas parcelas não estiver sustentada, o disponível fica indeterminado; não se usa zero como substituto.</p></section>
<section className="panel"><h2>Pesquisa e rastreabilidade previstas</h2><p>Produto por código, designação ou código de barras; armazém, categoria e situação de stock. Localização, lote, série e validade só serão apresentados se configurados.</p><p>A ficha dará acesso ao movimento e ao documento original. Diferenças físicas serão tratadas por contagem e ajuste autorizado em Inventário, nunca por edição directa do saldo.</p><button onClick={()=>navigate('stock_reports')}>Ver relatórios de controlo</button></section>
</>}
{operation&&<>
<section className="panel"><h2>{operation.title}</h2><p>{operation.purpose}</p><p className="stock-unavailable">Operação indisponível no SGF. Não há confirmação ou gravação neste ecrã.</p><h3>Campos do percurso previsto</h3><ul>{operation.fields.map(f=><li key={f}>{f}</li>)}</ul><p>Localizações, lotes, séries, validade e câmbio só entram quando o produto e a configuração os exigirem. Não são assumidos por omissão.</p></section>
<section className="panel"><h2>Etapas previstas</h2><ol>{operation.flow.map(step=><li key={step}>{step}</li>)}</ol><h3>O que já existe e o que falta</h3><p>{operation.existing}</p><p>{operation.missing}</p><p><strong>Prevenção de duplicações:</strong> {operation.control}</p></section>
</>}
{section==='stock_reports'&&<>
<section className="panel"><h2>Relatórios essenciais</h2><label>Relatório<select value={reportId} onChange={e=>setReportId(e.target.value)}>{reports.map(r=><option key={r.id} value={r.id}>{r.label}</option>)}</select></label><h3>{report.label}</h3><p><strong>Base temporal:</strong> {report.basis}.</p><p><strong>Informação prevista:</strong> {report.fields}</p><p role="status"><strong>Consulta indisponível.</strong> {report.missing}</p><p>Não é possível gerar, imprimir ou exportar um relatório sem a sua fonte. A falta de informação não é apresentada como ausência de movimentos.</p></section>
<section className="panel"><h2>Disponibilidade dos restantes relatórios</h2><p>Valorização exige custos, método configurado e permissão própria. Dados de custos não são carregados por este menu. Relatórios de trânsito, devoluções, lotes, inactividade e diferenças de inventário serão apresentados quando os respectivos percursos estiverem activos.</p><p>A consulta e a exportação terão de preservar os mesmos filtros, autorizações, abertura e fecho. Reservas e bloqueios históricos não serão substituídos por valores actuais.</p></section>
</>}
<section className="panel"><h2>Regras comuns do percurso</h2><p>Um rascunho não altera stock. A confirmação revalida permissões e quantidades, tem um único efeito e conserva actor, data, motivo e origem. Movimentos confirmados não são editados ou apagados; correcções exigem operação própria ligada ao original.</p><p>Acesso a este mapa não concede consulta de armazéns, custos ou movimentos. A integração operacional terá autorizações específicas e contexto explícito, preservando as diferenças entre empresa, unidade e armazém.</p></section>
</div>;
}
+33
View File
@@ -0,0 +1,33 @@
import React,{useEffect,useState} from 'react';
import {workshopSections,visibleWorkshopSections,workshopStates,filterWorkshopOrders,visibleWorkshopEquipment} from './workshop-menu.js';
export default function WorkshopMenuWorkspace({section,context,can,api,navigate}) {
const unit=context.unit?.id??null,active=workshopSections.find(s=>s.id===section);
const [data,setData]=useState(null),[error,setError]=useState(''),[reload,setReload]=useState(0),[search,setSearch]=useState(''),[state,setState]=useState('');
useEffect(()=>{
let current=true;setData(null);setError('');
if(unit===null||!can('workshop.view',unit)||!['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section))return ()=>{current=false;};
api('/api/workshop').then(d=>{if(current)setData(d);}).catch(e=>{if(current)setError(e.message);});
return ()=>{current=false;};
},[context.token,section,reload]);
if(!active)return null;
const orders=filterWorkshopOrders(data?.orders??[],search,state);
const link=(id,label,permission,scope=null)=>can(permission,scope)?<button key={id} onClick={()=>navigate(id)}>{label}</button>:null;
const orderTable=<div className="table-scroll"><table><thead><tr><th>Ordem</th><th>Equipamento</th><th>Cliente</th><th>Estado registado</th><th>Responsável</th><th>Acção</th></tr></thead><tbody>{orders.map(o=><tr key={o.id}><td>{o.reference}<small>#{o.id} · Revisão {o.revision}</small></td><td>{o.equipment?.identifier}<small>{o.equipment?.description}</small></td><td>#{o.client_id}</td><td>{workshopStates[o.state]??o.state}</td><td>{o.assigned_to?`#${o.assigned_to}`:'Por atribuir'}</td><td><button onClick={()=>navigate('workshop',o.id)}>Abrir ordem</button></td></tr>)}</tbody></table>{!orders.length&&<p>Sem ordens visíveis para os filtros seleccionados.</p>}</div>;
return <div className="workshop-menu-workspace"><div className="page-title"><p className="eyebrow">OFICINAS</p><h1>{active.label}</h1><p>{context.company.name} / {context.unit?.name??'Seleccione uma unidade'}</p></div>
<nav className="billing-section-tabs" aria-label="Submenus de oficinas">{visibleWorkshopSections(can,unit).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
<p className="alert home-notice">Demonstração sintética. O estado operacional, a autorização de trabalho, a facturação e o pagamento são distintos. Abrir o menu não altera permissões nem confirma uma operação.</p>
{active.pending&&<section className="panel"><h2>Capacidade ainda não disponível</h2><p>{active.pending}</p>{['workshop_stock','workshop_quotes'].includes(section)&&<button onClick={()=>navigate('workshop')}>Consultar ordens, autorizações e apontamentos existentes</button>}{section==='workshop_stock'&&can('company.view')&&<button onClick={()=>navigate('stock_panel')}>Abrir o menu de Stock — em preparação</button>}{section==='workshop_purchasing'&&<div className="actions">{link('purchasing_budget','Controlo orçamental disponível','purchasing_budget.view',unit)}{link('supplier_payables','Obrigações a fornecedores disponíveis','supplier_payables.view',unit)}</div>}</section>}
{section==='workshop_clients'&&<section className="panel"><h2>Cadastro de clientes existente</h2><p>Reutilize o cadastro da empresa; não há um segundo registo de clientes para a oficina. Conta corrente e histórico de manutenção completo não são acrescentados por este menu.</p>{link('clients','Abrir clientes','clients.view')}</section>}
{section==='workshop_finance'&&<section className="panel"><h2>Documentos e percursos financeiros existentes</h2><div className="actions">{link('service_invoices','Facturas e documentos de teste','service_invoices.view',unit)}{link('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit)}{link('workshop_billing','Oficina → rascunho','workshop_billing.view',unit)}{link('service_receivables','Recebimentos e afectações de teste','service_receivables.view',unit)}</div><p>Caixa, despesas, abertura e fecho de caixa ainda não estão implementados. Não se apresentam saldos fictícios, margens ou valores recebidos inferidos a partir de facturas.</p></section>}
{section==='workshop_settings'&&<section className="panel"><h2>Configurações existentes</h2><div className="actions">{link('company','Dados da empresa','company.view')}{link('access','Acessos autorizados','access.manage')}{link('services','Catálogo de serviços','services.view')}{link('preview_model','Modelo de apresentação documental','issuer_documents.view')}{link('service_fiscal','Perfis fiscais existentes','service_fiscal.view')}</div><p>Catálogo de tipos de equipamento, características técnicas, política de oficina e gestão de cópias de segurança ainda não têm interface própria. Os perfis do documento são uma proposta; não atribuem direitos automaticamente.</p></section>}
{['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section)&&<>
<div className="actions"><button onClick={()=>setReload(r=>r+1)}>Actualizar consulta</button>{can('workshop.open',unit)&&<button className="primary" onClick={()=>navigate('workshop')}>Abrir percurso de ordem de serviço</button>}</div>
{error?<p role="alert" className="alert error">Dados indisponíveis: {error}. Não é possível concluir que existam zero ordens.</p>:!data?<p role="status">A carregar ordens autorizadas…</p>:data.blocked?<section className="panel"><h2>Ensaio indisponível neste ambiente</h2><p>O percurso existente continua limitado à base sintética autorizada.</p></section>:<>
<section className="panel"><h2>{section==='workshop_equipment'?'Pesquisar equipamentos nas ordens autorizadas':'Pesquisar ordens autorizadas'}</h2><div className="form-grid"><label>Referência, equipamento, série, cliente ou responsável<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Estado registado<select value={state} onChange={e=>setState(e.target.value)}><option value="">Todos</option>{Object.entries(workshopStates).map(([s,label])=><option value={s} key={s}>{label}</option>)}</select></label></div>
{section==='workshop_equipment'?<><p>Equipamentos identificados nas ordens visíveis. O cadastro independente, tipos e características técnicas ainda não estão implementados.</p><div className="table-scroll"><table><thead><tr><th>Código</th><th>Descrição</th><th>Número de série</th><th>Ordens visíveis</th></tr></thead><tbody>{visibleWorkshopEquipment(orders).map(e=><tr key={e.id}><td>{e.identifier}</td><td>{e.description}</td><td>{e.serial||'Não registado'}</td><td>{e.orders.map(id=><button key={id} onClick={()=>navigate('workshop',id)}>Ordem #{id}</button>)}</td></tr>)}</tbody></table></div>{!orders.length&&<p>Sem equipamentos nas ordens que correspondem aos filtros.</p>}</>:orderTable}</section>
<section className="panel"><h2>{section==='workshop_team'?'Responsáveis e horas de serviço':section==='workshop_reports'?'Consulta operacional disponível':'Âmbito do painel'}</h2><p>{section==='workshop_team'?'A atribuição de responsáveis e o registo de horas mantêm-se nas ordens. O cadastro de recursos humanos, horários e disponibilidade não foram implementados.':section==='workshop_reports'?'Esta lista é uma consulta actual das ordens autorizadas. Relatório diário fechado, cortes, exportação e relatórios financeiros ainda não estão implementados.':'As ordens apresentadas respeitam a consulta e a atribuição autorizadas pelo servidor. Entradas, entregas do dia, alertas, agenda e indicadores de caixa ainda não dispõem das fontes necessárias neste percurso.'}</p><p>Os estados actuais de apontamentos não são convertidos nas fases futuras Recebida, Em reparação ou Entregue. Não se inferem margens, stock ou pendências de custódia.</p></section>
</>}
</>}
</div>;
}
+4 -2
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import {visibleWorkshopSections} from './workshop-menu.js';
const kindLabels={assignment:'Responsável atribuído',task:'Tarefa',authorization:'Autorização registada',start:'Início autorizado',time:'Tempo relatado',material:'Material observado'};
const eventLabels={required:'Requisitado',delivered:'Entregue',consumed:'Aplicado/consumido',returned:'Devolvido'};
const decisionLabels={verified:'Verificado',refused:'Recusado',acknowledged:'Ocorrência reconhecida para auditoria — continua fora do âmbito'};
const iso=v=>new Date(v).toISOString();
export default function WorkshopWorkspace({context,can,api,busy}) {
const [data,setData]=useState(null),[orderId,setOrderId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
export default function WorkshopWorkspace({context,can,api,busy,initialOrderId=null,navigate}) {
const [data,setData]=useState(null),[orderId,setOrderId]=useState(initialOrderId??''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
const attempt=useRef(null);
const allowed=r=>can(`workshop.${r}`,context.unit?.id??null);
const load=async()=>{const d=await api('/api/workshop');setData(d);return d;};
@@ -33,6 +34,7 @@ export default function WorkshopWorkspace({context,can,api,busy}) {
}
const correctionFields=kind=><><label>Versão anterior a corrigir (opcional)<select name="previous_id"><option value="">Novo acontecimento</option>{order.entries.filter(e=>e.kind===kind&&e.current).map(e=><option key={e.id} value={e.id}>#{e.id} · {e.origin} v{e.version}</option>)}</select></label><label>Motivo de correcção (se aplicável)<input name="correction_reason"/></label></>;
return <>
{navigate&&<nav className="billing-section-tabs" aria-label="Submenus de oficinas">{visibleWorkshopSections(can,context.unit?.id??null).map(s=><button key={s.id} aria-current={s.id==='workshop'?'page':undefined} onClick={()=>{if(s.id!=='workshop')navigate(s.id);}}>{s.label}</button>)}</nav>}
<section className="panel"><p className="eyebrow">WORKSHOP-RESOURCE-RECORD-v1</p><h1>Oficina — registo de recursos</h1><div className="alert error"><strong>TESTE — SEM UTILIZAÇÃO OPERACIONAL</strong><p>Sem stock, custos, facturação ou recebimentos.</p></div>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar revisão explicitamente</button><p className="help">Abertura não autoriza trabalho. Relatar uma ocorrência passada não lhe dá autorização retroactiva. Alterações concorrentes exigem nova revisão.</p></section>
{!data?<p>A carregar…</p>:data.blocked?<section className="panel"><h2>Utilização operacional bloqueada</h2><p>Disponível apenas na instância isolada ligada à base de testes.</p></section>:<>
{allowed('open')&&<section className="panel"><h2>Abrir ordem de teste</h2><Form label="Abrir ordem" disabled={disabled} save={v=>send('/orders',{...v,client_id:Number(v.client_id)})}><label>Referência operacional<input name="reference" required/></label><label>Cliente existente<select name="client_id">{data.clients.map(c=><option key={c.id} value={c.id}>#{c.id} · {c.name}</option>)}</select></label><label>Identificação individual do equipamento<input name="equipment_identifier" required/></label><label>Descrição / marca / modelo<input name="equipment_description" required/></label><label>Número de série (se conhecido)<input name="serial"/></label><label>Pedido / avaria<textarea name="request" required/></label><label>Localização<input name="location" required/></label><label>Evidência do pedido<input name="evidence" required/></label></Form><p className="help">Para reutilizar equipamento já identificado, a API aceita o seu ID; não criar outro só por existir uma nova ordem.</p></section>}
+1 -1
View File
@@ -20,7 +20,7 @@ export default function WorkspaceHome({context,can,groups,navigate}) {
['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>;}
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}{i.informational&&<small className="shortcut-status">Em preparação</small>}<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>
+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>;
}
+59
View File
@@ -0,0 +1,59 @@
export const accountingSections=[
{id:'accounting_panel',label:'Painel contabilístico'},
{id:'accounting_daily',label:'Trabalho diário'},
{id:'accounting_review',label:'Conferência'},
{id:'accounting_analysis',label:'Análise'},
{id:'accounting_closing',label:'Fecho'},
{id:'accounting_configuration',label:'Configuração'},
];
export function visibleAccountingSections(can) {
return can('company.view')||can('catalogue_accounting.view')||can('service_fiscal.view')?accountingSections:[];
}
export const accountingFunctions=[
{id:'accounts',section:'accounting_configuration',label:'Plano de contas',description:'Códigos, natureza, hierarquia e estado das contas; versões e aprovação das alterações estruturais.',status:'Existe classificação contabilística com propostas de plano/contas e validação por versão. Não equivale a um razão ou a um plano integral com todos os controlos operacionais.',target:'catalogue_accounting',permission:'catalogue_accounting.view',action:'Abrir plano e classificação existentes'},
{id:'journals',section:'accounting_configuration',label:'Diários',description:'Vendas, compras, bancos, caixa e operações diversas; configuração separada da utilização.',status:'Diários contabilísticos, numeração e contas predefinidas ainda não estão implementados. Séries de facturas não são diários contabilísticos.'},
{id:'entries',section:'accounting_daily',label:'Lançamentos',description:'Data, diário, referência, conta, descrição, débito, crédito, moeda, entidade e dimensão analítica.',status:'Criação, importação, conferência, validação e estorno de lançamentos ainda não estão implementados. Capturar um documento ou uma classificação não gera um lançamento.'},
{id:'recurrences',section:'accounting_daily',label:'Modelos e recorrências',description:'Despesas periódicas, acréscimos e diferimentos com frequência, vigência e responsável.',status:'Não existem modelos nem geração recorrente de lançamentos. A evolução prevista gera rascunhos para revisão, sem publicação automática.'},
{id:'reconciliation',section:'accounting_review',label:'Conferência e reconciliação',description:'Conferir clientes, fornecedores, bancos e impostos, mantendo correspondências parciais, diferenças e justificações.',status:'A reconciliação contabilística ainda não está implementada. O percurso de recebimentos simulados existente é separado do razão e de uma ligação bancária real.',target:'service_receivables',permission:'service_receivables.view',unit:true,action:'Abrir recebimentos e reconciliação de teste'},
{id:'balances',section:'accounting_analysis',label:'Balancetes e extractos',description:'Saldos iniciais, débitos, créditos, saldo final e ligação ao lançamento e ao comprovativo.',status:'Não existe razão contabilístico para produzir balancetes ou extractos. Ausência de fonte não significa saldo zero.'},
{id:'analytics',section:'accounting_analysis',label:'Contabilidade analítica',description:'Centros de custo, projectos, departamentos e repartições, com movimentos por classificar visíveis.',status:'A contabilidade analítica por lançamentos ainda não existe. Custos e orçamento industrial de teste mantêm o seu âmbito próprio e não são um razão analítico.',target:'industrial_budgets',permission:'industrial_budgets.view',unit:true,action:'Consultar orçamento industrial de teste — âmbito distinto'},
{id:'closing',section:'accounting_closing',label:'Operações de fecho',description:'Acréscimos, diferimentos, depreciações, câmbio e resultados, com responsável, evidência e aprovação.',status:'Não existe fecho contabilístico executável. Este menu não gera ajustamentos, depreciações ou apuramentos.'},
{id:'periods',section:'accounting_closing',label:'Períodos e bloqueios',description:'Abertura, fecho mensal/anual, datas de bloqueio e reabertura autorizada, com motivo e histórico.',status:'Os bloqueios e a reabertura de períodos contabilísticos ainda não estão implementados. Os bloqueios actuais de emissão fiscal são um controlo diferente.'},
{id:'statements',section:'accounting_analysis',label:'Demonstrações financeiras',description:'Balanço, resultados e fluxos de caixa com comparativos e ligação às contas de origem.',status:'Não há demonstrações financeiras geradas. São necessários lançamentos validados e modelos adequados ao referencial aplicável; não se apresentam estimativas como saldos reais.'},
{id:'audit',section:'accounting_review',label:'Auditoria e arquivo',description:'Histórico de criação, alteração, validação, estorno e reabertura; consulta de evidência dentro do âmbito autorizado.',status:'Os percursos existentes conservam o seu histórico próprio. Ainda não há um arquivo contabilístico ou uma consulta de auditoria integral agregada.',target:'catalogue_accounting',permission:'catalogue_accounting.view',action:'Consultar classificação e histórico existentes'},
];
export const accountingReports=[
['trial_balance','Balancete geral','Contabilidade','Saldos iniciais, débitos, créditos e saldos finais por conta e período.'],
['ledger','Razão e extractos de contas','Contabilidade','Movimentos, contrapartidas, comprovativos e saldo progressivo.'],
['journal','Diário de lançamentos','Contabilidade','Data, diário, referência e estado, separando rascunhos de lançamentos validados.'],
['balance_sheet','Balanço','Demonstrações','Activo, passivo e capital próprio à data de referência.'],
['income','Demonstração de resultados','Demonstrações','Rendimentos, gastos e resultados do período. Lucro não equivale a dinheiro disponível.'],
['cash_flow','Demonstração de fluxos de caixa','Demonstrações','Fluxos operacionais, de investimento e de financiamento, conferidos com caixa e equivalentes.'],
['equity','Alterações no capital próprio','Demonstrações','Movimentos nas rubricas e reconciliação dos saldos, quando aplicável.'],
['notes','Anexo às demonstrações financeiras','Demonstrações','Notas, políticas contabilísticas e suporte das rubricas relevantes.'],
['customers','Clientes e antiguidade de saldos','Tesouraria','Documento, saldo e vencimento sustentado. Vencimento desconhecido não é atraso zero.'],
['suppliers','Fornecedores e antiguidade de saldos','Tesouraria','Obrigações, vencimentos, moeda e aprovação, sem confundir dívida com pagamento.'],
['cash_bank','Posição de caixa e bancos','Tesouraria','Saldos contabilísticos e de extracto separados, por conta e moeda.'],
['bank_reconciliation','Reconciliação bancária','Tesouraria','Correspondências, pendentes e diferenças, com revisão independente.'],
['forecast','Previsão de tesouraria','Tesouraria','Recebimentos e pagamentos esperados, separados de estimativas e execução efectiva.'],
['payments','Pagamentos e aprovações','Tesouraria','Proposto, aprovado, executado, rejeitado e devolvido são estados distintos.'],
['vat','Apuramento de IVA','Fiscalidade','Operações e imposto reconciliados com contas e declarações aplicáveis. Não é activado por este menu.'],
['withholding','Retenções e obrigações fiscais','Fiscalidade','Bases, retenções e obrigações aplicáveis, sem presumir taxas ou incidências.'],
['payroll','Salários e contribuições','Pessoal','Resumo autorizado e obrigações aplicáveis; dados individuais permanecem restritos.'],
['assets','Activos e depreciações','Património','Cadastro, aquisições, alienações, depreciações e valor líquido.'],
['cost_centres','Centros de custo e projectos','Análise','Rendimentos e gastos por dimensão; movimentos sem classificação identificados.'],
['budget','Orçamento versus realizado','Análise','Desvios por período e dimensão, com base em valores validados.'],
['inventory','Inventário e valorização','Análise','Reconciliação do inventário com a contabilidade, quando aplicável.'],
['fx','Operações em moeda estrangeira','Contabilidade','Moeda, taxa, data de referência e diferenças cambiais.'],
['closing','Fecho e pendências','Fecho','Conferências, ajustamentos, documentos, bloqueios e aprovações do período.'],
['audit','Auditoria de alterações','Fecho','Actor, data, motivo e evidência, com consulta e exportação restritas.'],
].map(([id,label,family,description])=>({id,label,family,description}));
export function filterAccountingReports(search='',family='') {
const normalise=s=>String(s).normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLocaleLowerCase('pt');
const query=normalise(search).trim();
return accountingReports.filter(r=>(!family||r.family===family)&&(!query||normalise(r.label+' '+r.description).includes(query)));
}
+18 -4
View File
@@ -18,6 +18,15 @@ import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.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' };
@@ -32,7 +41,7 @@ function App() {
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(''); };
const navigate = (id,selection=null) => { setASelection(selection); 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) {
@@ -75,17 +84,22 @@ function App() {
{page === 'workshop_corrections' && <WorkshopCorrectionsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&<BillingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null,create=false)=>navigate(area,{area,id,create})}/>}
{stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
{accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
{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} />}
{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><a href="/supplier-payables">Abrir preparação e consulta de obrigações</a></section>}
{page === 'industrial_costs' && <IndustrialCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'service_invoice_setup' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{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>)}
+65
View File
@@ -0,0 +1,65 @@
// Navigation and read models only. Financial values are copied from preserved sources.
export const billingSections = [
{id:'billing_all',label:'Todos os documentos'},
{id:'service_invoices',label:'Facturas'},
{id:'billing_invoice_receipts',label:'Facturas-recibo',pending:'A emissão de facturas-recibo ainda não está implementada. Uma factura e um recebimento não serão reunidos automaticamente neste tipo de documento.'},
{id:'billing_receipts',label:'Recibos',pending:'O documento fiscal Recibo ainda não está implementado. O percurso existente de recebimentos e afectações de teste conserva a sua identidade própria.'},
{id:'billing_credit_notes',label:'Notas de crédito',pending:'A emissão de notas de crédito ainda não está implementada. Pedidos de correcção existentes não são notas de crédito e não alteram automaticamente a dívida, o stock ou o dinheiro.'},
{id:'billing_quotes',label:'Orçamentos',pending:'A proposta comercial ao cliente e a sua conversão em rascunho ainda não estão implementadas. Este orçamento comercial é distinto do controlo orçamental de Compras e do orçamento de custos industriais.'},
{id:'billing_drafts',label:'Rascunhos'},
];
export function billingAccess(can,unit) {
return (unit!==null && (can('service_invoices.view',unit)||can('billing_drafts.view',unit)||can('service_receivables.view',unit)))
|| can('issuer_documents.view')||can('client_documents.view')||(unit!==null&&can('draft_documents.view',unit));
}
export function visibleBillingSections(can,unit) {
if(!billingAccess(can,unit))return [];
return billingSections.filter(s=>s.id==='service_invoices'?unit!==null&&can('service_invoices.view',unit)
:s.id==='billing_drafts'?unit!==null&&can('billing_drafts.view',unit):true);
}
export function invoiceRow(record,detail) {
const snapshot=detail.snapshot,doc=snapshot.document;
return {key:`invoice:${record.id}`,id:record.id,type:'invoice',typeLabel:'Factura de teste',number:`${record.series_code} / ${record.number}`,
client:doc.client?.name??null,nuit:doc.client?.data?.nuit??null,issuedDate:(snapshot.issued_local??record.issued_at)?.slice(0,10)??null,
dueDate:null,total:snapshot.calculation?.total_document_proposed??null,currency:doc.data?.currency??null,
series:record.series_code,author:null,state:'Emitido apenas em teste',balance:null,payment:'Indeterminado',detail};
}
export function draftRow(record) {
return {key:`draft:${record.id}`,id:record.id,type:'draft',typeLabel:'Rascunho',number:`Interno #${record.id}`,name:record.name,
client:record.data?.client_name??null,nuit:null,issuedDate:null,dueDate:null,total:null,base:record.base_amount??null,currency:record.data?.currency??null,
series:null,author:record.actor_id??null,state:record.workshop_commercial_state==='superseded'?'Histórico / substituído':record.workshop_commercial_state==='cancelled'?'Cancelado':record.internal_confirmation?'Confirmado internamente':'Rascunho',
balance:null,payment:'Não aplicável',version:record.version,updatedAt:record.updated_at};
}
export function filterBillingRows(rows,filters) {
const normalise=v=>String(v??'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLocaleLowerCase('pt');
const query=normalise(filters.search).trim();
return rows.filter(r=>(!query||[r.number,r.name,r.client,r.nuit].some(v=>normalise(v).includes(query)))
&&(!filters.type||r.type===filters.type)&&(!filters.currency||r.currency===filters.currency)
&&(!filters.series||r.series===filters.series)&&(!filters.state||r.state===filters.state)
&&(!filters.author||String(r.author??'')===filters.author)
&&(!filters.from||(r.issuedDate&&r.issuedDate>=filters.from))&&(!filters.to||(r.issuedDate&&r.issuedDate<=filters.to)));
}
export async function loadBillingRows(api,can,unit) {
const jobs=[];
if(unit!==null&&can('service_invoices.view',unit))jobs.push(['Facturas',async()=>{
const list=await api('/api/service-invoices');
if(!list.test_enabled)return {rows:[],notice:list.notice??'Documentos de teste indisponíveis neste ambiente.'};
const rows=[];
// Bound simultaneous requests while reusing the existing scoped immutable document endpoint.
for(let start=0;start<list.invoices.length;start+=6){
rows.push(...await Promise.all(list.invoices.slice(start,start+6).map(async r=>invoiceRow(r,await api(`/api/service-invoices/${r.id}`)))));
}
return {rows};
}]);
if(unit!==null&&can('billing_drafts.view',unit))jobs.push(['Rascunhos',async()=>({rows:(await api('/api/a/billing_drafts')).records.map(draftRow)})]);
const sources=await Promise.all(jobs.map(async([label,job])=>{
try{return {label,...await job()};}catch(e){return {label,error:e.message,rows:[]};}
}));
return {rows:sources.flatMap(s=>s.rows),sources};
}
+59
View File
@@ -0,0 +1,59 @@
// Informational navigation until the existing stock implementation has a common adapter.
// No quantities, balances, permissions or stock commands are fabricated here.
export const stockSections=[
{id:'stock_panel',label:'Painel'},
{id:'stock_balances',label:'Existências'},
{id:'stock_receipts',label:'Entradas'},
{id:'stock_issues',label:'Saídas'},
{id:'stock_transfers',label:'Transferências'},
{id:'stock_returns',label:'Devoluções'},
{id:'stock_reports',label:'Relatórios'},
];
export function visibleStockSections(can) {
// company.view exposes this informational menu only, never warehouse data or actions.
return can('company.view')?stockSections:[];
}
export const stockOperations={
stock_receipts:{title:'Recepção de mercadoria',purpose:'Registar mercadoria efectivamente recebida. Um rascunho não aumenta o stock.',
fields:['Data efectiva e data de registo','Origem, motivo e documento original','Fornecedor, quando se trata de uma compra','Produto, quantidade e unidade','Armazém de destino','Responsável e evidência de recepção'],
flow:['Preparar a partir da origem ou de uma entrada justificada','Conferir quantidades, destino e informação obrigatória','Submeter para aprovação, quando o circuito o exigir','Confirmar uma única vez e conservar o comprovativo'],
existing:'Compras já tem recepções parciais de ordens e entradas de quantidades aceites. Esse percurso permanece na aplicação autónoma, sem ser duplicado neste menu.',
missing:'Falta disponibilizar o adaptador de stock no SGF. Entradas manuais, saldo inicial e quarentena do documento não estão entregues neste percurso.',
control:'Devoluções de clientes e recepções de transferências usam as suas origens próprias. Uma factura de fornecedor não gera outra entrada da mercadoria já recebida.'},
stock_issues:{title:'Expedição, consumo e abate',purpose:'Registar uma saída física, distinguindo venda, consumo interno e abate.',
fields:['Data efectiva e data de registo','Tipo, motivo e destinatário','Documento ou autorização de origem','Produto, quantidade e unidade','Armazém de origem','Responsável e comprovativo'],
flow:['Identificar origem, destinatário e motivo','Consultar disponibilidade na origem','Submeter a autorização aplicável','Revalidar o saldo e confirmar a saída uma única vez'],
existing:'Compras já tem saídas de stock com destino, motivo, responsável e prevenção de saldo negativo. Os apontamentos materiais da oficina não executam essas saídas.',
missing:'Falta ligar o percurso ao SGF. Consumo de reservas próprias, bloqueios, saídas por venda e autorização de abates exigem os respectivos fluxos.',
control:'Não descontar duas vezes uma reserva. Uma factura ou um apontamento de consumo não deve criar uma segunda saída para o mesmo facto.'},
stock_transfers:{title:'Transferência entre armazéns',purpose:'Mover mercadoria entre origem e destino sem representar uma venda.',
fields:['Armazéns de origem e destino distintos','Produto, unidade e quantidades pedida, expedida e recebida','Datas efectivas de expedição e recepção','Motivo e documento de acompanhamento','Divergências e responsáveis'],
flow:['Preparar e autorizar o pedido','Expedir da origem','Receber e conferir no destino','Resolver diferenças e concluir'],
existing:'Compras já tem transferência imediata entre armazéns, com dois movimentos na mesma transacção. Não representa o transporte em duas fases proposto no documento.',
missing:'Faltam o adaptador SGF e o fluxo separado de expedição, trânsito, recepção parcial e divergências. Não se inventa stock em trânsito a partir da transferência imediata.',
control:'Mercadoria em trânsito não fica disponível no destino antes da recepção. Uma transferência não aumenta a quantidade total da empresa.'},
stock_returns:{title:'Devoluções de mercadoria',purpose:'Distinguir devolução de cliente, que recebe mercadoria, de devolução a fornecedor, que a expede.',
fields:['Tipo de devolução e entidade','Documento original e linhas elegíveis','Quantidade, unidade, motivo e condição','Armazém e tratamento da mercadoria','Datas efectivas, responsáveis e comprovativos','Referência financeira, quando existir'],
flow:['Seleccionar a origem e a quantidade ainda elegível','Registar motivo e condição','Obter autorização','Receber ou expedir e conservar a ligação ao original'],
existing:'As devoluções documentais e físicas deste modelo ainda não estão implementadas como percurso operacional no SGF. Um material marcado como devolvido na oficina é uma observação, não este movimento.',
missing:'Faltam elegibilidade acumulada, reservas de pedidos activos, inspecção, quarentena, expedição e estornos próprios. Não são substituídos por ajustes genéricos.',
control:'Devolução física, nota de crédito e devolução de dinheiro têm efeitos separados. A nota de crédito não pode gerar outro movimento físico do mesmo facto.'},
};
export const stockReports=[
{id:'balances',label:'Existências por armazém',basis:'Situação à data de referência',fields:'Produto, unidade, armazém, localização quando configurada, físico, reservado, bloqueado e disponível.',missing:'Sem fonte de saldos SGF, a consulta é indisponível. Reservado e bloqueado não são substituídos por zero.'},
{id:'movements',label:'Movimentos de stock',basis:'Período entre duas datas',fields:'Data, documento, origem/destino, produto, unidade, entrada, saída e abertura/fecho com os mesmos filtros.',missing:'Falta ligar o livro de movimentos e as suas origens. Não se usam os apontamentos da oficina como movimentos de armazém.'},
{id:'minimum',label:'Stock mínimo',basis:'Data de referência e mínimos configurados',fields:'Produto, armazém, disponível, mínimo e diferença até ao mínimo.',missing:'Mínimos e fonte de disponível ainda não estão configurados no SGF. Não se geram alertas ou propostas de compra automáticos.'},
{id:'valuation',label:'Valorização do stock',basis:'Data de referência, método e moeda',fields:'Consulta restrita a utilizadores com autorização própria para custos.',restricted:true,missing:'Requer método, custos fiáveis e autorização de consulta. Não se estima valorização a partir de preços de venda ou dos custos incompletos da oficina.'},
{id:'inactive',label:'Produtos sem movimento',feature:'history',basis:'Período de inactividade escolhido',fields:'Produto, último movimento elegível e dias sem movimento.',missing:'Requer histórico suficiente e definição das operações elegíveis.'},
{id:'transit',label:'Transferências em trânsito',feature:'transit',basis:'Data de referência',fields:'Expedido, recebido e pendente por produto e unidade.',missing:'Requer o fluxo de expedição e recepção separado.'},
{id:'returns',label:'Devoluções',feature:'returns',basis:'Período, tipo e estado',fields:'Origem, entidade, quantidade e tratamento.',missing:'Requer o fluxo próprio de devoluções.'},
{id:'expiry',label:'Lotes próximos da validade',feature:'lots',basis:'Data de referência e janela configurada',fields:'Produto, lote, validade e quantidades elegíveis.',missing:'Requer controlo de lotes/validade activo.'},
{id:'inventory',label:'Diferenças de inventário',feature:'inventory',basis:'Contagem e período',fields:'Saldo à contagem, quantidade contada e ajuste associado.',missing:'Requer contagem física e ajuste autorizado integrados.'},
];
export function availableStockReports(features={},canViewCosts=false) {
return stockReports.filter(r=>(!r.feature||features[r.feature]===true)&&(!r.restricted||canViewCosts));
}
+32
View File
@@ -0,0 +1,32 @@
export const workshopSections=[
{id:'workshop_panel',label:'Painel da oficina'},
{id:'workshop_clients',label:'Clientes'},
{id:'workshop_equipment',label:'Equipamentos'},
{id:'workshop_schedule',label:'Agendamentos',pending:'Calendário, disponibilidade e lembretes ainda não estão implementados. Não são criadas marcações ou enviados lembretes por este menu.'},
{id:'workshop_quotes',label:'Orçamentos',pending:'O orçamento comercial da reparação e o registo da aprovação do cliente ainda não estão implementados como documento próprio. As autorizações de trabalho existentes conservam o seu âmbito e evidência; não são convertidas em orçamentos.'},
{id:'workshop',label:'Ordens de serviço'},
{id:'workshop_stock',label:'Peças e stock',pending:'O stock de oficina, reservas, mínimos e inventário ainda não estão implementados neste módulo. Os apontamentos de material existentes são observações de ocorrências, não saldos nem movimentos de armazém.'},
{id:'workshop_purchasing',label:'Compras e fornecedores',pending:'O percurso completo de Compras ainda não está incorporado nesta composição da oficina. As demonstrações de obrigações e orçamento mantêm os seus contextos próprios; nenhuma compra é criada por esta página.'},
{id:'workshop_finance',label:'Facturação e caixa'},
{id:'workshop_team',label:'Equipa'},
{id:'workshop_reports',label:'Relatórios'},
{id:'workshop_settings',label:'Configurações'},
];
export function visibleWorkshopSections(can,unit) {
if(unit===null||!can('workshop.view',unit))return [];
return workshopSections.filter(s=>s.id!=='workshop_clients'||can('clients.view')).filter(s=>s.id!=='workshop_finance'
||can('service_invoices.view',unit)||can('billing_drafts.view',unit)||can('service_receivables.view',unit)||can('workshop_billing.view',unit));
}
export const workshopStates={open:'Aberta',in_review:'Apontamentos em revisão',exception_review:'Ocorrências fora do âmbito para revisão',records_verified:'Registos verificados — sem conclusão comercial'};
export function filterWorkshopOrders(orders,search='',state='') {
const normalise=v=>String(v??'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLocaleLowerCase('pt');
const query=normalise(search).trim();
return orders.filter(o=>(!state||o.state===state)&&(!query||[o.reference,o.equipment?.identifier,o.equipment?.description,o.equipment?.serial,o.client_id,o.assigned_to].some(v=>normalise(v).includes(query))));
}
export function visibleWorkshopEquipment(orders) {
return [...new Map(orders.filter(o=>o.equipment).map(o=>[o.equipment.id,{...o.equipment,orders:orders.filter(other=>other.equipment?.id===o.equipment.id).map(other=>other.id)}])).values()];
}
+5 -1
View File
@@ -7,5 +7,9 @@
@viteReactRefresh
@vite('resources/js/app.jsx')
</head>
<body><div id="app"></div></body>
<body>
@if(app()->environment('browser-testing'))
<nav aria-label="Avaliação local" style="padding:10px 20px;background:#e1f1eb;color:#183d35;font:16px system-ui"><a href="/avaliar">Explorar SGF / trocar de função</a> · Ambiente de testes</nav>
@endif
<div id="app"></div></body>
</html>