Prepara proposta interactiva de qualificacao por processo
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user