499 lines
26 KiB
TypeScript
499 lines
26 KiB
TypeScript
import { Head } from '@inertiajs/react';
|
|
import { useState } from 'react';
|
|
import { PageTitle, Panel } from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
type Outcome = 'admit' | 'exclude' | 'clarify';
|
|
type Review = { actor: number; decision: 'approve' | 'return'; reason: string };
|
|
type Attempt = {
|
|
number: number;
|
|
supplier: string;
|
|
outcome: Outcome;
|
|
reason: string;
|
|
roles: string[];
|
|
required: number;
|
|
reviews: Review[];
|
|
};
|
|
const reviewers = [
|
|
{ id: 1, name: 'Conferente financeiro A', role: 'finance' },
|
|
{ id: 2, name: 'Conferente financeiro B', role: 'finance' },
|
|
{ id: 3, name: 'Chefia do departamento', role: 'department_head' },
|
|
];
|
|
const roles: Record<string, string> = {
|
|
finance: 'Finanças',
|
|
department_head: 'Chefia do departamento',
|
|
};
|
|
const outcomes: Record<Outcome, string> = {
|
|
admit: 'Admitir a proposta',
|
|
exclude: 'Excluir a proposta',
|
|
clarify: 'Solicitar esclarecimento',
|
|
};
|
|
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
|
|
const state = (attempt: Attempt) =>
|
|
attempt.reviews.some((review) => review.decision === 'return')
|
|
? 'Devolvida para revisão'
|
|
: attempt.reviews.length >= attempt.required
|
|
? 'Decisão confirmada na simulação'
|
|
: 'Aguarda conferência independente';
|
|
|
|
export default function AdmissionProposal() {
|
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
|
const [required, setRequired] = useState('');
|
|
const [supplier, setSupplier] = useState('Concorrente A');
|
|
const [outcome, setOutcome] = useState<Outcome>('admit');
|
|
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((reviewer) =>
|
|
selectedRoles.includes(reviewer.role),
|
|
);
|
|
const admissionReady = supplier === 'Concorrente A';
|
|
const valid =
|
|
selectedRoles.length > 0 &&
|
|
Number(required) >= 1 &&
|
|
Number(required) <= eligible.length &&
|
|
reason.trim().length >= 10 &&
|
|
considered &&
|
|
(outcome !== 'admit' || admissionReady);
|
|
const final =
|
|
attempt !== null &&
|
|
(attempt.reviews.some((review) => review.decision === 'return') ||
|
|
attempt.reviews.length >= attempt.required);
|
|
const propose = () => {
|
|
if (valid) {
|
|
setAttempt({
|
|
number: history.length + 1,
|
|
supplier,
|
|
outcome,
|
|
reason,
|
|
roles: [...selectedRoles],
|
|
required: Number(required),
|
|
reviews: [],
|
|
});
|
|
setReviewReasons({});
|
|
}
|
|
};
|
|
const review = (actor: number, decision: Review['decision']) => {
|
|
if (
|
|
!attempt ||
|
|
final ||
|
|
attempt.reviews.some((entry) => entry.actor === actor) ||
|
|
!reviewers.some(
|
|
(entry) =>
|
|
entry.id === actor && attempt.roles.includes(entry.role),
|
|
) ||
|
|
(reviewReasons[actor] ?? '').trim().length < 10
|
|
)
|
|
return;
|
|
setAttempt({
|
|
...attempt,
|
|
reviews: [
|
|
...attempt.reviews,
|
|
{ actor, decision, reason: reviewReasons[actor].trim() },
|
|
],
|
|
});
|
|
};
|
|
const next = () => {
|
|
if (attempt) setHistory([...history, structuredClone(attempt)]);
|
|
setAttempt(null);
|
|
setReason('');
|
|
setConsidered(false);
|
|
setReviewReasons({});
|
|
};
|
|
return (
|
|
<>
|
|
<Head title="Proposta de admissão e exclusão" />
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
|
<PageTitle
|
|
eyebrow="Proposta para validação"
|
|
title="Admissão e exclusão de propostas"
|
|
description="Experimente uma decisão manual com conferência independente e configuração própria da empresa."
|
|
/>
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
|
|
Demonstração com intervenientes e concorrentes fictícios.
|
|
Não guarda dados, publica políticas, exclui fornecedores ou
|
|
altera adjudicações. Simular outro conferente não autentica
|
|
outra pessoa.
|
|
</div>
|
|
<Panel
|
|
title="1. Circuito escolhido pela empresa"
|
|
description="Não existe número de confirmações ou perfil seleccionado por defeito. Nesta demonstração existem três conferentes fictícios, distintos do operador de Compras que propõe a decisão."
|
|
>
|
|
<fieldset className="space-y-3">
|
|
<legend className="mb-3 text-sm font-medium">
|
|
Perfis de conferência admitidos pela política
|
|
</legend>
|
|
{Object.entries(roles).map(([value, label]) => (
|
|
<Label
|
|
key={value}
|
|
className="flex items-center gap-3"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
disabled={attempt !== null}
|
|
checked={selectedRoles.includes(value)}
|
|
onChange={(event) =>
|
|
setSelectedRoles(
|
|
event.target.checked
|
|
? [...selectedRoles, value]
|
|
: selectedRoles.filter(
|
|
(entry) =>
|
|
entry !== value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
{label}
|
|
</Label>
|
|
))}
|
|
</fieldset>
|
|
<Label className="mt-4 max-w-md">
|
|
Confirmações de pessoas distintas necessárias
|
|
<select
|
|
disabled={attempt !== null}
|
|
className={selectClass}
|
|
value={required}
|
|
onChange={(event) =>
|
|
setRequired(event.target.value)
|
|
}
|
|
>
|
|
<option value="">A empresa deve definir</option>
|
|
{[1, 2, 3].map((count) => (
|
|
<option key={count} value={count}>
|
|
{count}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<p className="mt-3 text-xs text-muted-foreground">
|
|
{eligible.length} intervenientes fictícios elegíveis com
|
|
os perfis seleccionados. Uma mesma pessoa conta apenas
|
|
uma vez.
|
|
</p>
|
|
{required && Number(required) > eligible.length && (
|
|
<p className="mt-3 text-sm text-red-700">
|
|
Não existem intervenientes elegíveis suficientes
|
|
para a quantidade escolhida.
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
<Panel
|
|
title="2. Proposta manual de decisão por Compras"
|
|
description="A decisão deverá referenciar a versão dos requisitos, as propostas e as análises apreciadas. As pendências não são resolvidas por um cálculo automático."
|
|
>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Label>
|
|
Proposta
|
|
<select
|
|
disabled={attempt !== null}
|
|
className={selectClass}
|
|
value={supplier}
|
|
onChange={(event) =>
|
|
setSupplier(event.target.value)
|
|
}
|
|
>
|
|
{[
|
|
'Concorrente A',
|
|
'Concorrente B',
|
|
'Concorrente C',
|
|
].map((name) => (
|
|
<option key={name}>{name}</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<Label>
|
|
Decisão proposta
|
|
<select
|
|
disabled={attempt !== null}
|
|
className={selectClass}
|
|
value={outcome}
|
|
onChange={(event) =>
|
|
setOutcome(event.target.value as Outcome)
|
|
}
|
|
>
|
|
{Object.entries(outcomes).map(
|
|
([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</Label>
|
|
</div>
|
|
<p className="mt-4 rounded-lg border p-3 text-sm text-muted-foreground">
|
|
Base fictícia: requisitos v1. O caso A tem análise sem
|
|
pendências assinaladas; o caso B tem requisito
|
|
obrigatório não cumprido; o caso C tem pendência ou
|
|
divergência. “Solicitar esclarecimento” não envia
|
|
mensagens.
|
|
</p>
|
|
{outcome === 'admit' && !admissionReady && (
|
|
<p className="mt-3 text-sm text-red-700">
|
|
Neste primeiro âmbito proposto, a admissão aguarda
|
|
resolução das pendências, divergências ou requisitos
|
|
obrigatórios não cumpridos. Não se propõe uma
|
|
dispensa automática.
|
|
</p>
|
|
)}
|
|
<Label className="mt-4">
|
|
Fundamento da decisão e apreciação das pendências
|
|
<textarea
|
|
disabled={attempt !== null}
|
|
className="min-h-28 rounded-md border bg-background p-3 text-sm"
|
|
maxLength={4000}
|
|
value={reason}
|
|
onChange={(event) => setReason(event.target.value)}
|
|
/>
|
|
</Label>
|
|
<Label className="mt-4 flex items-start gap-3">
|
|
<input
|
|
type="checkbox"
|
|
disabled={attempt !== null}
|
|
checked={considered}
|
|
onChange={(event) =>
|
|
setConsidered(event.target.checked)
|
|
}
|
|
/>
|
|
<span>
|
|
Na simulação, o fundamento aprecia os resultados,
|
|
pendências e divergências relevantes. Isto não
|
|
representa uma dispensa de requisitos obrigatórios.
|
|
</span>
|
|
</Label>
|
|
{!attempt && (
|
|
<Button
|
|
className="mt-5"
|
|
disabled={!valid}
|
|
onClick={propose}
|
|
>
|
|
Simular proposta de decisão
|
|
</Button>
|
|
)}
|
|
</Panel>
|
|
{attempt && (
|
|
<Panel
|
|
title={`3. Conferência independente · Tentativa ${attempt.number}`}
|
|
description={`${attempt.supplier} · ${outcomes[attempt.outcome]} · ${state(attempt)}`}
|
|
>
|
|
<p className="text-sm whitespace-pre-wrap">
|
|
{attempt.reason}
|
|
</p>
|
|
<p className="mt-3 text-sm">
|
|
{
|
|
attempt.reviews.filter(
|
|
(entry) => entry.decision === 'approve',
|
|
).length
|
|
}
|
|
/{attempt.required} confirmações independentes.
|
|
</p>
|
|
<div className="mt-5 grid gap-4 lg:grid-cols-3">
|
|
{reviewers
|
|
.filter((entry) =>
|
|
attempt.roles.includes(entry.role),
|
|
)
|
|
.map((reviewer) => {
|
|
const existing = attempt.reviews.find(
|
|
(entry) => entry.actor === reviewer.id,
|
|
);
|
|
return (
|
|
<div
|
|
key={reviewer.id}
|
|
className="space-y-3 rounded-xl border p-4"
|
|
>
|
|
<h3 className="text-sm font-semibold">
|
|
{reviewer.name}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground">
|
|
{roles[reviewer.role]} ·
|
|
Interveniente fictício
|
|
</p>
|
|
{existing ? (
|
|
<>
|
|
<p className="text-sm font-medium">
|
|
{existing.decision ===
|
|
'approve'
|
|
? 'Conferência favorável'
|
|
: 'Devolvido para revisão'}
|
|
</p>
|
|
<p className="text-sm whitespace-pre-wrap">
|
|
{existing.reason}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Label>
|
|
Fundamento da
|
|
conferência
|
|
<textarea
|
|
disabled={final}
|
|
className="min-h-24 rounded-md border bg-background p-3 text-sm"
|
|
value={
|
|
reviewReasons[
|
|
reviewer.id
|
|
] ?? ''
|
|
}
|
|
maxLength={2000}
|
|
onChange={(event) =>
|
|
setReviewReasons(
|
|
{
|
|
...reviewReasons,
|
|
[reviewer.id]:
|
|
event
|
|
.target
|
|
.value,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
disabled={
|
|
final ||
|
|
(
|
|
reviewReasons[
|
|
reviewer
|
|
.id
|
|
] ?? ''
|
|
).trim()
|
|
.length < 10
|
|
}
|
|
onClick={() =>
|
|
review(
|
|
reviewer.id,
|
|
'approve',
|
|
)
|
|
}
|
|
>
|
|
Simular confirmação
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
disabled={
|
|
final ||
|
|
(
|
|
reviewReasons[
|
|
reviewer
|
|
.id
|
|
] ?? ''
|
|
).trim()
|
|
.length < 10
|
|
}
|
|
onClick={() =>
|
|
review(
|
|
reviewer.id,
|
|
'return',
|
|
)
|
|
}
|
|
>
|
|
Simular devolução
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="mt-4 text-sm text-muted-foreground">
|
|
Uma devolução rejeita esta proposta de decisão; não
|
|
exclui o concorrente por si. Admissão também não
|
|
significa adjudicação.
|
|
</p>
|
|
<Button
|
|
className="mt-5"
|
|
variant="outline"
|
|
onClick={next}
|
|
>
|
|
Preservar esta tentativa e preparar outra
|
|
</Button>
|
|
</Panel>
|
|
)}
|
|
<Panel
|
|
title="Efeitos propostos para a aplicação"
|
|
description="Pendentes de validação; nenhum está activo nesta demonstração."
|
|
>
|
|
<ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground">
|
|
<li>
|
|
Política explícita e versionada por empresa, sem
|
|
aplicar um quórum universal.
|
|
</li>
|
|
<li>
|
|
Proponente, solicitante e conferentes sujeitos a
|
|
identidade, elegibilidade, departamento e conflitos
|
|
verificados no servidor.
|
|
</li>
|
|
<li>
|
|
Requisitos ou propostas alterados exigem nova
|
|
apreciação; confirmações anteriores não são
|
|
copiadas.
|
|
</li>
|
|
<li>
|
|
Integração com a adjudicação apenas em processos que
|
|
adoptem expressamente o regime; nenhuma alteração
|
|
retroactiva de ordens existentes.
|
|
</li>
|
|
<li>
|
|
Excepções a requisitos obrigatórios e conflitos de
|
|
interesses não são dispensados por esta simulação.
|
|
</li>
|
|
</ul>
|
|
</Panel>
|
|
{history.length > 0 && (
|
|
<Panel
|
|
title="Tentativas anteriores desta simulação"
|
|
description="Conservadas apenas enquanto esta página estiver aberta. Recarregar elimina os dados locais."
|
|
>
|
|
{history.map((entry) => (
|
|
<details
|
|
key={entry.number}
|
|
className="border-b py-3"
|
|
>
|
|
<summary className="cursor-pointer text-sm font-medium">
|
|
Tentativa {entry.number} · {entry.supplier}{' '}
|
|
· {outcomes[entry.outcome]} · {state(entry)}
|
|
</summary>
|
|
<p className="mt-3 text-sm whitespace-pre-wrap">
|
|
{entry.reason}
|
|
</p>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Política simulada:{' '}
|
|
{entry.roles
|
|
.map((role) => roles[role])
|
|
.join(', ')}{' '}
|
|
· {entry.required} confirmações.
|
|
</p>
|
|
<ul className="mt-3 space-y-2 text-sm">
|
|
{entry.reviews.map((entryReview) => (
|
|
<li key={entryReview.actor}>
|
|
{
|
|
reviewers.find(
|
|
(actor) =>
|
|
actor.id ===
|
|
entryReview.actor,
|
|
)?.name
|
|
}
|
|
:{' '}
|
|
{entryReview.decision === 'approve'
|
|
? 'Favorável'
|
|
: 'Devolução'}{' '}
|
|
· {entryReview.reason}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
))}
|
|
</Panel>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|