511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
import { Head, Link, router, useForm } from '@inertiajs/react';
|
|
import {
|
|
FormErrors,
|
|
PageTitle,
|
|
Panel,
|
|
controlClass,
|
|
} from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { date, roleLabels } from '@/types/procurement';
|
|
|
|
type Adoption = {
|
|
id: number;
|
|
revision: number;
|
|
recommendation_policy_id: number;
|
|
reason: string;
|
|
snapshot: {
|
|
roles: string[];
|
|
required_count: number;
|
|
policy_revision: number;
|
|
};
|
|
};
|
|
type Row = {
|
|
id: number;
|
|
reference: string;
|
|
supplier_name: string;
|
|
currency: string;
|
|
total_minor: number;
|
|
score_minor: number | null;
|
|
eligible: boolean;
|
|
ineligibility_reasons: string[];
|
|
evaluation_count: number;
|
|
};
|
|
type Review = {
|
|
id: number;
|
|
verdict: string;
|
|
reason: string;
|
|
created_at: string;
|
|
snapshot: { name: string; purchasing_role: string; profile_label?: string };
|
|
};
|
|
type Attempt = {
|
|
id: number;
|
|
revision: number;
|
|
quotation_id: number;
|
|
reason: string;
|
|
status: string;
|
|
current: boolean;
|
|
canReview: boolean;
|
|
created_at: string;
|
|
basis_hash: string;
|
|
snapshot: { proponent: { name: string }; basis: { rows: Row[] } };
|
|
reviews: Review[];
|
|
};
|
|
type Props = {
|
|
requisition: { id: number; number: string };
|
|
policy: { id: number; revision: number; enabled: boolean } | null;
|
|
adoption: Adoption | null;
|
|
selected: Adoption | null;
|
|
adoptions: Adoption[];
|
|
hasAdmission: boolean;
|
|
basis: { hash: string; ready: boolean; top_ids: number[]; rows: Row[] };
|
|
previousId: number;
|
|
history: {
|
|
data: Attempt[];
|
|
prev_page_url: string | null;
|
|
next_page_url: string | null;
|
|
};
|
|
canAdopt: boolean;
|
|
canPropose: boolean;
|
|
closed: boolean;
|
|
};
|
|
const statuses: Record<string, string> = {
|
|
pending: 'A aguardar conferência',
|
|
confirmed: 'Recomendação confirmada',
|
|
returned: 'Tentativa devolvida',
|
|
};
|
|
const amount = (minor: number) =>
|
|
(minor / 100).toLocaleString('pt-PT', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
});
|
|
function Reason({
|
|
id,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={id}>Fundamento</Label>
|
|
<textarea
|
|
id={id}
|
|
required
|
|
minLength={10}
|
|
maxLength={4000}
|
|
className="min-h-24 w-full rounded-lg border p-3 text-sm"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
function Adopt({
|
|
requisition,
|
|
policy,
|
|
adoption,
|
|
hasAdmission,
|
|
}: Pick<Props, 'requisition' | 'policy' | 'adoption' | 'hasAdmission'>) {
|
|
const form = useForm({
|
|
policy_id: policy?.id ?? 0,
|
|
previous_id: adoption?.id ?? 0,
|
|
reason: '',
|
|
});
|
|
if (!hasAdmission)
|
|
return (
|
|
<p>
|
|
É necessário adoptar expressamente o regime de admissão antes da
|
|
recomendação.
|
|
</p>
|
|
);
|
|
if (!policy?.enabled)
|
|
return (
|
|
<p>
|
|
A empresa ainda não disponibilizou uma política activa de
|
|
recomendação.
|
|
</p>
|
|
);
|
|
if (adoption?.recommendation_policy_id === policy.id)
|
|
return <p>O processo já adoptou a versão activa.</p>;
|
|
return (
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.post(
|
|
`/requisicoes/${requisition.id}/recomendacao/adoptar`,
|
|
);
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<p>
|
|
Adoptar expressamente a política de recomendação versão{' '}
|
|
{policy.revision}. Uma nova adopção exige novas tentativas e
|
|
conferências.
|
|
</p>
|
|
<Reason
|
|
id="recommendation-adopt-reason"
|
|
value={form.data.reason}
|
|
onChange={(v) => form.setData('reason', v)}
|
|
/>
|
|
<Button disabled={form.processing}>
|
|
Adoptar política neste processo
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
function Propose({
|
|
requisition,
|
|
basis,
|
|
previousId,
|
|
}: Pick<Props, 'requisition' | 'basis' | 'previousId'>) {
|
|
const form = useForm({
|
|
previous_id: previousId,
|
|
basis_hash: basis.hash,
|
|
quotation_id: '',
|
|
reason: '',
|
|
});
|
|
return (
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.post(
|
|
`/requisicoes/${requisition.id}/recomendacao/tentativas`,
|
|
);
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<div className="space-y-2">
|
|
<Label htmlFor="recommendation-candidate">
|
|
Proposta a recomendar
|
|
</Label>
|
|
<select
|
|
id="recommendation-candidate"
|
|
className={controlClass}
|
|
required
|
|
value={form.data.quotation_id}
|
|
onChange={(e) =>
|
|
form.setData('quotation_id', e.target.value)
|
|
}
|
|
>
|
|
<option value="">Seleccione expressamente</option>
|
|
{basis.rows
|
|
.filter((row) => basis.top_ids.includes(row.id))
|
|
.map((row) => (
|
|
<option key={row.id} value={row.id}>
|
|
{row.supplier_name} · {row.reference}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
A escolha deve pertencer às maiores pontuações. Em empate,
|
|
fundamente a escolha entre as empatadas. Uma nova tentativa
|
|
preserva a anterior e começa sem conferências.
|
|
</p>
|
|
<Reason
|
|
id="recommendation-propose-reason"
|
|
value={form.data.reason}
|
|
onChange={(v) => form.setData('reason', v)}
|
|
/>
|
|
<Button disabled={form.processing}>
|
|
Registar nova tentativa de recomendação
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
function Confer({ attempt }: { attempt: Attempt }) {
|
|
const form = useForm({ verdict: '', reason: '' });
|
|
return (
|
|
<form
|
|
className="mt-4 space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.post(`/recomendacoes/${attempt.id}/conferir`);
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`recommendation-review-${attempt.id}`}>
|
|
Conferência independente
|
|
</Label>
|
|
<select
|
|
id={`recommendation-review-${attempt.id}`}
|
|
className={controlClass}
|
|
required
|
|
value={form.data.verdict}
|
|
onChange={(e) => form.setData('verdict', e.target.value)}
|
|
>
|
|
<option value="">Seleccione</option>
|
|
<option value="approved">Confirmar a recomendação</option>
|
|
<option value="returned">Devolver esta tentativa</option>
|
|
</select>
|
|
</div>
|
|
<Reason
|
|
id={`recommendation-review-reason-${attempt.id}`}
|
|
value={form.data.reason}
|
|
onChange={(v) => form.setData('reason', v)}
|
|
/>
|
|
<Button disabled={form.processing}>Registar conferência</Button>
|
|
</form>
|
|
);
|
|
}
|
|
function Matrix({ rows, topIds }: { rows: Row[]; topIds?: number[] }) {
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b">
|
|
<tr>
|
|
<th className="p-2">Fornecedor e proposta</th>
|
|
<th className="p-2">Total</th>
|
|
<th className="p-2">Avaliações elegíveis</th>
|
|
<th className="p-2">Pontuação / 10</th>
|
|
<th className="p-2">Situação</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.id} className="border-b">
|
|
<td className="p-2">
|
|
{row.supplier_name} · {row.reference}
|
|
</td>
|
|
<td className="p-2 whitespace-nowrap">
|
|
{amount(row.total_minor)} {row.currency}
|
|
</td>
|
|
<td className="p-2">{row.evaluation_count}</td>
|
|
<td className="p-2">
|
|
{row.score_minor === null
|
|
? 'Por concluir'
|
|
: amount(row.score_minor)}
|
|
</td>
|
|
<td className="p-2">
|
|
{row.eligible
|
|
? 'Elegível na comparação'
|
|
: row.ineligibility_reasons.join(' ')}
|
|
{topIds?.includes(row.id) &&
|
|
' · Maior pontuação'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
export default function Recommendation(props: Props) {
|
|
const {
|
|
requisition,
|
|
policy,
|
|
adoption,
|
|
selected,
|
|
adoptions,
|
|
basis,
|
|
previousId,
|
|
history,
|
|
canAdopt,
|
|
canPropose,
|
|
closed,
|
|
hasAdmission,
|
|
} = props;
|
|
const base = `/requisicoes/${requisition.id}`;
|
|
return (
|
|
<>
|
|
<Head title={`Recomendação · ${requisition.number}`} />
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
|
<PageTitle
|
|
eyebrow="Compras"
|
|
title={`Recomendação · ${requisition.number}`}
|
|
description="Sistema de Gestão de Contratações (SGC) · Decisão fundamentada antes da adjudicação"
|
|
/>
|
|
<nav className="flex flex-wrap gap-4 text-sm underline">
|
|
<Link href={`${base}/processo`}>Processo completo</Link>
|
|
<Link href={`${base}/admissao`}>Admissão</Link>
|
|
<Link href={`${base}/cotacoes`}>Comparação</Link>
|
|
<Link href={`${base}/adjudicacao`}>Adjudicação</Link>
|
|
</nav>
|
|
<Panel title="Regime do processo">
|
|
<p className="mb-4">
|
|
{adoption
|
|
? `Adopção ${adoption.revision} · política ${adoption.snapshot.policy_revision} · ${adoption.snapshot.required_count} pessoas distintas · ${adoption.snapshot.roles.map((r) => roleLabels[r] ?? r).join(', ')}.`
|
|
: 'Sem adopção do regime de recomendação: mantém-se o circuito existente.'}
|
|
</p>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
A recomendação não autoriza compra nem substitui
|
|
orçamento ou aprovações do valor final. Conserva o
|
|
cálculo ponderado e os mínimos existentes de três
|
|
fornecedores elegíveis e duas avaliações por proposta.
|
|
</p>
|
|
{closed && (
|
|
<p>
|
|
Existindo adjudicação, o processo está fechado para
|
|
novas adopções, tentativas e conferências. Uma base
|
|
alterada exige o circuito de revisão autorizado.
|
|
</p>
|
|
)}
|
|
{canAdopt && (
|
|
<Adopt
|
|
key={`${policy?.id}-${adoption?.id}`}
|
|
requisition={requisition}
|
|
policy={policy}
|
|
adoption={adoption}
|
|
hasAdmission={hasAdmission}
|
|
/>
|
|
)}
|
|
</Panel>
|
|
<Panel title="Comparação actual">
|
|
<p className="mb-4 text-sm">
|
|
{basis.ready
|
|
? 'Matriz pronta para recomendação fundamentada.'
|
|
: 'Resolva a admissão ou os impedimentos da comparação antes de recomendar.'}{' '}
|
|
Os valores desta matriz são os actuais; cada tentativa
|
|
conserva a sua própria base.
|
|
</p>
|
|
<Matrix
|
|
rows={basis.rows}
|
|
topIds={basis.ready ? basis.top_ids : []}
|
|
/>
|
|
</Panel>
|
|
{adoptions.length > 0 && (
|
|
<Panel title="Histórico de adopções">
|
|
<Label htmlFor="recommendation-adoption-version">
|
|
Versão consultada
|
|
</Label>
|
|
<select
|
|
id="recommendation-adoption-version"
|
|
className={`${controlClass} mt-2`}
|
|
value={selected?.id ?? ''}
|
|
onChange={(e) =>
|
|
router.get(`${base}/recomendacao`, {
|
|
version: e.target.value,
|
|
})
|
|
}
|
|
>
|
|
{adoptions.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
Adopção {a.revision} · política{' '}
|
|
{a.snapshot.policy_revision}
|
|
{a.id === adoption?.id
|
|
? ' · actual'
|
|
: ' · histórica'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="mt-3 text-sm whitespace-pre-wrap">
|
|
{selected?.reason}
|
|
</p>
|
|
</Panel>
|
|
)}
|
|
{canPropose && (
|
|
<Panel title="Proposta manual por Compras">
|
|
<Propose
|
|
key={`${selected?.id}-${previousId}-${basis.hash}`}
|
|
requisition={requisition}
|
|
basis={basis}
|
|
previousId={previousId}
|
|
/>
|
|
</Panel>
|
|
)}
|
|
<Panel title="Tentativas e conferências">
|
|
<div className="space-y-5">
|
|
{history.data.length === 0 && (
|
|
<p>Sem tentativas nesta adopção.</p>
|
|
)}
|
|
{history.data.map((attempt) => (
|
|
<article
|
|
className="rounded-lg border p-4"
|
|
key={attempt.id}
|
|
>
|
|
<h2 className="font-semibold">
|
|
Tentativa {attempt.revision} ·{' '}
|
|
{
|
|
attempt.snapshot.basis.rows.find(
|
|
(row) =>
|
|
row.id === attempt.quotation_id,
|
|
)?.supplier_name
|
|
}{' '}
|
|
· {statuses[attempt.status]}
|
|
</h2>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
{date(attempt.created_at)} ·{' '}
|
|
{attempt.snapshot.proponent.name}
|
|
{!attempt.current &&
|
|
' · histórica ou base desactualizada; não utilizável para adjudicar'}
|
|
</p>
|
|
<p className="my-4 whitespace-pre-wrap">
|
|
{attempt.reason}
|
|
</p>
|
|
<details className="mb-4 text-sm">
|
|
<summary className="cursor-pointer font-medium">
|
|
Matriz preservada nesta tentativa
|
|
</summary>
|
|
<Matrix
|
|
rows={attempt.snapshot.basis.rows}
|
|
/>
|
|
<p className="mt-2 text-xs break-all">
|
|
Base: {attempt.basis_hash}
|
|
</p>
|
|
</details>
|
|
{attempt.reviews.map((review) => (
|
|
<div
|
|
key={review.id}
|
|
className="mt-3 rounded-lg bg-muted p-3 text-sm"
|
|
>
|
|
<p>
|
|
{review.snapshot.name} ·{' '}
|
|
{review.snapshot.profile_label ??
|
|
roleLabels[
|
|
review.snapshot
|
|
.purchasing_role
|
|
] ??
|
|
review.snapshot
|
|
.purchasing_role}{' '}
|
|
·{' '}
|
|
{review.verdict === 'approved'
|
|
? 'Confirmou'
|
|
: 'Devolveu'}{' '}
|
|
· {date(review.created_at)}
|
|
</p>
|
|
<p className="mt-2 whitespace-pre-wrap">
|
|
{review.reason}
|
|
</p>
|
|
</div>
|
|
))}
|
|
{attempt.canReview && (
|
|
<Confer attempt={attempt} />
|
|
)}
|
|
<a
|
|
className="mt-4 inline-block text-sm underline"
|
|
href={`/recomendacoes/${attempt.id}/imprimir`}
|
|
>
|
|
Consultar relatório para impressão
|
|
</a>
|
|
</article>
|
|
))}
|
|
<p className="text-sm text-muted-foreground">
|
|
Uma devolução encerra a tentativa sem excluir o
|
|
concorrente ou cancelar o processo. As conferências
|
|
não são transferidas para outra tentativa.
|
|
</p>
|
|
<div className="flex gap-4">
|
|
{history.prev_page_url && (
|
|
<Link href={history.prev_page_url}>
|
|
Anteriores
|
|
</Link>
|
|
)}
|
|
{history.next_page_url && (
|
|
<Link href={history.next_page_url}>
|
|
Seguintes
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|