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