@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user