@@ -37,6 +37,9 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
|
||||
- Cotações para requisições aprovadas: uma proposta por fornecedor, linhas completas, IVA herdado e cálculo exacto em centavos.
|
||||
- Matriz comparativa com três fornecedores elegíveis e duas avaliações por proposta; preço e prazo automáticos, notas de qualidade, histórico e pagamento, declaração de ausência de conflito e auditoria.
|
||||
- RF-AF-01: declarações pessoais por fornecedor/processo, correcções com histórico, consultas por perfil/empresa e impedimentos no servidor. A associação de fornecedores volta a verificar os intervenientes anteriores; declarações de ausência não anulam conflitos registados.
|
||||
- Adjudicação fundamentada da proposta de maior pontuação, com escolha entre empates, revalidação de conflitos/propostas/avaliadores e excepções bloqueadas sem política aprovada.
|
||||
- Novo ciclo explícito de aprovação do valor final, segundo os escalões e a base de IVA guardados no processo. As decisões da estimativa não são reutilizadas.
|
||||
- Ordens únicas por processo, com valores e condições congelados, emissão idempotente, listagem, detalhe, histórico e impressão/gravação em PDF pelo navegador. São documentos de desenvolvimento, sem autorização para compras reais e sem validação orçamental.
|
||||
|
||||
Os menus **Fornecedores** e **Cotações** estão disponíveis para Administração, Procurement, Finanças e chefes de departamento. Procurement regista; Finanças valida os fornecedores; chefes apenas consultam/avaliam processos do seu departamento. O administrador não substitui os perfis operacionais: pode criar a equipa em **Empresa e equipa**. As contas existentes não foram alteradas.
|
||||
|
||||
@@ -48,9 +51,9 @@ O IVA é informado pelo utilizador e calculado no servidor. A taxa inicial de 0%
|
||||
|
||||
## Próximas etapas
|
||||
|
||||
Adjudicação e ordens de compra com nova verificação dos conflitos; dados bancários com dupla validação; equipamentos e peças com aprovação técnica; recepção e stock; orçamento, facturas e pagamentos; moedas e câmbio; subscrições, licenças e Docker. A política de periodicidade, validade e levantamento de conflitos continua por definir.
|
||||
Recepção parcial e qualidade; equipamentos e peças com aprovação técnica e stock; dados bancários com dupla validação; orçamento, facturas e pagamentos; moedas e câmbio; subscrições, licenças e Docker. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir.
|
||||
|
||||
O cadastro guarda referências e ficheiros anexos no disco privado `supplier_documents`, em `storage/app/supplier-documents`, fora da pasta pública e sem URLs públicas. As fichas abrem pelo directório **Fornecedores → Ver ficha → Abrir ficha, edição e documentos**. As versões antigas e documentos existentes são preservados na auditoria e no armazenamento; nesta entrega não há eliminação de documentos, antivírus, quotas acumuladas ou política de retenção. A validação do NUIT é de formato e unicidade, sem consulta a uma autoridade fiscal. Além das declarações por operação, existem declarações pessoais por fornecedor e processo; a periodicidade anual e a política de validade/levantamento ainda não foram definidas. As propostas e avaliações registadas não são editáveis nesta etapa. Os pesos 40/20/15/15/10 são provisórios e ficam guardados por proposta. A matriz assinala quando está completa, mas não adjudica nem emite ordens. As propostas usam MZN; a multimoeda fica para a fase correspondente.
|
||||
O cadastro guarda referências e ficheiros anexos no disco privado `supplier_documents`, em `storage/app/supplier-documents`, fora da pasta pública e sem URLs públicas. As fichas abrem pelo directório **Fornecedores → Ver ficha → Abrir ficha, edição e documentos**. As versões antigas e documentos existentes são preservados na auditoria e no armazenamento; nesta entrega não há eliminação de documentos, antivírus, quotas acumuladas ou política de retenção. A validação do NUIT é de formato e unicidade, sem consulta a uma autoridade fiscal. Além das declarações por operação, existem declarações pessoais por fornecedor e processo; a periodicidade anual e a política de validade/levantamento ainda não foram definidas. As propostas e avaliações registadas não são editáveis nesta etapa. Os pesos 40/20/15/15/10 são provisórios e ficam guardados por proposta. A comparação liga à fase de adjudicação e emissão. A ordem exige novas aprovações do valor final e revalidação no servidor. As propostas usam MZN; a multimoeda fica para a fase correspondente.
|
||||
|
||||
Esta base não implementa ainda os onze controlos anti-fraude nem todos os requisitos não funcionais do documento. MFA obrigatório, gestão completa do ciclo de vida dos acessos, delegações, política de levantamento de conflitos, alteração de regras com dupla aprovação, retenção, cópias de segurança e restauro por empresa serão tratados nas etapas correspondentes. Não usar para compras reais antes da validação destas regras e controlos.
|
||||
|
||||
|
||||
@@ -13,4 +13,9 @@ class PurchaseOrderItem extends Model
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ class ProposalComparison
|
||||
private function validWeights(array $weights): bool
|
||||
{
|
||||
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
|
||||
if (count($weights) !== count($keys) || array_sum($weights) !== 100) {
|
||||
if (count($weights) !== count($keys)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($keys as $key) {
|
||||
@@ -134,7 +134,7 @@ class ProposalComparison
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return array_sum($weights) === 100;
|
||||
}
|
||||
|
||||
private function validAmounts(Quotation $quote, Requisition $requisition): bool
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\QuotationItem;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -112,6 +115,11 @@ class PurchaseWorkflow
|
||||
...$source->only(['unit', 'category', 'purchase_type']), 'tenant_id' => $user->tenant_id,
|
||||
];
|
||||
}
|
||||
$validUntil = $quote->attributesToArray()['valid_until'] ?? null;
|
||||
$deliveryDate = $award->attributesToArray()['delivery_date'] ?? null;
|
||||
if (! is_string($validUntil) || ! is_string($deliveryDate)) {
|
||||
throw ValidationException::withMessages(['delivery_date' => 'As datas da proposta e da entrega devem estar completas antes da emissão.']);
|
||||
}
|
||||
$snapshot = [
|
||||
'development_only' => true, 'budget_checked' => false,
|
||||
'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']),
|
||||
@@ -120,12 +128,12 @@ class PurchaseWorkflow
|
||||
'cost_center' => $requisition->costCenter->only(['id', 'code', 'name']),
|
||||
'requester' => $requisition->requester?->only(['id', 'name']),
|
||||
'supplier' => $supplier->only(['id', 'name', 'nuit', 'contact', 'email', 'phone', 'address']),
|
||||
'quotation' => $quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days', 'valid_until']),
|
||||
'award' => $award->only(['id', 'justification', 'delivery_date', 'delivery_location']),
|
||||
'quotation' => [...$quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days']), 'valid_until' => $validUntil],
|
||||
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
|
||||
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
|
||||
'estimate_approvals' => $requisition->steps()->get()->toArray(),
|
||||
'final_approvals' => $award->approvals()->get()->toArray(),
|
||||
'decision_matrix' => $award->decision_matrix, 'items' => $lines,
|
||||
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
|
||||
];
|
||||
$order = PurchaseOrder::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'purchase_award_id' => $award->id,
|
||||
@@ -161,6 +169,13 @@ class PurchaseWorkflow
|
||||
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
|
||||
private function checkComparison(User $user, Requisition $requisition, Quotation $quote): array
|
||||
{
|
||||
// Also coordinate with supplier reviews, which lock supplier rows without the company mutex.
|
||||
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
|
||||
Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
|
||||
QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
|
||||
ProposalEvaluation::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
|
||||
User::query()->where('tenant_id', $user->tenant_id)->orderBy('id')->lockForUpdate()->get();
|
||||
$requisition->items()->orderBy('id')->lockForUpdate()->get();
|
||||
$this->guard->assertParticipation($user, $requisition);
|
||||
$this->guard->assertProcess($requisition, $quote->supplier_id, $user);
|
||||
$result = $this->comparison->inspect($requisition);
|
||||
@@ -213,7 +228,7 @@ class PurchaseWorkflow
|
||||
throw ValidationException::withMessages(['approvals' => 'A requisição deve ter todas as aprovações obrigatórias da estimativa, preservadas no histórico.']);
|
||||
}
|
||||
foreach ($steps as $index => $step) {
|
||||
if ($step->decision !== 'approved' || $step->role !== $roles[$index] || $step->approver_id === null || in_array($step->approver_id, $seen, true) ||
|
||||
if ($step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $step->approver_id === null || in_array($step->approver_id, $seen, true) ||
|
||||
! User::query()->where('tenant_id', $requisition->tenant_id)->whereKey($step->approver_id)->exists()) {
|
||||
throw ValidationException::withMessages(['approvals' => 'O histórico de aprovação da estimativa está incompleto ou inconsistente.']);
|
||||
}
|
||||
@@ -232,7 +247,7 @@ class PurchaseWorkflow
|
||||
}
|
||||
foreach ($steps as $index => $step) {
|
||||
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($step->approver_id);
|
||||
if (! $user || $step->decision !== 'approved' || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
|
||||
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
|
||||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
|
||||
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Consulta livre para revisão visual
|
||||
|
||||
A entrada `/` encaminha para `/consulta`, uma demonstração pública sem login,
|
||||
criada a pedido do utilizador para avaliar navegação, ecrãs e cores.
|
||||
|
||||
O ecrã usa exclusivamente exemplos fictícios locais e não consulta registos de
|
||||
negócio. Não permite gravar, aprovar, adjudicar ou descarregar documentos reais.
|
||||
As rotas operacionais mantêm autenticação e as permissões existentes. A conta,
|
||||
os dados e a configuração da empresa foram preservados.
|
||||
|
||||
Implementação: `routes/preview.php` e `resources/js/pages/preview/index.tsx`.
|
||||
O login oferece também uma ligação à demonstração. A página não usa o layout
|
||||
autenticado e não recebe a identidade do utilizador nas propriedades públicas.
|
||||
|
||||
Ao desenvolver novos módulos, manter a demonstração identificada como fictícia.
|
||||
Os exemplos de ordens são ilustrativos; não constituem prova de implementação
|
||||
do fluxo operacional. Não substituir os controlos de acesso por acesso anónimo.
|
||||
|
||||
Validação: quatro testes dirigidos passaram, com 41 asserções, incluindo a
|
||||
entrada livre, ausência de identidade e manutenção da autenticação nas rotas
|
||||
de negócio. Revisão visual no navegador pendente por falha da ligação de controlo.
|
||||
@@ -1,5 +1,61 @@
|
||||
# Desenvolvimento do ProcuraFlow
|
||||
|
||||
## Incremento actual de adjudicação e ordens de compra
|
||||
|
||||
Implementado em 28 de Outubro de 2026 no projecto Laravel existente, segundo a secção «Próximo incremento após RF-AF-01» das orientações. O RF-AF-01, os fornecedores e os documentos privados foram reutilizados e preservados. Não foram recriadas contas, executados seeds ou alterados o SGF Tempo, o Investmoz ou a configuração local.
|
||||
|
||||
### Selecção e revalidação
|
||||
|
||||
- Procurement regista a adjudicação a partir da página de comparação de cotações. Exige justificação escrita e data/local de entrega. O solicitante não selecciona a sua própria compra; Administração consulta, sem substituir os perfis operacionais.
|
||||
- `ProposalComparison` unifica a matriz da interface e a decisão no servidor. Recalcula preços, prazos e notas com aritmética racional; arredonda a pontuação final a duas casas decimais. Quantidades, preços e IVA são verificados em precisão decimal e centavos. Linhas devem corresponder à requisição, e os totais têm de conferir com as linhas. A entrega suporta apenas MZN.
|
||||
- Requer três fornecedores distintos com propostas elegíveis, válidas e comparáveis; cada proposta elegível precisa de duas avaliações elegíveis. Os avaliadores têm de continuar na mesma empresa e num perfil operacional permitido, respeitar o departamento quando são chefes, ser distintos e não ser o solicitante. Uma declaração de ausência não substitui a verificação de conflitos.
|
||||
- Escolha limitada ao topo da pontuação. Empates permitem seleccionar uma das propostas empatadas, sempre com fundamento escrito. Dispensa de cotações, escolha abaixo do topo e outros caminhos de excepção permanecem bloqueados sem política aprovada; parâmetros enviados pelo navegador não dispensam estas regras.
|
||||
- Cada operação decisiva revalida o `ConflictGuard`, incluindo solicitante, aprovadores, autores de cotações, avaliadores e agora autor/aprovadores da adjudicação. Novas declarações continuam a bloquear operações sem apagar o histórico.
|
||||
- A selecção fecha o registo de novas propostas e avaliações desse processo. A matriz de decisão e a sua impressão digital são guardadas. Alterações aos valores, avaliações, ponderações ou elegibilidade da matriz impedem a utilização das aprovações para emitir sobre uma base diferente.
|
||||
|
||||
### Escalões e ciclo de aprovação do valor final
|
||||
|
||||
- Os limites e a base com/sem IVA vêm do snapshot guardado na requisição; não se substituem pela configuração actual da empresa. Processos sem snapshot válido ou histórico completo de aprovação da estimativa são bloqueados.
|
||||
- A adjudicação abre explicitamente um novo ciclo de aprovações para o valor final, mesmo quando fica no mesmo escalão. As decisões da estimativa são preservadas e não são reaproveitadas. O topo do primeiro escalão é inclusivo, tal como o segundo; ultrapassagens acrescentam Procurement e Finanças, conforme as regras guardadas.
|
||||
- As etapas são sequenciais. Solicitante e autor da adjudicação não aprovam o ciclo; cada etapa é decidida por uma pessoa distinta. A rejeição exige fundamento. Antes de emitir, verifica-se novamente se os aprovadores finais continuam elegíveis e sem conflito.
|
||||
- Equipamento e peças com tipos ainda não suportados são bloqueados, sem presumir aprovação técnica ou limites próprios. Não existem excepções automáticas para urgência, fornecedor único ou valores acima de limites sem política definida.
|
||||
|
||||
### Ordem emitida e consulta
|
||||
|
||||
- Ordem única por requisição e adjudicação, com referências `OC-ANO-ID`, transacção e unicidade na base de dados. Repetir a emissão devolve a ordem existente sem novas linhas, novo número, nova auditoria ou alteração dos dados congelados. Podem existir intervalos na sequência de identificadores.
|
||||
- A ordem congela na emissão linhas, quantidades/unidades, preços, IVA, totais, moeda, fornecedor/contactos, empresa, departamento, centro de custo, referência/condições da proposta, entrega, fundamento, emitente, matriz revalidada e aprovações. A matriz guardada na adjudicação continua separada como histórico da escolha. Alterações posteriores às fichas e propostas não reescrevem a ordem.
|
||||
- Listagem, detalhe, emitente, histórico e versão HTML para impressão ou gravação em PDF pelo navegador. A impressão escapa o conteúdo fornecido pelos utilizadores. Não há geração de PDF no servidor ou envio por email.
|
||||
- Administração, Procurement e Finanças consultam na própria empresa; chefes apenas processos do seu departamento. Solicitantes e armazém não recebem acesso aos documentos financeiros nesta etapa.
|
||||
- Todas as ordens exibem **Documento de desenvolvimento. Não autoriza compras reais.** O snapshot guarda `development_only=true` e `budget_checked=false`. **O orçamento ainda não é verificado**, tanto na interface como na impressão. Não foram criadas ordens reais neste incremento.
|
||||
- Falhar a auditoria reverte a adjudicação e as suas etapas, ou a emissão e as suas linhas. Os modelos das ordens e linhas impedem edição/eliminação; a protecção contra SQL administrativo continua dependente das permissões operacionais.
|
||||
|
||||
### Verificação e preservação
|
||||
|
||||
- Suite completa Laravel: **165 passaram, 3 ignorados, 1.662 asserções**. Os 46 testes específicos de adjudicação/ordens passaram; a suite inclui também os testes anteriores de RF-AF-01/fornecedores/anexos e os três testes de pré-visualização pública acrescentados em paralelo. Os testes usam SQLite em memória, não a base de negócio.
|
||||
- Cobertura: acesso por perfil e departamento; isolamento de empresas; valores/IVA e quantidades fraccionárias; fronteiras de 25.000 e 150.000 MZN; base com/sem IVA guardada; novo ciclo de aprovação; empates; excepções bloqueadas; conflitos/suspensão/expiração após avaliação ou selecção; elegibilidade de avaliadores/aprovadores; integridade das linhas; prevenção de repetição com unicidade/idempotência; snapshots; impressão com escape de HTML; rollback se falhar a auditoria.
|
||||
- PHPStan aprovado, sem erros; Pint aprovado em todos os ficheiros PHP; verificação frontend sem avisos ou erros; TypeScript aprovado; compilação aprovada; `git diff --check` sem erros.
|
||||
- Migração aditiva `2026_10_28_000005_create_purchase_awards_and_orders` aplicada no PostgreSQL local após os testes. Contagens antes/depois iguais: 1 utilizador, 1 empresa e 0 requisições, fornecedores, cotações, avaliações, documentos e declarações de conflito. Novas tabelas de adjudicações, aprovações finais, ordens e linhas vazias. Não foram criados registos sintéticos na base real.
|
||||
- O SHA-256 do `.env` manteve-se igual antes e depois. Não houve seeds, alterações de credenciais ou configuração, nem reinicialização de dados.
|
||||
- As rotas de ordens/adjudicação estão registadas. O acesso sem autenticação a `/ordens` redireccionou para a entrada ProcuraFlow em 8015, com HTTP 200. As páginas autenticadas e o conteúdo da impressão foram verificados em testes HTTP/Inertia, não visualmente.
|
||||
|
||||
Foram detectadas alterações paralelas na pré-visualização pública, na entrada e nas respectivas rotas/testes; foram preservadas e incluídas na verificação final, sem as apresentar como parte desta implementação. As alterações funcionais deste incremento permanecem limitadas à adjudicação/ordens e à integração necessária com os módulos anteriores.
|
||||
|
||||
### Limitações e próximo passo
|
||||
|
||||
**Revisão visual autenticada, revisão visual da impressão/PDF e concorrência real em PostgreSQL continuam pendentes.** Não foram realizados testes de carga. O código usa bloqueio por empresa e bloqueios dos registos de comparação/fornecedores/utilizadores dentro da transacção; isso não constitui validação de concorrência real.
|
||||
|
||||
Ainda não existe cancelamento/reabertura de adjudicações, alteração da data de entrega depois da selecção ou substituição de uma ordem emitida. Se a base aprovada mudar, faltar elegibilidade ou a data de entrega passar, o sistema impede a emissão; não assume uma autorização de correcção. As políticas e o fluxo de revisão/cancelamento devem ser definidos antes de ampliar esses caminhos.
|
||||
|
||||
Permanecem pendentes orçamento, multimoeda, dados bancários com dupla validação, aprovações técnicas de equipamentos/peças e as políticas comerciais em aberto. Próximo incremento: recepção parcial e conferência de qualidade com segregação do emitente, seguido de equipamentos, peças e stock. Não avançar para compras ou pagamentos reais.
|
||||
|
||||
### Ficheiros deste incremento
|
||||
|
||||
Migração `2026_10_28_000005`; modelos `PurchaseAward`, `PurchaseAwardApproval`, `PurchaseOrder`, `PurchaseOrderItem` e trait de imutabilidade; serviços `ProposalComparison` e `PurchaseWorkflow`; controlador `PurchaseController`; integração em `ConflictGuard` e `QuotationController`; casts decimais de `QuotationItem` e `ProposalEvaluation`; rotas autenticadas e menu; tipos `purchase.ts`, páginas `purchases/award`, `purchases/index`, `purchases/show`, ligação da comparação e vista `purchases/print.blade.php`; testes `PurchaseAwardOrderTest`; documentação.
|
||||
|
||||
## Entrega anterior RF-AF-01
|
||||
|
||||
Os registos abaixo conservam os resultados anteriores. A indicação de adjudicação e ordens como próximo passo nessas entregas fica substituída pelo incremento acima.
|
||||
|
||||
## Incremento actual RF-AF-01 de 28 de Outubro de 2026
|
||||
|
||||
### Implementado
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Continuidade do desenvolvimento do ProcuraFlow
|
||||
|
||||
## Entrada livre para revisão visual
|
||||
|
||||
A pedido do utilizador, `/` abre agora `/consulta`, com exemplos fictícios sem
|
||||
login. Consultar `docs/consulta-livre.md`. Preservar esta entrada e a ligação no
|
||||
login; manter as rotas e os dados reais protegidos. Esta demonstração é uma
|
||||
ferramenta de revisão visual e não substitui os módulos operacionais abaixo.
|
||||
|
||||
## Próximo incremento após RF-AF-01
|
||||
|
||||
O relatório RF-AF-01 está actualizado e a migração 000004 foi confirmada aplicada.
|
||||
|
||||
@@ -13,6 +13,7 @@ void createInertiaApp({
|
||||
layout: (name) => {
|
||||
switch (true) {
|
||||
case name === 'welcome':
|
||||
case name.startsWith('preview/'):
|
||||
return null;
|
||||
case name.startsWith('auth/'):
|
||||
return AuthLayout;
|
||||
|
||||
@@ -21,6 +21,12 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
<>
|
||||
<Head title="Entrar" />
|
||||
|
||||
<Button asChild variant="outline" className="mb-5 w-full">
|
||||
<TextLink href="/consulta">
|
||||
Consultar demonstração sem entrar
|
||||
</TextLink>
|
||||
</Button>
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password']}
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
LockKeyhole,
|
||||
Menu,
|
||||
Palette,
|
||||
Scale,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { PageTitle, Panel, Status } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { money } from '@/types/procurement';
|
||||
|
||||
// Static examples only: this page never loads or submits business data.
|
||||
const requests = [
|
||||
{
|
||||
number: 'DEMO-001',
|
||||
title: 'Material de escritório',
|
||||
department: 'Administração',
|
||||
status: 'approved',
|
||||
amount: 1856000,
|
||||
author: 'Utilizador de exemplo A',
|
||||
},
|
||||
{
|
||||
number: 'DEMO-002',
|
||||
title: 'Peças para manutenção',
|
||||
department: 'Produção',
|
||||
status: 'pending',
|
||||
amount: 6264000,
|
||||
author: 'Utilizador de exemplo B',
|
||||
},
|
||||
{
|
||||
number: 'DEMO-003',
|
||||
title: 'Equipamento informático',
|
||||
department: 'Tecnologia',
|
||||
status: 'draft',
|
||||
amount: 13920000,
|
||||
author: 'Utilizador de exemplo C',
|
||||
},
|
||||
];
|
||||
const suppliers = [
|
||||
{
|
||||
name: 'Fornecedor de demonstração A',
|
||||
category: 'Material de escritório',
|
||||
status: 'Activo',
|
||||
review: '2027-01-15',
|
||||
contact: 'contacto-a@example.invalid',
|
||||
},
|
||||
{
|
||||
name: 'Fornecedor de demonstração B',
|
||||
category: 'Equipamentos e peças',
|
||||
status: 'Pendente de validação',
|
||||
review: '2027-02-20',
|
||||
contact: 'contacto-b@example.invalid',
|
||||
},
|
||||
{
|
||||
name: 'Fornecedor de demonstração C',
|
||||
category: 'Tecnologia',
|
||||
status: 'Activo',
|
||||
review: '2027-03-10',
|
||||
contact: 'contacto-c@example.invalid',
|
||||
},
|
||||
];
|
||||
const quotes = [
|
||||
{
|
||||
supplier: 'Fornecedor de demonstração A',
|
||||
amount: 1600000,
|
||||
days: 7,
|
||||
score: '8,65',
|
||||
evaluations: 2,
|
||||
},
|
||||
{
|
||||
supplier: 'Fornecedor de demonstração B',
|
||||
amount: 1750000,
|
||||
days: 10,
|
||||
score: 'Pendente',
|
||||
evaluations: 1,
|
||||
},
|
||||
{
|
||||
supplier: 'Fornecedor de demonstração C',
|
||||
amount: 1680000,
|
||||
days: 8,
|
||||
score: '8,12',
|
||||
evaluations: 2,
|
||||
},
|
||||
];
|
||||
const navigation = [
|
||||
{ id: 'overview', title: 'Visão geral', icon: LayoutGrid },
|
||||
{ id: 'requests', title: 'Requisições', icon: ClipboardList },
|
||||
{ id: 'suppliers', title: 'Fornecedores e anexos', icon: Users },
|
||||
{ id: 'quotes', title: 'Cotações', icon: Scale },
|
||||
{ id: 'conflicts', title: 'Conflitos de interesses', icon: ShieldCheck },
|
||||
{ id: 'orders', title: 'Ordens de compra', icon: FileText },
|
||||
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
|
||||
];
|
||||
type Detail = { title: string; subtitle: string; rows: [string, string][] };
|
||||
export default function Preview() {
|
||||
const [screen, setScreen] = useState('overview');
|
||||
const [search, setSearch] = useState('');
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const title = navigation.find((n) => n.id === screen)?.title ?? 'Consulta';
|
||||
function navigate(id: string) {
|
||||
setScreen(id);
|
||||
setSearch('');
|
||||
setMenu(false);
|
||||
}
|
||||
const filteredRequests = requests.filter((r) =>
|
||||
`${r.number} ${r.title} ${r.department}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
);
|
||||
const requestTable = (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">Requisição</th>
|
||||
<th className="pr-4">Departamento</th>
|
||||
<th className="pr-4">Estado</th>
|
||||
<th className="pr-4 text-right">Total com IVA</th>
|
||||
<th>
|
||||
<span className="sr-only">Consultar</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRequests.map((r) => (
|
||||
<tr key={r.number} className="border-b last:border-0">
|
||||
<td className="py-5 pr-4">
|
||||
<strong>{r.number}</strong>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{r.title}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pr-4">{r.department}</td>
|
||||
<td className="pr-4">
|
||||
<Status value={r.status} />
|
||||
</td>
|
||||
<td className="pr-4 text-right whitespace-nowrap">
|
||||
{money(r.amount)}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDetail({
|
||||
title: r.number,
|
||||
subtitle: r.title,
|
||||
rows: [
|
||||
['Solicitante', r.author],
|
||||
['Departamento', r.department],
|
||||
[
|
||||
'Total com IVA',
|
||||
money(r.amount),
|
||||
],
|
||||
[
|
||||
'Finalidade',
|
||||
'Exemplo fictício para consulta visual.',
|
||||
],
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
<span className="sr-only">
|
||||
Ver {r.number}
|
||||
</span>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!filteredRequests.length && (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
Nenhum exemplo corresponde à pesquisa.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head title="Consulta livre" />
|
||||
<div className="min-h-screen bg-muted/25 text-foreground">
|
||||
<aside
|
||||
className={`${menu ? 'flex' : 'hidden'} fixed inset-y-0 left-0 z-30 w-64 flex-col border-r bg-background p-5 lg:flex`}
|
||||
>
|
||||
<Link
|
||||
href="/consulta"
|
||||
className="mb-8 flex items-center gap-3 text-xl font-semibold"
|
||||
>
|
||||
<span className="grid size-9 place-items-center rounded-xl bg-teal-700 text-sm text-white">
|
||||
PF
|
||||
</span>
|
||||
ProcuraFlow
|
||||
</Link>
|
||||
<div className="mb-7 rounded-xl border bg-muted/30 p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Building2 className="size-4" />
|
||||
Empresa de demonstração
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Consulta livre · sem autenticação
|
||||
</p>
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
{navigation.map((n) => (
|
||||
<button
|
||||
type="button"
|
||||
key={n.id}
|
||||
onClick={() => navigate(n.id)}
|
||||
aria-current={
|
||||
screen === n.id ? 'page' : undefined
|
||||
}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${screen === n.id ? 'bg-teal-50 font-medium text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'text-muted-foreground hover:bg-muted'}`}
|
||||
>
|
||||
<n.icon className="size-4" />
|
||||
{n.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<p className="mt-auto pt-8 text-xs leading-5 text-muted-foreground">
|
||||
Dados fictícios. A navegação e a pesquisa permitem
|
||||
avaliar a apresentação; não executam compras.
|
||||
</p>
|
||||
</aside>
|
||||
{menu && (
|
||||
<button
|
||||
className="fixed inset-0 z-20 bg-black/25 lg:hidden"
|
||||
aria-label="Fechar menu"
|
||||
onClick={() => setMenu(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="lg:ml-64">
|
||||
<header className="flex min-h-20 items-center justify-between gap-3 border-b bg-background px-5 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
aria-label="Abrir menu"
|
||||
onClick={() => setMenu(!menu)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Consulta visual <span className="mx-2">/</span>
|
||||
<strong className="font-medium text-foreground">
|
||||
{title}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/login">
|
||||
<LockKeyhole className="size-4" />
|
||||
Entrar na empresa
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
<main className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900 dark:border-teal-900 dark:bg-teal-950 dark:text-teal-100">
|
||||
<span className="flex items-center gap-2">
|
||||
<Eye className="size-4" />
|
||||
<strong>Entrada livre para consulta</strong>
|
||||
</span>
|
||||
<span>
|
||||
Exemplos fictícios · sem gravação de dados
|
||||
</span>
|
||||
</div>
|
||||
<PageTitle
|
||||
eyebrow="ProcuraFlow · Demonstração"
|
||||
title={title}
|
||||
description="Explore os exemplos para verificar a organização, os ecrãs, as cores e a navegação. Os dados apresentados não pertencem à sua empresa."
|
||||
/>
|
||||
{screen === 'overview' && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
[
|
||||
'Requisições de exemplo',
|
||||
'3',
|
||||
'Uma aprovada, uma pendente e um rascunho',
|
||||
],
|
||||
[
|
||||
'A aguardar decisão',
|
||||
'1',
|
||||
'Exemplo de aprovação por departamento',
|
||||
],
|
||||
[
|
||||
'Fornecedores de exemplo',
|
||||
'3',
|
||||
'Com estados de validação diferentes',
|
||||
],
|
||||
[
|
||||
'Valor aprovado de exemplo',
|
||||
money(1856000),
|
||||
'Estimativa em MZN, com IVA',
|
||||
],
|
||||
].map(([label, value, note]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded-xl border bg-card p-5 shadow-xs"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<strong className="mt-5 block text-2xl tracking-tight">
|
||||
{value}
|
||||
</strong>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{note}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Panel
|
||||
title="Requisições recentes"
|
||||
description="Clique no ícone de consulta para abrir os detalhes do exemplo."
|
||||
>
|
||||
{requestTable}
|
||||
</Panel>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
[
|
||||
'suppliers',
|
||||
'Ficha e documentos',
|
||||
'Consultar identificação, revisão e anexos.',
|
||||
],
|
||||
[
|
||||
'quotes',
|
||||
'Comparação de propostas',
|
||||
'Comparar preços, prazos e avaliações.',
|
||||
],
|
||||
[
|
||||
'conflicts',
|
||||
'Transparência nas decisões',
|
||||
'Consultar exemplos de declarações pessoais.',
|
||||
],
|
||||
].map(([id, label, note]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => navigate(id)}
|
||||
className="rounded-xl border bg-card p-5 text-left shadow-xs hover:border-teal-500"
|
||||
>
|
||||
<strong className="flex items-center justify-between text-sm">
|
||||
{label}
|
||||
<ArrowRight className="size-4 text-teal-700" />
|
||||
</strong>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{note}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{screen === 'requests' && (
|
||||
<Panel
|
||||
title="Pedidos de compra"
|
||||
description="Pesquisa e detalhe disponíveis nesta demonstração."
|
||||
>
|
||||
<div className="relative mb-5">
|
||||
<Search className="absolute top-3 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
aria-label="Pesquisar exemplos"
|
||||
placeholder="Pesquisar referência, descrição ou departamento"
|
||||
value={search}
|
||||
onChange={(e) =>
|
||||
setSearch(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{requestTable}
|
||||
</Panel>
|
||||
)}
|
||||
{screen === 'suppliers' && (
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
{suppliers.map((s) => (
|
||||
<Panel
|
||||
key={s.name}
|
||||
title={s.name}
|
||||
description={s.category}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<span
|
||||
className={`inline-block rounded-full px-3 py-1 text-xs ${s.status === 'Activo' ? 'bg-teal-50 text-teal-800' : 'bg-amber-50 text-amber-900'}`}
|
||||
>
|
||||
{s.status}
|
||||
</span>
|
||||
<p>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Próxima revisão
|
||||
</span>
|
||||
{s.review}
|
||||
</p>
|
||||
<p className="break-all">
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Contacto fictício
|
||||
</span>
|
||||
{s.contact}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
setDetail({
|
||||
title: s.name,
|
||||
subtitle:
|
||||
'Ficha de demonstração · dados fictícios',
|
||||
rows: [
|
||||
[
|
||||
'Categoria',
|
||||
s.category,
|
||||
],
|
||||
[
|
||||
'Estado',
|
||||
s.status,
|
||||
],
|
||||
[
|
||||
'Próxima revisão',
|
||||
s.review,
|
||||
],
|
||||
[
|
||||
'Documentos de exemplo',
|
||||
'Alvará.pdf · Declaração.pdf (sem ficheiros reais)',
|
||||
],
|
||||
[
|
||||
'Controlo',
|
||||
'Alterações e novos anexos exigem nova validação por Finanças.',
|
||||
],
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Consultar ficha
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{screen === 'quotes' && (
|
||||
<Panel
|
||||
title="Matriz de avaliação · DEMO-001"
|
||||
description="Valores ilustrativos. Preço 40% · qualidade 20% · prazo 15% · histórico 15% · pagamento 10%."
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
{[
|
||||
'Fornecedor',
|
||||
'Preço sem IVA',
|
||||
'Prazo',
|
||||
'Avaliações',
|
||||
'Pontuação',
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="pr-4 pb-3"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quotes.map((q) => (
|
||||
<tr
|
||||
className="border-b last:border-0"
|
||||
key={q.supplier}
|
||||
>
|
||||
<td className="py-5 pr-4">
|
||||
{q.supplier}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{money(q.amount)}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{q.days} dias
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{q.evaluations} / 2
|
||||
</td>
|
||||
<td className="pr-4 font-semibold">
|
||||
{q.score}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-5 rounded-lg bg-amber-50 p-4 text-sm text-amber-900">
|
||||
Exemplo de comparação incompleta: uma das
|
||||
propostas ainda não tem duas avaliações. A
|
||||
consulta não permite adjudicar.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{screen === 'conflicts' && (
|
||||
<Panel
|
||||
title="Declarações pessoais"
|
||||
description="Exemplo fictício de um conflito por fornecedor, com histórico preservado."
|
||||
>
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<strong>
|
||||
Fornecedor de demonstração B
|
||||
</strong>
|
||||
<span className="rounded-full bg-rose-50 px-3 py-1 text-xs text-rose-800">
|
||||
Conflito declarado
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-5 grid gap-5 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Autor
|
||||
</dt>
|
||||
<dd>Utilizador de exemplo A</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Âmbito
|
||||
</dt>
|
||||
<dd>
|
||||
Fornecedor em todos os processos
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Fundamento fictício
|
||||
</dt>
|
||||
<dd>
|
||||
Relação pessoal com o
|
||||
fornecedor.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Efeito
|
||||
</dt>
|
||||
<dd>
|
||||
Participação impedida nos
|
||||
processos afectados.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-5 border-t pt-4 text-xs text-muted-foreground">
|
||||
Uma declaração de ausência não anula um
|
||||
conflito registado. A política de
|
||||
levantamento continua por definir.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{screen === 'orders' && (
|
||||
<Panel
|
||||
title="Adjudicação e ordens de compra"
|
||||
description="Etapa em desenvolvimento no projecto. Este ecrã é apenas uma ilustração, não uma ordem emitida."
|
||||
>
|
||||
<div className="rounded-lg border p-6">
|
||||
<div className="flex flex-wrap justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs tracking-widest text-teal-700 uppercase">
|
||||
Exemplo visual
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold">
|
||||
Ordem DEMO-OC-001
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Sem validade comercial
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-6 text-sm">
|
||||
Fornecedor de demonstração A · Material
|
||||
de escritório
|
||||
</p>
|
||||
<div className="mt-5 grid gap-4 border-y py-5 text-sm sm:grid-cols-3">
|
||||
<p>
|
||||
Sem IVA
|
||||
<br />
|
||||
<strong>{money(1600000)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
IVA ilustrativo
|
||||
<br />
|
||||
<strong>{money(256000)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Total
|
||||
<br />
|
||||
<strong>{money(1856000)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-5 text-sm text-muted-foreground">
|
||||
A emissão real depende das cotações,
|
||||
avaliações, aprovações e verificações de
|
||||
conflito no servidor.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{screen === 'appearance' && (
|
||||
<>
|
||||
<Panel
|
||||
title="Paleta e estados"
|
||||
description="Componentes e estilos partilhados com a aplicação para apoiar a revisão visual."
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
{[
|
||||
['Principal', 'bg-teal-700'],
|
||||
['Aprovação', 'bg-teal-100'],
|
||||
['Atenção', 'bg-amber-100'],
|
||||
['Impedimento', 'bg-rose-100'],
|
||||
].map(([label, color]) => (
|
||||
<div key={label}>
|
||||
<div
|
||||
className={`h-20 rounded-lg border ${color}`}
|
||||
/>
|
||||
<p className="mt-2 text-sm">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Status value="draft" />
|
||||
<Status value="pending" />
|
||||
<Status value="approved" />
|
||||
<Status value="rejected" />
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Campos e botões"
|
||||
description="Os campos abaixo são apenas locais e não são enviados ou guardados."
|
||||
>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sample-title">
|
||||
Descrição de exemplo
|
||||
</Label>
|
||||
<Input
|
||||
id="sample-title"
|
||||
placeholder="Escreva para experimentar o campo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sample-disabled">
|
||||
Campo apenas de consulta
|
||||
</Label>
|
||||
<Input
|
||||
id="sample-disabled"
|
||||
disabled
|
||||
value="Valor de demonstração"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setDetail({
|
||||
title: 'Exemplo de diálogo',
|
||||
subtitle:
|
||||
'Sem alteração de dados',
|
||||
rows: [
|
||||
[
|
||||
'Acção',
|
||||
'Este botão apenas demonstra a apresentação de uma confirmação.',
|
||||
],
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Experimentar diálogo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('overview')}
|
||||
>
|
||||
Voltar à visão geral
|
||||
</Button>
|
||||
<Button disabled>
|
||||
Acção indisponível
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
<footer className="py-4 text-xs text-muted-foreground">
|
||||
ProcuraFlow · Consulta pública de exemplos · Os
|
||||
processos reais permanecem no acesso autenticado.
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={detail !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetail(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{detail?.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{detail?.subtitle}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<dl className="space-y-4">
|
||||
{detail?.rows.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="border-t pt-4 text-xs text-muted-foreground">
|
||||
Consulta livre · dados fictícios · nenhuma operação de
|
||||
compra é efectuada.
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { FormErrors, PageTitle, Panel, controlClass } from '@/components/procurement';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -7,20 +12,384 @@ import { money, roleLabels, date } from '@/types/procurement';
|
||||
import type { MatrixRow, PurchaseAward } from '@/types/purchase';
|
||||
|
||||
type Props = {
|
||||
requisition: { id: number; number: string }; comparison: { rows: MatrixRow[]; ready: boolean; top_ids: number[] };
|
||||
award: PurchaseAward | null; canSelect: boolean; canDecide: boolean; canIssue: boolean; order: { id: number; number: string } | null;
|
||||
requisition: { id: number; number: string };
|
||||
comparison: { rows: MatrixRow[]; ready: boolean; top_ids: number[] };
|
||||
award: PurchaseAward | null;
|
||||
canSelect: boolean;
|
||||
canDecide: boolean;
|
||||
canIssue: boolean;
|
||||
order: { id: number; number: string } | null;
|
||||
};
|
||||
export default function Award({ requisition, comparison, award, canSelect, canDecide, canIssue, order }: Props) {
|
||||
const selection = useForm({ quotation_id: '', justification: '', delivery_date: '', delivery_location: '' });
|
||||
export default function Award({
|
||||
requisition,
|
||||
comparison,
|
||||
award,
|
||||
canSelect,
|
||||
canDecide,
|
||||
canIssue,
|
||||
order,
|
||||
}: Props) {
|
||||
const selection = useForm({
|
||||
quotation_id: '',
|
||||
justification: '',
|
||||
delivery_date: '',
|
||||
delivery_location: '',
|
||||
});
|
||||
const decision = useForm({ decision: 'approved', comment: '' });
|
||||
const issue = useForm({});
|
||||
return <><Head title={`Adjudicação · ${requisition.number}`} /><div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow={requisition.number} title="Adjudicação e emissão" description="Escolha fundamentada da proposta de maior pontuação, seguida de aprovação explícita do valor final." action={<Button asChild variant="outline"><Link href={`/requisicoes/${requisition.id}/cotacoes`}>Consultar cotações</Link></Button>} />
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">Ambiente de desenvolvimento: os documentos não autorizam compras reais. O orçamento ainda não é verificado. Excepções às três propostas ou à maior pontuação permanecem bloqueadas enquanto não existir política aprovada.</div>
|
||||
<Panel title="Comparação actual" description={comparison.ready ? 'Matriz completa para análise. O servidor volta a verificar tudo em cada operação.' : 'A matriz ainda não reúne três propostas elegíveis, cada uma com duas avaliações elegíveis.'}><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Fornecedor</th><th className="pr-4">Total com IVA</th><th className="pr-4">Avaliações elegíveis</th><th>Pontuação</th></tr></thead><tbody>{comparison.rows.map(q => <tr key={q.id} className="border-b last:border-0"><td className="py-4 pr-4"><p className="font-medium">{q.supplier?.name} · {q.reference}</p>{q.ineligibility_reasons.map(reason => <p className="mt-1 text-xs text-destructive" key={reason}>{reason}</p>)}</td><td className="pr-4 whitespace-nowrap">{money(q.total_minor)}</td><td className="pr-4">{q.evaluation_count} / 2</td><td>{q.score === null ? 'Pendente' : `${q.score.toFixed(2)} / 10`}{comparison.top_ids.includes(q.id) && <p className="text-xs font-medium text-teal-700 dark:text-teal-400">Maior pontuação</p>}</td></tr>)}</tbody></table></div></Panel>
|
||||
{canSelect && <Panel title="Seleccionar proposta" description="Empates permitem escolha fundamentada entre as propostas no topo. A selecção fecha novas propostas e avaliações."><form className="space-y-5" onSubmit={e => { e.preventDefault(); selection.post(`/requisicoes/${requisition.id}/adjudicacao`); }}><FormErrors errors={selection.errors} /><div className="space-y-2"><Label htmlFor="award-quote">Proposta</Label><select id="award-quote" className={controlClass} required value={selection.data.quotation_id} onChange={e => selection.setData('quotation_id', e.target.value)}><option value="">Seleccione uma proposta no topo</option>{comparison.rows.filter(q => comparison.top_ids.includes(q.id)).map(q => <option key={q.id} value={q.id}>{q.supplier.name} · {money(q.total_minor)} · {q.score?.toFixed(2)} / 10</option>)}</select></div><div className="space-y-2"><Label htmlFor="award-justification">Justificação da adjudicação</Label><textarea id="award-justification" className={`${controlClass} h-24 py-3`} required minLength={10} maxLength={5000} value={selection.data.justification} onChange={e => selection.setData('justification', e.target.value)} /></div><div className="grid gap-4 sm:grid-cols-2"><div className="space-y-2"><Label htmlFor="award-date">Data acordada de entrega</Label><Input id="award-date" type="date" required value={selection.data.delivery_date} onChange={e => selection.setData('delivery_date', e.target.value)} /></div><div className="space-y-2"><Label htmlFor="award-location">Local de entrega</Label><Input id="award-location" required maxLength={500} value={selection.data.delivery_location} onChange={e => selection.setData('delivery_location', e.target.value)} /></div></div><p className="text-sm text-muted-foreground">Será aberto um novo ciclo de aprovações pelo valor adjudicado. As decisões da estimativa ficam preservadas e não são reutilizadas. Solicitante e autor da adjudicação não aprovam este ciclo.</p><Button disabled={selection.processing || !comparison.ready}>Registar adjudicação para aprovação</Button></form></Panel>}
|
||||
{award && <Panel title={`Adjudicação #${award.id}`} description={`Estado: ${award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}`}><p className="text-sm whitespace-pre-wrap">{award.justification}</p><dl className="mt-5 grid gap-4 text-sm sm:grid-cols-3"><div><dt className="text-muted-foreground">Total final com IVA</dt><dd className="mt-1 font-semibold">{money(award.total_minor)}</dd></div><div><dt className="text-muted-foreground">Base dos escalões guardados</dt><dd className="mt-1">{award.approval_snapshot.approval_includes_tax ? 'Com IVA' : 'Sem IVA'}{award.approval_snapshot.rules_provisional ? ' · Provisórios' : ''}</dd></div><div><dt className="text-muted-foreground">Entrega</dt><dd className="mt-1">{date(award.delivery_date)} · {award.delivery_location}</dd></div></dl><ol className="mt-6 space-y-3 border-t pt-5">{award.approvals.map(step => <li key={step.id} className="text-sm"><strong>{step.position}. {roleLabels[step.role]}</strong> · {step.decision === 'pending' ? 'Aguarda decisão' : step.decision === 'approved' ? 'Aprovada' : 'Rejeitada'}{step.approver_id ? ` · Utilizador #${step.approver_id}` : ''}{step.comment && <p className="mt-1 text-muted-foreground">{step.comment}</p>}</li>)}</ol></Panel>}
|
||||
{canDecide && award && <Panel title="Decidir sobre o valor final"><form className="space-y-4" onSubmit={e => { e.preventDefault(); decision.post(`/adjudicacoes/${award.id}/decidir`); }}><FormErrors errors={decision.errors} /><Label htmlFor="award-decision">Decisão</Label><select id="award-decision" className={controlClass} value={decision.data.decision} onChange={e => decision.setData('decision', e.target.value)}><option value="approved">Aprovar etapa</option><option value="rejected">Rejeitar adjudicação</option></select><Label htmlFor="award-comment">Fundamento (obrigatório se rejeitar)</Label><textarea id="award-comment" className={`${controlClass} h-24 py-3`} required={decision.data.decision === 'rejected'} maxLength={2000} value={decision.data.comment} onChange={e => decision.setData('comment', e.target.value)} /><Button disabled={decision.processing}>Registar decisão</Button></form></Panel>}
|
||||
{order ? <Button asChild><Link href={`/ordens/${order.id}`}>Abrir ordem {order.number}</Link></Button> : canIssue && award && <Panel title="Emitir ordem para revisão" description="O servidor revalida a matriz, os conflitos e as aprovações. A ordem emitida mantém um retrato dos dados e não pode ser reescrita."><FormErrors errors={issue.errors} /><Button className="mt-4" disabled={issue.processing} onClick={() => issue.post(`/adjudicacoes/${award.id}/emitir`)}>Emitir documento de desenvolvimento</Button></Panel>}
|
||||
</div></>;
|
||||
return (
|
||||
<>
|
||||
<Head title={`Adjudicação · ${requisition.number}`} />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={requisition.number}
|
||||
title="Adjudicação e emissão"
|
||||
description="Escolha fundamentada da proposta de maior pontuação, seguida de aprovação explícita do valor final."
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/requisicoes/${requisition.id}/cotacoes`}
|
||||
>
|
||||
Consultar cotações
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
Ambiente de desenvolvimento: os documentos não autorizam
|
||||
compras reais. O orçamento ainda não é verificado. Excepções
|
||||
às três propostas ou à maior pontuação permanecem bloqueadas
|
||||
enquanto não existir política aprovada.
|
||||
</div>
|
||||
<Panel
|
||||
title="Comparação actual"
|
||||
description={
|
||||
comparison.ready
|
||||
? 'Matriz completa para análise. O servidor volta a verificar tudo em cada operação.'
|
||||
: 'A matriz ainda não reúne três propostas elegíveis, cada uma com duas avaliações elegíveis.'
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">Fornecedor</th>
|
||||
<th className="pr-4">Total com IVA</th>
|
||||
<th className="pr-4">
|
||||
Avaliações elegíveis
|
||||
</th>
|
||||
<th>Pontuação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparison.rows.map((q) => (
|
||||
<tr
|
||||
key={q.id}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-4 pr-4">
|
||||
<p className="font-medium">
|
||||
{q.supplier?.name} ·{' '}
|
||||
{q.reference}
|
||||
</p>
|
||||
{q.ineligibility_reasons.map(
|
||||
(reason) => (
|
||||
<p
|
||||
className="mt-1 text-xs text-destructive"
|
||||
key={reason}
|
||||
>
|
||||
{reason}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{money(q.total_minor)}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{q.evaluation_count} / 2
|
||||
</td>
|
||||
<td>
|
||||
{q.score === null
|
||||
? 'Pendente'
|
||||
: `${q.score.toFixed(2)} / 10`}
|
||||
{comparison.top_ids.includes(
|
||||
q.id,
|
||||
) && (
|
||||
<p className="text-xs font-medium text-teal-700 dark:text-teal-400">
|
||||
Maior pontuação
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
{canSelect && (
|
||||
<Panel
|
||||
title="Seleccionar proposta"
|
||||
description="Empates permitem escolha fundamentada entre as propostas no topo. A selecção fecha novas propostas e avaliações."
|
||||
>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
selection.post(
|
||||
`/requisicoes/${requisition.id}/adjudicacao`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={selection.errors} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="award-quote">Proposta</Label>
|
||||
<select
|
||||
id="award-quote"
|
||||
className={controlClass}
|
||||
required
|
||||
value={selection.data.quotation_id}
|
||||
onChange={(e) =>
|
||||
selection.setData(
|
||||
'quotation_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione uma proposta no topo
|
||||
</option>
|
||||
{comparison.rows
|
||||
.filter((q) =>
|
||||
comparison.top_ids.includes(q.id),
|
||||
)
|
||||
.map((q) => (
|
||||
<option key={q.id} value={q.id}>
|
||||
{q.supplier.name} ·{' '}
|
||||
{money(q.total_minor)} ·{' '}
|
||||
{q.score?.toFixed(2)} / 10
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="award-justification">
|
||||
Justificação da adjudicação
|
||||
</Label>
|
||||
<textarea
|
||||
id="award-justification"
|
||||
className={`${controlClass} h-24 py-3`}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
value={selection.data.justification}
|
||||
onChange={(e) =>
|
||||
selection.setData(
|
||||
'justification',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="award-date">
|
||||
Data acordada de entrega
|
||||
</Label>
|
||||
<Input
|
||||
id="award-date"
|
||||
type="date"
|
||||
required
|
||||
value={selection.data.delivery_date}
|
||||
onChange={(e) =>
|
||||
selection.setData(
|
||||
'delivery_date',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="award-location">
|
||||
Local de entrega
|
||||
</Label>
|
||||
<Input
|
||||
id="award-location"
|
||||
required
|
||||
maxLength={500}
|
||||
value={selection.data.delivery_location}
|
||||
onChange={(e) =>
|
||||
selection.setData(
|
||||
'delivery_location',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Será aberto um novo ciclo de aprovações pelo
|
||||
valor adjudicado. As decisões da estimativa
|
||||
ficam preservadas e não são reutilizadas.
|
||||
Solicitante e autor da adjudicação não aprovam
|
||||
este ciclo.
|
||||
</p>
|
||||
<Button
|
||||
disabled={
|
||||
selection.processing || !comparison.ready
|
||||
}
|
||||
>
|
||||
Registar adjudicação para aprovação
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{award && (
|
||||
<Panel
|
||||
title={`Adjudicação #${award.id}`}
|
||||
description={`Estado: ${award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{award.justification}
|
||||
</p>
|
||||
<dl className="mt-5 grid gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Total final com IVA
|
||||
</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{money(award.total_minor)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Base dos escalões guardados
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
{award.approval_snapshot
|
||||
.approval_includes_tax
|
||||
? 'Com IVA'
|
||||
: 'Sem IVA'}
|
||||
{award.approval_snapshot.rules_provisional
|
||||
? ' · Provisórios'
|
||||
: ''}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Entrega
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
{date(award.delivery_date)} ·{' '}
|
||||
{award.delivery_location}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ol className="mt-6 space-y-3 border-t pt-5">
|
||||
{award.approvals.map((step) => (
|
||||
<li key={step.id} className="text-sm">
|
||||
<strong>
|
||||
{step.position}. {roleLabels[step.role]}
|
||||
</strong>{' '}
|
||||
·{' '}
|
||||
{step.decision === 'pending'
|
||||
? 'Aguarda decisão'
|
||||
: step.decision === 'approved'
|
||||
? 'Aprovada'
|
||||
: 'Rejeitada'}
|
||||
{step.approver_id
|
||||
? ` · Utilizador #${step.approver_id}`
|
||||
: ''}
|
||||
{step.comment && (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{step.comment}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Panel>
|
||||
)}
|
||||
{canDecide && award && (
|
||||
<Panel title="Decidir sobre o valor final">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
decision.post(
|
||||
`/adjudicacoes/${award.id}/decidir`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={decision.errors} />
|
||||
<Label htmlFor="award-decision">Decisão</Label>
|
||||
<select
|
||||
id="award-decision"
|
||||
className={controlClass}
|
||||
value={decision.data.decision}
|
||||
onChange={(e) =>
|
||||
decision.setData('decision', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="approved">Aprovar etapa</option>
|
||||
<option value="rejected">
|
||||
Rejeitar adjudicação
|
||||
</option>
|
||||
</select>
|
||||
<Label htmlFor="award-comment">
|
||||
Fundamento (obrigatório se rejeitar)
|
||||
</Label>
|
||||
<textarea
|
||||
id="award-comment"
|
||||
className={`${controlClass} h-24 py-3`}
|
||||
required={decision.data.decision === 'rejected'}
|
||||
maxLength={2000}
|
||||
value={decision.data.comment}
|
||||
onChange={(e) =>
|
||||
decision.setData('comment', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button disabled={decision.processing}>
|
||||
Registar decisão
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{order ? (
|
||||
<Button asChild>
|
||||
<Link href={`/ordens/${order.id}`}>
|
||||
Abrir ordem {order.number}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
canIssue &&
|
||||
award && (
|
||||
<Panel
|
||||
title="Emitir ordem para revisão"
|
||||
description="O servidor revalida a matriz, os conflitos e as aprovações. A ordem emitida mantém um retrato dos dados e não pode ser reescrita."
|
||||
>
|
||||
<FormErrors errors={issue.errors} />
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={issue.processing}
|
||||
onClick={() =>
|
||||
issue.post(
|
||||
`/adjudicacoes/${award.id}/emitir`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Emitir documento de desenvolvimento
|
||||
</Button>
|
||||
</Panel>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Award.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }, { title: 'Adjudicação', href: '#' }] };
|
||||
Award.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Ordens de compra', href: '/ordens' },
|
||||
{ title: 'Adjudicação', href: '#' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,8 +3,125 @@ import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { money, date } from '@/types/procurement';
|
||||
import type { PurchaseAward, PurchaseOrder } from '@/types/purchase';
|
||||
|
||||
type Page<T> = { data: T[]; prev_page_url: string | null; next_page_url: string | null };
|
||||
export default function Orders({ orders, awards }: { orders: Page<PurchaseOrder>; awards: Page<PurchaseAward> }) {
|
||||
return <><Head title="Ordens de compra" /><div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow="Compras" title="Adjudicações e ordens" description="Acompanhe as aprovações dos valores finais e os documentos emitidos para revisão." /><Panel title="Ordens emitidas"><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Ordem</th><th className="pr-4">Fornecedor</th><th className="pr-4">Emissão</th><th>Total com IVA</th></tr></thead><tbody>{orders.data.map(order => <tr key={order.id} className="border-b last:border-0"><td className="py-4 pr-4"><Link href={`/ordens/${order.id}`} className="font-medium text-teal-700 dark:text-teal-400">{order.number}</Link><p className="mt-1 text-xs text-muted-foreground">{order.snapshot.requisition.number}</p></td><td className="pr-4">{order.snapshot.supplier.name}</td><td className="pr-4">{date(order.issued_at)}</td><td>{money(order.total_minor)}</td></tr>)}</tbody></table></div>{orders.data.length === 0 && <p className="py-6 text-sm text-muted-foreground">Ainda não foram emitidas ordens.</p>}<div className="mt-4 flex gap-4 text-sm">{orders.prev_page_url && <Link href={orders.prev_page_url}>Anterior</Link>}{orders.next_page_url && <Link href={orders.next_page_url}>Seguinte</Link>}</div></Panel><Panel title="Adjudicações"><div className="divide-y">{awards.data.map(award => <Link href={`/requisicoes/${award.requisition_id}/adjudicacao`} key={award.id} className="flex items-center justify-between gap-4 py-4 text-sm"><div><strong>Adjudicação #{award.id}</strong><p className="mt-1 text-muted-foreground">{award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}</p></div><span>{money(award.total_minor)}</span></Link>)}{awards.data.length === 0 && <p className="py-6 text-sm text-muted-foreground">Inicie a adjudicação a partir da comparação de cotações.</p>}</div><div className="mt-4 flex gap-4 text-sm">{awards.prev_page_url && <Link href={awards.prev_page_url}>Anterior</Link>}{awards.next_page_url && <Link href={awards.next_page_url}>Seguinte</Link>}</div></Panel></div></>;
|
||||
type Page<T> = {
|
||||
data: T[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
export default function Orders({
|
||||
orders,
|
||||
awards,
|
||||
}: {
|
||||
orders: Page<PurchaseOrder>;
|
||||
awards: Page<PurchaseAward>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Ordens de compra" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Compras"
|
||||
title="Adjudicações e ordens"
|
||||
description="Acompanhe as aprovações dos valores finais e os documentos emitidos para revisão."
|
||||
/>
|
||||
<Panel title="Ordens emitidas">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">Ordem</th>
|
||||
<th className="pr-4">Fornecedor</th>
|
||||
<th className="pr-4">Emissão</th>
|
||||
<th>Total com IVA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.data.map((order) => (
|
||||
<tr
|
||||
key={order.id}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-4 pr-4">
|
||||
<Link
|
||||
href={`/ordens/${order.id}`}
|
||||
className="font-medium text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
{order.number}
|
||||
</Link>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{
|
||||
order.snapshot.requisition
|
||||
.number
|
||||
}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{order.snapshot.supplier.name}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{date(order.issued_at)}
|
||||
</td>
|
||||
<td>{money(order.total_minor)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{orders.data.length === 0 && (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Ainda não foram emitidas ordens.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-4 text-sm">
|
||||
{orders.prev_page_url && (
|
||||
<Link href={orders.prev_page_url}>Anterior</Link>
|
||||
)}
|
||||
{orders.next_page_url && (
|
||||
<Link href={orders.next_page_url}>Seguinte</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Adjudicações">
|
||||
<div className="divide-y">
|
||||
{awards.data.map((award) => (
|
||||
<Link
|
||||
href={`/requisicoes/${award.requisition_id}/adjudicacao`}
|
||||
key={award.id}
|
||||
className="flex items-center justify-between gap-4 py-4 text-sm"
|
||||
>
|
||||
<div>
|
||||
<strong>Adjudicação #{award.id}</strong>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{award.status === 'pending'
|
||||
? 'Em aprovação'
|
||||
: award.status === 'approved'
|
||||
? 'Aprovada para emissão'
|
||||
: 'Rejeitada'}
|
||||
</p>
|
||||
</div>
|
||||
<span>{money(award.total_minor)}</span>
|
||||
</Link>
|
||||
))}
|
||||
{awards.data.length === 0 && (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Inicie a adjudicação a partir da comparação de
|
||||
cotações.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-4 text-sm">
|
||||
{awards.prev_page_url && (
|
||||
<Link href={awards.prev_page_url}>Anterior</Link>
|
||||
)}
|
||||
{awards.next_page_url && (
|
||||
<Link href={awards.next_page_url}>Seguinte</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Orders.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }] };
|
||||
Orders.layout = {
|
||||
breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }],
|
||||
};
|
||||
|
||||
@@ -5,15 +5,260 @@ import { Button } from '@/components/ui/button';
|
||||
import { date, money, roleLabels } from '@/types/procurement';
|
||||
import type { PurchaseOrder } from '@/types/purchase';
|
||||
|
||||
type History = { id: number; action: string; user_id: number; created_at: string };
|
||||
const actions: Record<string, string> = { 'award.created': 'Adjudicação registada', 'award.approved': 'Etapa do valor final aprovada', 'award.rejected': 'Adjudicação rejeitada', 'purchase_order.issued': 'Ordem emitida para revisão' };
|
||||
export default function OrderShow({ order, history }: { order: PurchaseOrder; history: History[] }) {
|
||||
type History = {
|
||||
id: number;
|
||||
action: string;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
};
|
||||
const actions: Record<string, string> = {
|
||||
'award.created': 'Adjudicação registada',
|
||||
'award.approved': 'Etapa do valor final aprovada',
|
||||
'award.rejected': 'Adjudicação rejeitada',
|
||||
'purchase_order.issued': 'Ordem emitida para revisão',
|
||||
};
|
||||
export default function OrderShow({
|
||||
order,
|
||||
history,
|
||||
}: {
|
||||
order: PurchaseOrder;
|
||||
history: History[];
|
||||
}) {
|
||||
const snapshot = order.snapshot;
|
||||
return <><Head title={order.number} /><div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow={snapshot.company.name} title={order.number} description={`${snapshot.requisition.number} · Emitida em ${date(order.issued_at)} por ${snapshot.issuer.name}`} action={<Button asChild><a href={`/ordens/${order.id}/imprimir`} target="_blank" rel="noopener noreferrer"><Printer className="size-4" />Imprimir / guardar PDF</a></Button>} /><div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"><strong>Documento de desenvolvimento. Não autoriza compras reais.</strong> Orçamento não verificado. Os dados apresentados foram congelados na emissão.{snapshot.approval_rules.rules_provisional && ' Regras de aprovação provisórias.'}</div>
|
||||
<div className="grid gap-6 md:grid-cols-2"><Panel title="Fornecedor"><dl className="space-y-3 text-sm"><div><dt className="text-muted-foreground">Nome e identificação fiscal</dt><dd>{snapshot.supplier.name} · NUIT {snapshot.supplier.nuit}</dd></div><div><dt className="text-muted-foreground">Contacto</dt><dd>{snapshot.supplier.contact} · {snapshot.supplier.phone}<br />{snapshot.supplier.email}</dd></div><div><dt className="text-muted-foreground">Morada</dt><dd className="whitespace-pre-wrap">{snapshot.supplier.address}</dd></div></dl></Panel><Panel title="Condições acordadas"><dl className="space-y-3 text-sm"><div><dt className="text-muted-foreground">Entrega</dt><dd>{date(snapshot.award.delivery_date)} · {snapshot.award.delivery_location}</dd></div><div><dt className="text-muted-foreground">Pagamento</dt><dd>{snapshot.quotation.payment_terms}</dd></div><div><dt className="text-muted-foreground">Departamento e centro de custo</dt><dd>{snapshot.department.name} · {snapshot.cost_center.code} · {snapshot.cost_center.name}</dd></div><div><dt className="text-muted-foreground">Proposta</dt><dd>{snapshot.quotation.reference} · {order.currency}</dd></div></dl></Panel></div>
|
||||
<Panel title="Artigos e valores congelados"><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Descrição</th><th className="pr-4">Quantidade</th><th className="pr-4">Preço sem IVA</th><th className="pr-4">IVA</th><th>Total com IVA</th></tr></thead><tbody>{order.items.map(item => <tr key={item.id} className="border-b"><td className="py-4 pr-4">{item.description}</td><td className="pr-4 whitespace-nowrap">{Number(item.quantity)} {item.unit}</td><td className="pr-4 whitespace-nowrap">{money(item.unit_price_minor)}</td><td className="pr-4">{Number(item.tax_rate)}%</td><td className="whitespace-nowrap">{money(item.total_minor)}</td></tr>)}</tbody></table></div><dl className="mt-5 ml-auto max-w-sm space-y-3 text-sm"><div className="flex justify-between gap-4"><dt>Subtotal sem IVA</dt><dd>{money(order.subtotal_minor)}</dd></div><div className="flex justify-between gap-4"><dt>IVA</dt><dd>{money(order.tax_minor)}</dd></div><div className="flex justify-between gap-4 border-t pt-3 font-semibold"><dt>Total com IVA</dt><dd>{money(order.total_minor)}</dd></div></dl></Panel>
|
||||
<Panel title="Fundamento e matriz congelada"><p className="text-sm whitespace-pre-wrap">{snapshot.award.justification}</p><div className="mt-5 space-y-4">{snapshot.decision_matrix.map(row => <div key={row.id} className="rounded-lg border p-4 text-sm"><p className="font-semibold">{row.supplier.name} · {row.reference} · {row.score === null ? 'Não elegível / incompleta' : `${row.score.toFixed(2)} / 10`}</p><p className="mt-1 text-muted-foreground">{money(row.total_minor)} com IVA · {row.evaluation_count} avaliações elegíveis</p>{row.evaluations.map(e => <p key={e.evaluator_id} className="mt-2 text-xs">Avaliador #{e.evaluator_id}: qualidade {e.quality}, histórico {e.history}, pagamento {e.payment}. {e.comment}</p>)}</div>)}</div></Panel>
|
||||
<Panel title="Aprovações do valor final"><ol className="space-y-3">{snapshot.final_approvals.map(step => <li key={step.id} className="text-sm">{roleLabels[step.role]} · Utilizador #{step.approver_id} · Aprovada{step.comment && <p className="mt-1 text-muted-foreground">{step.comment}</p>}</li>)}</ol><p className="mt-4 text-xs text-muted-foreground">Base: {snapshot.approval_rules.approval_includes_tax ? 'com' : 'sem'} IVA. As aprovações da estimativa foram preservadas separadamente.</p></Panel><Panel title="Histórico do processo"><ol className="space-y-3">{history.map(event => <li key={event.id} className="text-sm">{actions[event.action]} · {date(event.created_at)} · Utilizador #{event.user_id}</li>)}</ol></Panel><Link href="/ordens" className="text-sm text-teal-700 dark:text-teal-400">Voltar às ordens</Link>
|
||||
</div></>;
|
||||
return (
|
||||
<>
|
||||
<Head title={order.number} />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={snapshot.company.name}
|
||||
title={order.number}
|
||||
description={`${snapshot.requisition.number} · Emitida em ${date(order.issued_at)} por ${snapshot.issuer.name}`}
|
||||
action={
|
||||
<Button asChild>
|
||||
<a
|
||||
href={`/ordens/${order.id}/imprimir`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
Imprimir / guardar PDF
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
<strong>
|
||||
Documento de desenvolvimento. Não autoriza compras
|
||||
reais.
|
||||
</strong>{' '}
|
||||
Orçamento não verificado. Os dados apresentados foram
|
||||
congelados na emissão.
|
||||
{snapshot.approval_rules.rules_provisional &&
|
||||
' Regras de aprovação provisórias.'}
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Panel title="Fornecedor">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Nome e identificação fiscal
|
||||
</dt>
|
||||
<dd>
|
||||
{snapshot.supplier.name} · NUIT{' '}
|
||||
{snapshot.supplier.nuit}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Contacto
|
||||
</dt>
|
||||
<dd>
|
||||
{snapshot.supplier.contact} ·{' '}
|
||||
{snapshot.supplier.phone}
|
||||
<br />
|
||||
{snapshot.supplier.email}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Morada
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{snapshot.supplier.address}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
<Panel title="Condições acordadas">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Entrega
|
||||
</dt>
|
||||
<dd>
|
||||
{date(snapshot.award.delivery_date)} ·{' '}
|
||||
{snapshot.award.delivery_location}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Pagamento
|
||||
</dt>
|
||||
<dd>{snapshot.quotation.payment_terms}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Departamento e centro de custo
|
||||
</dt>
|
||||
<dd>
|
||||
{snapshot.department.name} ·{' '}
|
||||
{snapshot.cost_center.code} ·{' '}
|
||||
{snapshot.cost_center.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Proposta
|
||||
</dt>
|
||||
<dd>
|
||||
{snapshot.quotation.reference} ·{' '}
|
||||
{order.currency}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
</div>
|
||||
<Panel title="Artigos e valores congelados">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">Descrição</th>
|
||||
<th className="pr-4">Quantidade</th>
|
||||
<th className="pr-4">Preço sem IVA</th>
|
||||
<th className="pr-4">IVA</th>
|
||||
<th>Total com IVA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id} className="border-b">
|
||||
<td className="py-4 pr-4">
|
||||
{item.description}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{Number(item.quantity)} {item.unit}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{money(item.unit_price_minor)}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{Number(item.tax_rate)}%
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
{money(item.total_minor)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<dl className="mt-5 ml-auto max-w-sm space-y-3 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Subtotal sem IVA</dt>
|
||||
<dd>{money(order.subtotal_minor)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>IVA</dt>
|
||||
<dd>{money(order.tax_minor)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-t pt-3 font-semibold">
|
||||
<dt>Total com IVA</dt>
|
||||
<dd>{money(order.total_minor)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
<Panel title="Fundamento e matriz congelada">
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{snapshot.award.justification}
|
||||
</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
{snapshot.decision_matrix.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<p className="font-semibold">
|
||||
{row.supplier.name} · {row.reference} ·{' '}
|
||||
{row.score === null
|
||||
? 'Não elegível / incompleta'
|
||||
: `${row.score.toFixed(2)} / 10`}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{money(row.total_minor)} com IVA ·{' '}
|
||||
{row.evaluation_count} avaliações elegíveis
|
||||
</p>
|
||||
{row.evaluations.map((e) => (
|
||||
<p
|
||||
key={e.evaluator_id}
|
||||
className="mt-2 text-xs"
|
||||
>
|
||||
Avaliador #{e.evaluator_id}: qualidade{' '}
|
||||
{e.quality}, histórico {e.history},
|
||||
pagamento {e.payment}. {e.comment}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Aprovações do valor final">
|
||||
<ol className="space-y-3">
|
||||
{snapshot.final_approvals.map((step) => (
|
||||
<li key={step.id} className="text-sm">
|
||||
{roleLabels[step.role]} · Utilizador #
|
||||
{step.approver_id} · Aprovada
|
||||
{step.comment && (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{step.comment}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Base:{' '}
|
||||
{snapshot.approval_rules.approval_includes_tax
|
||||
? 'com'
|
||||
: 'sem'}{' '}
|
||||
IVA. As aprovações da estimativa foram preservadas
|
||||
separadamente.
|
||||
</p>
|
||||
</Panel>
|
||||
<Panel title="Histórico do processo">
|
||||
<ol className="space-y-3">
|
||||
{history.map((event) => (
|
||||
<li key={event.id} className="text-sm">
|
||||
{actions[event.action]} ·{' '}
|
||||
{date(event.created_at)} · Utilizador #
|
||||
{event.user_id}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Panel>
|
||||
<Link
|
||||
href="/ordens"
|
||||
className="text-sm text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
Voltar às ordens
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
OrderShow.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }, { title: 'Documento emitido', href: '#' }] };
|
||||
OrderShow.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Ordens de compra', href: '/ordens' },
|
||||
{ title: 'Documento emitido', href: '#' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ type Quote = {
|
||||
valid_until: string;
|
||||
payment_terms: string;
|
||||
evaluations: Evaluation[];
|
||||
evaluation_count: number;
|
||||
eligible: boolean;
|
||||
conflict_blocked: boolean;
|
||||
score: number | null;
|
||||
@@ -103,7 +104,12 @@ export default function Comparison({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Link className="text-sm font-medium text-teal-700 dark:text-teal-400" href={`/requisicoes/${requisition.id}/adjudicacao`}>Adjudicação, aprovações do valor final e ordem de compra</Link>
|
||||
<Link
|
||||
className="text-sm font-medium text-teal-700 dark:text-teal-400"
|
||||
href={`/requisicoes/${requisition.id}/adjudicacao`}
|
||||
>
|
||||
Adjudicação, aprovações do valor final e ordem de compra
|
||||
</Link>
|
||||
<Link
|
||||
className="text-sm text-teal-700"
|
||||
href={`/requisicoes/${requisition.id}`}
|
||||
|
||||
@@ -1,25 +1,96 @@
|
||||
export type MatrixRow = {
|
||||
id: number; reference: string; supplier: { name: string }; supplier_id: number;
|
||||
eligible: boolean; ineligibility_reasons: string[]; evaluation_count: number;
|
||||
score: number | null; score_minor: number | null; subtotal_minor: number; tax_minor: number; total_minor: number;
|
||||
evaluations: { evaluator_id: number; quality: string; history: string; payment: string; comment: string }[];
|
||||
id: number;
|
||||
reference: string;
|
||||
supplier: { name: string };
|
||||
supplier_id: number;
|
||||
eligible: boolean;
|
||||
ineligibility_reasons: string[];
|
||||
evaluation_count: number;
|
||||
score: number | null;
|
||||
score_minor: number | null;
|
||||
subtotal_minor: number;
|
||||
tax_minor: number;
|
||||
total_minor: number;
|
||||
evaluations: {
|
||||
evaluator_id: number;
|
||||
quality: string;
|
||||
history: string;
|
||||
payment: string;
|
||||
comment: string;
|
||||
}[];
|
||||
};
|
||||
export type AwardApproval = {
|
||||
id: number;
|
||||
position: number;
|
||||
role: string;
|
||||
decision: string;
|
||||
approver_id: number | null;
|
||||
comment: string | null;
|
||||
};
|
||||
export type AwardApproval = { id: number; position: number; role: string; decision: string; approver_id: number | null; comment: string | null };
|
||||
export type PurchaseAward = {
|
||||
id: number; requisition_id: number; quotation_id: number; status: string; justification: string;
|
||||
subtotal_minor: number; tax_minor: number; total_minor: number; delivery_date: string; delivery_location: string;
|
||||
approval_snapshot: { approval_includes_tax: boolean; rules_provisional: boolean }; approvals: AwardApproval[];
|
||||
id: number;
|
||||
requisition_id: number;
|
||||
quotation_id: number;
|
||||
status: string;
|
||||
justification: string;
|
||||
subtotal_minor: number;
|
||||
tax_minor: number;
|
||||
total_minor: number;
|
||||
delivery_date: string;
|
||||
delivery_location: string;
|
||||
approval_snapshot: {
|
||||
approval_includes_tax: boolean;
|
||||
rules_provisional: boolean;
|
||||
};
|
||||
approvals: AwardApproval[];
|
||||
};
|
||||
export type OrderItem = {
|
||||
id: number;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
unit_price_minor: number;
|
||||
tax_rate: string;
|
||||
subtotal_minor: number;
|
||||
tax_minor: number;
|
||||
total_minor: number;
|
||||
};
|
||||
export type OrderItem = { id: number; description: string; quantity: string; unit: string; unit_price_minor: number; tax_rate: string; subtotal_minor: number; tax_minor: number; total_minor: number };
|
||||
export type PurchaseOrder = {
|
||||
id: number; number: string; requisition_id: number; issued_at: string; currency: string;
|
||||
subtotal_minor: number; tax_minor: number; total_minor: number; items: OrderItem[];
|
||||
id: number;
|
||||
number: string;
|
||||
requisition_id: number;
|
||||
issued_at: string;
|
||||
currency: string;
|
||||
subtotal_minor: number;
|
||||
tax_minor: number;
|
||||
total_minor: number;
|
||||
items: OrderItem[];
|
||||
snapshot: {
|
||||
company: { name: string }; department: { name: string }; cost_center: { code: string; name: string };
|
||||
requisition: { number: string }; issuer: { name: string }; requester: { name: string };
|
||||
supplier: { name: string; nuit: string; contact: string; phone: string; email: string; address: string };
|
||||
quotation: { reference: string; payment_terms: string }; award: { justification: string; delivery_date: string; delivery_location: string };
|
||||
approval_rules: { approval_includes_tax: boolean; rules_provisional: boolean };
|
||||
decision_matrix: MatrixRow[]; final_approvals: AwardApproval[];
|
||||
company: { name: string };
|
||||
department: { name: string };
|
||||
cost_center: { code: string; name: string };
|
||||
requisition: { number: string };
|
||||
issuer: { name: string };
|
||||
requester: { name: string };
|
||||
supplier: {
|
||||
name: string;
|
||||
nuit: string;
|
||||
contact: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
};
|
||||
quotation: { reference: string; payment_terms: string };
|
||||
award: {
|
||||
justification: string;
|
||||
delivery_date: string;
|
||||
delivery_location: string;
|
||||
};
|
||||
approval_rules: {
|
||||
approval_includes_tax: boolean;
|
||||
rules_provisional: boolean;
|
||||
};
|
||||
decision_matrix: MatrixRow[];
|
||||
final_approvals: AwardApproval[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// Public visual consultation. No business records or authenticated identities.
|
||||
Route::get('consulta', fn () => Inertia::render('preview/index', [
|
||||
'auth' => ['user' => null],
|
||||
]))->name('preview');
|
||||
+3
-2
@@ -2,15 +2,16 @@
|
||||
|
||||
use App\Http\Controllers\CompanyController;
|
||||
use App\Http\Controllers\ConflictDeclarationController;
|
||||
use App\Http\Controllers\QuotationController;
|
||||
use App\Http\Controllers\PurchaseController;
|
||||
use App\Http\Controllers\QuotationController;
|
||||
use App\Http\Controllers\RequisitionController;
|
||||
use App\Http\Controllers\SupplierController;
|
||||
use App\Http\Controllers\SupplierDocumentController;
|
||||
use App\Http\Middleware\EnsureCompanyIsConfigured;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::redirect('/', '/dashboard')->name('home');
|
||||
Route::redirect('/', '/consulta')->name('home');
|
||||
require __DIR__.'/preview.php';
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', [RequisitionController::class, 'dashboard'])->name('dashboard');
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
test('returns a successful response', function () {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertRedirect('/dashboard');
|
||||
$response->assertRedirect('/consulta');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('visual consultation is accessible without authentication', function () {
|
||||
$this->get('/consulta')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('preview/index')
|
||||
->where('auth.user', null)
|
||||
->missing('suppliers')->missing('requisitions')->missing('documents'));
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('preview does not expose authenticated account data or log it out', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->get('/consulta')->assertOk()->assertInertia(fn (Assert $page) => $page->where('auth.user', null));
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('consultation creates no business access or mutation endpoint', function () {
|
||||
$this->get('/consulta')->assertOk();
|
||||
foreach (['/dashboard', '/fornecedores', '/cotacoes', '/conflitos'] as $path) {
|
||||
$this->get($path)->assertRedirect('/login');
|
||||
}
|
||||
$this->post('/fornecedores', ['name' => 'Tentativa pública'])->assertRedirect('/login');
|
||||
$this->post('/consulta', [])->assertStatus(405);
|
||||
expect(User::query()->count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function awardPrices(object $test, int $firstPrice, int $taxRate = 0): void
|
||||
{
|
||||
$test->r->items()->update(['tax_rate' => $taxRate]);
|
||||
foreach ($test->quotes as $index => $quote) {
|
||||
$price = $firstPrice + $index * 10000;
|
||||
$tax = intdiv($price * $taxRate + 50, 100);
|
||||
$quote->items()->update(['unit_price_minor' => $price, 'tax_rate' => $taxRate, 'subtotal_minor' => $price, 'tax_minor' => $tax, 'total_minor' => $price + $tax]);
|
||||
$quote->update(['subtotal_minor' => $price, 'tax_minor' => $tax, 'total_minor' => $price + $tax]);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPurchaseAward(object $test): PurchaseAward
|
||||
{
|
||||
$test->actingAs($test->buyer)->post($test->awardUrl, $test->selection)->assertSessionHasNoErrors()->assertRedirect($test->awardUrl);
|
||||
|
||||
return PurchaseAward::query()->firstOrFail();
|
||||
}
|
||||
|
||||
function approvePurchaseAward(object $test, PurchaseAward $award): void
|
||||
{
|
||||
foreach ($award->approvals()->get() as $step) {
|
||||
$user = match ($step->role) {
|
||||
'department_head' => $test->head, 'procurement' => $test->secondBuyer, 'finance' => $test->finance,
|
||||
};
|
||||
$test->actingAs($user)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors()->assertRedirect();
|
||||
}
|
||||
}
|
||||
|
||||
function issuePurchaseOrder(object $test, PurchaseAward $award): PurchaseOrder
|
||||
{
|
||||
approvePurchaseAward($test, $award);
|
||||
$test->actingAs($test->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors()->assertRedirect();
|
||||
|
||||
return PurchaseOrder::query()->firstOrFail();
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Empresa das ordens']);
|
||||
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Produção']);
|
||||
$center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'code' => 'PROD', 'name' => 'Produção']);
|
||||
foreach (['owner' => PurchasingRole::Requester, 'buyer' => PurchasingRole::Procurement, 'secondBuyer' => PurchasingRole::Procurement, 'head' => PurchasingRole::DepartmentHead, 'finance' => PurchasingRole::Finance, 'admin' => PurchasingRole::Admin] as $key => $role) {
|
||||
$this->{$key} = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
|
||||
}
|
||||
$this->actingAs($this->buyer);
|
||||
$this->r = Requisition::query()->create([
|
||||
'tenant_id' => $this->tenant->id, 'requester_id' => $this->owner->id, 'department_id' => $this->department->id, 'cost_center_id' => $center->id,
|
||||
'number' => 'REQ-ORDER', 'priority' => 'normal', 'needed_by' => now()->addMonth(), 'justification' => 'Necessidade de materiais para produção.',
|
||||
'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000,
|
||||
'approval_snapshot' => ['first_limit_minor' => 2500000, 'second_limit_minor' => 15000000, 'approval_includes_tax' => true, 'rules_provisional' => true],
|
||||
]);
|
||||
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $this->head->id, 'decided_at' => now()]);
|
||||
$this->item = $this->r->items()->create(['tenant_id' => $this->tenant->id, 'description' => 'Material de teste', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$this->quotes = [];
|
||||
$this->suppliers = [];
|
||||
foreach (['111222333', '222333444', '333444555'] as $index => $nuit) {
|
||||
$supplier = Supplier::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'name' => 'Fornecedor '.$index, 'nuit' => $nuit, 'contact' => 'Contacto', 'email' => 'fornecedor'.$index.'@example.test', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário', 'document_references' => 'Alvará conferido.', 'diligence_notes' => 'Documentação regular no teste.', 'status' => 'active', 'next_review' => now()->addYear()]);
|
||||
$this->suppliers[] = $supplier;
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', ['supplier_id' => $supplier->id, 'reference' => 'PROP-'.$index, 'delivery_days' => 5, 'valid_until' => now()->addMonth()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => (string) (100 + $index * 100)]]])->assertSessionHasNoErrors();
|
||||
$quote = Quotation::query()->latest('id')->firstOrFail();
|
||||
$this->quotes[] = $quote;
|
||||
foreach ([$this->buyer, $this->finance] as $evaluator) {
|
||||
$this->actingAs($evaluator)->post('/cotacoes/'.$quote->id.'/avaliar', ['quality' => '8', 'history' => '5', 'payment' => '8', 'comment' => 'Avaliação independente fundamentada.', 'no_conflict' => true])->assertSessionHasNoErrors();
|
||||
}
|
||||
}
|
||||
$this->actingAs($this->buyer);
|
||||
$this->awardUrl = '/requisicoes/'.$this->r->id.'/adjudicacao';
|
||||
$this->selection = ['quotation_id' => $this->quotes[0]->id, 'justification' => 'Proposta com melhor pontuação técnica e comercial.', 'delivery_date' => now()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Armazém central de Maputo'];
|
||||
});
|
||||
|
||||
test('award starts a new approval cycle and order freezes all agreed data', function () {
|
||||
$sourceSteps = $this->r->steps()->get()->toArray();
|
||||
$award = selectPurchaseAward($this);
|
||||
expect($award->status)->toBe('pending')->and($award->approvals()->count())->toBe(1);
|
||||
expect($award->approvals()->firstOrFail()->decision)->toBe('pending');
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
expect($order->number)->toMatch('/^OC-\d{4}-\d{6}$/')->and($order->total_minor)->toBe(10000);
|
||||
expect($order->snapshot['supplier']['name'])->toBe('Fornecedor 0')->and($order->snapshot['quotation']['payment_terms'])->toBe('30 dias');
|
||||
expect($order->snapshot['budget_checked'])->toBeFalse()->and($order->snapshot['development_only'])->toBeTrue();
|
||||
expect($order->snapshot['award']['delivery_date'])->toBe($this->selection['delivery_date']);
|
||||
expect($order->items()->count())->toBe(1)->and($order->snapshot['decision_matrix'])->toHaveCount(3);
|
||||
expect($this->r->steps()->get()->toArray())->toBe($sourceSteps);
|
||||
$this->get('/ordens/'.$order->id)->assertInertia(fn (Assert $page) => $page->component('purchases/show')->where('order.number', $order->number)->has('history', 3));
|
||||
$this->get('/ordens')->assertInertia(fn (Assert $page) => $page->has('orders.data', 1)->has('awards.data', 1));
|
||||
});
|
||||
|
||||
test('final approval tier uses inclusive boundaries of the stored process rules', function (int $priceMinor, int $count) {
|
||||
awardPrices($this, $priceMinor);
|
||||
$this->tenant->update(['first_limit_minor' => 1, 'second_limit_minor' => 2]);
|
||||
$award = selectPurchaseAward($this);
|
||||
expect($award->approvals()->count())->toBe($count);
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
expect($order->total_minor)->toBe($priceMinor)->and($order->snapshot['approval_rules']['first_limit_minor'])->toBe(2500000);
|
||||
})->with([[2500000, 1], [2500001, 2], [15000000, 2], [15000001, 3]]);
|
||||
|
||||
test('stored tax basis governs the final approval tier', function (bool $includesTax, int $count) {
|
||||
awardPrices($this, 2500000, 16);
|
||||
$rules = $this->r->approval_snapshot;
|
||||
$rules['approval_includes_tax'] = $includesTax;
|
||||
$this->r->update(['approval_snapshot' => $rules]);
|
||||
$award = selectPurchaseAward($this);
|
||||
expect($award->approvals()->count())->toBe($count)->and($award->tax_minor)->toBe(400000)->and($award->total_minor)->toBe(2900000);
|
||||
})->with([[true, 2], [false, 1]]);
|
||||
|
||||
test('fractional quantities and VAT remain exact in issued lines and totals', function () {
|
||||
$this->item->update(['quantity' => '1.125', 'tax_rate' => '16']);
|
||||
foreach ([[123, 138, 22, 160], [223, 251, 40, 291], [323, 363, 58, 421]] as $index => [$unit, $subtotal, $tax, $total]) {
|
||||
$q = $this->quotes[$index];
|
||||
$q->items()->update(['quantity' => '1.125', 'tax_rate' => '16', 'unit_price_minor' => $unit, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $total]);
|
||||
$q->update(['subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $total]);
|
||||
}
|
||||
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
|
||||
expect($order->subtotal_minor)->toBe(138)->and($order->tax_minor)->toBe(22)->and($order->total_minor)->toBe(160);
|
||||
expect($order->items()->firstOrFail()->quantity)->toBe('1.125');
|
||||
$this->get('/ordens/'.$order->id.'/imprimir')->assertOk()->assertSee('1,60 MZN')->assertSee('Não autoriza compras reais');
|
||||
});
|
||||
|
||||
test('ties allow a justified choice between top scoring proposals', function () {
|
||||
foreach ($this->quotes as $q) {
|
||||
$q->items()->update(['unit_price_minor' => 10000, 'subtotal_minor' => 10000, 'total_minor' => 10000]);
|
||||
$q->update(['subtotal_minor' => 10000, 'total_minor' => 10000]);
|
||||
}
|
||||
$this->selection['quotation_id'] = $this->quotes[2]->id;
|
||||
$award = selectPurchaseAward($this);
|
||||
expect($award->quotation_id)->toBe($this->quotes[2]->id);
|
||||
});
|
||||
|
||||
test('non top selection and quote exemptions remain blocked without policy', function () {
|
||||
$this->post($this->awardUrl, [...$this->selection, 'quotation_id' => $this->quotes[2]->id])->assertSessionHasErrors('quotation_id');
|
||||
$this->post($this->awardUrl, [...$this->selection, 'override' => true])->assertSessionHasErrors('override');
|
||||
$this->quotes[2]->update(['valid_until' => now()->subDay()]);
|
||||
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('comparison');
|
||||
expect(PurchaseAward::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('two currently eligible independent evaluators are required for every proposal', function (string $change) {
|
||||
if ($change === 'role') {
|
||||
$this->finance->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
||||
} elseif ($change === 'absence') {
|
||||
$this->quotes[0]->evaluations()->where('evaluator_id', $this->finance->id)->update(['no_conflict' => false]);
|
||||
} elseif ($change === 'requester') {
|
||||
$this->quotes[0]->evaluations()->where('evaluator_id', $this->finance->id)->update(['evaluator_id' => $this->owner->id]);
|
||||
$this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
} else {
|
||||
$this->quotes[0]->evaluations()->where('evaluator_id', $this->finance->id)->delete();
|
||||
}
|
||||
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('comparison');
|
||||
})->with(['role', 'absence', 'requester', 'missing']);
|
||||
|
||||
test('invalid source policy approval or unsupported purchase type cannot be presumed valid', function (string $change, string $error) {
|
||||
if ($change === 'snapshot') {
|
||||
$this->r->update(['approval_snapshot' => null]);
|
||||
} elseif ($change === 'approval') {
|
||||
$this->r->steps()->update(['decision' => 'pending']);
|
||||
} else {
|
||||
$this->item->update(['purchase_type' => $change]);
|
||||
}
|
||||
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors($error);
|
||||
expect(PurchaseAward::query()->count())->toBe(0);
|
||||
})->with([['snapshot', 'policy'], ['approval', 'approvals'], ['equipment', 'purchase_type'], ['spare_part', 'purchase_type']]);
|
||||
|
||||
test('award approvals enforce role order department distinct people and no self approval', function () {
|
||||
awardPrices($this, 15000001);
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->actingAs($this->finance)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
$this->actingAs($this->admin)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
$otherDepartment = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outro']);
|
||||
$otherHead = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $otherDepartment->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($otherHead)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
$this->actingAs($this->head)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
$this->head->forceFill(['purchasing_role' => PurchasingRole::Procurement])->save();
|
||||
$this->actingAs($this->head)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
$this->actingAs($this->secondBuyer)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->finance)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
|
||||
});
|
||||
|
||||
test('rejection requires a reason and prevents issuance', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->actingAs($this->head)->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'rejected'])->assertSessionHasErrors('comment');
|
||||
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'rejected', 'comment' => 'Necessário rever a necessidade e a proposta.'])->assertSessionHasNoErrors();
|
||||
expect($award->refresh()->status)->toBe('rejected');
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('approvals');
|
||||
});
|
||||
|
||||
test('issuer revalidates suspension expiry conflicts and evaluator eligibility after approval', function (string $change) {
|
||||
$award = selectPurchaseAward($this);
|
||||
approvePurchaseAward($this, $award);
|
||||
if ($change === 'suspension') {
|
||||
$this->suppliers[0]->update(['status' => 'suspended']);
|
||||
} elseif ($change === 'expiry') {
|
||||
$this->quotes[0]->update(['valid_until' => now()->subDay()]);
|
||||
} elseif ($change === 'role') {
|
||||
$this->finance->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
||||
} else {
|
||||
$user = $change === 'conflict_evaluator' ? $this->finance : $this->head;
|
||||
$this->actingAs($user)->post('/conflitos', ['scope' => 'supplier', 'supplier_id' => $this->suppliers[0]->id, 'has_conflict' => true, 'known_since' => now()->format('Y-m-d'), 'reason' => 'Conflito conhecido após a avaliação e aprovação.'])->assertSessionHasNoErrors();
|
||||
}
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors();
|
||||
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||
})->with(['suspension', 'expiry', 'role', 'conflict_evaluator', 'conflict_approver']);
|
||||
|
||||
test('changed prices or matrix after award cannot reuse approvals', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
approvePurchaseAward($this, $award);
|
||||
awardPrices($this, 10001);
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('comparison');
|
||||
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('invalid line totals are rejected instead of trusting stored totals', function () {
|
||||
$this->quotes[0]->items()->update(['tax_minor' => 99]);
|
||||
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('comparison');
|
||||
});
|
||||
|
||||
test('repeated issuance returns the same order and does not duplicate audit or lines', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertRedirect('/ordens/'.$order->id);
|
||||
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->items()->count())->toBe(1);
|
||||
expect(AuditLog::query()->where('action', 'purchase_order.issued')->count())->toBe(1);
|
||||
$this->post($this->awardUrl, $this->selection)->assertSessionHasErrors('award');
|
||||
});
|
||||
|
||||
test('database uniqueness prevents a second document for the same process', function () {
|
||||
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
|
||||
$duplicate = $order->getAttributes();
|
||||
unset($duplicate['id']);
|
||||
$duplicate['number'] = 'OC-DUPLICATE';
|
||||
expect(fn () => DB::table('purchase_orders')->insert($duplicate))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('changes after issuance cannot rewrite supplier terms evaluations or printed content', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
$snapshot = $order->snapshot;
|
||||
$this->suppliers[0]->update(['name' => 'Nome posterior', 'address' => 'Morada posterior']);
|
||||
$this->quotes[0]->update(['payment_terms' => 'Pagamento posterior']);
|
||||
$this->quotes[0]->evaluations()->update(['quality' => 1]);
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertRedirect('/ordens/'.$order->id);
|
||||
expect($order->refresh()->snapshot)->toBe($snapshot);
|
||||
$this->get('/ordens/'.$order->id.'/imprimir')->assertOk()->assertSee('Fornecedor 0')->assertDontSee('Nome posterior')->assertDontSee('Pagamento posterior');
|
||||
expect(fn () => $order->update(['total_minor' => 1]))->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('new quotations and evaluations cannot change a selected comparison', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', ['supplier_id' => $this->suppliers[0]->id, 'reference' => 'Nova', 'delivery_days' => 1, 'valid_until' => now()->addWeek()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => '1']]])->assertSessionHasErrors('award');
|
||||
$this->actingAs($this->secondBuyer)->post('/cotacoes/'.$this->quotes[0]->id.'/avaliar', ['quality' => '8', 'history' => '5', 'payment' => '8', 'comment' => 'Nova avaliação depois da selecção.', 'no_conflict' => true])->assertSessionHasErrors('award');
|
||||
expect($award->approvals()->firstOrFail()->decision)->toBe('pending');
|
||||
});
|
||||
|
||||
test('browser cannot set award or order money status or override rules', function () {
|
||||
$this->post($this->awardUrl, [...$this->selection, 'total_minor' => 1, 'status' => 'approved'])->assertSessionHasErrors(['total_minor', 'status']);
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir', ['total_minor' => 1, 'status' => 'paid', 'override' => true])->assertSessionHasErrors(['total_minor', 'status', 'override']);
|
||||
});
|
||||
|
||||
test('award and order operations restrict profiles and departments', function (PurchasingRole $role) {
|
||||
$award = selectPurchaseAward($this);
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->post($this->awardUrl, $this->selection)->assertForbidden();
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertForbidden();
|
||||
if (in_array($role, [PurchasingRole::Requester, PurchasingRole::Warehouse])) {
|
||||
$this->get('/ordens')->assertForbidden();
|
||||
$this->get($this->awardUrl)->assertForbidden();
|
||||
}
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::DepartmentHead, PurchasingRole::Requester, PurchasingRole::Warehouse]);
|
||||
|
||||
test('other companies cannot select approve issue list read or print documents', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
$tenant = Tenant::query()->create(['name' => 'Outra empresa']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->actingAs($user)->get('/ordens')->assertInertia(fn (Assert $page) => $page->has('orders.data', 0)->has('awards.data', 0));
|
||||
$this->get($this->awardUrl)->assertNotFound();
|
||||
$this->post($this->awardUrl, $this->selection)->assertNotFound();
|
||||
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertNotFound();
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertNotFound();
|
||||
$this->get('/ordens/'.$order->id)->assertNotFound();
|
||||
$this->get('/ordens/'.$order->id.'/imprimir')->assertNotFound();
|
||||
});
|
||||
|
||||
test('audit failure rolls back issuance including document lines and number', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
approvePurchaseAward($this, $award);
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria da emissão.'));
|
||||
});
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertStatus(500);
|
||||
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||
$this->assertDatabaseCount('purchase_order_items', 0);
|
||||
expect($award->refresh()->status)->toBe('approved');
|
||||
});
|
||||
|
||||
test('audit failure rolls back award and its approval cycle', function () {
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria da adjudicação.'));
|
||||
});
|
||||
$this->post($this->awardUrl, $this->selection)->assertStatus(500);
|
||||
expect(PurchaseAward::query()->count())->toBe(0);
|
||||
$this->assertDatabaseCount('purchase_award_approvals', 0);
|
||||
});
|
||||
|
||||
test('supplier names are frozen at issuance rather than read from live records', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
approvePurchaseAward($this, $award);
|
||||
$this->suppliers[0]->update(['name' => 'Nome validado antes de emitir']);
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
|
||||
$order = PurchaseOrder::query()->firstOrFail();
|
||||
expect($order->snapshot['supplier']['name'])->toBe('Nome validado antes de emitir');
|
||||
expect($order->snapshot['decision_matrix'][0]['supplier']['name'])->toBe('Nome validado antes de emitir');
|
||||
});
|
||||
|
||||
test('print escapes supplied HTML and keeps the development and budget notices', function () {
|
||||
$this->selection['justification'] = 'Fundamento escrito <script>alert(1)</script> para teste de impressão.';
|
||||
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
|
||||
$this->get('/ordens/'.$order->id.'/imprimir')->assertOk()->assertSee('<script>', false)->assertDontSee('<script>alert(1)</script>', false)->assertSee('Orçamento não verificado')->assertSee('Não autoriza compras reais');
|
||||
});
|
||||
|
||||
test('department heads cannot read or print another departments order', function () {
|
||||
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
|
||||
$other = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Departamento distinto']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $other->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get('/ordens')->assertInertia(fn (Assert $page) => $page->has('orders.data', 0)->has('awards.data', 0));
|
||||
$this->get('/ordens/'.$order->id)->assertNotFound();
|
||||
$this->get('/ordens/'.$order->id.'/imprimir')->assertNotFound();
|
||||
$this->get($this->awardUrl)->assertNotFound();
|
||||
});
|
||||
|
||||
test('quotation from a different process cannot be selected', function () {
|
||||
$other = $this->r->replicate();
|
||||
$other->number = 'REQ-OTHER';
|
||||
$other->save();
|
||||
$q = $this->quotes[0]->replicate();
|
||||
$q->requisition_id = $other->id;
|
||||
$q->save();
|
||||
$this->post($this->awardUrl, [...$this->selection, 'quotation_id' => $q->id])->assertNotFound();
|
||||
});
|
||||
|
||||
test('a conflict recorded after selection blocks the remaining approvals', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->actingAs($this->head)->post('/conflitos', ['scope' => 'process', 'requisition_id' => $this->r->id, 'has_conflict' => true, 'known_since' => now()->format('Y-m-d'), 'reason' => 'Conflito identificado antes da decisão final.'])->assertSessionHasNoErrors();
|
||||
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
expect($award->approvals()->firstOrFail()->decision)->toBe('pending');
|
||||
});
|
||||
|
||||
test('delivery dates that expire before emission are not silently replaced', function () {
|
||||
$this->selection['delivery_date'] = now()->format('Y-m-d');
|
||||
$award = selectPurchaseAward($this);
|
||||
approvePurchaseAward($this, $award);
|
||||
$this->travel(1)->days();
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('delivery_date');
|
||||
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user