Files
compras/resources/js/pages/quotations/show.tsx
T

570 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '#' },
],
};