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

736 lines
30 KiB
TypeScript

import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { date, roleLabels } from '@/types/procurement';
type Criterion = {
id: number;
label: string;
category: string;
required: boolean;
};
type Answer = {
criterion_id: number;
result: 'pending' | 'met' | 'not_met' | 'not_applicable';
evidence: string;
reason: string;
};
type Proposal = {
id: number;
reference: string;
supplier: { name: string } | null;
};
type Set = {
id: number;
revision: number;
reason: string;
created_at: string;
criteria: Criterion[];
snapshot: { author: { name: string }; proposals: Proposal[] };
};
type Assessment = {
id: number;
quotation_id: number;
analyst_id: number;
revision: number;
answers: Answer[];
reason: string;
created_at: string;
snapshot: { analyst: { name: string; purchasing_role: string } };
};
type Props = {
requisition: { id: number; number: string; justification: string };
sets: Pick<Set, 'id' | 'revision' | 'reason' | 'created_at'>[];
selected: Set | null;
latestId: number;
latestCriteria: Criterion[];
basisHash: string;
isCurrent: boolean;
proposals: Proposal[];
records: Assessment[];
history: {
data: Assessment[];
prev_page_url: string | null;
next_page_url: string | null;
};
actorId: number;
canDefine: boolean;
canAssess: boolean;
closedForChanges: boolean;
};
const categories: Record<string, string> = {
legal: 'Jurídica',
financial: 'Económico-financeira',
fiscal: 'Fiscal',
technical: 'Técnica',
administrative: 'Administrativa',
};
const results: Record<Answer['result'], string> = {
pending: 'Por analisar',
met: 'Cumprido',
not_met: 'Não cumprido',
not_applicable: 'Não aplicável',
};
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
function DefineForm({
requisition,
latestId,
latestCriteria,
basisHash,
proposals,
}: Pick<
Props,
'requisition' | 'latestId' | 'latestCriteria' | 'basisHash' | 'proposals'
>) {
const form = useForm({
previous_id: latestId,
basis_hash: basisHash,
reason: '',
criteria: latestCriteria.length
? latestCriteria.map(({ label, category, required }) => ({
label,
category,
required,
}))
: [{ label: '', category: 'administrative', required: false }],
});
const change = (
index: number,
values: Partial<Pick<Criterion, 'label' | 'category' | 'required'>>,
) =>
form.setData(
'criteria',
form.data.criteria.map((criterion, position) =>
position === index ? { ...criterion, ...values } : criterion,
),
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(`/requisicoes/${requisition.id}/qualificacao`, {
preserveScroll: true,
onSuccess: () => form.setData('reason', ''),
});
}}
className="space-y-4"
>
{(form.data.previous_id !== latestId ||
form.data.basis_hash !== basisHash) && (
<div className="rounded-lg border border-amber-300 p-4 text-sm">
<p>
Os requisitos ou as propostas mudaram. O texto
introduzido foi mantido; confira a base actual antes de
criar uma nova versão.
</p>
<Button
type="button"
variant="outline"
className="mt-3"
onClick={() => {
form.setData('previous_id', latestId);
form.setData('basis_hash', basisHash);
}}
>
Usar a base actual para conferência
</Button>
</div>
)}
<p className="text-sm text-muted-foreground">
Esta versão ficará ligada às {proposals.length} propostas
actualmente registadas:{' '}
{proposals.map((proposal) => proposal.reference).join(', ')}.
</p>
{form.data.criteria.map((criterion, index) => (
<div
key={index}
className="grid gap-3 rounded-xl border p-4 sm:grid-cols-2"
>
<Label>
Requisito
<Input
required
minLength={3}
maxLength={500}
value={criterion.label}
onChange={(event) =>
change(index, { label: event.target.value })
}
/>
</Label>
<Label>
Categoria
<select
className={selectClass}
value={criterion.category}
onChange={(event) =>
change(index, { 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">
<input
type="checkbox"
checked={criterion.required}
onChange={(event) =>
change(index, {
required: event.target.checked,
})
}
/>
Obrigatório segundo a regra deste processo
</Label>
{form.data.criteria.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() =>
form.setData(
'criteria',
form.data.criteria.filter(
(_, position) => position !== index,
),
)
}
>
Retirar requisito
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
disabled={form.data.criteria.length >= 30}
onClick={() =>
form.setData('criteria', [
...form.data.criteria,
{
label: '',
category: 'administrative',
required: false,
},
])
}
>
Adicionar requisito
</Button>
<Label>
Fundamento dos requisitos ou da alteração
<textarea
required
minLength={10}
maxLength={4000}
className="min-h-24 rounded-md border bg-background p-3 text-sm"
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing}>
Guardar nova versão e iniciar análise
</Button>
</form>
);
}
function AssessForm({
selected,
records,
actorId,
}: {
selected: Set;
records: Assessment[];
actorId: number;
}) {
const blank = (): Answer[] =>
selected.criteria.map((criterion) => ({
criterion_id: criterion.id,
result: 'pending',
evidence: '',
reason: '',
}));
const form = useForm({
previous_id: 0,
quotation_id: '',
reason: '',
answers: blank(),
});
const choose = (id: string) => {
const current = records.find(
(record) =>
record.quotation_id === Number(id) &&
record.analyst_id === actorId,
);
form.setData({
previous_id: current?.id ?? 0,
quotation_id: id,
reason: '',
answers: current
? current.answers.map((answer) => ({ ...answer }))
: blank(),
});
};
const latestMine = records.find(
(record) =>
record.quotation_id === Number(form.data.quotation_id) &&
record.analyst_id === actorId,
);
const change = (id: number, values: Partial<Answer>) =>
form.setData(
'answers',
form.data.answers.map((answer) =>
answer.criterion_id === id ? { ...answer, ...values } : answer,
),
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.post(`/qualificacoes/${selected.id}/analises`, {
preserveScroll: true,
onSuccess: () => form.reset(),
});
}}
className="space-y-4"
>
{form.data.quotation_id &&
(latestMine?.id ?? 0) !== form.data.previous_id && (
<div className="rounded-lg border border-amber-300 p-4 text-sm">
<p>
A sua análise já tem outra versão. Confira o último
registo abaixo antes de guardar a correcção. O texto
introduzido foi mantido.
</p>
<Button
type="button"
variant="outline"
className="mt-3"
onClick={() =>
form.setData('previous_id', latestMine?.id ?? 0)
}
>
Usar a última versão como antecedente
</Button>
</div>
)}
<Label>
Proposta
<select
required
className={selectClass}
value={form.data.quotation_id}
onChange={(event) => choose(event.target.value)}
>
<option value="">Seleccionar proposta</option>
{selected.snapshot.proposals.map((proposal) => (
<option key={proposal.id} value={proposal.id}>
{proposal.supplier?.name ??
'Fornecedor indisponível'}{' '}
· {proposal.reference}
</option>
))}
</select>
</Label>
{form.data.quotation_id &&
selected.criteria.map((criterion) => {
const answer = form.data.answers.find(
(entry) => entry.criterion_id === criterion.id,
)!;
return (
<fieldset
key={criterion.id}
className="space-y-3 rounded-xl border p-4"
>
<legend className="px-2 text-sm font-semibold">
{criterion.label}
</legend>
<p className="text-xs text-muted-foreground">
{categories[criterion.category]} ·{' '}
{criterion.required
? 'Declarado obrigatório'
: 'Sem obrigatoriedade declarada'}
</p>
<Label>
Resultado
<select
className={selectClass}
value={answer.result}
onChange={(event) =>
change(criterion.id, {
result: event.target
.value as Answer['result'],
})
}
>
{Object.entries(results).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</Label>
<Label>
Referência de evidência
<Input
required={
answer.result === 'met' ||
answer.result === 'not_met'
}
maxLength={1000}
value={answer.evidence}
onChange={(event) =>
change(criterion.id, {
evidence: event.target.value,
})
}
/>
</Label>
<Label>
Fundamento ou justificação
<textarea
required={answer.result !== 'pending'}
minLength={
answer.result !== 'pending'
? 10
: undefined
}
maxLength={4000}
className="min-h-20 rounded-md border bg-background p-3 text-sm"
value={answer.reason}
onChange={(event) =>
change(criterion.id, {
reason: event.target.value,
})
}
/>
</Label>
</fieldset>
);
})}
<Label>
Fundamento do registo ou correcção
<textarea
required
minLength={10}
maxLength={4000}
className="min-h-20 rounded-md border bg-background p-3 text-sm"
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing || !form.data.quotation_id}>
Guardar a minha análise por versão
</Button>
<p className="text-xs text-muted-foreground">
Outro analista conserva o seu próprio registo. Uma correcção
cria uma nova versão da sua análise.
</p>
</form>
);
}
function AssessmentDetails({
record,
selected,
}: {
record: Assessment;
selected: Set;
}) {
const proposal = selected.snapshot.proposals.find(
(entry) => entry.id === record.quotation_id,
);
return (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer text-sm font-medium">
{proposal?.supplier?.name ?? 'Proposta'} · {proposal?.reference}{' '}
· {record.snapshot.analyst.name} · Análise v{record.revision}
</summary>
<p className="mt-3 text-xs text-muted-foreground">
{roleLabels[record.snapshot.analyst.purchasing_role]} ·{' '}
{date(record.created_at)}
</p>
<p className="mt-3 text-sm whitespace-pre-wrap">{record.reason}</p>
<p className="mt-3 text-xs text-muted-foreground">
{
record.answers.filter(
(answer) => answer.result === 'pending',
).length
}{' '}
por analisar ·{' '}
{
record.answers.filter(
(answer) => answer.result === 'not_met',
).length
}{' '}
não cumpridos. Resumo apenas deste analista, sem decisão formal.
</p>
<ul className="mt-4 space-y-4">
{record.answers.map((answer) => (
<li
key={answer.criterion_id}
className="border-t pt-3 text-sm"
>
<p className="font-medium">
{
selected.criteria.find(
(criterion) =>
criterion.id === answer.criterion_id,
)?.label
}{' '}
· {results[answer.result]}
</p>
<p className="mt-2 whitespace-pre-wrap">
{answer.reason ||
'Sem fundamento registado (por analisar).'}
</p>
<p className="mt-2 text-xs whitespace-pre-wrap text-muted-foreground">
Referência: {answer.evidence || 'Não indicada'}
</p>
</li>
))}
</ul>
</details>
);
}
export default function QualificationPage(props: Props) {
const {
requisition,
sets,
selected,
latestId,
isCurrent,
records,
history,
actorId,
canDefine,
canAssess,
closedForChanges,
} = props;
const { errors } = usePage().props;
const base = `/requisicoes/${requisition.id}/qualificacao`;
return (
<>
<Head title={`Qualificação documental · ${requisition.number}`} />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow={requisition.number}
title="Qualificação documental"
description={requisition.justification}
action={
<Button asChild variant="outline">
<Link
href={`/requisicoes/${requisition.id}/processo`}
>
Ver processo
</Link>
</Button>
}
/>
<FormErrors errors={errors} />
{closedForChanges && (
<p className="rounded-lg border p-4 text-sm text-muted-foreground">
O estado do pedido ou a existência de adjudicação
impedem novos registos. Os requisitos e análises
anteriores continuam disponíveis para consulta.
</p>
)}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Os requisitos e resultados documentais apoiam a conferência;
não constituem uma decisão formal nem certificam evidências.
Num processo que adoptou o regime de admissão, estas versões
integram a base da decisão e alterações exigem reapreciação.
Não aplicável exige fundamento e não dispensa
automaticamente um requisito obrigatório. As pontuações
mantêm o cálculo ponderado existente.
</div>
<Link
className="text-sm underline"
href={`/requisicoes/${requisition.id}/admissao`}
>
Consultar regime, decisões e conferências de admissão
</Link>
<Link
className="text-sm underline"
href={`/requisicoes/${requisition.id}/cotacoes`}
>
Consultar propostas e comparação actual
</Link>
{canDefine && (
<Panel
title={
latestId
? 'Preparar nova versão dos requisitos'
: 'Definir requisitos do processo'
}
description="A versão será preservada com a base actual das propostas. Não existem requisitos ou valores mínimos predefinidos."
>
<DefineForm {...props} />
</Panel>
)}
{sets.length > 0 && (
<Label className="max-w-md">
Versão dos requisitos
<select
className={selectClass}
value={selected?.id ?? ''}
onChange={(event) =>
router.get(
base,
{ version: event.target.value },
{ preserveScroll: true },
)
}
>
{sets.map((set) => (
<option key={set.id} value={set.id}>
Requisitos v{set.revision}
{set.id === latestId
? ' · Mais recente'
: ' · Histórica'}
</option>
))}
</select>
</Label>
)}
{selected ? (
<>
<Panel
title={`Requisitos v${selected.revision} · ${isCurrent ? 'Base actual' : 'Histórico ou base desactualizada'}`}
description={`Registados por ${selected.snapshot.author.name} · ${date(selected.created_at)}`}
>
<p className="text-sm whitespace-pre-wrap">
{selected.reason}
</p>
<ul className="mt-4 space-y-3">
{selected.criteria.map((criterion) => (
<li
key={criterion.id}
className="rounded-lg border p-3 text-sm"
>
<p className="font-medium">
{criterion.label}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{categories[criterion.category]} ·{' '}
{criterion.required
? 'Declarado obrigatório segundo o processo'
: 'Sem obrigatoriedade declarada'}
</p>
</li>
))}
</ul>
{!isCurrent && (
<p className="mt-4 text-sm text-amber-800 dark:text-amber-300">
Esta base não aceita novas análises. As
versões anteriores foram preservadas e não
certificam os requisitos ou propostas
actuais.
</p>
)}
</Panel>
{canAssess && (
<Panel
title="A minha análise documental"
description="O analista deve ser distinto do solicitante. Resultados preenchidos exigem fundamento e, quando aplicável, referência de evidência."
>
<AssessForm
key={selected.id}
selected={selected}
records={records}
actorId={actorId}
/>
</Panel>
)}
<Panel
title="Último registo de cada analista por proposta"
description="Os registos não são consolidados por maioria ou quórum e não constituem decisão formal de admissão."
>
{records.length ? (
<div className="space-y-3">
{records.map((record) => (
<AssessmentDetails
key={record.id}
record={record}
selected={selected}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Sem análises registadas nesta versão.
</p>
)}
</Panel>
<Panel title="Histórico das análises desta versão">
<div className="space-y-3">
{history.data.map((record) => (
<AssessmentDetails
key={record.id}
record={record}
selected={selected}
/>
))}
</div>
<nav
aria-label="Histórico das análises"
className="mt-4 flex justify-between gap-3"
>
{history.prev_page_url ? (
<Button asChild variant="outline">
<Link href={history.prev_page_url}>
Mais recentes
</Link>
</Button>
) : (
<span />
)}
{history.next_page_url && (
<Button asChild variant="outline">
<Link href={history.next_page_url}>
Anteriores
</Link>
</Button>
)}
</nav>
</Panel>
</>
) : (
<Panel title="Sem requisitos documentais registados">
<p className="text-sm text-muted-foreground">
Compras pode definir a análise documental após a
aprovação do pedido e o registo de propostas, antes
da adjudicação.
</p>
</Panel>
)}
</div>
</>
);
}
QualificationPage.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Qualificação documental', href: '#' },
],
};