commit1
tests / ci (push) Waiting to run

This commit is contained in:
2026-10-28 12:23:16 +02:00
parent 4d42f083ce
commit eb1c8b8074
20 changed files with 2141 additions and 58 deletions
+1
View File
@@ -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;
+6
View File
@@ -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']}
+748
View File
@@ -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>
</>
);
}
+383 -14
View File
@@ -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: '#' },
],
};
+121 -4
View File
@@ -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' }],
};
+255 -10
View File
@@ -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: '#' },
],
};
+7 -1
View File
@@ -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}`}
+88 -17
View File
@@ -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[];
};
};