584 lines
25 KiB
TypeScript
584 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 Person = { id: number; name: string };
|
|
type Plan = {
|
|
title: string;
|
|
description: string;
|
|
responsible_id: string;
|
|
due_on: string;
|
|
};
|
|
type Measure = {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
responsible_id: number;
|
|
due_on: string;
|
|
status: string;
|
|
version: number;
|
|
result: string | null;
|
|
evidence: string | null;
|
|
reviewed_by: number | null;
|
|
review_reason: string | null;
|
|
reviewed_at: string | null;
|
|
canPlan: boolean;
|
|
canExecute: boolean;
|
|
canReview: boolean;
|
|
};
|
|
type Event = {
|
|
id: number;
|
|
action: string;
|
|
user_id: number;
|
|
created_at: string;
|
|
after: Measure & { change_reason?: string };
|
|
};
|
|
export type ImprovementData = {
|
|
ready: boolean;
|
|
canCreate: boolean;
|
|
today: string;
|
|
records: Measure[];
|
|
assignees: Person[];
|
|
history: {
|
|
data: Event[];
|
|
total: number;
|
|
prev_page_url: string | null;
|
|
next_page_url: string | null;
|
|
} | null;
|
|
};
|
|
const states: Record<string, string> = {
|
|
open: 'Em execução',
|
|
review: 'A aguardar conferência',
|
|
returned: 'Devolvida para correcção',
|
|
closed: 'Encerrada',
|
|
};
|
|
const events: Record<string, string> = {
|
|
create: 'Criação',
|
|
plan: 'Alteração do plano',
|
|
progress: 'Execução registada',
|
|
submit: 'Pedido de encerramento',
|
|
return: 'Devolução',
|
|
close: 'Encerramento independente',
|
|
};
|
|
const freshPlan: Plan = {
|
|
title: '',
|
|
description: '',
|
|
responsible_id: '',
|
|
due_on: '',
|
|
};
|
|
function Errors({ errors }: { errors: Record<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, text]) => (
|
|
<p key={key}>{text}</p>
|
|
))}
|
|
</div>
|
|
) : null;
|
|
}
|
|
function PlanFields({
|
|
data,
|
|
change,
|
|
people,
|
|
}: {
|
|
data: Plan;
|
|
change: (key: keyof Plan, value: string) => void;
|
|
people: Person[];
|
|
}) {
|
|
return (
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<label className="space-y-1 sm:col-span-2">
|
|
Medida
|
|
<input
|
|
required
|
|
minLength={3}
|
|
maxLength={200}
|
|
className={controlClass}
|
|
value={data.title}
|
|
onChange={(e) => change('title', e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="space-y-1 sm:col-span-2">
|
|
Objectivo e resultado esperado
|
|
<textarea
|
|
required
|
|
minLength={10}
|
|
maxLength={10000}
|
|
className={controlClass}
|
|
value={data.description}
|
|
onChange={(e) => change('description', e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
Responsável interno
|
|
<select
|
|
required
|
|
className={controlClass}
|
|
value={data.responsible_id}
|
|
onChange={(e) => change('responsible_id', e.target.value)}
|
|
>
|
|
<option value="">Escolher responsável</option>
|
|
{people.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1">
|
|
Prazo
|
|
<input
|
|
required
|
|
type="date"
|
|
className={controlClass}
|
|
value={data.due_on}
|
|
onChange={(e) => change('due_on', e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
function MeasureCard({
|
|
measure: m,
|
|
base,
|
|
people,
|
|
names,
|
|
today,
|
|
}: {
|
|
measure: Measure;
|
|
base: string;
|
|
people: Person[];
|
|
names: Record<number, string>;
|
|
today: string;
|
|
}) {
|
|
const plan = useForm({
|
|
version: m.version,
|
|
title: m.title,
|
|
description: m.description,
|
|
responsible_id: String(m.responsible_id),
|
|
due_on: m.due_on,
|
|
reason: '',
|
|
});
|
|
const progress = useForm({
|
|
version: m.version,
|
|
result: m.result ?? '',
|
|
evidence: m.evidence ?? '',
|
|
});
|
|
const decision = useForm({ version: m.version, reason: '' });
|
|
const url = `${base}/${m.id}`;
|
|
const overdue = m.status !== 'closed' && m.due_on < today;
|
|
return (
|
|
<article id={`medida-${m.id}`} className="space-y-4 rounded border p-4">
|
|
<div className="flex flex-wrap justify-between gap-2">
|
|
<h3 className="font-semibold">
|
|
MM-{m.id} · {m.title}
|
|
</h3>
|
|
<span
|
|
className={
|
|
overdue
|
|
? 'font-medium text-amber-800 dark:text-amber-300'
|
|
: ''
|
|
}
|
|
>
|
|
{states[m.status]}
|
|
{overdue ? ' · Prazo ultrapassado' : ''}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap">{m.description}</p>
|
|
<p className="text-sm">
|
|
Responsável: {names[m.responsible_id] ?? `#${m.responsible_id}`}{' '}
|
|
· Prazo: {m.due_on} · Versão {m.version}
|
|
</p>
|
|
<dl className="space-y-2 text-sm">
|
|
<dt className="font-medium">Resultado registado</dt>
|
|
<dd className="whitespace-pre-wrap">
|
|
{m.result || 'Ainda não registado.'}
|
|
</dd>
|
|
<dt className="font-medium">
|
|
Evidência / referência documental
|
|
</dt>
|
|
<dd className="whitespace-pre-wrap">
|
|
{m.evidence || 'Ainda não indicada.'}
|
|
</dd>
|
|
</dl>
|
|
{m.review_reason && (
|
|
<p className="rounded bg-muted p-3 text-sm whitespace-pre-wrap">
|
|
Última conferência por{' '}
|
|
{names[m.reviewed_by ?? 0] ?? `#${m.reviewed_by}`}
|
|
{m.reviewed_at
|
|
? ` em ${new Date(m.reviewed_at).toLocaleString('pt-MZ')}`
|
|
: ''}
|
|
: {m.review_reason}
|
|
</p>
|
|
)}
|
|
{m.canPlan && (
|
|
<details className="print:hidden">
|
|
<summary className="cursor-pointer text-sm underline">
|
|
Alterar plano, responsável ou prazo
|
|
</summary>
|
|
<form
|
|
className="mt-4 space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
plan.post(url + '/plan', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
>
|
|
<Errors errors={plan.errors} />
|
|
<PlanFields
|
|
data={plan.data}
|
|
change={(key, value) => plan.setData(key, value)}
|
|
people={people}
|
|
/>
|
|
<label className="block space-y-1">
|
|
Justificação da alteração
|
|
<textarea
|
|
required
|
|
minLength={10}
|
|
maxLength={4000}
|
|
className={controlClass}
|
|
value={plan.data.reason}
|
|
onChange={(e) =>
|
|
plan.setData('reason', e.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<Button disabled={plan.processing}>
|
|
Guardar alteração justificada
|
|
</Button>
|
|
</form>
|
|
</details>
|
|
)}
|
|
{m.canExecute && (
|
|
<form
|
|
className="space-y-4 print:hidden"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
progress.post(url + '/progress', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
>
|
|
<Errors errors={progress.errors} />
|
|
<label className="block space-y-1">
|
|
Resultado da execução
|
|
<textarea
|
|
maxLength={10000}
|
|
className={controlClass}
|
|
value={progress.data.result}
|
|
onChange={(e) =>
|
|
progress.setData('result', e.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1">
|
|
Referência da evidência
|
|
<textarea
|
|
maxLength={10000}
|
|
className={controlClass}
|
|
value={progress.data.evidence}
|
|
onChange={(e) =>
|
|
progress.setData('evidence', e.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
variant="outline"
|
|
disabled={progress.processing}
|
|
>
|
|
Guardar progresso
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={
|
|
progress.processing ||
|
|
!progress.data.result.trim() ||
|
|
!progress.data.evidence.trim()
|
|
}
|
|
onClick={() =>
|
|
progress.post(url + '/submit', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
>
|
|
Pedir encerramento
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
{m.canReview && (
|
|
<div className="space-y-3 print:hidden">
|
|
<Errors errors={decision.errors} />
|
|
<label className="block space-y-1">
|
|
Fundamentação da conferência
|
|
<textarea
|
|
minLength={10}
|
|
maxLength={4000}
|
|
className={controlClass}
|
|
value={decision.data.reason}
|
|
onChange={(e) =>
|
|
decision.setData('reason', e.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
disabled={
|
|
decision.processing ||
|
|
decision.data.reason.trim().length < 10
|
|
}
|
|
onClick={() =>
|
|
decision.post(url + '/close', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
>
|
|
Confirmar encerramento
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
disabled={
|
|
decision.processing ||
|
|
decision.data.reason.trim().length < 10
|
|
}
|
|
onClick={() =>
|
|
decision.post(url + '/return', {
|
|
preserveState: 'errors',
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
>
|
|
Devolver para correcção
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
export default function SupplierImprovements({
|
|
data,
|
|
evaluationId,
|
|
names,
|
|
reportOnly,
|
|
generatedAt,
|
|
}: {
|
|
data: ImprovementData;
|
|
evaluationId: number;
|
|
names: Record<number, string>;
|
|
reportOnly: boolean;
|
|
generatedAt: string;
|
|
}) {
|
|
const create = useForm(freshPlan);
|
|
const operation = useRef<string | null>(null);
|
|
const base = `/fornecedores/avaliacoes/${evaluationId}/medidas`;
|
|
const overdue = data.records.filter(
|
|
(m) => m.status !== 'closed' && m.due_on < data.today,
|
|
).length;
|
|
return (
|
|
<Panel title="Medidas de melhoria e acompanhamento">
|
|
<div className="space-y-5">
|
|
<p className="text-sm text-muted-foreground">
|
|
Acompanhamento associado a esta revisão, consultado em{' '}
|
|
{new Date(generatedAt).toLocaleString('pt-MZ')}. Não altera
|
|
o conteúdo da avaliação aprovada nem a elegibilidade do
|
|
fornecedor. O prazo ultrapassado é um aviso; não encerra a
|
|
medida automaticamente.
|
|
</p>
|
|
{!data.ready ? (
|
|
<p>
|
|
A instalação das medidas de melhoria ainda não está
|
|
concluída.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<p className="text-sm">
|
|
{data.records.length} medida(s) ·{' '}
|
|
{
|
|
data.records.filter(
|
|
(m) => m.status === 'closed',
|
|
).length
|
|
}{' '}
|
|
encerrada(s) · {overdue} com prazo ultrapassado
|
|
</p>
|
|
{data.records.length === 0 && (
|
|
<p className="text-sm">
|
|
Sem medidas registadas nesta revisão. As medidas
|
|
de revisões anteriores permanecem nos
|
|
respectivos relatórios.
|
|
</p>
|
|
)}
|
|
{data.records.map((m) => (
|
|
<MeasureCard
|
|
key={`${m.id}-${m.version}`}
|
|
measure={m}
|
|
base={base}
|
|
people={data.assignees}
|
|
names={names}
|
|
today={data.today}
|
|
/>
|
|
))}
|
|
{data.canCreate && (
|
|
<details className="rounded border p-4 print:hidden">
|
|
<summary className="cursor-pointer font-medium">
|
|
Adicionar medida de melhoria
|
|
</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} />
|
|
<PlanFields
|
|
data={create.data}
|
|
change={(key, value) =>
|
|
create.setData(key, value)
|
|
}
|
|
people={data.assignees}
|
|
/>
|
|
<Button disabled={create.processing}>
|
|
Registar medida
|
|
</Button>
|
|
</form>
|
|
</details>
|
|
)}
|
|
{!reportOnly &&
|
|
data.history &&
|
|
data.history.total > 0 && (
|
|
<details className="print:hidden">
|
|
<summary className="cursor-pointer font-medium">
|
|
Histórico das medidas (
|
|
{data.history.total})
|
|
</summary>
|
|
<div className="mt-3 space-y-3">
|
|
{data.history.data.map((event) => (
|
|
<details
|
|
key={event.id}
|
|
className="rounded border p-3"
|
|
>
|
|
<summary className="cursor-pointer text-sm">
|
|
MM-{event.after.id} ·{' '}
|
|
{events[
|
|
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.title} ·{' '}
|
|
{
|
|
states[
|
|
event.after
|
|
.status
|
|
]
|
|
}{' '}
|
|
· Versão{' '}
|
|
{event.after.version}
|
|
</p>
|
|
<p>
|
|
{
|
|
event.after
|
|
.description
|
|
}
|
|
</p>
|
|
<p>
|
|
Responsável:{' '}
|
|
{names[
|
|
event.after
|
|
.responsible_id
|
|
] ??
|
|
`#${event.after.responsible_id}`}{' '}
|
|
· Prazo:{' '}
|
|
{event.after.due_on}
|
|
</p>
|
|
{event.after
|
|
.change_reason && (
|
|
<p>
|
|
Alteração:{' '}
|
|
{
|
|
event.after
|
|
.change_reason
|
|
}
|
|
</p>
|
|
)}
|
|
<p>
|
|
{event.after.result ||
|
|
'Sem resultado registado.'}
|
|
</p>
|
|
<p>
|
|
{event.after.evidence ||
|
|
'Sem evidência registada.'}
|
|
</p>
|
|
{event.after
|
|
.review_reason && (
|
|
<p>
|
|
Conferência:{' '}
|
|
{
|
|
event.after
|
|
.review_reason
|
|
}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</details>
|
|
))}
|
|
</div>
|
|
<nav
|
|
aria-label="Paginação do histórico das medidas"
|
|
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>
|
|
);
|
|
}
|