504 lines
27 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|