417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
import { Head, Link, router, useForm } from '@inertiajs/react';
|
|
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { date, roleLabels } from '@/types/procurement';
|
|
|
|
type Adoption = {
|
|
id: number;
|
|
revision: number;
|
|
admission_policy_id: number;
|
|
reason: string;
|
|
snapshot: {
|
|
roles: string[];
|
|
required_count: number;
|
|
policy_revision: number;
|
|
};
|
|
};
|
|
type Proposal = {
|
|
id: number;
|
|
reference: string;
|
|
supplier: { name: string } | null;
|
|
basisHash: string | null;
|
|
clear: boolean;
|
|
previousId: number;
|
|
};
|
|
type Review = {
|
|
id: number;
|
|
verdict: string;
|
|
reason: string;
|
|
created_at: string;
|
|
snapshot: { name: string; purchasing_role: string };
|
|
};
|
|
type Decision = {
|
|
id: number;
|
|
revision: number;
|
|
quotation_id: number;
|
|
outcome: string;
|
|
reason: string;
|
|
status: string;
|
|
current: boolean;
|
|
canReview: boolean;
|
|
created_at: string;
|
|
snapshot: { proponent: { name: string } };
|
|
reviews: Review[];
|
|
};
|
|
type Props = {
|
|
requisition: { id: number; number: string };
|
|
policy: { id: number; revision: number; enabled: boolean } | null;
|
|
adoption: Adoption | null;
|
|
selected: Adoption | null;
|
|
adoptions: Adoption[];
|
|
proposals: Proposal[];
|
|
history: {
|
|
data: Decision[];
|
|
prev_page_url: string | null;
|
|
next_page_url: string | null;
|
|
};
|
|
canAdopt: boolean;
|
|
canPropose: boolean;
|
|
closed: boolean;
|
|
};
|
|
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
|
|
const outcomes: Record<string, string> = {
|
|
admit: 'Admitir',
|
|
exclude: 'Excluir',
|
|
clarify: 'Solicitar esclarecimento interno',
|
|
};
|
|
const statuses: Record<string, string> = {
|
|
pending: 'A aguardar conferência',
|
|
confirmed: 'Decisão confirmada',
|
|
returned: 'Tentativa devolvida',
|
|
};
|
|
function Reason({
|
|
value,
|
|
onChange,
|
|
id,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
id: string;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={id}>Fundamento</Label>
|
|
<textarea
|
|
className="min-h-24 w-full rounded-md border p-3"
|
|
id={id}
|
|
required
|
|
minLength={10}
|
|
maxLength={4000}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
function Adopt({
|
|
requisition,
|
|
policy,
|
|
adoption,
|
|
}: Pick<Props, 'requisition' | 'policy' | 'adoption'>) {
|
|
const form = useForm({
|
|
policy_id: policy?.id ?? 0,
|
|
previous_id: adoption?.id ?? 0,
|
|
reason: '',
|
|
});
|
|
if (!policy?.enabled || adoption?.admission_policy_id === policy.id)
|
|
return (
|
|
<p className="text-sm">
|
|
{!policy?.enabled
|
|
? 'Sem política activa para nova adopção. A Administração configura a política da empresa.'
|
|
: 'O processo já adoptou a versão activa.'}
|
|
</p>
|
|
);
|
|
return (
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.post(`/requisicoes/${requisition.id}/admissao/adoptar`);
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<p>
|
|
Adoptar expressamente a política versão {policy.revision}. A
|
|
adopção exige decisões e conferências próprias; não permite
|
|
regressar ao circuito sem admissão.
|
|
</p>
|
|
<Reason
|
|
id="adopt-reason"
|
|
value={form.data.reason}
|
|
onChange={(v) => form.setData('reason', v)}
|
|
/>
|
|
<Button disabled={form.processing}>
|
|
Adoptar política neste processo
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
function Propose({ proposal, id }: { proposal: Proposal; id: number }) {
|
|
const form = useForm({
|
|
quotation_id: proposal.id,
|
|
previous_id: proposal.previousId,
|
|
basis_hash: proposal.basisHash ?? '',
|
|
outcome: '',
|
|
reason: '',
|
|
});
|
|
if (!proposal.basisHash)
|
|
return (
|
|
<p className="text-sm">
|
|
Actualize a qualificação documental para poder propor uma
|
|
decisão.
|
|
</p>
|
|
);
|
|
return (
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.post(`/requisicoes/${id}/admissao/decisoes`);
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`outcome-${proposal.id}`}>
|
|
Proposta de decisão
|
|
</Label>
|
|
<select
|
|
id={`outcome-${proposal.id}`}
|
|
className={selectClass}
|
|
required
|
|
value={form.data.outcome}
|
|
onChange={(e) => form.setData('outcome', e.target.value)}
|
|
>
|
|
<option value="">Seleccione</option>
|
|
{Object.entries(outcomes).map(([value, label]) => (
|
|
<option
|
|
key={value}
|
|
value={value}
|
|
disabled={value === 'admit' && !proposal.clear}
|
|
>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{!proposal.clear && (
|
|
<p className="text-sm text-muted-foreground">
|
|
A admissão está impedida: faltam análises, há
|
|
pendências/divergências ou requisitos obrigatórios por
|
|
resolver.
|
|
</p>
|
|
)}
|
|
<Reason
|
|
id={`propose-reason-${proposal.id}`}
|
|
value={form.data.reason}
|
|
onChange={(v) => form.setData('reason', v)}
|
|
/>
|
|
<Button disabled={form.processing}>Registar nova tentativa</Button>
|
|
</form>
|
|
);
|
|
}
|
|
function Confer({ decision }: { decision: Decision }) {
|
|
const form = useForm({ verdict: '', reason: '' });
|
|
return (
|
|
<form
|
|
className="mt-4 space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.post(`/admissoes/${decision.id}/conferir`);
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<Label htmlFor={`review-${decision.id}`}>
|
|
Conferência independente
|
|
</Label>
|
|
<select
|
|
id={`review-${decision.id}`}
|
|
required
|
|
className={selectClass}
|
|
value={form.data.verdict}
|
|
onChange={(e) => form.setData('verdict', e.target.value)}
|
|
>
|
|
<option value="">Seleccione</option>
|
|
<option value="approved">Confirmar a decisão proposta</option>
|
|
<option value="returned">Devolver esta tentativa</option>
|
|
</select>
|
|
<Reason
|
|
id={`review-reason-${decision.id}`}
|
|
value={form.data.reason}
|
|
onChange={(v) => form.setData('reason', v)}
|
|
/>
|
|
<Button disabled={form.processing}>Registar conferência</Button>
|
|
</form>
|
|
);
|
|
}
|
|
export default function Admission({
|
|
requisition,
|
|
policy,
|
|
adoption,
|
|
selected,
|
|
adoptions,
|
|
proposals,
|
|
history,
|
|
canAdopt,
|
|
canPropose,
|
|
closed,
|
|
}: Props) {
|
|
const base = `/requisicoes/${requisition.id}`;
|
|
return (
|
|
<>
|
|
<Head title={`Admissão · ${requisition.number}`} />
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
|
<PageTitle
|
|
eyebrow="Compras"
|
|
title={`Admissão de propostas · ${requisition.number}`}
|
|
description="Sistema de Gestão de Compras (SGC) · Decisões e conferências preservadas"
|
|
/>
|
|
<nav className="mb-5 flex flex-wrap gap-4 text-sm">
|
|
<Link href={`${base}/processo`}>Processo completo</Link>
|
|
<Link href={`${base}/qualificacao`}>
|
|
Qualificação documental
|
|
</Link>
|
|
<Link href={`${base}/cotacoes`}>Comparação</Link>
|
|
</nav>
|
|
<Panel title="Regime do processo">
|
|
<p className="mb-4">
|
|
{adoption
|
|
? `Adopção ${adoption.revision}: política ${adoption.snapshot.policy_revision}, ${adoption.snapshot.required_count} pessoa(s) distinta(s), ${adoption.snapshot.roles.map((r) => roleLabels[r] ?? r).join(', ')}.`
|
|
: 'Sem adopção: mantém-se o circuito de comparação e adjudicação existente.'}
|
|
</p>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
Admissão não autoriza compra. Mantêm-se pontuação,
|
|
conflitos, orçamento, aprovações e o mínimo existente de
|
|
três fornecedores elegíveis com duas avaliações por
|
|
proposta. Esclarecimentos são registos internos, sem
|
|
envio de comunicações.
|
|
</p>
|
|
{closed && (
|
|
<p>
|
|
O processo está fechado para alterações de admissão.
|
|
</p>
|
|
)}
|
|
{canAdopt && (
|
|
<Adopt
|
|
key={policy?.id}
|
|
requisition={requisition}
|
|
policy={policy}
|
|
adoption={adoption}
|
|
/>
|
|
)}
|
|
</Panel>
|
|
{adoptions.length > 0 && (
|
|
<Panel title="Versão consultada">
|
|
<Label htmlFor="adoption-version">
|
|
Histórico de adopções
|
|
</Label>
|
|
<select
|
|
id="adoption-version"
|
|
className={`${selectClass} mt-2`}
|
|
value={selected?.id ?? ''}
|
|
onChange={(e) =>
|
|
router.get(`${base}/admissao`, {
|
|
version: e.target.value,
|
|
})
|
|
}
|
|
>
|
|
{adoptions.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
Adopção {a.revision} · política{' '}
|
|
{a.snapshot.policy_revision}
|
|
{a.id === adoption?.id
|
|
? ' · actual'
|
|
: ' · histórica'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="mt-3 text-sm whitespace-pre-wrap">
|
|
{selected?.reason}
|
|
</p>
|
|
</Panel>
|
|
)}
|
|
{canPropose && (
|
|
<Panel title="Propor decisões">
|
|
<div className="space-y-5">
|
|
{proposals.map((p) => (
|
|
<article
|
|
key={`${p.id}-${p.previousId}-${p.basisHash}`}
|
|
className="rounded-md border p-4"
|
|
>
|
|
<h2 className="mb-4 font-semibold">
|
|
{p.supplier?.name ?? 'Fornecedor'} ·{' '}
|
|
{p.reference}
|
|
</h2>
|
|
<Propose proposal={p} id={requisition.id} />
|
|
</article>
|
|
))}
|
|
</div>
|
|
</Panel>
|
|
)}
|
|
<Panel title="Tentativas e conferências">
|
|
<div className="space-y-5">
|
|
{history.data.length === 0 && (
|
|
<p>Ainda não há tentativas nesta adopção.</p>
|
|
)}
|
|
{history.data.map((d) => (
|
|
<article
|
|
key={d.id}
|
|
className="rounded-md border p-4"
|
|
>
|
|
<h2 className="font-semibold">
|
|
{proposals.find(
|
|
(p) => p.id === d.quotation_id,
|
|
)?.supplier?.name ??
|
|
`Proposta ${d.quotation_id}`}{' '}
|
|
· tentativa {d.revision} ·{' '}
|
|
{outcomes[d.outcome]}
|
|
</h2>
|
|
<p className="text-sm">
|
|
{statuses[d.status]}
|
|
{!d.current &&
|
|
' · histórica ou base desactualizada'}{' '}
|
|
· {date(d.created_at)} ·{' '}
|
|
{d.snapshot.proponent.name}
|
|
</p>
|
|
<p className="my-3 whitespace-pre-wrap">
|
|
{d.reason}
|
|
</p>
|
|
{d.reviews.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
className="mt-2 rounded-md bg-muted p-3 text-sm"
|
|
>
|
|
<p>
|
|
{r.snapshot.name} ·{' '}
|
|
{roleLabels[
|
|
r.snapshot.purchasing_role
|
|
] ??
|
|
r.snapshot.purchasing_role}{' '}
|
|
·{' '}
|
|
{r.verdict === 'approved'
|
|
? 'Confirmou'
|
|
: 'Devolveu'}{' '}
|
|
· {date(r.created_at)}
|
|
</p>
|
|
<p className="whitespace-pre-wrap">
|
|
{r.reason}
|
|
</p>
|
|
</div>
|
|
))}
|
|
{d.canReview && <Confer decision={d} />}
|
|
</article>
|
|
))}
|
|
<p className="text-sm text-muted-foreground">
|
|
Devolver impede a conclusão da tentativa e exige
|
|
nova proposta fundamentada. Não exclui
|
|
automaticamente o concorrente.
|
|
</p>
|
|
<div className="flex gap-4">
|
|
{history.prev_page_url && (
|
|
<Link href={history.prev_page_url}>
|
|
Anteriores
|
|
</Link>
|
|
)}
|
|
{history.next_page_url && (
|
|
<Link href={history.next_page_url}>
|
|
Seguintes
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|