333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { Button } from '@/components/ui/button';
|
|
import { controlClass } from '@/components/procurement';
|
|
|
|
export type CriterionResult =
|
|
| 'pending'
|
|
| 'observed'
|
|
| 'insufficient'
|
|
| 'not_applicable';
|
|
export type EvaluationPayload = {
|
|
title: string;
|
|
nature: string;
|
|
scope: string;
|
|
period_start: string | null;
|
|
period_end: string | null;
|
|
order_ids: number[];
|
|
criteria: {
|
|
label: string;
|
|
result: CriterionResult;
|
|
reason: string;
|
|
evidence: string;
|
|
}[];
|
|
conclusion: string;
|
|
critical_occurrences: string;
|
|
critical_evidence: string;
|
|
};
|
|
export type EvaluationOrder = {
|
|
id: number;
|
|
number: string;
|
|
supplier_id: number;
|
|
};
|
|
export const evaluationStates: Record<string, string> = {
|
|
draft: 'Rascunho',
|
|
review: 'Em revisão',
|
|
returned: 'Devolvida para correcção',
|
|
approved: 'Aprovada',
|
|
};
|
|
export const criterionStates: Record<CriterionResult, string> = {
|
|
pending: 'Por analisar',
|
|
observed: 'Observação fundamentada',
|
|
insufficient: 'Evidência insuficiente',
|
|
not_applicable: 'Não aplicável',
|
|
};
|
|
export const emptyEvaluation = (): EvaluationPayload => ({
|
|
title: '',
|
|
nature: '',
|
|
scope: '',
|
|
period_start: '',
|
|
period_end: '',
|
|
order_ids: [],
|
|
criteria: [],
|
|
conclusion: '',
|
|
critical_occurrences: '',
|
|
critical_evidence: '',
|
|
});
|
|
|
|
export default function SupplierEvaluationFields({
|
|
payload,
|
|
orders,
|
|
disabled,
|
|
onChange,
|
|
}: {
|
|
payload: EvaluationPayload;
|
|
orders: EvaluationOrder[];
|
|
disabled: boolean;
|
|
onChange: (value: EvaluationPayload) => void;
|
|
}) {
|
|
const set = <K extends keyof EvaluationPayload>(
|
|
key: K,
|
|
value: EvaluationPayload[K],
|
|
) => onChange({ ...payload, [key]: value });
|
|
const criterion = (
|
|
index: number,
|
|
patch: Partial<EvaluationPayload['criteria'][number]>,
|
|
) =>
|
|
set(
|
|
'criteria',
|
|
payload.criteria.map((row, i) =>
|
|
i === index ? { ...row, ...patch } : row,
|
|
),
|
|
);
|
|
return (
|
|
<fieldset disabled={disabled} className="space-y-5">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label className="grid gap-1 text-sm">
|
|
Título
|
|
<input
|
|
className={controlClass}
|
|
value={payload.title}
|
|
maxLength={200}
|
|
onChange={(event) => set('title', event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Componente avaliada
|
|
<select
|
|
className={controlClass}
|
|
value={payload.nature}
|
|
onChange={(event) => set('nature', event.target.value)}
|
|
>
|
|
<option value="">Escolher</option>
|
|
<option value="goods">Bens</option>
|
|
<option value="services">Serviços</option>
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Período — início
|
|
<input
|
|
type="date"
|
|
className={controlClass}
|
|
value={payload.period_start ?? ''}
|
|
onChange={(event) =>
|
|
set('period_start', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Período — fim
|
|
<input
|
|
type="date"
|
|
className={controlClass}
|
|
value={payload.period_end ?? ''}
|
|
onChange={(event) =>
|
|
set('period_end', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="grid gap-1 text-sm">
|
|
Âmbito, locais, entregas e exclusões justificadas
|
|
<textarea
|
|
className={controlClass + ' h-auto min-h-24 py-2'}
|
|
value={payload.scope}
|
|
maxLength={10000}
|
|
onChange={(event) => set('scope', event.target.value)}
|
|
/>
|
|
</label>
|
|
<details className="rounded-lg border p-3">
|
|
<summary className="cursor-pointer text-sm font-medium">
|
|
Ordens abrangidas ({payload.order_ids.length})
|
|
</summary>
|
|
<p className="my-2 text-xs text-muted-foreground">
|
|
Seleccione apenas as ordens efectivamente abrangidas. Outras
|
|
referências contratuais podem ser descritas no âmbito.
|
|
</p>
|
|
<div className="max-h-56 space-y-2 overflow-y-auto">
|
|
{orders.map((order) => (
|
|
<label
|
|
key={order.id}
|
|
className="flex items-center gap-2 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={payload.order_ids.includes(order.id)}
|
|
onChange={(event) =>
|
|
set(
|
|
'order_ids',
|
|
event.target.checked
|
|
? [...payload.order_ids, order.id]
|
|
: payload.order_ids.filter(
|
|
(id) => id !== order.id,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
{order.number}
|
|
</label>
|
|
))}
|
|
{orders.length === 0 && (
|
|
<p className="text-sm">
|
|
Sem ordens autorizadas disponíveis para este
|
|
fornecedor.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</details>
|
|
<section className="space-y-4" aria-label="Critérios e evidências">
|
|
<h3 className="font-medium">Critérios e evidências</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Registe o cumprimento da entidade fornecedora, com os
|
|
requisitos acordados e referências recuperáveis. Falta de
|
|
evidência permanece explícita; não recebe uma nota mínima.
|
|
Este ciclo é qualitativo, sem pesos ou classificações
|
|
automáticas.
|
|
</p>
|
|
{payload.criteria.map((row, index) => (
|
|
<fieldset
|
|
key={index}
|
|
className="space-y-3 rounded-lg border p-4"
|
|
>
|
|
<legend className="px-2 text-sm font-medium">
|
|
Critério {index + 1}
|
|
</legend>
|
|
<label className="grid gap-1 text-sm">
|
|
Critério e condição verificável
|
|
<input
|
|
className={controlClass}
|
|
value={row.label}
|
|
maxLength={300}
|
|
onChange={(event) =>
|
|
criterion(index, {
|
|
label: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Situação
|
|
<select
|
|
className={controlClass}
|
|
value={row.result}
|
|
onChange={(event) =>
|
|
criterion(index, {
|
|
result: event.target
|
|
.value as CriterionResult,
|
|
})
|
|
}
|
|
>
|
|
{Object.entries(criterionStates).map(
|
|
([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Justificação, limites e atribuição das ocorrências
|
|
<textarea
|
|
className={
|
|
controlClass + ' h-auto min-h-20 py-2'
|
|
}
|
|
value={row.reason}
|
|
maxLength={4000}
|
|
onChange={(event) =>
|
|
criterion(index, {
|
|
reason: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Referência de evidência (recepção, inspecção,
|
|
documento, comunicação)
|
|
<textarea
|
|
className={
|
|
controlClass + ' h-auto min-h-20 py-2'
|
|
}
|
|
value={row.evidence}
|
|
maxLength={4000}
|
|
onChange={(event) =>
|
|
criterion(index, {
|
|
evidence: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
set(
|
|
'criteria',
|
|
payload.criteria.filter(
|
|
(_, i) => i !== index,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
Retirar este critério do rascunho
|
|
</Button>
|
|
</fieldset>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={payload.criteria.length >= 30}
|
|
onClick={() =>
|
|
set('criteria', [
|
|
...payload.criteria,
|
|
{
|
|
label: '',
|
|
result: 'pending',
|
|
reason: '',
|
|
evidence: '',
|
|
},
|
|
])
|
|
}
|
|
>
|
|
Acrescentar critério
|
|
</Button>
|
|
</section>
|
|
<label className="grid gap-1 text-sm">
|
|
Conclusão e encaminhamento
|
|
<textarea
|
|
className={controlClass + ' h-auto min-h-24 py-2'}
|
|
value={payload.conclusion}
|
|
maxLength={10000}
|
|
onChange={(event) => set('conclusion', event.target.value)}
|
|
/>
|
|
</label>
|
|
<details className="space-y-3 rounded-lg border p-3">
|
|
<summary className="cursor-pointer text-sm font-medium">
|
|
Ocorrências críticas
|
|
</summary>
|
|
<label className="grid gap-1 text-sm">
|
|
Ocorrências e impacto documentado
|
|
<textarea
|
|
className={controlClass + ' h-auto min-h-20 py-2'}
|
|
value={payload.critical_occurrences}
|
|
maxLength={4000}
|
|
onChange={(event) =>
|
|
set('critical_occurrences', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Referência das evidências
|
|
<textarea
|
|
className={controlClass + ' h-auto min-h-20 py-2'}
|
|
value={payload.critical_evidence}
|
|
maxLength={4000}
|
|
onChange={(event) =>
|
|
set('critical_evidence', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
</details>
|
|
</fieldset>
|
|
);
|
|
}
|