Files
compras/resources/js/pages/preview/recommendation.tsx
T
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

622 lines
32 KiB
TypeScript

import { Head, Link } from '@inertiajs/react';
import { Printer } from 'lucide-react';
import { useState } from 'react';
import { controlClass, PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
type Scenario = 'ready' | 'tie' | 'incomplete' | 'admission';
type Policy = { revision: number; roles: string[]; required: number };
type Review = {
actor: number;
verdict: 'confirmed' | 'returned';
reason: string;
};
type Attempt = {
revision: number;
basis: number;
scenario: Scenario;
candidate: string;
reason: string;
policy: Policy;
reviews: Review[];
};
const reviewers = [
{ id: 1, name: 'Conferente fictício de Finanças A', role: 'finance' },
{ id: 2, name: 'Conferente fictício de Finanças B', role: 'finance' },
{ id: 3, name: 'Chefia fictícia do departamento', role: 'department_head' },
];
const roleNames: Record<string, string> = {
finance: 'Finanças',
department_head: 'Chefia do departamento',
};
const scenarios: Record<Scenario, string> = {
ready: 'Matriz completa e admissões confirmadas',
tie: 'Empate na maior pontuação',
incomplete: 'Avaliações insuficientes',
admission: 'Admissão por confirmar',
};
const candidates = [
'Concorrente fictício A',
'Concorrente fictício B',
'Concorrente fictício C',
];
function status(attempt: Attempt) {
if (attempt.reviews.some((r) => r.verdict === 'returned'))
return 'Tentativa devolvida';
return attempt.reviews.length >= attempt.policy.required
? 'Recomendação confirmada na simulação'
: 'A aguardar conferência independente';
}
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}
className="min-h-24 w-full rounded-lg border p-3 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
minLength={10}
maxLength={4000}
/>
</div>
);
}
export default function RecommendationProposal() {
const [roles, setRoles] = useState<string[]>([]);
const [required, setRequired] = useState('');
const [enabled, setEnabled] = useState(false);
const [adoption, setAdoption] = useState<Policy | null>(null);
const [scenario, setScenario] = useState<Scenario>('ready');
const [basis, setBasis] = useState(1);
const [candidate, setCandidate] = useState('');
const [reason, setReason] = useState('');
const [considered, setConsidered] = useState(false);
const [attempt, setAttempt] = useState<Attempt | null>(null);
const [history, setHistory] = useState<Attempt[]>([]);
const [reviewReasons, setReviewReasons] = useState<Record<number, string>>(
{},
);
const eligible = reviewers.filter((r) => roles.includes(r.role));
const configured =
enabled &&
roles.length > 0 &&
Number.isInteger(Number(required)) &&
Number(required) > 0 &&
Number(required) <= eligible.length;
const ready = scenario === 'ready' || scenario === 'tie';
const top =
scenario === 'tie' ? candidates.slice(0, 2) : candidates.slice(0, 1);
const fresh =
attempt !== null &&
attempt.basis === basis &&
attempt.policy.revision === adoption?.revision;
const final =
attempt !== null &&
(attempt.reviews.some((r) => r.verdict === 'returned') ||
attempt.reviews.length >= attempt.policy.required);
const canPropose =
adoption !== null &&
ready &&
top.includes(candidate) &&
considered &&
reason.trim().length >= 10 &&
(!attempt || !fresh || final);
const usable =
fresh &&
final &&
attempt !== null &&
!attempt.reviews.some((r) => r.verdict === 'returned');
function changeBasis(nextScenario: Scenario) {
setScenario(nextScenario);
setBasis(basis + 1);
setConsidered(false);
}
function propose() {
if (!canPropose || !adoption) return;
if (attempt) setHistory([...history, structuredClone(attempt)]);
setAttempt({
revision: (attempt?.revision ?? 0) + 1,
basis,
scenario,
candidate,
reason: reason.trim(),
policy: structuredClone(adoption),
reviews: [],
});
setReviewReasons({});
setReason('');
setConsidered(false);
}
function review(actor: number, verdict: Review['verdict']) {
if (
!attempt ||
!fresh ||
final ||
attempt.reviews.some((r) => r.actor === actor) ||
!reviewers.some(
(r) => r.id === actor && attempt.policy.roles.includes(r.role),
) ||
(reviewReasons[actor] ?? '').trim().length < 10
)
return;
setAttempt({
...attempt,
reviews: [
...attempt.reviews,
{ actor, verdict, reason: reviewReasons[actor].trim() },
],
});
}
return (
<>
<Head title="Proposta de recomendação formal" />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8 print:p-0">
<PageTitle
eyebrow="Proposta para validação"
title="Recomendação antes da adjudicação"
description="Sistema de Gestão de Contratações (SGC) · Percurso proposto por empresa, com decisão manual e conferência independente."
/>
<p className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Demonstração com dados fictícios. As acções apenas simulam o
circuito nesta página e desaparecem ao recarregar. Trocar de
conferente não autentica pessoas diferentes. O relatório
impresso é uma simulação e não autoriza compras.
</p>
<nav className="flex flex-wrap gap-4 text-sm underline print:hidden">
<Link href="/proposta/principal">Ecrã principal</Link>
<Link href="/proposta/admissao">Admissão de propostas</Link>
<Link href="/consulta/cotacoes">
Comparação demonstrativa
</Link>
</nav>
<div className="print:hidden">
<Panel
title="1. Política e adopção expressa"
description="A política de recomendação seria própria, separada da política de admissão. A Administração configuraria a empresa; Compras adoptaria a versão antes de adjudicar."
>
<div className="grid gap-5 md:grid-cols-2">
<fieldset className="space-y-3">
<legend className="mb-3 text-sm font-medium">
Perfis autorizados a conferir
</legend>
{Object.entries(roleNames).map(
([role, name]) => (
<Label
className="flex items-center gap-2"
key={role}
>
<input
type="checkbox"
checked={roles.includes(role)}
onChange={(e) =>
setRoles(
e.target.checked
? [...roles, role]
: roles.filter(
(r) =>
r !==
role,
),
)
}
/>
{name}
</Label>
),
)}
</fieldset>
<div className="space-y-3">
<Label htmlFor="recommendation-quorum">
Quantidade de pessoas distintas
</Label>
<input
id="recommendation-quorum"
className={controlClass}
type="number"
min={1}
value={required}
onChange={(e) =>
setRequired(e.target.value)
}
/>
<Label className="flex items-center gap-2">
<input
type="checkbox"
checked={enabled}
onChange={(e) =>
setEnabled(e.target.checked)
}
/>
Activar para novas adopções
</Label>
</div>
</div>
<p className="my-4 text-sm text-muted-foreground">
Há três conferentes fictícios nesta demonstração,
distintos do solicitante e do proponente de Compras.
A quantidade disponível no exemplo não é um limite
nem um quórum do produto. Perfis e quantidade
começam por definir.
</p>
<Button
disabled={!configured}
onClick={() => {
setAdoption({
revision: (adoption?.revision ?? 0) + 1,
roles: [...roles],
required: Number(required),
});
setConsidered(false);
}}
>
Simular configuração e adopção de{' '}
{adoption ? 'nova versão' : 'política'}
</Button>
<p role="status" className="mt-4 text-sm">
{adoption
? `Versão adoptada ${adoption.revision}: ${adoption.required} pessoa(s), ${adoption.roles.map((r) => roleNames[r]).join(', ')}. Alterar os campos acima não reescreve esta versão; uma nova adopção exige novas conferências.`
: 'Sem adopção: o circuito existente da aplicação mantém-se.'}
</p>
</Panel>
</div>
<div className="print:hidden">
<Panel
title="2. Base apreciada"
description="O primeiro circuito proposto exige o regime de admissão já adoptado, admissões confirmadas e matriz pronta. Não altera o método de pontuação existente."
>
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-64 flex-1 space-y-2">
<Label htmlFor="recommendation-scenario">
Cenário fictício
</Label>
<select
id="recommendation-scenario"
className={controlClass}
value={scenario}
onChange={(e) =>
changeBasis(e.target.value as Scenario)
}
>
{Object.entries(scenarios).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</div>
<Button
variant="outline"
onClick={() => changeBasis(scenario)}
>
Simular alteração da base
</Button>
</div>
<p className="my-4 text-sm">
Base demonstrativa {basis} ·{' '}
{ready
? 'Pronta para propor recomendação'
: 'Impedida: resolva a avaliação ou a admissão antes de recomendar'}
. Os três concorrentes são fornecedores distintos;
no cenário pronto têm duas avaliações por proposta.
</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="mb-3 text-left text-xs text-muted-foreground">
Pontuações ilustrativas, sem recálculo de
negócio ou importação dos exemplos
históricos.
</caption>
<thead className="border-b">
<tr>
<th className="p-2">Concorrente</th>
<th className="p-2">
Pontuação ilustrativa
</th>
<th className="p-2">Admissão</th>
<th className="p-2">Situação</th>
</tr>
</thead>
<tbody>
{candidates.map((name, index) => (
<tr key={name} className="border-b">
<td className="p-2">{name}</td>
<td className="p-2">
{scenario === 'incomplete'
? 'Por concluir'
: index === 0 ||
(index === 1 &&
scenario === 'tie')
? '8,80'
: index === 1
? '8,40'
: '7,90'}
</td>
<td className="p-2">
{scenario === 'admission' &&
index === 0
? 'Por confirmar'
: 'Confirmada na simulação'}
</td>
<td className="p-2">
{ready && top.includes(name)
? 'Maior pontuação'
: ready
? 'Fora da maior pontuação'
: 'Comparação impedida'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
</div>
<div className="print:hidden">
<Panel
title="3. Proposta manual por Compras"
description="Escolha expressa entre as propostas de maior pontuação, com fundamento também em caso de empate. Esta etapa não cria adjudicação."
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="recommendation-candidate">
Proposta a recomendar
</Label>
<select
id="recommendation-candidate"
className={controlClass}
value={candidate}
onChange={(e) =>
setCandidate(e.target.value)
}
>
<option value="">
Seleccione expressamente
</option>
{candidates.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
{candidate && ready && !top.includes(candidate) && (
<p
role="alert"
className="text-sm text-destructive"
>
A escolha está fora da maior pontuação. Este
primeiro circuito não admite essa excepção.
</p>
)}
<Reason
id="recommendation-reason"
value={reason}
onChange={setReason}
/>
<Label className="flex items-center gap-2">
<input
type="checkbox"
checked={considered}
onChange={(e) =>
setConsidered(e.target.checked)
}
/>
Conferi a matriz, admissões e fundamento desta
base fictícia.
</Label>
<Button disabled={!canPropose} onClick={propose}>
Simular nova tentativa de recomendação
</Button>
{attempt && fresh && !final && (
<p className="text-sm text-muted-foreground">
A tentativa actual aguarda conferência. Pode
ser devolvida; uma alteração da base exige
outra tentativa.
</p>
)}
</div>
</Panel>
</div>
{attempt && (
<Panel
title={`4. Relatório demonstrativo · Tentativa ${attempt.revision}`}
description="O relatório preservaria a base, a proposta fundamentada e cada conferência. Confirmar recomendação não substitui aprovações da adjudicação ou controlo orçamental."
>
<div className="space-y-3">
<p className="font-semibold">{attempt.candidate}</p>
<p className="text-sm whitespace-pre-wrap">
{attempt.reason}
</p>
<p className="text-sm">
Base {attempt.basis} ·{' '}
{scenarios[attempt.scenario]} · política{' '}
{attempt.policy.revision} ·{' '}
{attempt.policy.required} pessoa(s) distinta(s).
</p>
<p
role="status"
className="rounded-lg bg-muted p-3 text-sm"
>
{status(attempt)}
{!fresh &&
' · Histórica: a base ou a política adoptada mudou. Não utilizável para adjudicar.'}
</p>
{attempt.reviews.map((r) => (
<article
key={r.actor}
className="rounded-lg border p-3 text-sm"
>
<p className="font-medium">
{
reviewers.find(
(a) => a.id === r.actor,
)?.name
}{' '}
·{' '}
{r.verdict === 'confirmed'
? 'Confirmou'
: 'Devolveu'}
</p>
<p className="whitespace-pre-wrap">
{r.reason}
</p>
</article>
))}
{fresh && !final && (
<div className="space-y-4 print:hidden">
{reviewers
.filter(
(r) =>
attempt.policy.roles.includes(
r.role,
) &&
!attempt.reviews.some(
(entry) =>
entry.actor === r.id,
),
)
.map((r) => (
<article
key={r.id}
className="space-y-3 rounded-lg border p-4"
>
<h3 className="text-sm font-semibold">
{r.name}
</h3>
<Reason
id={`recommendation-review-${r.id}`}
value={
reviewReasons[r.id] ??
''
}
onChange={(value) =>
setReviewReasons({
...reviewReasons,
[r.id]: value,
})
}
/>
<div className="flex flex-wrap gap-3">
<Button
disabled={
(
reviewReasons[
r.id
] ?? ''
).trim().length < 10
}
onClick={() =>
review(
r.id,
'confirmed',
)
}
>
Simular confirmação
</Button>
<Button
variant="outline"
disabled={
(
reviewReasons[
r.id
] ?? ''
).trim().length < 10
}
onClick={() =>
review(
r.id,
'returned',
)
}
>
Simular devolução
</Button>
</div>
</article>
))}
</div>
)}
<p className="text-sm">
{usable
? 'Nesta simulação, uma futura adjudicação só poderia usar esta proposta e esta base, sujeita às restantes verificações e aprovações.'
: 'Esta tentativa não pode fundamentar a adjudicação na simulação.'}
</p>
<p className="text-sm text-muted-foreground">
Devolver a recomendação não exclui o concorrente
nem cancela o processo. Uma nova tentativa
começa sem confirmações herdadas.
</p>
<Button
variant="outline"
className="print:hidden"
onClick={() => window.print()}
>
<Printer className="mr-2 size-4" />
Imprimir simulação
</Button>
</div>
</Panel>
)}
{history.length > 0 && (
<Panel title="Tentativas anteriores nesta simulação">
<div className="space-y-4">
{history.toReversed().map((entry) => (
<details
key={entry.revision}
className="rounded-lg border p-3 text-sm"
>
<summary className="cursor-pointer font-medium">
Tentativa {entry.revision} ·{' '}
{entry.candidate} · {status(entry)} ·
histórica, sem autorização actual
</summary>
<p className="mt-3 whitespace-pre-wrap">
{entry.reason}
</p>
<p className="mt-2">
Base {entry.basis} · política{' '}
{entry.policy.revision} ·{' '}
{entry.reviews.length} conferência(s)
preservada(s).
</p>
{entry.reviews.map((r) => (
<p
className="mt-2 whitespace-pre-wrap"
key={r.actor}
>
{
reviewers.find(
(a) => a.id === r.actor,
)?.name
}{' '}
·{' '}
{r.verdict === 'confirmed'
? 'Confirmou'
: 'Devolveu'}{' '}
· {r.reason}
</p>
))}
</details>
))}
</div>
</Panel>
)}
</div>
</>
);
}