622 lines
32 KiB
TypeScript
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 Compras (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>
|
|
</>
|
|
);
|
|
}
|