417 lines
21 KiB
TypeScript
417 lines
21 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/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, 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;
|
|
recommendation: { adopted: boolean; quotation_id: number | null };
|
|
};
|
|
export default function Award({
|
|
requisition,
|
|
comparison,
|
|
award,
|
|
canSelect,
|
|
canDecide,
|
|
canIssue,
|
|
order,
|
|
recommendation,
|
|
}: 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="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>
|
|
<p className="text-sm">
|
|
{recommendation.adopted
|
|
? recommendation.quotation_id
|
|
? 'Regime de recomendação adoptado: a selecção deve coincidir com a proposta recomendada.'
|
|
: 'Conclua uma recomendação actual antes de seleccionar a adjudicação.'
|
|
: 'Sem adopção do regime de recomendação: mantém-se o circuito existente.'}{' '}
|
|
<Link
|
|
className="underline"
|
|
href={`/requisicoes/${requisition.id}/recomendacao`}
|
|
>
|
|
Consultar recomendação
|
|
</Link>
|
|
</p>
|
|
{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,
|
|
) &&
|
|
(!recommendation.adopted ||
|
|
recommendation.quotation_id ===
|
|
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' : award.status === 'cancelled' ? 'Cancelada' : '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: '#' },
|
|
],
|
|
};
|