commit5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 10:55:18 +02:00
parent 0235c7609e
commit 48347e2d3a
161 changed files with 11814 additions and 265 deletions
+34 -15
View File
@@ -36,8 +36,12 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
const [mobileOpen, setMobileOpen] = useState(false);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const access = page.props.organizationAccess as {
active: boolean;
permissions: string[];
} | null;
const groups = procurementNavigation(
user?.purchasing_role ?? '',
access?.active ? access.permissions : (user?.purchasing_role ?? ''),
page.props.payablesAvailable === true,
);
const activeId = activeProcurementEntry(groups, page.url);
@@ -404,16 +408,18 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
</div>
{searchField()}
<div className="flex shrink-0 items-center gap-2">
<Button
asChild
size="sm"
className="hidden bg-[#ed1c24] text-white hover:bg-[#d01720] sm:inline-flex"
>
<Link href="/requisicoes/criar">
<Plus className="size-4" />
Nova requisição
</Link>
</Button>
{page.props.canRequest === true && (
<Button
asChild
size="sm"
className="hidden bg-[#ed1c24] text-white hover:bg-[#d01720] sm:inline-flex"
>
<Link href="/requisicoes/criar">
<Plus className="size-4" />
Nova requisição
</Link>
</Button>
)}
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -438,10 +444,23 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
{user.name}
</span>
<span className="block text-[10px] text-muted-foreground">
{roleLabels[
user.purchasing_role ??
''
] ?? 'Utilizador'}
{(
page.props
.organizationAccess as {
active: boolean;
profileLabel: string;
} | null
)?.active
? (
page.props
.organizationAccess as {
profileLabel: string;
}
).profileLabel
: (roleLabels[
user.purchasing_role ??
''
] ?? 'Utilizador')}
</span>
</span>
<ChevronDown className="size-3 text-muted-foreground" />
@@ -0,0 +1,332 @@
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>
);
}
@@ -0,0 +1,583 @@
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>
);
}
@@ -0,0 +1,575 @@
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>
);
}