Files
compras/resources/js/components/people-clarifications.tsx
T
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

428 lines
16 KiB
TypeScript

import { useForm } from '@inertiajs/react';
import { FormErrors, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Person = { id: number; name: string };
type Source = {
review_id: number;
criterion_index: number;
application_id: number;
question: string;
};
type Clarification = {
id: number;
application_id: number;
version: number;
status: string;
due_on: string;
outcome: string | null;
question?: string;
responsible_id?: number;
canRespond: boolean;
canReview: boolean;
canReassign: boolean;
responses?: {
answer: string;
reference?: string | null;
evidence_type: string;
by: number;
at: string;
}[];
decisions?: { outcome: string; reason: string; by: number; at: string }[];
};
export type ClarificationData = {
preservedDecision?: boolean;
ready: boolean;
sources: Source[];
items: Clarification[];
users: Person[];
};
const outcomes: Record<string, string> = {
accepted: 'Resposta acolhida',
insufficient: 'Informação permanece por comprovar',
not_required: 'Seguimento dispensado com fundamento',
returned: 'Devolvido para esclarecimentos',
};
const evidence: Record<string, string> = {
reported: 'Relatada',
observed: 'Observada',
verified: 'Comprovada por fonte indicada',
};
function Assignment({
processId,
users,
source,
item,
}: {
processId: number;
users: Person[];
source?: Source;
item?: Clarification;
}) {
const form = useForm({
operation_key: crypto.randomUUID(),
clarification_id: item?.id,
version: item?.version,
review_id: source?.review_id,
criterion_index: source?.criterion_index,
responsible_id: item?.responsible_id ? String(item.responsible_id) : '',
due_on: item?.due_on ?? '',
reason: source
? 'Seguimento do esclarecimento registado na avaliação.'
: '',
});
return (
<form
id={
source
? `clarification-source-${source.review_id}-${source.criterion_index}`
: undefined
}
className="scroll-mt-24 space-y-3 rounded border p-4"
onSubmit={(event) => {
event.preventDefault();
form.post(
`/pessoas/${processId}/esclarecimentos/${source ? 'create' : 'reassign'}`,
{ preserveState: 'errors', preserveScroll: true },
);
}}
>
<FormErrors errors={form.errors} />
{source && (
<>
<strong>
Candidatura #{source.application_id} · avaliação #
{source.review_id} · critério{' '}
{source.criterion_index + 1}
</strong>
<p className="whitespace-pre-wrap">{source.question}</p>
</>
)}
<label className="block">
Responsável pela resposta
<select
className={controlClass}
required
value={form.data.responsible_id}
onChange={(e) =>
form.setData('responsible_id', e.target.value)
}
>
<option value="">Seleccione</option>
{users.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</select>
</label>
<label className="block">
Prazo
<input
className={controlClass}
type="date"
required
value={form.data.due_on}
onChange={(e) => form.setData('due_on', e.target.value)}
/>
</label>
<label className="block">
Motivo
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<Button disabled={form.processing}>
{source ? 'Atribuir seguimento' : 'Guardar responsável e prazo'}
</Button>
</form>
);
}
function Response({
processId,
item,
}: {
processId: number;
item: Clarification;
}) {
const form = useForm({
clarification_id: item.id,
version: item.version,
answer: '',
evidence_type: '',
reference: '',
reason: '',
});
return (
<form
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
form.transform((values) => ({
...values,
reason: values.answer,
}));
form.post(`/pessoas/${processId}/esclarecimentos/respond`, {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<label className="block">
Resposta e fundamento
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.answer}
onChange={(e) => form.setData('answer', e.target.value)}
/>
</label>
<label className="block">
Tipo de evidência
<select
className={controlClass}
required
value={form.data.evidence_type}
onChange={(e) =>
form.setData('evidence_type', e.target.value)
}
>
<option value="">Seleccione</option>
{Object.entries(evidence).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
Fonte ou referência documental
<input
className={controlClass}
required={form.data.evidence_type === 'verified'}
maxLength={2000}
value={form.data.reference}
onChange={(e) => form.setData('reference', e.target.value)}
/>
</label>
<Button disabled={form.processing}>Registar resposta</Button>
</form>
);
}
function Review({
processId,
item,
}: {
processId: number;
item: Clarification;
}) {
const form = useForm({
clarification_id: item.id,
version: item.version,
outcome: item.status === 'open' ? 'not_required' : 'accepted',
reason: '',
no_conflict: false,
});
return (
<form
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
form.post(`/pessoas/${processId}/esclarecimentos/review`, {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<label className="block">
Conclusão
<select
className={controlClass}
value={form.data.outcome}
onChange={(e) => form.setData('outcome', e.target.value)}
>
{Object.entries(outcomes)
.filter(
([key]) =>
item.status === 'responded' ||
key === 'not_required',
)
.map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
Fundamento da conferência
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<label className="flex gap-3">
<input
type="checkbox"
required
checked={form.data.no_conflict}
onChange={(e) =>
form.setData('no_conflict', e.target.checked)
}
/>
Declaro não ter conflito de interesses nesta conferência.
</label>
<Button disabled={form.processing}>Registar conferência</Button>
</form>
);
}
export function PeopleClarifications({
processId,
data,
}: {
processId: number;
data?: ClarificationData;
}) {
if (!data?.ready) return null;
return (
<div id="people-clarifications" className="scroll-mt-24">
<Panel
title="Esclarecimentos e seguimento"
description="A questão da avaliação é preservada. Respostas, conferências e prazos são registos próprios; as notas e os requisitos não são alterados automaticamente."
>
{data.preservedDecision && (
<p className="mb-4 text-sm">
A selecção aprovada conserva a base registada na
decisão. Este painel apresenta o seguimento actual.
</p>
)}
{data.items.length === 0 && data.sources.length === 0 && (
<p>
Sem esclarecimentos disponíveis para a sua intervenção
neste processo.
</p>
)}
<div className="space-y-4">
{data.items.map((item) => (
<section
id={`clarification-${item.id}`}
key={`${item.id}-${item.version}`}
className="scroll-mt-24 rounded-lg border p-4"
>
<h3 className="font-semibold">
Esclarecimento #{item.id} · candidatura #
{item.application_id}
</h3>
<p className="text-sm">
{item.status === 'open'
? 'Resposta pendente'
: item.status === 'responded'
? 'Resposta por conferir'
: 'Concluído'}{' '}
· prazo {item.due_on}
{item.outcome
? ` · Última conferência: ${outcomes[item.outcome]}`
: ''}
</p>
{item.question ? (
<p className="mt-3 whitespace-pre-wrap">
{item.question}
</p>
) : (
<p className="mt-3 text-sm text-muted-foreground">
Conteúdo reservado às pessoas autorizadas.
</p>
)}
{item.responses?.map((response, index) => (
<div
key={index}
className="mt-3 rounded border p-3 text-sm"
>
<strong>
Resposta {index + 1} · pessoa #
{response.by} · {response.at}
</strong>
<p className="whitespace-pre-wrap">
{response.answer}
</p>
<p>
{evidence[response.evidence_type]}
{response.reference
? ` · Fonte: ${response.reference}`
: ''}
</p>
</div>
))}
{item.decisions?.map((decision, index) => (
<p
key={index}
className="mt-3 text-sm whitespace-pre-wrap"
>
{outcomes[decision.outcome]} · pessoa #
{decision.by} · {decision.at} ·{' '}
{decision.reason}
</p>
))}
<div className="print:hidden">
{item.canRespond && (
<Response
processId={processId}
item={item}
/>
)}
{item.canReview && (
<Review processId={processId} item={item} />
)}
{item.canReassign && (
<details className="mt-4">
<summary className="cursor-pointer">
Alterar responsável ou prazo
</summary>
<Assignment
processId={processId}
users={data.users}
item={item}
/>
</details>
)}
</div>
</section>
))}
</div>
{data.sources.length > 0 && (
<div className="mt-5 space-y-4 print:hidden">
<h3 className="font-semibold">
Esclarecimentos da avaliação por atribuir
</h3>
{data.sources.map((source) => (
<Assignment
key={`${source.review_id}-${source.criterion_index}`}
processId={processId}
users={data.users}
source={source}
/>
))}
</div>
)}
</Panel>
</div>
);
}