Files
compras/resources/js/components/supplier-evaluation-fields.tsx
nala 48347e2d3a
tests / ci (push) Canceled after 0s
commit5
2026-10-30 10:55:18 +02:00

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>
);
}