Implementa recomendação formal com adopção e conferência independente
This commit is contained in:
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Recomendação · {{ $requisition->number }} · Sistema de Gestão de Compras (SGC)</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 14mm; }
|
||||
body { font: 12px Arial, sans-serif; color: #111; margin: 24px; }
|
||||
header { border-bottom: 3px solid #ed1c24; margin-bottom: 18px; padding-bottom: 12px; }
|
||||
h1 { font-size: 21px; } h2 { margin-top: 22px; font-size: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { border: 1px solid #bbb; padding: 7px; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||
th { background: #eee; } thead { display: table-header-group; }
|
||||
tr, article { break-inside: avoid; } p { overflow-wrap: anywhere; }
|
||||
.reason { white-space: pre-wrap; } .notice { border: 1px solid #999; padding: 12px; }
|
||||
article { border-left: 3px solid #ddd; padding-left: 12px; margin: 14px 0; }
|
||||
footer { border-top: 1px solid #999; margin-top: 24px; padding-top: 10px; }
|
||||
@media print { .controls { display: none; } body { margin: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="controls"><a href="{{ route('recommendations.show', $requisition) }}">Voltar ao processo</a> · <button onclick="window.print()">Imprimir</button></nav>
|
||||
<header><strong>Sistema de Gestão de Compras (SGC)</strong><h1>Recomendação antes da adjudicação · {{ $requisition->number }}</h1><p>Adopção {{ $adoption->revision }} · política {{ $adoption->snapshot['policy_revision'] }} · tentativa {{ $attempt->revision }}</p></header>
|
||||
@php
|
||||
$states = ['pending' => 'A aguardar conferência', 'confirmed' => 'Recomendação confirmada', 'returned' => 'Tentativa devolvida'];
|
||||
$rows = $attempt->snapshot['basis']['rows'];
|
||||
$candidate = collect($rows)->firstWhere('id', $attempt->quotation_id);
|
||||
@endphp
|
||||
<p class="notice"><strong>{{ $states[$state] }} · {{ $current ? 'Base actual na consulta' : 'Histórica ou base desactualizada: não utilizável para adjudicar' }}</strong><br>Recomendação não autoriza compra, não substitui aprovações do valor final e não constitui assinatura digital ou certificação de evidências.</p>
|
||||
<p>Consulta por {{ $generatedBy }} em {{ $generatedAt->format('d/m/Y H:i:s') }} · Africa/Johannesburg (UTC+2). A situação actual pode mudar após esta consulta.</p>
|
||||
<h2>Proposta recomendada</h2><p><strong>{{ $candidate['supplier_name'] ?? 'Fornecedor indisponível' }}</strong> · {{ $candidate['reference'] ?? $attempt->quotation_id }}</p>
|
||||
<p>Proponente: {{ $attempt->snapshot['proponent']['name'] }} · Registo: {{ $attempt->created_at }} UTC</p><p class="reason">{{ $attempt->reason }}</p>
|
||||
<p>Política preservada: {{ $adoption->snapshot['required_count'] }} pessoas distintas; {{ implode(', ', array_map(fn ($role) => \App\Enums\PurchasingRole::from($role)->label(), $adoption->snapshot['roles'])) }}.</p>
|
||||
<p>Base preservada: {{ $attempt->basis_hash }}</p>
|
||||
<h2>Matriz preservada na tentativa</h2>
|
||||
<table><thead><tr><th>Fornecedor e proposta</th><th>Total</th><th>Avaliações elegíveis</th><th>Pontuação / 10</th><th>Situação na base preservada</th></tr></thead><tbody>
|
||||
@foreach($rows as $row)
|
||||
<tr><td>{{ $row['supplier_name'] }} · {{ $row['reference'] }}</td><td>{{ number_format($row['total_minor'] / 100, 2, ',', ' ') }} {{ $row['currency'] }}</td><td>{{ $row['evaluation_count'] }}</td><td>{{ $row['score_minor'] === null ? 'Por concluir' : number_format($row['score_minor'] / 100, 2, ',', ' ') }}</td><td>{{ $row['eligible'] ? 'Elegível, com admissão confirmada' : implode(' ', $row['ineligibility_reasons']) }}{{ in_array($row['id'], $attempt->snapshot['basis']['top_ids'], true) ? ' · Maior pontuação' : '' }}</td></tr>
|
||||
@endforeach
|
||||
</tbody></table>
|
||||
<h2>Referências de admissão preservadas</h2>
|
||||
@foreach($attempt->snapshot['basis']['admissions'] as $admission)
|
||||
<p>Proposta #{{ $admission['quotation_id'] }} · requisitos #{{ $admission['basis']['set_id'] ?? 'não disponíveis' }} · decisão #{{ $admission['decision']['id'] ?? 'não disponível' }} · conferências {{ implode(', ', array_column($admission['reviews'], 'id')) ?: 'sem registos' }}.</p>
|
||||
@endforeach
|
||||
<h2>Conferências desta tentativa</h2>
|
||||
@forelse($reviews as $review)
|
||||
<article><strong>{{ $review->snapshot['name'] }} · {{ \App\Enums\PurchasingRole::from($review->snapshot['purchasing_role'])->label() }} · {{ $review->verdict === 'approved' ? 'Confirmou' : 'Devolveu' }}</strong><p>{{ $review->created_at }} UTC</p><p class="reason">{{ $review->reason }}</p></article>
|
||||
@empty<p>Sem conferências registadas.</p>@endforelse
|
||||
<h2>Histórico desta adopção até à tentativa impressa</h2>
|
||||
@forelse($history as $entry)
|
||||
<article><strong>Tentativa {{ $entry['attempt']->revision }} · proposta #{{ $entry['attempt']->quotation_id }} · {{ $states[$entry['state']] }} · histórica</strong><p class="reason">{{ $entry['attempt']->reason }}</p>
|
||||
@foreach($entry['reviews'] as $review)<p class="reason">{{ $review->snapshot['name'] }} · {{ $review->verdict === 'approved' ? 'Confirmou' : 'Devolveu' }} · {{ $review->created_at }} UTC · {{ $review->reason }}</p>@endforeach</article>
|
||||
@empty<p>Sem tentativas anteriores nesta adopção.</p>@endforelse
|
||||
<footer>Sistema de Gestão de Compras (SGC) · Registos preservados para conferência. Outras adopções são consultáveis no histórico autenticado do processo.</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user