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

504 lines
27 KiB
TypeScript

import { Head } from '@inertiajs/react';
import { Plus, RotateCcw } from 'lucide-react';
import { useState } from 'react';
import { PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Criterion = {
id: number;
label: string;
category: string;
required: boolean;
};
type Finding = {
result: 'pending' | 'met' | 'not_met' | 'not_applicable';
evidence: string;
reason: string;
};
type Answers = Record<string, Finding>;
type Version = { number: number; criteria: Criterion[]; answers: Answers };
const suppliers = ['Concorrente A', 'Concorrente B', 'Concorrente C'];
const initialCriteria: Criterion[] = [
{
id: 1,
label: 'Documento definido pela empresa para este processo',
category: 'administrative',
required: false,
},
{
id: 2,
label: 'Conformidade com a especificação definida no processo',
category: 'technical',
required: false,
},
];
const categories: Record<string, string> = {
legal: 'Jurídica',
financial: 'Económico-financeira',
fiscal: 'Fiscal',
technical: 'Técnica',
administrative: 'Administrativa',
};
const results: Record<Finding['result'], string> = {
pending: 'Por analisar',
met: 'Cumprido',
not_met: 'Não cumprido',
not_applicable: 'Não aplicável',
};
const emptyFinding: Finding = { result: 'pending', evidence: '', reason: '' };
const key = (criterion: number, supplier: number) => `${criterion}-${supplier}`;
const complete = (finding: Finding) =>
finding.result !== 'pending' &&
finding.reason.trim().length >= 10 &&
(finding.result === 'not_applicable' ||
finding.evidence.trim().length >= 3);
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
export default function QualificationProposal() {
const [criteria, setCriteria] = useState(initialCriteria);
const [answers, setAnswers] = useState<Answers>({});
const [started, setStarted] = useState(false);
const [version, setVersion] = useState(1);
const [history, setHistory] = useState<Version[]>([]);
const [nextId, setNextId] = useState(3);
const validCriteria =
criteria.length > 0 &&
criteria.every((criterion) => criterion.label.trim().length >= 3) &&
new Set(
criteria.map((criterion) =>
criterion.label.trim().toLocaleLowerCase('pt-PT'),
),
).size === criteria.length;
const updateCriterion = (id: number, change: Partial<Criterion>) =>
setCriteria(
criteria.map((criterion) =>
criterion.id === id ? { ...criterion, ...change } : criterion,
),
);
const finding = (criterion: number, supplier: number) =>
answers[key(criterion, supplier)] ?? emptyFinding;
const updateFinding = (
criterion: number,
supplier: number,
change: Partial<Finding>,
) =>
setAnswers({
...answers,
[key(criterion, supplier)]: {
...finding(criterion, supplier),
...change,
},
});
const revise = () => {
setHistory([
...history,
{
number: version,
criteria: criteria.map((criterion) => ({ ...criterion })),
answers: structuredClone(answers),
},
]);
setVersion(version + 1);
setStarted(false);
setAnswers({});
};
const reset = () => {
setCriteria(initialCriteria);
setAnswers({});
setStarted(false);
setVersion(1);
setHistory([]);
setNextId(3);
};
return (
<>
<Head title="Proposta de qualificaçã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="Qualificação das propostas"
description="Experimente requisitos por processo, evidências e fundamentos por concorrente."
action={
<Button variant="outline" onClick={reset}>
<RotateCcw className="size-4" />
Repor exemplos
</Button>
}
/>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Esta demonstração usa concorrentes fictícios e não guarda
dados. Os dois requisitos iniciais são exemplos editáveis.
Nenhuma escolha aqui altera fornecedores, pontuações ou
adjudicações da aplicação.
</div>
<Panel
title={`1. Requisitos do processo · Versão ${version}`}
description={
started
? 'Os requisitos desta versão estão fixados para a simulação. Outra versão conserva a anterior no histórico desta página.'
: 'A empresa define o conteúdo e a importância de cada requisito. Não existem montantes mínimos ou certificados impostos neste exemplo.'
}
>
<div className="space-y-4">
{criteria.map((criterion) => (
<div
key={criterion.id}
className="grid gap-3 rounded-xl border p-4 md:grid-cols-[minmax(0,1fr)_220px]"
>
<Label>
Requisito
<Input
disabled={started}
value={criterion.label}
maxLength={300}
onChange={(event) =>
updateCriterion(criterion.id, {
label: event.target.value,
})
}
/>
</Label>
<Label>
Categoria
<select
className={selectClass}
disabled={started}
value={criterion.category}
onChange={(event) =>
updateCriterion(criterion.id, {
category: event.target.value,
})
}
>
{Object.entries(categories).map(
([value, label]) => (
<option
key={value}
value={value}
>
{label}
</option>
),
)}
</select>
</Label>
<Label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
disabled={started}
checked={criterion.required}
onChange={(event) =>
updateCriterion(criterion.id, {
required: event.target.checked,
})
}
/>
Obrigatório segundo a regra do processo
</Label>
{!started && (
<Button
variant="ghost"
className="justify-self-end"
onClick={() =>
setCriteria(
criteria.filter(
(entry) =>
entry.id !==
criterion.id,
),
)
}
>
Retirar requisito
</Button>
)}
</div>
))}
</div>
<div className="mt-5 flex flex-wrap gap-3">
{started ? (
<Button variant="outline" onClick={revise}>
Preparar outra versão
</Button>
) : (
<>
<Button
variant="outline"
disabled={criteria.length >= 20}
onClick={() => {
setCriteria([
...criteria,
{
id: nextId,
label: '',
category: 'administrative',
required: false,
},
]);
setNextId(nextId + 1);
}}
>
<Plus className="size-4" />
Adicionar requisito
</Button>
<Button
disabled={!validCriteria}
onClick={() => setStarted(true)}
>
Simular início da análise
</Button>
</>
)}
</div>
{!validCriteria && (
<p className="mt-3 text-sm text-red-700">
Defina pelo menos um requisito com um nome distinto
de três ou mais caracteres.
</p>
)}
</Panel>
<Panel
title="2. Análise por concorrente"
description="Um resultado preenchido precisa de fundamento. Cumprido e não cumprido precisam também de uma referência de evidência; não aplicável exige justificação."
>
{!started ? (
<p className="text-sm text-muted-foreground">
Defina os requisitos e inicie a simulação para
preencher a análise.
</p>
) : (
<div className="space-y-6">
{criteria.map((criterion) => (
<section
key={criterion.id}
className="space-y-3"
>
<h3 className="font-semibold">
{criterion.label}
</h3>
<p className="text-xs text-muted-foreground">
{categories[criterion.category]} ·{' '}
{criterion.required
? 'Declarado obrigatório neste exemplo'
: 'Sem obrigatoriedade declarada'}
</p>
<div className="grid gap-4 xl:grid-cols-3">
{suppliers.map((supplier, index) => {
const answer = finding(
criterion.id,
index,
);
return (
<div
key={supplier}
className="space-y-3 rounded-xl border p-4"
>
<h4 className="text-sm font-semibold">
{supplier}
</h4>
<Label>
Resultado
<select
className={
selectClass
}
value={
answer.result
}
onChange={(event) =>
updateFinding(
criterion.id,
index,
{
result: event
.target
.value as Finding['result'],
},
)
}
>
{Object.entries(
results,
).map(
([
value,
label,
]) => (
<option
key={
value
}
value={
value
}
>
{label}
</option>
),
)}
</select>
</Label>
<Label>
Referência da evidência
<Input
maxLength={300}
placeholder="Ex.: documento fictício, página 2"
value={
answer.evidence
}
onChange={(event) =>
updateFinding(
criterion.id,
index,
{
evidence:
event
.target
.value,
},
)
}
/>
</Label>
<Label>
Fundamento
<textarea
className="min-h-24 w-full rounded-md border bg-background p-3 text-sm"
maxLength={2000}
value={
answer.reason
}
onChange={(event) =>
updateFinding(
criterion.id,
index,
{
reason: event
.target
.value,
},
)
}
/>
</Label>
<p className="text-xs text-muted-foreground">
{answer.result ===
'pending'
? 'Análise por preencher.'
: complete(answer)
? 'Preenchimento completo para conferência.'
: 'Falta fundamento de pelo menos 10 caracteres ou referência de evidência.'}
</p>
</div>
);
})}
</div>
</section>
))}
</div>
)}
</Panel>
<Panel
title="3. Resumo para conferência"
description="Não há admissão, exclusão ou aprovação automática. A decisão formal e o seu circuito dependem da regra aprovada pela empresa."
>
<div className="grid gap-4 sm:grid-cols-3">
{suppliers.map((supplier, index) => {
const completed = criteria.filter((criterion) =>
complete(finding(criterion.id, index)),
).length;
const failures = criteria.filter(
(criterion) =>
criterion.required &&
finding(criterion.id, index).result ===
'not_met',
).length;
return (
<div
key={supplier}
className="rounded-xl border p-4"
>
<h3 className="font-semibold">
{supplier}
</h3>
<p className="mt-3 text-sm">
{completed}/{criteria.length} análises
com preenchimento completo
</p>
<p className="mt-2 text-sm text-muted-foreground">
{failures
? `${failures} requisitos declarados obrigatórios não cumpridos: requerem apreciação.`
: 'Decisão formal por registar.'}
</p>
</div>
);
})}
</div>
</Panel>
{history.length > 0 && (
<Panel
title="Versões anteriores da simulação"
description="Conservadas apenas enquanto esta página estiver aberta. Uma nova versão começa sem respostas; as anteriores continuam abaixo."
>
{history.map((entry) => (
<details
key={entry.number}
className="border-b py-3"
>
<summary className="cursor-pointer text-sm font-medium">
Versão {entry.number} ·{' '}
{entry.criteria.length} requisitos
</summary>
<ul className="mt-3 space-y-3">
{entry.criteria.map((criterion) => (
<li
key={criterion.id}
className="text-sm"
>
<strong className="font-medium">
{criterion.label}
</strong>
<ul className="mt-2 space-y-1 text-muted-foreground">
{suppliers.map(
(supplier, index) => {
const answer =
entry.answers[
key(
criterion.id,
index,
)
] ?? emptyFinding;
return (
<li key={supplier}>
{supplier}:{' '}
{
results[
answer
.result
]
}{' '}
·{' '}
{answer.evidence ||
'Sem referência'}{' '}
·{' '}
{answer.reason ||
'Sem fundamento'}
</li>
);
},
)}
</ul>
</li>
))}
</ul>
</details>
))}
</Panel>
)}
</div>
</>
);
}