Implementa recomendação formal com adopção e conferência independente

This commit is contained in:
2026-10-28 20:49:30 +02:00
parent 29cd859385
commit ee17ff7764
33 changed files with 2233 additions and 37 deletions
+6
View File
@@ -94,6 +94,12 @@ const groups: { label: string; entries: Entry[] }[] = [
demo: '/proposta/admissao',
roles: ['admin'],
},
{
label: 'Política de recomendação',
path: '/politica-recomendacao',
demo: '',
roles: ['admin'],
},
],
},
{
+23 -2
View File
@@ -19,6 +19,7 @@ type Props = {
canDecide: boolean;
canIssue: boolean;
order: { id: number; number: string } | null;
recommendation: { adopted: boolean; quotation_id: number | null };
};
export default function Award({
requisition,
@@ -28,6 +29,7 @@ export default function Award({
canDecide,
canIssue,
order,
recommendation,
}: Props) {
const selection = useForm({
quotation_id: '',
@@ -127,6 +129,19 @@ export default function Award({
</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"
@@ -160,8 +175,14 @@ export default function Award({
Seleccione uma proposta no topo
</option>
{comparison.rows
.filter((q) =>
comparison.top_ids.includes(q.id),
.filter(
(q) =>
comparison.top_ids.includes(
q.id,
) &&
(!recommendation.adopted ||
recommendation.quotation_id ===
q.id),
)
.map((q) => (
<option key={q.id} value={q.id}>
+16 -10
View File
@@ -53,6 +53,10 @@ const events: Record<string, string> = {
'admission.adopted': 'Política de admissão adoptada expressamente',
'admission.proposed': 'Tentativa de decisão de admissão registada',
'admission.reviewed': 'Conferência independente de admissão registada',
'recommendation.adopted': 'Política de recomendação adoptada expressamente',
'recommendation.proposed': 'Tentativa de recomendação registada',
'recommendation.reviewed':
'Conferência independente da recomendação registada',
'opening.recorded': 'Versão da acta de abertura registada',
'requisition.created': 'Requisição criada',
'requisition.updated': 'Requisição actualizada',
@@ -276,16 +280,18 @@ export default function PurchaseProcess({
</p>
)}
</Panel>
<Panel
title="Etapas previstas no roteiro"
description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída."
>
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
{future.map((stage) => (
<li key={stage}>{stage}</li>
))}
</ul>
</Panel>
{future.length > 0 && (
<Panel
title="Etapas previstas no roteiro"
description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída."
>
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
{future.map((stage) => (
<li key={stage}>{stage}</li>
))}
</ul>
</Panel>
)}
</div>
</>
);
@@ -0,0 +1,186 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { date, roleLabels } from '@/types/procurement';
type Policy = {
id: number;
revision: number;
enabled: boolean;
roles: string[];
required_count: number;
reason: string;
created_at: string;
};
type Props = {
latest: Policy | null;
policies: {
data: Policy[];
prev_page_url: string | null;
next_page_url: string | null;
};
};
export default function RecommendationPolicy({ latest, policies }: Props) {
return (
<PolicyForm key={latest?.id ?? 0} latest={latest} policies={policies} />
);
}
function PolicyForm({ latest, policies }: Props) {
const form = useForm({
previous_id: latest?.id ?? 0,
enabled: false,
roles: [] as string[],
required_count: '',
reason: '',
});
return (
<>
<Head title="Política de recomendação" />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Configuração da empresa"
title="Política de recomendação"
description="Sistema de Gestão de Compras (SGC) · Configuração da empresa"
/>
<Panel title="Nova versão da política">
<p className="mb-4 text-sm text-muted-foreground">
Defina os perfis e a quantidade de pessoas distintas.
Activar permite novas adopções expressas pelos
processos; desactivar impede novas adopções. As versões
já adoptadas mantêm-se. O limite técnico de 50
confirmações não define uma regra da empresa.
</p>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post('/politica-recomendacao');
}}
>
<FormErrors errors={form.errors} />
<Label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.enabled}
onChange={(e) =>
form.setData('enabled', e.target.checked)
}
/>
Activar para novas adopções
</Label>
<fieldset className="space-y-2">
<legend className="mb-2 text-sm font-medium">
Perfis autorizados a conferir
</legend>
{['finance', 'department_head'].map((role) => (
<Label
key={role}
className="flex items-center gap-2"
>
<input
type="checkbox"
checked={form.data.roles.includes(role)}
onChange={(e) =>
form.setData(
'roles',
e.target.checked
? [...form.data.roles, role]
: form.data.roles.filter(
(r) => r !== role,
),
)
}
/>
{roleLabels[role] ?? role}
{role === 'department_head' &&
' do departamento do processo'}
</Label>
))}
</fieldset>
<div className="space-y-2">
<Label htmlFor="required-count">
Quantidade de pessoas distintas exigida
</Label>
<Input
id="required-count"
type="number"
min={1}
max={50}
required
value={form.data.required_count}
onChange={(e) =>
form.setData(
'required_count',
e.target.value,
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="policy-reason">
Fundamento desta versão
</Label>
<textarea
id="policy-reason"
className="min-h-24 w-full rounded-md border p-3"
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</div>
<Button disabled={form.processing}>
Registar versão {latest ? latest.revision + 1 : 1}
</Button>
</form>
</Panel>
<Panel title="Versões preservadas">
<div className="space-y-4">
{policies.data.length === 0 && (
<p>A empresa ainda não definiu uma política.</p>
)}
{policies.data.map((p) => (
<article
key={p.id}
className="rounded-md border p-4"
>
<h2 className="font-semibold">
Versão {p.revision} ·{' '}
{p.enabled ? 'Activada' : 'Desactivada'} ·{' '}
{date(p.created_at)}
</h2>
<p>
{p.required_count} pessoa(s) ·{' '}
{p.roles
.map((r) => roleLabels[r] ?? r)
.join(', ')}
</p>
<p className="text-sm whitespace-pre-wrap">
{p.reason}
</p>
</article>
))}
<div className="flex gap-4">
{policies.prev_page_url && (
<Link href={policies.prev_page_url}>
Anteriores
</Link>
)}
{policies.next_page_url && (
<Link href={policies.next_page_url}>
Seguintes
</Link>
)}
</div>
</div>
</Panel>
</div>
</>
);
}
@@ -0,0 +1,508 @@
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 };
};
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 Compras (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} ·{' '}
{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>
</>
);
}
@@ -54,6 +54,7 @@ type Report = {
requester: string;
};
comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] };
recommendation: { id: number; revision: number } | null;
admission: {
id: number;
revision: number;
@@ -169,6 +170,17 @@ export default function ReviewReport({ report }: { report: Report }) {
a qualificação documental são apresentadas separadamente.
</div>
<Panel title="Identificação do pedido">
<p className="mb-4 text-sm">
{report.recommendation
? 'Regime de recomendação adoptado. A adjudicação exige recomendação confirmada e actual.'
: 'Sem adopção do regime de recomendação.'}{' '}
<Link
className="underline print:hidden"
href={`/requisicoes/${r.id}/recomendacao`}
>
Consultar recomendação e relatório preservado
</Link>
</p>
<p className="mb-4 text-sm">
{report.admission
? `Regime de admissão adoptado: política ${report.admission.snapshot.policy_revision}, ${report.admission.snapshot.required_count} confirmações independentes. A elegibilidade da comparação inclui admissão confirmada na base actual.`
+6
View File
@@ -128,6 +128,12 @@ export default function Comparison({
>
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`}