commit de compras

This commit is contained in:
2026-11-04 09:37:20 +02:00
parent fcf31e8d48
commit 0b05a1192c
741 changed files with 114127 additions and 116 deletions
+3 -1
View File
@@ -17,7 +17,9 @@ export default function AccountingMenuWorkspace({section,context,can,navigate})
<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>)}
{context.accounting?.stock_valuation_read!==true&&unit!==null&&can('general_ledger.view',unit)&&<section className="panel"><h2>Primeiro lançamento contabilístico sintético</h2><p>Obrigação reconhecida → proposta → revisão independente → aplicação única. Duas contas e um período artificiais; sem utilização real, fecho ou contabilidade geral completa.</p><a href="/general-ledger">Abrir lançamento e extracto de ensaio</a></section>}
{context.accounting?.stock_valuation_read===true&&unit!==null&&can('stock.view',unit)&&can('general_ledger.view',unit)&&<section className="panel"><h2>Stock valorizado — consulta do livro de origem</h2><p>Quantidades físicas, valores contabilizados e pendências no mesmo livro QA. Esta consulta não executa movimentos ou lançamentos.</p><a href="/general-ledger/stock-valuation">Conferir stock e valorização</a></section>}
{functions.map(f=><section className="panel" key={f.id}><h2>{f.label}</h2><p>{f.description}</p><p><strong>Disponibilidade:</strong> {context.accounting?.stock_valuation_read===true&&['entries','balances'].includes(f.id)?'Consulta do mesmo livro de stock e valorização QA; confirmações e conferência completa na composição de origem.':unit!==null&&can('general_ledger.view',unit)&&['entries','balances'].includes(f.id)?'Primeiro percurso sintético de obrigação de fornecedor: lançamento equilibrado e extracto em duas contas artificiais. Não constitui contabilidade operacional, balancete legal, fecho ou demonstrações financeiras.':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>}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
export default function GeographyLink({enabled,kind,id,children}) {
if(enabled!==true||!['company','unit','client'].includes(kind)||!Number.isSafeInteger(Number(id))||Number(id)<1)return null;
return <a href={`/geography/${kind}/${Number(id)}`}>{children||'Localização para segmentação'}</a>;
}
+10 -2
View File
@@ -4,9 +4,17 @@ 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);
const requisitions=unit!==null&&context.purchasing?.internal_requisitions===true&&can('purchasing_requisitions.view',unit);
const sourceRequests=unit!==null&&context.purchasing?.source_requests===true&&can('purchasing_requests.view',unit);
const suppliers=unit!==null&&context.purchasing?.suppliers===true&&can('purchasing_suppliers.view',unit);
const sourcing=unit!==null&&context.purchasing?.sourcing===true&&can('purchasing_sourcing.view',unit);
const orders=unit!==null&&context.purchasing?.orders===true&&can('purchasing_orders.view',unit);
const obligations=unit!==null&&context.purchasing?.supplier_obligations===true&&can('purchasing_obligations.view',unit);
const invoices=unit!==null&&context.purchasing?.supplier_invoices===true&&can('purchasing_invoices.view',unit);
const receipts=unit!==null&&context.purchasing?.receipts===true&&can('purchasing_receipts.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>
<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">{(sourceRequests||suppliers||sourcing||orders||receipts||invoices||obligations)&&<a href="/purchasing/workspace">Percurso de Compras</a>}{requisitions&&<a href="/purchasing/internal-requisitions">Requisições internas</a>}{sourceRequests&&<a href="/purchasing/requests">Requisições para compra</a>}{suppliers&&<a href="/purchasing/suppliers">Fornecedores e revisão</a>}{sourcing&&<a href="/purchasing/sourcing">Propostas e adjudicação</a>}{orders&&<a href="/purchasing/orders">Ordens de compra</a>}{receipts&&<a href="/purchasing/receipts">Recepção de mercadoria</a>}{obligations&&<a href="/purchasing/obligations">Reconhecimento de obrigações</a>}{invoices&&<a href="/purchasing/invoices">Facturas de fornecedor</a>}{budget&&<button onClick={()=>navigate('purchasing_budget')}>Controlo orçamental</button>}{payables&&<button onClick={()=>navigate('supplier_payables')}>Obrigações a fornecedores</button>}</div>{!requisitions&&!sourceRequests&&!suppliers&&!sourcing&&!orders&&!receipts&&!invoices&&!obligations&&!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. Os percursos disponíveis acima reutilizam os serviços de Compras e os mesmos documentos nesta instalaçã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>
</>;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import GeographyLink from './GeographyLink.jsx';
import React, { useEffect, useState } from 'react';
import DocumentPreview from './DocumentPreview.jsx';
import {visibleBillingSections} from './billing-menu.js';
@@ -99,7 +100,7 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
{selected.confirmation && <InternalDocument confirmation={selected.confirmation} />}
{area === 'billing_drafts' && (selected.confirmation ? <button onClick={() => setPreviewMode('history')}>Abrir confirmação interna histórica</button> : can('issuer_documents.view') && can('client_documents.view') && can('draft_documents.view',context.unit?.id) && <button className="primary" onClick={() => setPreviewMode('draft')}>Pré-visualizar rascunho</button>)}
</>}
<details className="a-history"><summary>Histórico e captura durável ({selected.history?.length || 0} versões)</summary>{selected.history?.map(entry => <div key={entry.id}><strong>Versão {entry.version}</strong><p className="help">{entry.created_at} · Autor #{entry.actor_id} · Evento #{entry.event?.id} · Capturado, sem processamento contabilístico</p><details><summary>Dados preservados nesta versão</summary><pre>{JSON.stringify(entry.snapshot, null, 2)}</pre></details></div>)}</details>
{area==='clients'&&<GeographyLink enabled={context.geography_enabled} kind="client" id={selected.id}/>}<details className="a-history"><summary>Histórico e captura durável ({selected.history?.length || 0} versões)</summary>{selected.history?.map(entry => <div key={entry.id}><strong>Versão {entry.version}</strong><p className="help">{entry.created_at} · Autor #{entry.actor_id} · Evento #{entry.event?.id} · Capturado, sem processamento contabilístico</p><details><summary>Dados preservados nesta versão</summary><pre>{JSON.stringify(entry.snapshot, null, 2)}</pre></details></div>)}</details>
</> : <><h2>Seleccione um registo</h2><p>Os dados e o histórico aparecem aqui. As operações disponíveis dependem das suas concessões explícitas.</p></>}
</section></div></>}
</>;
+5 -3
View File
@@ -1,8 +1,9 @@
import React,{useEffect,useState} from 'react';
import {stockSections,visibleStockSections,stockOperations,availableStockReports} from './stock-menu.js';
import StockReadWorkspace from './StockReadWorkspace.jsx';
import StockPeriodReportWorkspace from './StockPeriodReportWorkspace.jsx';
export default function StockMenuWorkspace({section,context,can,navigate,api}) {
export default function StockMenuWorkspace({section,context,can,navigate,api,openReceiptOrigin}) {
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>;
@@ -10,6 +11,7 @@ export default function StockMenuWorkspace({section,context,can,navigate,api}) {
if(!active)return null;
const operation=stockOperations[section];
const canRead=Boolean(api&&context.unit&&can('stock.view',context.unit.id));
const periodAvailable=canRead&&context.stock?.period_report===true&&reportId==='movements';
// 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];
@@ -20,7 +22,7 @@ export default function StockMenuWorkspace({section,context,can,navigate,api}) {
<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'&&canRead&&<StockReadWorkspace key={context.token} api={api} context={context}/>}
{section==='stock_balances'&&canRead&&<StockReadWorkspace key={context.token} api={api} context={context} openReceiptOrigin={openReceiptOrigin}/>}
{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>
@@ -30,7 +32,7 @@ export default function StockMenuWorkspace({section,context,can,navigate,api}) {
<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>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>{periodAvailable?<StockPeriodReportWorkspace key={context.token} api={api} context={context}/>:<><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>
@@ -0,0 +1,29 @@
import React,{useEffect,useState} from 'react';
import {milliQuantity} from './StockReadWorkspace.jsx';
import {requestStockPeriod} from './stock-period-request.js';
export function StockPeriodTable({rows}) {
return <div className="table-wrap"><table><thead><tr><th>Artigo / unidade</th><th>Armazém</th><th>Abertura</th><th>Entradas</th><th>Saídas</th><th>Fecho</th><th>Movimentos no período</th></tr></thead>
<tbody>{rows.map(row=><tr key={`${row.stock_item_id}:${row.warehouse_id}`}><td>{row.code} · {row.name} · {row.unit}</td><td>{row.warehouse_code} · {row.warehouse_name}</td>
{['opening_milli','incoming_milli','outgoing_milli','closing_milli'].map(key=><td key={key}>{milliQuantity(row[key])}</td>)}<td>{row.period_movements}</td></tr>)}</tbody></table></div>;
}
export default function StockPeriodReportWorkspace({api,context}) {
const [query,setQuery]=useState(null),[data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);
useEffect(()=>{if(!query)return;return requestStockPeriod({query,api,data:setData,error:setError,loading:setLoading});},[query,context.token]);
const submit=e=>{e.preventDefault();const values=Object.fromEntries(new FormData(e.currentTarget));setData(null);setQuery({view:'period',from:values.from,to:values.to,search:values.search,warehouse_id:values.warehouse_id||undefined,per_page:Number(values.per_page),page:1});};
return <section aria-label="Relatório físico por período"><h3>Resumo físico por artigo e armazém</h3>
<p>Escolha as datas do registo em UTC. Abertura antes do primeiro dia; fecho até ao fim do último dia. As designações são as actuais.</p>
<form onSubmit={submit}><label>Primeiro dia (UTC)<input name="from" type="date" min="1900-01-01" max="9998-12-31" required/></label><label>Último dia (UTC)<input name="to" type="date" min="1900-01-01" max="9998-12-31" required/></label>
<label>Artigo por código ou nome<input name="search" maxLength={100}/></label><label>Identificador do armazém autorizado<input name="warehouse_id" type="number" min="1"/></label>
<label>Linhas por página<select name="per_page" defaultValue="25"><option value="25">25</option><option value="100">100</option></select></label><button disabled={loading}>Consultar período</button></form>
{!query&&<p>Seleccione o período; não foi consultada nem escolhida uma data automaticamente.</p>}
{loading&&<p role="status">A consultar o período autorizado…</p>}
{error&&<div role="alert"><p>{error}</p><button onClick={()=>{setData(null);setQuery({...query,page:1,snapshot:undefined});}}>Actualizar relatório</button></div>}
{data&&<><p role="status">{data.notice}</p><p>{data.period.from} a {data.period.to} · {data.period.timezone} · referência {data.observed_at}. Página {data.pagination.page} de {data.pagination.last_page}; {data.pagination.total} pares artigo/armazém.</p>
<StockPeriodTable rows={data.rows}/>{data.rows.length===0&&<p>Sem pares com história elegível até ao fecho para estes filtros. Não se inventam existências zero.</p>}
<button disabled={loading||data.pagination.page<=1} onClick={()=>setQuery({...query,page:data.pagination.page-1,snapshot:data.snapshot})}>Anterior</button>
<button disabled={loading||data.pagination.page>=data.pagination.last_page} onClick={()=>setQuery({...query,page:data.pagination.page+1,snapshot:data.snapshot})}>Seguinte</button>
<button disabled={loading} onClick={()=>{setData(null);setQuery({...query,page:1,snapshot:undefined});}}>Nova referência de leitura</button></>}
<p>Fecho = abertura + entradas − saídas, por linha e unidade. Sem valorização, reservas, bloqueios ou disponibilidade calculada.</p>
</section>;
}
+8 -3
View File
@@ -1,12 +1,16 @@
import React,{useEffect,useRef,useState} from 'react';
const labels={items:'Artigos',warehouses:'Armazéns',balances:'Existências físicas',movements:'Movimentos'};
export function StockReceiptOrigin({row,enabled,onOpen,loading}) {
if(!enabled||row.movement_type!=='receipt_in'||!Number.isSafeInteger(row.id)||row.id<1||typeof onOpen!=='function')return null;
return <button type="button" disabled={loading} onClick={()=>onOpen(row.id)}>Conferir recepção em Compras</button>;
}
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}) {
export default function StockReadWorkspace({api,context,openReceiptOrigin}) {
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);
@@ -19,6 +23,7 @@ export default function StockReadWorkspace({api,context}) {
},[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});}
const trace=query.view==='movements'&&data?.capabilities?.receipt_trace===true&&typeof openReceiptOrigin==='function';
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>
@@ -29,8 +34,8 @@ export default function StockReadWorkspace({api,context}) {
{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>
<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></>}{trace&&<th>Documento de origem</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></>}{trace&&<td><StockReceiptOrigin row={row} enabled={trace} onOpen={openReceiptOrigin} loading={loading}/></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></>}
+13 -3
View File
@@ -1,3 +1,4 @@
import GeographyLink from './GeographyLink.jsx';
import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import '../css/app.css';
@@ -27,6 +28,7 @@ import {billingSections} from './billing-menu.js';
import WorkshopMenuWorkspace from './WorkshopMenuWorkspace.jsx';
import {workshopSections} from './workshop-menu.js';
import StockMenuWorkspace from './StockMenuWorkspace.jsx';
import {openStockReceipt} from './stock-receipt-trace.js';
import {stockSections} from './stock-menu.js';
import AccountingMenuWorkspace from './AccountingMenuWorkspace.jsx';
import {accountingSections} from './accounting-menu.js';
@@ -83,6 +85,14 @@ function App() {
};
return method==='GET' ? execute() : transitions.current.command(execute);
}
const openStockReceiptOrigin = movement => transition(async()=>{
setError('');
try { await openStockReceipt({movement,context,active:()=>currentToken.current===context.token,
select:body=>api('/api/context','POST',body),
commit:next=>{mountedToken.current=next.context.token;currentToken.current=next.context.token;setState(next);},
go:href=>location.assign(href)});
}catch(e){setError(e.message);}
});
async function refresh() {
const response = await fetch('/api/session', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Não foi possível carregar a sessão.');
@@ -113,11 +123,11 @@ function App() {
{page === 'module_records' && <ModuleRecordsWorkspace context={context} can={can} api={api}/>}
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} />}
{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>}</>}
{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.')} /><GeographyLink enabled={context.geography_enabled} kind="company" id={context.company.id}/>{!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>{context.unit&&<GeographyLink enabled={context.geography_enabled} kind="unit" id={context.unit.id}/>}{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} api={api}/>}
{stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate} api={api} openReceiptOrigin={openStockReceiptOrigin}/>}
{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} />}
+9
View File
@@ -0,0 +1,9 @@
/** One cancellable read: a late response cannot replace another period or context. */
export function requestStockPeriod({query,api,data,error,loading}) {
let cancelled=false;
data(null);error('');loading(true);
const params=new URLSearchParams(Object.entries(query).filter(([,value])=>value!==undefined&&value!==''));
Promise.resolve().then(()=>api('/api/stock-read?'+params)).then(value=>{if(!cancelled)data(value);})
.catch(e=>{if(!cancelled)error(e.message);}).finally(()=>{if(!cancelled)loading(false);});
return ()=>{cancelled=true;};
}
+18
View File
@@ -0,0 +1,18 @@
export function receiptMovementHref(id) {
if(!Number.isSafeInteger(id)||id<1)throw Error('Movimento inválido. Actualize a consulta.');
return `/purchasing/receipts?movement=${id}`;
}
/** Called only inside the host's existing dirty-form/context transition guard. */
export async function openStockReceipt({movement,context,active,select,commit,go}) {
const href=receiptMovementHref(movement),unit=context?.unit?.id;
const allowed=p=>context?.grants?.some(g=>g.permission===p&&g.unit_id===unit);
if(context?.module_id!=='stock'||!unit||!context?.purchasing?.receipts
||!context?.modules?.some(m=>m.id==='purchasing')||!allowed('stock.view')||!allowed('purchasing_receipts.view'))throw Error('Origem sem acesso autorizado neste contexto.');
if(!active())throw Error('O contexto mudou. Actualize a consulta.');
const next=await select({tenant_id:context.company.tenant_id,company_id:context.company.id,unit_id:unit,module_id:'purchasing'});
if(!active())throw Error('Resposta de um contexto anterior ignorada.');
if(next?.context?.module_id!=='purchasing'||next.context.company?.id!==context.company.id
||next.context.company?.tenant_id!==context.company.tenant_id||next.context.unit?.id!==unit||!next.context.token)throw Error('O destino não confirmou o contexto pedido.');
commit(next);go(href);
}