Prepara proposta interactiva de qualificacao por processo

This commit is contained in:
2026-10-28 19:10:40 +02:00
parent 07e775c492
commit b7a52e9e00
3 changed files with 595 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
# Proposta de âmbito para qualificação das propostas
Estado: demonstração para validação do produto. Não há implementação de
qualificação na base de negócio nem regras empresariais activadas.
Data: 28 de Outubro de 2026.
## Demonstração disponível
`/proposta/qualificacao` apresenta requisitos editáveis por categoria,
três concorrentes fictícios, resultados por requisito, referências de
evidência, fundamentos e um resumo para conferência. A simulação fixa os
requisitos ao iniciar a análise e conserva a versão anterior em memória
quando se prepara outra. Recarregar a página elimina as alterações locais.
Não há gravação, importação dos exemplos, carregamento de anexos ou alteração
de propostas, fornecedores, pontuações, adjudicações ou orçamento.
Os dois requisitos iniciais são exemplos genéricos. Não se adoptaram
certificados, valores de facturação, capital, crédito ou prazos dos modelos
históricos como requisitos universais para empresas aderentes.
## Primeiro incremento proposto para a aplicação
1. Compras define os requisitos documentais do processo, com categoria,
descrição e indicação da obrigatoriedade segundo a regra desse processo.
Categorias: jurídica, económico-financeira, fiscal, técnica e administrativa.
A categoria não cria um limite financeiro nem uma exigência documental.
Para este primeiro âmbito, propõe-se definição por Compras e análise por
Compras, Finanças ou chefia do departamento autorizada, com analista distinto
do solicitante e sujeito aos conflitos existentes. Administração consulta.
2. Ao iniciar a análise, guardar a versão dos requisitos e a base das propostas
a que se aplicam. Correcções posteriores criam nova versão, mantendo
requisitos, resultados e fundamentos anteriores. Resultados antigos não
são apresentados como análise válida de uma base nova.
3. O avaliador identificado regista, por proposta e requisito, «por analisar»,
«cumprido», «não cumprido» ou «não aplicável». Um resultado preenchido
exige fundamento; «cumprido» e «não cumprido» exigem também referência
de evidência. «Não aplicável» exige justificação e não constitui, por si,
dispensa de um requisito declarado obrigatório.
Registos de analistas diferentes não se substituem; correcções conservam
o autor e a versão anterior. Não há quórum ou consenso presumido.
4. O primeiro âmbito é documental: apresentar pendências, não conformidades
e resultados para conferência, sem admitir/excluir concorrentes ou decidir
a adjudicação automaticamente. Não substituir a comparação ponderada
actual por menor preço, preço corrigido ou preço avaliado.
5. Manter os perfis de consulta existentes para propostas, o isolamento por
empresa/departamento, a segregação do solicitante, os conflitos de interesses,
a auditoria e a revalidação dos operadores nas transacções. A implementação
deverá recusar bases ou versões desactualizadas e testar concorrência e
rollback. Nenhum resultado será uma certificação automática da evidência.
Esta validação é do âmbito de produto, não da política de uma empresa real.
O circuito formal de admissão/exclusão, quem fixa/aprova requisitos, quem
analisa e quem confere, o número de intervenientes e os efeitos de cada
resultado deverão ser definidos explicitamente antes de activar esses efeitos.
Não se propõe presumir um júri ou quórum igual para todas as empresas.
## Fora deste primeiro âmbito
- Exclusão automática ou alteração dos critérios de elegibilidade usados
actualmente na adjudicação.
- Novos métodos de classificação, preços corrigidos/avaliados ou valores
financeiros importados dos exemplos históricos.
- Certificação de documentos, assinaturas verificadas ou contactos externos.
- Publicação de concurso, propostas seladas, prazos legais universais ou
presunção de que os critérios foram divulgados antes de receber propostas.
- Cancelamento de ordens emitidas, estornos, devoluções ou reversões financeiras.
## Validação solicitada
Validar o primeiro incremento documental: **requisitos e resultados por
processo, com evidências, fundamento e versões preservadas, sem exclusão ou
adjudicação automática**. A demonstração permite experimentar o preenchimento
antes dessa decisão. A implementação com dados de negócio fica pendente.
A orientação de continuidade actual pede «definir com a empresa o próximo
âmbito pendente antes de o implementar» em `docs/orientacoes-continuidade.md`.
As alterações locais desse ficheiro e de `docs/compatibilidade.md` e
`docs/desenvolvimento.md` foram preservadas, sem as incluir nesta entrega.
## Verificações da preparação
A rota respondeu HTTP 200 com componente `preview/qualification`, utilizador
nulo e apenas propriedades gerais da consulta pública. Três testes existentes
de consulta pública passaram, com 39 asserções. TypeScript, build, Pint e
lint/formatação dos ficheiros desta proposta passaram. Não se repetiu a bateria
completa de negócio: não houve alterações nesses circuitos ou no esquema.
A verificação global de formatação assinala `docs/compatibilidade.md`, que
contém alterações locais alheias a esta entrega; não foi corrigido nem incluído
no commit. O navegador devolveu `The browser access host is unavailable`.
Não se declara revisão visual nem teste das interacções no navegador concluído.
@@ -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>
</>
);
}
+1
View File
@@ -6,6 +6,7 @@ use Inertia\Inertia;
// Public visual consultation. No business records or authenticated identities.
Route::inertia('proposta/principal', 'preview/principal', ['auth' => ['user' => null]])->name('preview.principal');
Route::inertia('proposta/qualificacao', 'preview/qualification', ['auth' => ['user' => null]])->name('preview.qualification');
Route::get('consulta', function (Request $request) {
$screen = $request->query('ecra', 'overview');