428 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|