576 lines
25 KiB
TypeScript
576 lines
25 KiB
TypeScript
import { Link, useForm } from '@inertiajs/react';
|
|
import { useRef } from 'react';
|
|
import { Panel, controlClass } from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
type Payload = {
|
|
title: string;
|
|
received_on: string | null;
|
|
source_reference: string;
|
|
statement: string;
|
|
};
|
|
type Decision = {
|
|
outcome: string;
|
|
reason: string;
|
|
follow_up: string;
|
|
response_reference: string;
|
|
};
|
|
type Record = {
|
|
id: number;
|
|
version: number;
|
|
status: string;
|
|
payload: Payload;
|
|
decision: Decision | null;
|
|
created_by: number;
|
|
reviewed_by: number | null;
|
|
reviewed_at: string | null;
|
|
review_reason: string | null;
|
|
canEdit: boolean;
|
|
canReview: boolean;
|
|
};
|
|
type History = {
|
|
id: number;
|
|
user_id: number;
|
|
action: string;
|
|
created_at: string;
|
|
after: Record;
|
|
};
|
|
export type ResponseData = {
|
|
ready: boolean;
|
|
canCreate: boolean;
|
|
records: Record[];
|
|
history: {
|
|
data: History[];
|
|
total: number;
|
|
prev_page_url: string | null;
|
|
next_page_url: string | null;
|
|
} | null;
|
|
};
|
|
const states: { [key: string]: string } = {
|
|
draft: 'Em preparação',
|
|
review: 'Em análise',
|
|
returned: 'Devolvido para completar',
|
|
decided: 'Conclusão registada',
|
|
};
|
|
const outcomes: { [key: string]: string } = {
|
|
accepted: 'Argumentos acolhidos',
|
|
partially_accepted: 'Argumentos parcialmente acolhidos',
|
|
not_accepted: 'Argumentos não acolhidos',
|
|
};
|
|
const actions: { [key: string]: string } = {
|
|
create: 'Registo',
|
|
save: 'Alteração',
|
|
submit: 'Submissão para análise',
|
|
return: 'Devolução',
|
|
decide: 'Conclusão independente',
|
|
};
|
|
const empty: Payload = {
|
|
title: '',
|
|
received_on: '',
|
|
source_reference: '',
|
|
statement: '',
|
|
};
|
|
function Errors({ errors }: { errors: { [key: string]: string } }) {
|
|
return Object.keys(errors).length ? (
|
|
<div role="alert" className="rounded border border-red-300 p-3 text-sm">
|
|
{Object.entries(errors).map(([key, message]) => (
|
|
<p key={key}>{message}</p>
|
|
))}
|
|
</div>
|
|
) : null;
|
|
}
|
|
function Fields({
|
|
payload,
|
|
change,
|
|
}: {
|
|
payload: Payload;
|
|
change: (key: keyof Payload, value: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<label className="space-y-1">
|
|
Assunto
|
|
<input
|
|
required
|
|
minLength={3}
|
|
maxLength={200}
|
|
className={controlClass}
|
|
value={payload.title}
|
|
onChange={(e) => change('title', e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
Recebido em
|
|
<input
|
|
type="date"
|
|
className={controlClass}
|
|
value={payload.received_on ?? ''}
|
|
onChange={(e) => change('received_on', e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="space-y-1 sm:col-span-2">
|
|
Documento ou origem recebida
|
|
<textarea
|
|
maxLength={4000}
|
|
className={controlClass}
|
|
value={payload.source_reference}
|
|
onChange={(e) => change('source_reference', e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="space-y-1 sm:col-span-2">
|
|
Argumentos apresentados pelo fornecedor
|
|
<textarea
|
|
rows={5}
|
|
maxLength={20000}
|
|
className={controlClass}
|
|
value={payload.statement}
|
|
onChange={(e) => change('statement', e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
function ResponseCard({
|
|
record: r,
|
|
base,
|
|
names,
|
|
}: {
|
|
record: Record;
|
|
base: string;
|
|
names: { [key: number]: string };
|
|
}) {
|
|
const edit = useForm({ version: r.version, payload: r.payload });
|
|
const submit = useForm({ version: r.version });
|
|
const review = useForm({
|
|
version: r.version,
|
|
outcome: '',
|
|
reason: '',
|
|
follow_up: '',
|
|
response_reference: '',
|
|
});
|
|
const url = `${base}/${r.id}`;
|
|
const dirty =
|
|
JSON.stringify(edit.data.payload) !== JSON.stringify(r.payload);
|
|
return (
|
|
<article
|
|
id={`contraditorio-${r.id}`}
|
|
className="space-y-4 rounded border p-4"
|
|
>
|
|
<h3 className="font-semibold">
|
|
CTR-{r.id} · {r.payload.title}
|
|
</h3>
|
|
<p className="text-sm">
|
|
{states[r.status]} · Versão {r.version} · Registado por{' '}
|
|
{names[r.created_by] ?? `#${r.created_by}`}
|
|
</p>
|
|
{r.canEdit ? (
|
|
<form
|
|
className="space-y-4 print:hidden"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
edit.post(url + '/save', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
>
|
|
<Errors errors={edit.errors} />
|
|
<Errors errors={submit.errors} />
|
|
<Fields
|
|
payload={edit.data.payload}
|
|
change={(key, value) =>
|
|
edit.setData('payload', {
|
|
...edit.data.payload,
|
|
[key]: value,
|
|
})
|
|
}
|
|
/>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button variant="outline" disabled={edit.processing}>
|
|
Guardar registo
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={
|
|
edit.processing || submit.processing || dirty
|
|
}
|
|
onClick={() =>
|
|
submit.post(url + '/submit', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
>
|
|
Submeter para análise
|
|
</Button>
|
|
</div>
|
|
{dirty && (
|
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
|
Guarde as alterações antes de submeter.
|
|
</p>
|
|
)}
|
|
</form>
|
|
) : (
|
|
<div className="space-y-2 text-sm whitespace-pre-wrap">
|
|
<p>
|
|
Recebido em:{' '}
|
|
{r.payload.received_on || 'Ainda não indicado'}
|
|
</p>
|
|
<p>
|
|
Documento/origem:{' '}
|
|
{r.payload.source_reference || 'Ainda não indicado'}
|
|
</p>
|
|
<p>
|
|
{r.payload.statement ||
|
|
'Argumentos ainda não transcritos.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{r.review_reason && (
|
|
<p className="rounded bg-muted p-3 text-sm whitespace-pre-wrap">
|
|
Última análise por{' '}
|
|
{names[r.reviewed_by ?? 0] ?? `#${r.reviewed_by}`}
|
|
{r.reviewed_at
|
|
? ` em ${new Date(r.reviewed_at).toLocaleString('pt-MZ')}`
|
|
: ''}
|
|
: {r.review_reason}
|
|
</p>
|
|
)}
|
|
{r.decision && (
|
|
<div className="space-y-2 text-sm whitespace-pre-wrap">
|
|
<p className="font-semibold">
|
|
{outcomes[r.decision.outcome]}
|
|
</p>
|
|
{r.decision.follow_up && (
|
|
<p>Seguimento: {r.decision.follow_up}</p>
|
|
)}
|
|
{r.decision.response_reference && (
|
|
<p>
|
|
Referência da resposta:{' '}
|
|
{r.decision.response_reference}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{r.canReview && (
|
|
<div className="space-y-4 print:hidden">
|
|
<Errors errors={review.errors} />
|
|
<label className="block space-y-1">
|
|
Conclusão
|
|
<select
|
|
className={controlClass}
|
|
value={review.data.outcome}
|
|
onChange={(e) =>
|
|
review.setData('outcome', e.target.value)
|
|
}
|
|
>
|
|
<option value="">Escolher conclusão</option>
|
|
{Object.entries(outcomes).map(([key, label]) => (
|
|
<option key={key} value={key}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block space-y-1">
|
|
Análise fundamentada
|
|
<textarea
|
|
minLength={10}
|
|
maxLength={10000}
|
|
rows={4}
|
|
className={controlClass}
|
|
value={review.data.reason}
|
|
onChange={(e) =>
|
|
review.setData('reason', e.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1">
|
|
Seguimento a dar
|
|
<textarea
|
|
maxLength={10000}
|
|
className={controlClass}
|
|
value={review.data.follow_up}
|
|
onChange={(e) =>
|
|
review.setData('follow_up', e.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1">
|
|
Referência da resposta preparada (opcional)
|
|
<input
|
|
maxLength={4000}
|
|
className={controlClass}
|
|
value={review.data.response_reference}
|
|
onChange={(e) =>
|
|
review.setData(
|
|
'response_reference',
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
disabled={
|
|
review.processing ||
|
|
!review.data.outcome ||
|
|
review.data.reason.trim().length < 10
|
|
}
|
|
onClick={() => {
|
|
review.transform((values) => values);
|
|
review.post(url + '/decide', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
>
|
|
Registar conclusão da análise
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
disabled={
|
|
review.processing ||
|
|
review.data.reason.trim().length < 10
|
|
}
|
|
onClick={() => {
|
|
review.transform((values) => ({
|
|
version: values.version,
|
|
reason: values.reason,
|
|
}));
|
|
review.post(url + '/return', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
>
|
|
Devolver para completar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
export default function SupplierResponses({
|
|
data,
|
|
evaluationId,
|
|
names,
|
|
reportOnly,
|
|
generatedAt,
|
|
}: {
|
|
data: ResponseData;
|
|
evaluationId: number;
|
|
names: { [key: number]: string };
|
|
reportOnly: boolean;
|
|
generatedAt: string;
|
|
}) {
|
|
const create = useForm({ payload: empty });
|
|
const operation = useRef<string | null>(null);
|
|
const base = `/fornecedores/avaliacoes/${evaluationId}/contraditorios`;
|
|
return (
|
|
<Panel title="Contraditório do fornecedor">
|
|
<div className="space-y-5">
|
|
<p className="text-sm text-muted-foreground">
|
|
Documentos e argumentos recebidos, registados pela equipa.
|
|
Acompanhamento consultado em{' '}
|
|
{new Date(generatedAt).toLocaleString('pt-MZ')}. A conclusão
|
|
fica registada para comunicação ao fornecedor; a avaliação
|
|
aprovada conserva-se e qualquer rectificação exige uma nova
|
|
revisão.
|
|
</p>
|
|
{!data.ready ? (
|
|
<p>
|
|
A instalação do contraditório ainda não está concluída.
|
|
</p>
|
|
) : (
|
|
<>
|
|
{!data.records.length && (
|
|
<p className="text-sm">
|
|
Sem contraditórios registados nesta revisão.
|
|
</p>
|
|
)}
|
|
{data.records.map((r) => (
|
|
<ResponseCard
|
|
key={`${r.id}-${r.version}`}
|
|
record={r}
|
|
base={base}
|
|
names={names}
|
|
/>
|
|
))}
|
|
{data.canCreate && (
|
|
<details className="rounded border p-4 print:hidden">
|
|
<summary className="cursor-pointer font-medium">
|
|
Registar documento recebido
|
|
</summary>
|
|
<form
|
|
className="mt-4 space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
operation.current ??=
|
|
crypto.randomUUID();
|
|
create.transform((values) => ({
|
|
...values,
|
|
operation_key: operation.current,
|
|
}));
|
|
create.post(base, {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
>
|
|
<Errors errors={create.errors} />
|
|
<Fields
|
|
payload={create.data.payload}
|
|
change={(key, value) =>
|
|
create.setData('payload', {
|
|
...create.data.payload,
|
|
[key]: value,
|
|
})
|
|
}
|
|
/>
|
|
<Button disabled={create.processing}>
|
|
Guardar rascunho
|
|
</Button>
|
|
</form>
|
|
</details>
|
|
)}
|
|
{!reportOnly &&
|
|
data.history &&
|
|
data.history.total > 0 && (
|
|
<details className="print:hidden">
|
|
<summary className="cursor-pointer font-medium">
|
|
Histórico do contraditório (
|
|
{data.history.total})
|
|
</summary>
|
|
<div className="mt-3 space-y-3">
|
|
{data.history.data.map((event) => (
|
|
<details
|
|
className="rounded border p-3"
|
|
key={event.id}
|
|
>
|
|
<summary className="cursor-pointer text-sm">
|
|
CTR-{event.after.id} ·{' '}
|
|
{actions[
|
|
event.action.split(
|
|
'.',
|
|
)[1]
|
|
] ?? event.action}{' '}
|
|
·{' '}
|
|
{names[event.user_id] ??
|
|
`#${event.user_id}`}{' '}
|
|
·{' '}
|
|
{new Date(
|
|
event.created_at,
|
|
).toLocaleString('pt-MZ')}
|
|
</summary>
|
|
<div className="mt-3 space-y-2 text-sm whitespace-pre-wrap">
|
|
<p>
|
|
{
|
|
event.after.payload
|
|
.title
|
|
}{' '}
|
|
·{' '}
|
|
{
|
|
states[
|
|
event.after
|
|
.status
|
|
]
|
|
}{' '}
|
|
· Versão{' '}
|
|
{event.after.version}
|
|
</p>
|
|
<p>
|
|
Recebido:{' '}
|
|
{event.after.payload
|
|
.received_on ||
|
|
'Não indicado'}
|
|
</p>
|
|
<p>
|
|
{
|
|
event.after.payload
|
|
.source_reference
|
|
}
|
|
</p>
|
|
<p>
|
|
{
|
|
event.after.payload
|
|
.statement
|
|
}
|
|
</p>
|
|
{event.after
|
|
.review_reason && (
|
|
<p>
|
|
Análise:{' '}
|
|
{
|
|
event.after
|
|
.review_reason
|
|
}
|
|
</p>
|
|
)}
|
|
{event.after.decision && (
|
|
<>
|
|
<p>
|
|
{
|
|
outcomes[
|
|
event
|
|
.after
|
|
.decision
|
|
.outcome
|
|
]
|
|
}
|
|
</p>
|
|
<p>
|
|
{
|
|
event.after
|
|
.decision
|
|
.follow_up
|
|
}
|
|
</p>
|
|
<p>
|
|
{
|
|
event.after
|
|
.decision
|
|
.response_reference
|
|
}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</details>
|
|
))}
|
|
</div>
|
|
<nav
|
|
aria-label="Paginação do histórico do contraditório"
|
|
className="mt-3 flex gap-4 text-sm"
|
|
>
|
|
{data.history.prev_page_url && (
|
|
<Link
|
|
className="underline"
|
|
href={
|
|
data.history.prev_page_url
|
|
}
|
|
>
|
|
Anterior
|
|
</Link>
|
|
)}
|
|
{data.history.next_page_url && (
|
|
<Link
|
|
className="underline"
|
|
href={
|
|
data.history.next_page_url
|
|
}
|
|
>
|
|
Seguinte
|
|
</Link>
|
|
)}
|
|
</nav>
|
|
</details>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|