This commit is contained in:
2026-11-02 20:29:34 +02:00
parent 340f3bb5fa
commit fcf31e8d48
181 changed files with 33383 additions and 51 deletions
+6 -3
View File
@@ -1,24 +1,27 @@
import React,{useEffect,useState} from 'react';
import {stockSections,visibleStockSections,stockOperations,availableStockReports} from './stock-menu.js';
import StockReadWorkspace from './StockReadWorkspace.jsx';
export default function StockMenuWorkspace({section,context,can,navigate}) {
export default function StockMenuWorkspace({section,context,can,navigate,api}) {
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];
const canRead=Boolean(api&&context.unit&&can('stock.view',context.unit.id));
// 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>
{canRead?<div className="alert home-notice" role="status"><strong>Consulta condicionada à fonte sintética autorizada.</strong><p>Entradas, saídas, transferências e custos continuam indisponíveis. A consulta não altera os saldos de Compras.</p></div>:<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==='stock_balances'&&canRead&&<StockReadWorkspace key={context.token} api={api} context={context}/>}
{section==='stock_balances'&&!canRead&&<>
<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>
</>}
+38
View File
@@ -0,0 +1,38 @@
import React,{useEffect,useRef,useState} from 'react';
const labels={items:'Artigos',warehouses:'Armazéns',balances:'Existências físicas',movements:'Movimentos'};
export function milliQuantity(value) {
if(value===null||value===undefined)return 'Indeterminado';
const n=BigInt(value),negative=n<0n,absolute=negative?-n:n;
return `${negative?'-':''}${absolute/1000n},${String(absolute%1000n).padStart(3,'0')}`;
}
export default function StockReadWorkspace({api,context}) {
const initial={view:'balances',page:1,per_page:25,search:''};
const [query,setQuery]=useState(initial),[data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);
const generation=useRef(0);
useEffect(()=>{
const current=++generation.current;setData(null);setError('');setLoading(true);
const params=new URLSearchParams(Object.entries(query).filter(([,v])=>v!==undefined&&v!==''));
api(`/api/stock-read?${params}`).then(r=>{if(current===generation.current)setData(r);})
.catch(e=>{if(current===generation.current)setError(e.message);}).finally(()=>{if(current===generation.current)setLoading(false);});
return ()=>{generation.current++;};
},[context.token,query]);
function filter(e){e.preventDefault();const f=Object.fromEntries(new FormData(e.currentTarget));setQuery({view:query.view,page:1,per_page:Number(f.per_page),search:f.search,warehouse_id:f.warehouse_id||undefined});}
function page(n){setQuery({...query,page:n,snapshot:data.snapshot});}
return <section className="panel"><h2>Consulta de Stock</h2>
<p>Somente leitura. As restantes operações de Stock continuam indisponíveis neste percurso.</p>
<nav aria-label="Consultas de stock">{Object.entries(labels).map(([id,label])=><button key={id} disabled={loading} aria-current={query.view===id?'page':undefined} onClick={()=>setQuery({...initial,view:id})}>{label}</button>)}</nav>
<form onSubmit={filter} key={query.view}><label>Pesquisa por código ou nome<input name="search" maxLength={100}/></label>
{['balances','movements'].includes(query.view)&&<label>Identificador do armazém autorizado<input name="warehouse_id" type="number" min="1"/></label>}
<label>Registos por página<select name="per_page" defaultValue="25"><option value="25">25</option><option value="100">100</option></select></label>
<button disabled={loading}>Pesquisar</button></form>
{loading&&<p role="status">A consultar a fonte autorizada…</p>}
{error&&<div role="alert"><p>{error}</p><button onClick={()=>setQuery({...query,page:1,snapshot:undefined})}>Actualizar consulta</button></div>}
{data&&<><p role="status">{data.notice}</p><p>Referência de leitura: {data.observed_at}. {data.pagination.total} registos; página {data.pagination.page} de {data.pagination.last_page}.</p>
<div className="table-wrap"><table><thead><tr><th>Código / origem</th><th>Designação</th>{query.view!=='warehouses'&&<th>Unidade</th>}{['balances','movements'].includes(query.view)&&<><th>Armazém</th><th>{query.view==='balances'?'Físico':'Quantidade'}</th><th>{query.view==='balances'?'Disponível':'Tipo / data'}</th></>}</tr></thead>
<tbody>{data.rows.map(row=><tr key={row.id??`${row.stock_item_id}:${row.warehouse_id}`}><td>#{row.id??row.stock_item_id} · {row.code}{row.source_reference&&<small> · {row.source_reference}</small>}</td><td>{row.name}</td>{query.view!=='warehouses'&&<td>{row.unit}</td>}{['balances','movements'].includes(query.view)&&<><td>#{row.warehouse_id} · {row.warehouse_code} · {row.warehouse_name}</td><td>{milliQuantity(query.view==='balances'?row.physical_milli:row.quantity_milli)}</td><td>{query.view==='balances'?'Indeterminado':`${row.movement_type === 'workshop_return_in' ? 'Devolução de excedente' : row.movement_type} · ${row.moved_at}`}</td></>}</tr>)}</tbody></table></div>
{data.rows.length===0&&<p>Sem registos elegíveis para estes filtros nesta fonte autorizada.</p>}
<button disabled={loading||query.page<=1} onClick={()=>page(query.page-1)}>Anterior</button><button disabled={loading||query.page>=data.pagination.last_page} onClick={()=>page(query.page+1)}>Seguinte</button>
<button disabled={loading} onClick={()=>setQuery({...query,page:1,snapshot:undefined})}>Nova referência de leitura</button></>}
</section>;
}
+3 -1
View File
@@ -1,5 +1,6 @@
import React,{useEffect,useState} from 'react';
import {workshopSections,visibleWorkshopSections,workshopStates,filterWorkshopOrders,visibleWorkshopEquipment} from './workshop-menu.js';
import WorkshopStockWorkspace from './WorkshopStockWorkspace.jsx';
export default function WorkshopMenuWorkspace({section,context,can,api,navigate}) {
const unit=context.unit?.id??null,active=workshopSections.find(s=>s.id===section);
@@ -16,8 +17,9 @@ export default function WorkshopMenuWorkspace({section,context,can,api,navigate}
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>
{section==='workshop_stock'&&unit!==null&&can('workshop_stock.view',unit)&&<WorkshopStockWorkspace key={context.token} api={api} context={context} can={can}/>}
<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>}
{active.pending&&!(section==='workshop_stock'&&can('workshop_stock.view',unit))&&<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>}
+34
View File
@@ -0,0 +1,34 @@
import React,{useEffect,useRef,useState} from 'react';
import {milliQuantity} from './StockReadWorkspace.jsx';
export function WorkshopStockImpact({impact:p}) {
return <p>Físico: {milliQuantity(p.physical_before)} → {milliQuantity(p.physical_after)}. Nova saída: {milliQuantity(p.quantity_milli)}. {['link_existing_delivery','allocate_existing_delivery'].includes(p.mode)&&<>Ligar entrega #{p.existing_movement_id}: {milliQuantity(p.linked_quantity_milli)}, sem novo abatimento. </>}{p.mode==='return_existing_delivery'&&<>Entrada por devolução: {milliQuantity(p.return_quantity_milli)}, da entrega #{p.existing_movement_id}. </>}{['allocate_existing_delivery','return_existing_delivery'].includes(p.mode)&&<>Quantidade não associada: {milliQuantity(p.unallocated_before)} → {milliQuantity(p.unallocated_after)}. </>}Artigo #{p.stock_item_id}, armazém #{p.warehouse_id}, origem versão {p.source_version}.</p>;
}
export function WorkshopStockClosureImpact({action}) {
return <p>Encerrar esta proposta como {action==='withdraw'?'retirada':'recusada'}. Efeito físico: 0; nenhuma origem será associada ou libertada. O original e esta decisão ficam no histórico; voltar a tratar a origem exige nova conferência.</p>;
}
export default function WorkshopStockWorkspace({api,context,can}) {
const [data,setData]=useState(null),[error,setError]=useState(''),[reload,setReload]=useState(0),[page,setPage]=useState(1),[busy,setBusy]=useState(false),[preview,setPreview]=useState(null),[proposalPage,setProposalPage]=useState(1),[status,setStatus]=useState("all");
const request=useRef(null),generation=useRef(0),unit=context.unit.id;
useEffect(()=>{setPreview(null);setBusy(false);request.current=null;},[context.token]);
useEffect(()=>{const n=++generation.current;setData(null);setError('');api(`/api/workshop-stock?page=${page}&proposal_page=${proposalPage}&status=${status}`).then(d=>{if(n===generation.current)setData(d);}).catch(e=>{if(n===generation.current)setError(e.message);});return()=>{generation.current++;};},[context.token,reload,page,proposalPage,status]);
async function prepare(e,source){e.preventDefault();const n=generation.current;setBusy(true);setError('');const body={...Object.fromEntries(new FormData(e.currentTarget)),entry_id:source.id,version:source.version};body.warehouse_id=Number(body.warehouse_id);
const text=JSON.stringify(body);if(request.current?.text!==text)request.current={text,key:crypto.randomUUID()};
const allocation=body.mode==='allocate_existing_delivery',returning=body.mode==='return_existing_delivery',delivery=returning||allocation||body.mode==='link_existing_delivery';delete body.mode;if(delivery)body.movement_id=Number(body.movement_id);else delete body.movement_id;
try{const result=await api(returning?'/api/workshop-stock/prepare-return':allocation?'/api/workshop-stock/prepare-allocation':delivery?'/api/workshop-stock/prepare-delivery':'/api/workshop-stock/prepare','POST',body,undefined,request.current.key);if(n===generation.current){setPreview(result);setReload(v=>v+1);}}catch(e){if(n===generation.current)setError(e.message);}finally{if(n===generation.current)setBusy(false);}}
async function confirm(e,p){e.preventDefault();const n=generation.current;setBusy(true);setError('');const body=Object.fromEntries(new FormData(e.currentTarget));if('received_in_warehouse' in body)body.received_in_warehouse=body.received_in_warehouse==='on';try{await api(`/api/workshop-stock/${p.id}/confirm`,'POST',body);if(n===generation.current){setPreview(null);setReload(v=>v+1);}}catch(e){if(n===generation.current)setError(e.message);}finally{if(n===generation.current)setBusy(false);}}
async function close(e,p,action){e.preventDefault();const n=generation.current;setBusy(true);setError('');const body={...Object.fromEntries(new FormData(e.currentTarget)),state_version:p.state_version,source_hash:p.source_hash};try{await api(`/api/workshop-stock/${p.id}/${action}`,'POST',body);if(n===generation.current){setPreview(null);setReload(v=>v+1);}}catch(e){if(n===generation.current)setError(e.message);}finally{if(n===generation.current)setBusy(false);}}
const closureForm=(p,action)=><details><summary>{action==='withdraw'?'Retirar proposta própria':'Recusar proposta'}</summary><WorkshopStockClosureImpact action={action}/><form onSubmit={e=>close(e,p,action)}><label>Fundamento do encerramento<textarea name="reason" minLength={10} maxLength={1000} required/></label><button disabled={busy}>{action==='withdraw'?'Confirmar retirada sem movimento':'Confirmar recusa sem movimento'}</button></form></details>;
const impact=p=><WorkshopStockImpact impact={p.impact}/>;
return <section className="panel"><h2>Conferência de consumo de material</h2><p>Uma origem verificada não provoca saída automática. Entregas já abatidas e correcções exigem reconciliação própria.</p>
{error&&<p role="alert">{error}</p>}{!data&&!error&&<p role="status">A consultar origens autorizadas…</p>}
<button disabled={busy} onClick={()=>setReload(n=>n+1)}>Actualizar</button>
{preview&&<div role="status"><h3>Proposta #{preview.id}</h3>{impact(preview)}<p>{preview.reason} · Evidência: {preview.evidence}</p><p>{preview.state&&preview.state!=='pending'?'Proposta já encerrada; a prévia é histórica.':'A confirmação é individual, por outra identidade autorizada.'}</p></div>}
{data&&<><p>{data.notice}</p><h3>Origens verificadas</h3>{data.sources.map(source=>{const d=typeof source.data==='string'?JSON.parse(source.data):source.data;return <article className="panel" key={source.id}><h4>Ordem #{source.order_id} · Origem #{source.id} / v{source.version}</h4><p>{d.resource} · {source.quantity} {d.unit} · {d.event} · {source.origin}</p>
{source.movement_id?<p>Esta origem já tem o movimento #{source.movement_id}. Uma nova versão exige conferência/correcção física separada, sem novo abatimento automático.</p>:can('workshop_stock.prepare',unit)&&<form onSubmit={e=>prepare(e,source)}><label>Tratamento<select name="mode" required defaultValue=""><option value="" disabled>Seleccione individualmente</option>{d.event!=="returned"&&<><option value="new_issue">Registar saída nova</option><option value="link_existing_delivery">Ligar saída já registada, sem novo abatimento</option><option value="allocate_existing_delivery">Associar este consumo à parte restante de uma entrega</option></>}{d.event==="returned"&&<option value="return_existing_delivery">Confirmar entrada da devolução não consumida</option>}</select></label><label>Movimento da entrega existente (apenas para ligação)<input name="movement_id" type="number" min="1"/></label><label>Armazém autorizado<input name="warehouse_id" type="number" min="1" required/></label><label>Fundamento<textarea name="reason" minLength={10} maxLength={1000} required/></label><label>Evidência da entrega e do tratamento físico<input name="evidence" minLength={3} maxLength={500} required/></label><button disabled={busy}>Conferir impacto</button></form>}</article>;})}
<button disabled={busy||page<=1} onClick={()=>setPage(p=>p-1)}>Anterior</button><button disabled={busy||page>=data.pagination.last_page} onClick={()=>setPage(p=>p+1)}>Seguinte</button>
<h3>Histórico de propostas da unidade</h3><label>Estado<select value={status} onChange={e=>{setStatus(e.target.value);setProposalPage(1);}}><option value="all">Todas</option><option value="pending">Pendentes</option><option value="confirmed">Confirmadas</option>{data.closure_supported&&<><option value="withdrawn">Retiradas</option><option value="rejected">Recusadas</option></>}</select></label>{data.proposals.map(p=><article className="panel" key={p.id}><h4>Proposta #{p.id} · Origem #{p.entry_id} / v{p.entry_version}</h4>{impact(p)}<p>Preparador #{p.preparer_id} · {p.created_at}</p><p>{p.reason} · Evidência: {p.evidence}</p>
{p.closure?<p>Prévia arquivada, não executada. {p.state==='withdrawn'?'Retirada':'Recusada'} por #{p.closure.actor_id} em {p.closure.created_at}: {p.closure.reason}.</p>:p.movement_id?<p>Tratamento já registado: movimento #{p.movement_id}.</p>:<>{p.can_review&&can('workshop_stock.confirm',unit)?<><form onSubmit={e=>confirm(e,p)}>{p.impact.mode==="return_existing_delivery"&&<label><input type="checkbox" name="received_in_warehouse" required/>Confirmo a recepção física desta quantidade no armazém indicado</label>}<label>Fundamento da confirmação<textarea name="reason" minLength={10} maxLength={1000} required/></label><p>O servidor exige outra identidade e revalida origem, mapa, direitos e livro antes de gravar.</p><button disabled={busy}>Confirmar tratamento conferido</button></form>{data.closure_supported&&closureForm(p,'reject')}</>:<p>Aguarda confirmação independente.</p>}{p.can_withdraw&&can('workshop_stock.prepare',unit)&&closureForm(p,'withdraw')}</>}</article>)}<button disabled={busy||proposalPage<=1} onClick={()=>setProposalPage(p=>p-1)}>Propostas anteriores</button><button disabled={busy||proposalPage>=data.proposal_pagination.last_page} onClick={()=>setProposalPage(p=>p+1)}>Propostas seguintes</button></>}
</section>;
}
+1 -1
View File
@@ -117,7 +117,7 @@ function App() {
{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} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => navigate(area,{area,id})} />}
{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}/>}
{stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate} api={api}/>}
{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} />}