570 lines
28 KiB
TypeScript
570 lines
28 KiB
TypeScript
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||
import { useState } from 'react';
|
||
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';
|
||
import { money } from '@/types/procurement';
|
||
|
||
type Item = {
|
||
id: number;
|
||
description: string;
|
||
quantity: string;
|
||
tax_rate: string;
|
||
unit_price_minor: number;
|
||
total_minor: number;
|
||
};
|
||
type Evaluation = {
|
||
id: number;
|
||
evaluator_id: number;
|
||
quality: string;
|
||
history: string;
|
||
payment: string;
|
||
comment: string;
|
||
};
|
||
type Quote = {
|
||
id: number;
|
||
reference: string;
|
||
supplier: { name: string };
|
||
subtotal_minor: number;
|
||
tax_minor: number;
|
||
total_minor: number;
|
||
delivery_days: number;
|
||
valid_until: string;
|
||
payment_terms: string;
|
||
evaluations: Evaluation[];
|
||
evaluation_count: number;
|
||
eligible: boolean;
|
||
conflict_blocked: boolean;
|
||
score: number | null;
|
||
items: Item[];
|
||
};
|
||
export default function Comparison({
|
||
requisition,
|
||
quotations,
|
||
suppliers,
|
||
canCreate,
|
||
canEvaluate,
|
||
ready,
|
||
}: {
|
||
requisition: {
|
||
id: number;
|
||
number: string;
|
||
justification: string;
|
||
items: Item[];
|
||
};
|
||
quotations: Quote[];
|
||
suppliers: { id: number; name: string }[];
|
||
canCreate: boolean;
|
||
canEvaluate: boolean;
|
||
ready: boolean;
|
||
}) {
|
||
const { auth } = usePage().props;
|
||
const [adding, setAdding] = useState(false);
|
||
const [selected, setSelected] = useState<number | null>(null);
|
||
const form = useForm({
|
||
supplier_id: '',
|
||
reference: '',
|
||
delivery_days: '1',
|
||
valid_until: '',
|
||
payment_terms: '',
|
||
no_conflict: false,
|
||
items: requisition.items.map((i) => ({
|
||
requisition_item_id: i.id,
|
||
unit_price: '',
|
||
})),
|
||
});
|
||
const evaluation = useForm({
|
||
quality: '5',
|
||
history: '5',
|
||
payment: '5',
|
||
comment: '',
|
||
no_conflict: false,
|
||
});
|
||
return (
|
||
<>
|
||
<Head title={`Cotações · ${requisition.number}`} />
|
||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||
<PageTitle
|
||
eyebrow={requisition.number}
|
||
title="Comparar propostas"
|
||
description={requisition.justification}
|
||
action={
|
||
canCreate ? (
|
||
<Button onClick={() => setAdding(!adding)}>
|
||
{adding
|
||
? 'Fechar formulário'
|
||
: 'Registar cotação'}
|
||
</Button>
|
||
) : 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 underline"
|
||
href={`/requisicoes/${requisition.id}/abertura`}
|
||
>
|
||
Sessão de abertura e actas
|
||
</Link>
|
||
<Link
|
||
className="text-sm font-medium underline"
|
||
href={`/requisicoes/${requisition.id}/qualificacao`}
|
||
>
|
||
Qualificação documental
|
||
</Link>
|
||
<Link
|
||
className="text-sm font-medium underline"
|
||
href={`/requisicoes/${requisition.id}/admissao`}
|
||
>
|
||
Admissão e exclusão de propostas
|
||
</Link>
|
||
<Link
|
||
className="text-sm font-medium underline"
|
||
href={`/requisicoes/${requisition.id}/recomendacao`}
|
||
>
|
||
Recomendação antes da adjudicação
|
||
</Link>
|
||
<Link
|
||
className="text-sm font-medium underline"
|
||
href={`/requisicoes/${requisition.id}/relatorio`}
|
||
>
|
||
Relatório de conferência e impressão
|
||
</Link>
|
||
<Link
|
||
className="text-sm text-teal-700"
|
||
href={`/requisicoes/${requisition.id}`}
|
||
>
|
||
Consultar requisição e aprovações
|
||
</Link>
|
||
<Link className="text-sm text-teal-700" href="/conflitos">
|
||
Registar ou consultar a minha declaração de conflito
|
||
</Link>
|
||
<div
|
||
className={`rounded-xl border p-5 text-sm ${ready ? 'border-teal-200 bg-teal-50 text-teal-900' : 'border-amber-200 bg-amber-50 text-amber-900'}`}
|
||
>
|
||
<strong>
|
||
{ready
|
||
? 'Comparação completa para revisão.'
|
||
: 'Comparação ainda incompleta.'}
|
||
</strong>{' '}
|
||
São necessárias três propostas elegíveis de fornecedores
|
||
distintos e duas avaliações independentes por proposta. Esta
|
||
etapa prepara a adjudicação e a emissão numa fase própria.
|
||
</div>
|
||
{adding && (
|
||
<Panel
|
||
title="Nova proposta"
|
||
description="MZN · Quantidades e IVA herdados da requisição. Os preços são calculados no servidor."
|
||
>
|
||
<form
|
||
className="space-y-5"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
form.post(
|
||
`/requisicoes/${requisition.id}/cotacoes`,
|
||
{
|
||
onSuccess: () => {
|
||
form.reset();
|
||
setAdding(false);
|
||
},
|
||
},
|
||
);
|
||
}}
|
||
>
|
||
<FormErrors errors={form.errors} />
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="supplier">
|
||
Fornecedor activo
|
||
</Label>
|
||
<select
|
||
id="supplier"
|
||
className={controlClass}
|
||
required
|
||
value={form.data.supplier_id}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'supplier_id',
|
||
e.target.value,
|
||
)
|
||
}
|
||
>
|
||
<option value="">Seleccione</option>
|
||
{suppliers.map((s) => (
|
||
<option value={s.id} key={s.id}>
|
||
{s.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{(
|
||
[
|
||
[
|
||
'reference',
|
||
'Referência do fornecedor',
|
||
'text',
|
||
],
|
||
[
|
||
'delivery_days',
|
||
'Prazo de entrega (dias)',
|
||
'number',
|
||
],
|
||
['valid_until', 'Válida até', 'date'],
|
||
[
|
||
'payment_terms',
|
||
'Condições de pagamento',
|
||
'text',
|
||
],
|
||
] as const
|
||
).map(([key, label, type]) => (
|
||
<div className="space-y-2" key={key}>
|
||
<Label htmlFor={key}>{label}</Label>
|
||
<Input
|
||
id={key}
|
||
type={type}
|
||
min={
|
||
type === 'number'
|
||
? 1
|
||
: undefined
|
||
}
|
||
required
|
||
value={form.data[key]}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
key,
|
||
e.target.value,
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="space-y-4">
|
||
{requisition.items.map((i, index) => (
|
||
<div
|
||
className="grid items-center gap-3 rounded-lg border p-4 md:grid-cols-2"
|
||
key={i.id}
|
||
>
|
||
<div>
|
||
<strong className="text-sm">
|
||
{i.description}
|
||
</strong>
|
||
<p className="text-xs text-muted-foreground">
|
||
{i.quantity} unidades · IVA{' '}
|
||
{i.tax_rate}%
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`price-${i.id}`}>
|
||
Preço unitário sem IVA (MZN)
|
||
</Label>
|
||
<Input
|
||
id={`price-${i.id}`}
|
||
type="number"
|
||
min="0.01"
|
||
step="0.01"
|
||
required
|
||
value={
|
||
form.data.items[index]
|
||
.unit_price
|
||
}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'items',
|
||
form.data.items.map(
|
||
(line, k) =>
|
||
k === index
|
||
? {
|
||
...line,
|
||
unit_price:
|
||
e
|
||
.target
|
||
.value,
|
||
}
|
||
: line,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<label className="flex items-start gap-3 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
required
|
||
checked={form.data.no_conflict}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'no_conflict',
|
||
e.target.checked,
|
||
)
|
||
}
|
||
/>
|
||
Declaro que não tenho conflito de interesses com
|
||
este fornecedor.
|
||
</label>
|
||
<Button disabled={form.processing}>
|
||
Guardar proposta
|
||
</Button>
|
||
</form>
|
||
</Panel>
|
||
)}
|
||
<Panel
|
||
title="Matriz de comparação"
|
||
description="Pesos provisórios: preço 40%, qualidade 20%, prazo 15%, histórico 15%, pagamento 10%. Preço e prazo calculados automaticamente entre propostas elegíveis."
|
||
>
|
||
<div className="overflow-auto">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="border-b text-xs text-muted-foreground">
|
||
<tr>
|
||
{[
|
||
'Fornecedor / referência',
|
||
'Sem IVA',
|
||
'IVA',
|
||
'Total',
|
||
'Entrega',
|
||
'Avaliações',
|
||
'Pontuação',
|
||
'',
|
||
].map((h, i) => (
|
||
<th key={i} className="pr-4 pb-3">
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{quotations.map((q) => (
|
||
<tr className="border-b" key={q.id}>
|
||
<td className="py-4 pr-4">
|
||
<strong>{q.supplier.name}</strong>
|
||
<p className="text-xs text-muted-foreground">
|
||
{q.reference} · válida até{' '}
|
||
{q.valid_until.slice(0, 10)}
|
||
</p>
|
||
{!q.eligible && (
|
||
<p className="text-xs text-red-700">
|
||
Proposta não elegível:
|
||
validade, fornecedor ou
|
||
conflito de intervenientes.
|
||
</p>
|
||
)}
|
||
{q.conflict_blocked && (
|
||
<p className="text-xs text-red-700">
|
||
Participação impedida por
|
||
conflito de interesses.
|
||
</p>
|
||
)}
|
||
</td>
|
||
<td className="pr-4 whitespace-nowrap">
|
||
{money(q.subtotal_minor)}
|
||
</td>
|
||
<td className="pr-4 whitespace-nowrap">
|
||
{money(q.tax_minor)}
|
||
</td>
|
||
<td className="pr-4 whitespace-nowrap">
|
||
{money(q.total_minor)}
|
||
</td>
|
||
<td className="pr-4">
|
||
{q.delivery_days} dias
|
||
</td>
|
||
<td className="pr-4">
|
||
{q.evaluation_count} / 2
|
||
</td>
|
||
<td className="pr-4">
|
||
{q.score === null
|
||
? 'Pendente'
|
||
: `${q.score.toFixed(2)} / 10`}
|
||
</td>
|
||
<td>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setSelected(q.id);
|
||
evaluation.reset();
|
||
evaluation.clearErrors();
|
||
}}
|
||
>
|
||
Detalhe
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{!quotations.length && (
|
||
<p className="py-8 text-center text-muted-foreground">
|
||
Ainda não foram registadas propostas.
|
||
</p>
|
||
)}
|
||
</Panel>
|
||
{quotations
|
||
.filter((q) => q.id === selected)
|
||
.map((q) => (
|
||
<Panel
|
||
key={q.id}
|
||
title={`${q.supplier.name} · ${q.reference}`}
|
||
description={`Pagamento: ${q.payment_terms}`}
|
||
>
|
||
<div className="space-y-3">
|
||
{q.items.map((i) => (
|
||
<p className="text-sm" key={i.id}>
|
||
{i.description} · {i.quantity} ×{' '}
|
||
{money(i.unit_price_minor)} · total com
|
||
IVA {money(i.total_minor)}
|
||
</p>
|
||
))}
|
||
</div>
|
||
<h3 className="mt-6 mb-3 font-semibold">
|
||
Avaliações registadas
|
||
</h3>
|
||
{q.evaluations.map((ev) => (
|
||
<div
|
||
key={ev.id}
|
||
className="mb-3 rounded-lg border p-4 text-sm"
|
||
>
|
||
<strong>
|
||
Avaliador #{ev.evaluator_id}
|
||
</strong>
|
||
<p>
|
||
Qualidade {ev.quality} · histórico{' '}
|
||
{ev.history} · pagamento {ev.payment}
|
||
</p>
|
||
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
||
{ev.comment}
|
||
</p>
|
||
</div>
|
||
))}
|
||
{canEvaluate &&
|
||
q.eligible &&
|
||
!q.conflict_blocked &&
|
||
!q.evaluations.some(
|
||
(ev) => ev.evaluator_id === auth.user.id,
|
||
) && (
|
||
<form
|
||
className="mt-5 space-y-4 border-t pt-5"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
evaluation.post(
|
||
`/cotacoes/${q.id}/avaliar`,
|
||
{
|
||
onSuccess: () =>
|
||
evaluation.reset(),
|
||
},
|
||
);
|
||
}}
|
||
>
|
||
<FormErrors
|
||
errors={evaluation.errors}
|
||
/>
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
{(
|
||
[
|
||
[
|
||
'quality',
|
||
'Qualidade técnica',
|
||
],
|
||
[
|
||
'history',
|
||
'Histórico (5 para novo)',
|
||
],
|
||
[
|
||
'payment',
|
||
'Condições de pagamento',
|
||
],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<div
|
||
className="space-y-2"
|
||
key={key}
|
||
>
|
||
<Label htmlFor={key}>
|
||
{label} · 0 a 10
|
||
</Label>
|
||
<Input
|
||
id={key}
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
step="0.01"
|
||
required
|
||
value={
|
||
evaluation.data[key]
|
||
}
|
||
onChange={(e) =>
|
||
evaluation.setData(
|
||
key,
|
||
e.target.value,
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Label htmlFor="comment">
|
||
Fundamento da avaliação
|
||
</Label>
|
||
<textarea
|
||
id="comment"
|
||
className={controlClass}
|
||
required
|
||
minLength={10}
|
||
value={evaluation.data.comment}
|
||
onChange={(e) =>
|
||
evaluation.setData(
|
||
'comment',
|
||
e.target.value,
|
||
)
|
||
}
|
||
/>
|
||
<label className="flex items-start gap-3 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
required
|
||
checked={
|
||
evaluation.data.no_conflict
|
||
}
|
||
onChange={(e) =>
|
||
evaluation.setData(
|
||
'no_conflict',
|
||
e.target.checked,
|
||
)
|
||
}
|
||
/>
|
||
Declaro não ter conflito de
|
||
interesses e confirmo a minha
|
||
avaliação independente.
|
||
</label>
|
||
<Button
|
||
disabled={evaluation.processing}
|
||
>
|
||
Registar avaliação
|
||
</Button>
|
||
</form>
|
||
)}
|
||
</Panel>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
Comparison.layout = {
|
||
breadcrumbs: [
|
||
{ title: 'Cotações', href: '/cotacoes' },
|
||
{ title: 'Comparação', href: '#' },
|
||
],
|
||
};
|