305 lines
14 KiB
TypeScript
305 lines
14 KiB
TypeScript
import { Head, Link } from '@inertiajs/react';
|
|
import { ArrowRight, FileText, Link2 } from 'lucide-react';
|
|
import { PageTitle, Panel, Status } from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { date, roleLabels } from '@/types/procurement';
|
|
|
|
type Stage = {
|
|
label: string;
|
|
state: string;
|
|
owner: string;
|
|
detail: string;
|
|
href: string | null;
|
|
};
|
|
type Document = { label: string; kind: string; href: string };
|
|
type Props = {
|
|
process: {
|
|
number: string;
|
|
status: string;
|
|
requester: string;
|
|
department: string;
|
|
needed_by: string;
|
|
href: string;
|
|
};
|
|
stages: Stage[];
|
|
documents: Document[];
|
|
decisions: {
|
|
area: string;
|
|
role: string;
|
|
decision: string;
|
|
actor: string | null;
|
|
date: string | null;
|
|
}[];
|
|
history: { id: number; action: string; created_at: string }[];
|
|
related: { label: string; href: string }[];
|
|
cancellation: { status: string; href: string } | null;
|
|
future: string[];
|
|
};
|
|
const labels: Record<string, string> = {
|
|
draft: 'Rascunho',
|
|
pending: 'Em aprovação',
|
|
approved: 'Aprovada',
|
|
rejected: 'Rejeitada',
|
|
cancelled: 'Cancelada',
|
|
not_started: 'Sem registos',
|
|
not_required: 'Não aplicável aos artigos actuais',
|
|
restricted: 'Acesso reservado',
|
|
recorded: 'Com registos',
|
|
issued: 'Emitida',
|
|
};
|
|
const events: Record<string, string> = {
|
|
'qualification.defined': 'Versão dos requisitos documentais registada',
|
|
'qualification.assessed': 'Versão da análise documental registada',
|
|
'admission.adopted': 'Política de admissão adoptada expressamente',
|
|
'admission.proposed': 'Tentativa de decisão de admissão registada',
|
|
'admission.reviewed': 'Conferência independente de admissão registada',
|
|
'recommendation.adopted': 'Política de recomendação adoptada expressamente',
|
|
'recommendation.proposed': 'Tentativa de recomendação registada',
|
|
'recommendation.reviewed':
|
|
'Conferência independente da recomendação registada',
|
|
'opening.recorded': 'Versão da acta de abertura registada',
|
|
'requisition.created': 'Requisição criada',
|
|
'requisition.updated': 'Requisição actualizada',
|
|
'requisition.submitted': 'Requisição submetida',
|
|
'requisition.revision_created': 'Revisão criada em novo processo',
|
|
'approval.approved': 'Etapa da estimativa aprovada',
|
|
'approval.rejected': 'Estimativa rejeitada',
|
|
'equipment.technical_approved': 'Validação técnica aprovada',
|
|
'equipment.technical_rejected': 'Validação técnica rejeitada',
|
|
'award.created': 'Adjudicação proposta',
|
|
'award.approved': 'Etapa da adjudicação aprovada',
|
|
'award.rejected': 'Adjudicação rejeitada',
|
|
'purchase_order.issued': 'Ordem emitida',
|
|
'receipt.confirmed': 'Recepção registada',
|
|
'invoice.created': 'Factura registada',
|
|
'invoice.validated': 'Factura validada',
|
|
'payment.registered': 'Pagamento externo registado',
|
|
'payment.confirmed': 'Pagamento externo confirmado',
|
|
'payment.rejected': 'Pagamento externo rejeitado',
|
|
'budget.reserve': 'Reserva orçamental registada',
|
|
'budget.award': 'Reserva ajustada à adjudicação',
|
|
'budget.commit': 'Compromisso registado',
|
|
'budget.release': 'Reserva libertada',
|
|
'cancellation.requested': 'Cancelamento solicitado',
|
|
'cancellation.approved': 'Cancelamento aprovado',
|
|
'cancellation.rejected': 'Cancelamento rejeitado',
|
|
};
|
|
|
|
export default function PurchaseProcess({
|
|
process,
|
|
stages,
|
|
documents,
|
|
decisions,
|
|
history,
|
|
related,
|
|
cancellation,
|
|
future,
|
|
}: Props) {
|
|
return (
|
|
<>
|
|
<Head title={`Processo · ${process.number}`} />
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
|
<PageTitle
|
|
eyebrow="Processo de compra"
|
|
title={process.number}
|
|
description={`${process.requester} · ${process.department} · Necessário em ${date(process.needed_by)}`}
|
|
action={
|
|
<Button asChild variant="outline">
|
|
<Link href={process.href}>
|
|
Abrir requisição
|
|
<ArrowRight className="size-4" />
|
|
</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Estado da requisição. As restantes etapas têm estados
|
|
próprios.
|
|
</p>
|
|
<Status value={process.status} />
|
|
</div>
|
|
{cancellation && (
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
|
|
<p>
|
|
Cancelamento da adjudicação:{' '}
|
|
<strong>{labels[cancellation.status]}</strong>.{' '}
|
|
{cancellation.status === 'pending' &&
|
|
'Novas decisões finais e emissão estão suspensas.'}
|
|
</p>
|
|
<Link
|
|
className="mt-2 inline-flex underline"
|
|
href={cancellation.href}
|
|
>
|
|
Consultar cancelamento e revisão
|
|
</Link>
|
|
</div>
|
|
)}
|
|
<section
|
|
aria-label="Etapas do processo"
|
|
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
|
>
|
|
{stages.map((stage, index) => (
|
|
<article
|
|
key={stage.label}
|
|
className="flex flex-col rounded-xl border bg-card p-5"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-xs font-semibold">
|
|
{String(index + 1).padStart(2, '0')}
|
|
</span>
|
|
<h2 className="text-sm font-semibold">
|
|
{stage.label}
|
|
</h2>
|
|
</div>
|
|
<p
|
|
className={`mt-4 text-sm font-medium ${stage.state === 'restricted' ? 'text-muted-foreground' : stage.state === 'rejected' || stage.state === 'cancelled' ? 'text-red-700 dark:text-red-400' : 'text-foreground'}`}
|
|
>
|
|
{labels[stage.state] ?? stage.state}
|
|
</p>
|
|
<p className="mt-2 text-xs font-medium text-muted-foreground">
|
|
Responsável: {stage.owner}
|
|
</p>
|
|
<p className="mt-3 flex-1 text-sm leading-6 text-muted-foreground">
|
|
{stage.detail}
|
|
</p>
|
|
{stage.href && (
|
|
<Link
|
|
href={stage.href}
|
|
className="mt-4 inline-flex items-center gap-2 text-sm font-medium underline underline-offset-4"
|
|
>
|
|
Consultar etapa
|
|
<ArrowRight className="size-3.5" />
|
|
</Link>
|
|
)}
|
|
</article>
|
|
))}
|
|
</section>
|
|
<div className="grid items-start gap-6 lg:grid-cols-2">
|
|
<Panel
|
|
title="Documentos e registos"
|
|
description="Atalhos para os registos disponíveis ao seu perfil; não inclui anexos privados nem dados bancários."
|
|
>
|
|
<ul className="divide-y">
|
|
{documents.map((document, index) => (
|
|
<li key={`${document.kind}-${index}`}>
|
|
<Link
|
|
href={document.href}
|
|
className="flex items-center gap-3 py-3 text-sm"
|
|
>
|
|
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="flex-1">
|
|
<span className="block font-medium">
|
|
{document.label}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{document.kind}
|
|
</span>
|
|
</span>
|
|
<ArrowRight className="size-4" />
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Panel>
|
|
<Panel
|
|
title="Decisões registadas"
|
|
description="Histórico por etapa. Uma aprovação anterior não substitui as verificações actuais."
|
|
>
|
|
{!decisions.length && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Ainda não existem etapas de decisão registadas.
|
|
</p>
|
|
)}
|
|
<ol className="divide-y">
|
|
{decisions.map((decision, index) => (
|
|
<li key={index} className="py-3 text-sm">
|
|
<div className="flex flex-wrap justify-between gap-2">
|
|
<strong className="font-medium">
|
|
{decision.area} ·{' '}
|
|
{roleLabels[decision.role] ??
|
|
decision.role}
|
|
</strong>
|
|
<span>
|
|
{labels[decision.decision] ??
|
|
decision.decision}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{decision.actor ??
|
|
'Por designar na decisão'}
|
|
{decision.date
|
|
? ` · ${date(decision.date)}`
|
|
: ''}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</Panel>
|
|
</div>
|
|
{related.length > 0 && (
|
|
<Panel title="Processos ligados">
|
|
<div className="flex flex-wrap gap-4">
|
|
{related.map((link) => (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
className="inline-flex items-center gap-2 text-sm underline"
|
|
>
|
|
<Link2 className="size-4" />
|
|
{link.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</Panel>
|
|
)}
|
|
<Panel
|
|
title="Histórico do processo"
|
|
description="Eventos disponíveis ao seu perfil, sem expor o conteúdo privado dos registos."
|
|
>
|
|
{history.length ? (
|
|
<ol className="grid gap-3 sm:grid-cols-2">
|
|
{history.map((event) => (
|
|
<li
|
|
key={event.id}
|
|
className="border-l-2 border-[#ed1c24] pl-3 text-sm"
|
|
>
|
|
<p>
|
|
{events[event.action] ??
|
|
'Evento do processo'}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{date(event.created_at)}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Sem eventos disponíveis.
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
{future.length > 0 && (
|
|
<Panel
|
|
title="Etapas previstas no roteiro"
|
|
description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída."
|
|
>
|
|
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
|
|
{future.map((stage) => (
|
|
<li key={stage}>{stage}</li>
|
|
))}
|
|
</ul>
|
|
</Panel>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
PurchaseProcess.layout = {
|
|
breadcrumbs: [
|
|
{ title: 'Requisições', href: '/requisicoes' },
|
|
{ title: 'Processo de compra', href: '#' },
|
|
],
|
|
};
|