Files
compras/resources/js/pages/preview/admission.tsx
T

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>
</>
);
}