Files
compras/resources/js/pages/purchases/award.tsx
T

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: '#' },
],
};