358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/react';
|
|
import { PageTitle, Panel } from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
type Policy = {
|
|
id: number;
|
|
revision: number;
|
|
status: string;
|
|
created_by: number;
|
|
enabled: boolean;
|
|
valid_from: string;
|
|
valid_until: string;
|
|
};
|
|
type Cancellation = {
|
|
id: number;
|
|
purchase_award_id: number;
|
|
created_by: number;
|
|
status: string;
|
|
reason: string;
|
|
review_reason: string | null;
|
|
};
|
|
type Props = {
|
|
role: string;
|
|
actorId: number;
|
|
policies: Policy[];
|
|
reviews: {
|
|
purchase_control_policy_id: number;
|
|
reviewed_by: number;
|
|
decision: string;
|
|
}[];
|
|
awards: { id: number; requisition_id: number; status: string }[];
|
|
cancellations: Cancellation[];
|
|
sources: { id: number; number: string; revision_id: number | null }[];
|
|
};
|
|
const states: Record<string, string> = {
|
|
pending: 'Pendente',
|
|
approved: 'Aprovado',
|
|
rejected: 'Rejeitado',
|
|
cancelled: 'Cancelado',
|
|
};
|
|
|
|
function ReasonForm({
|
|
url,
|
|
decisions = false,
|
|
}: {
|
|
url: string;
|
|
decisions?: boolean;
|
|
}) {
|
|
const form = useForm({ reason: '', decision: 'approved' });
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post(url, { preserveScroll: true });
|
|
}}
|
|
className="mt-3 space-y-3"
|
|
>
|
|
<Label>
|
|
Fundamento
|
|
<Input
|
|
value={form.data.reason}
|
|
onChange={(event) =>
|
|
form.setData('reason', event.target.value)
|
|
}
|
|
minLength={10}
|
|
maxLength={4000}
|
|
required
|
|
/>
|
|
</Label>
|
|
{decisions && (
|
|
<Label>
|
|
Decisão
|
|
<select
|
|
className="block w-full rounded border bg-background p-2"
|
|
value={form.data.decision}
|
|
onChange={(event) =>
|
|
form.setData('decision', event.target.value)
|
|
}
|
|
>
|
|
<option value="approved">Aprovar</option>
|
|
<option value="rejected">Rejeitar</option>
|
|
</select>
|
|
</Label>
|
|
)}
|
|
{Object.entries(form.errors).map(([key, message]) => (
|
|
<p key={key} className="text-sm text-red-700">
|
|
{message}
|
|
</p>
|
|
))}
|
|
<Button disabled={form.processing}>
|
|
{decisions ? 'Registar decisão' : 'Solicitar cancelamento'}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function PolicyForm({ previousId }: { previousId: number }) {
|
|
const form = useForm({
|
|
previous_id: previousId,
|
|
mode: 'unissued_award',
|
|
enabled: false,
|
|
valid_from: '',
|
|
valid_until: '',
|
|
reason: '',
|
|
});
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post('/revisao-compras/politicas', {
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
className="grid gap-4 sm:grid-cols-2"
|
|
>
|
|
<Label>
|
|
Início da vigência
|
|
<Input
|
|
type="date"
|
|
required
|
|
value={form.data.valid_from}
|
|
onChange={(event) =>
|
|
form.setData('valid_from', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<Label>
|
|
Fim da vigência
|
|
<Input
|
|
type="date"
|
|
required
|
|
value={form.data.valid_until}
|
|
onChange={(event) =>
|
|
form.setData('valid_until', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<Label className="sm:col-span-2">
|
|
Fundamento
|
|
<Input
|
|
required
|
|
minLength={10}
|
|
value={form.data.reason}
|
|
onChange={(event) =>
|
|
form.setData('reason', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<Label className="flex items-center gap-2 sm:col-span-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.data.enabled}
|
|
onChange={(event) =>
|
|
form.setData('enabled', event.target.checked)
|
|
}
|
|
/>
|
|
Permitir cancelamento de adjudicações sem ordem emitida
|
|
</Label>
|
|
{Object.entries(form.errors).map(([key, message]) => (
|
|
<p key={key} className="text-sm text-red-700">
|
|
{message}
|
|
</p>
|
|
))}
|
|
<Button disabled={form.processing}>Propor regra da empresa</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function RevisionForm({ id }: { id: number }) {
|
|
const form = useForm({ needed_by: '', justification: '' });
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post(`/cancelamentos/${id}/revisao`);
|
|
}}
|
|
className="mt-3 grid gap-3"
|
|
>
|
|
<Label>
|
|
Nova data necessária
|
|
<Input
|
|
type="date"
|
|
required
|
|
value={form.data.needed_by}
|
|
onChange={(event) =>
|
|
form.setData('needed_by', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<Label>
|
|
Justificação actualizada
|
|
<Input
|
|
required
|
|
minLength={10}
|
|
value={form.data.justification}
|
|
onChange={(event) =>
|
|
form.setData('justification', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
{Object.entries(form.errors).map(([key, message]) => (
|
|
<p key={key} className="text-sm text-red-700">
|
|
{message}
|
|
</p>
|
|
))}
|
|
<Button disabled={form.processing}>Preparar novo rascunho</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function PurchaseControls({
|
|
role,
|
|
actorId,
|
|
policies,
|
|
reviews,
|
|
awards,
|
|
cancellations,
|
|
sources,
|
|
}: Props) {
|
|
return (
|
|
<>
|
|
<Head title="Cancelamento e revisão" />
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
|
<PageTitle
|
|
eyebrow="Controlo de compras"
|
|
title="Cancelamento e revisão"
|
|
description="Preserve o processo anterior e prepare uma nova requisição após cancelamento aprovado."
|
|
/>
|
|
<p className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
|
Este circuito abrange apenas adjudicações sem ordem emitida.
|
|
Um pedido pendente suspende decisões e emissão. Ordens,
|
|
recepções, facturas e pagamentos exigem um circuito próprio.
|
|
</p>
|
|
{role === 'admin' && (
|
|
<Panel
|
|
title="Regra da empresa"
|
|
description="A publicação requer dois administradores distintos do proponente. A regra não vem activada por defeito."
|
|
>
|
|
<PolicyForm
|
|
key={policies[0]?.id ?? 0}
|
|
previousId={policies[0]?.id ?? 0}
|
|
/>
|
|
</Panel>
|
|
)}
|
|
{policies.map((policy) => (
|
|
<Panel
|
|
key={policy.id}
|
|
title={`Regra v${policy.revision} · ${states[policy.status]}`}
|
|
description={`${policy.enabled ? 'Permite cancelamento' : 'Não permite cancelamento'} · ${policy.valid_from} a ${policy.valid_until}`}
|
|
>
|
|
<p className="text-sm">
|
|
Assinaturas:{' '}
|
|
{
|
|
reviews.filter(
|
|
(review) =>
|
|
review.purchase_control_policy_id ===
|
|
policy.id &&
|
|
review.decision === 'approved',
|
|
).length
|
|
}
|
|
/2
|
|
</p>
|
|
{role === 'admin' &&
|
|
policy.status === 'pending' &&
|
|
policy.id === policies[0]?.id &&
|
|
policy.created_by !== actorId &&
|
|
!reviews.some(
|
|
(review) =>
|
|
review.purchase_control_policy_id ===
|
|
policy.id &&
|
|
review.reviewed_by === actorId,
|
|
) && (
|
|
<ReasonForm
|
|
url={`/revisao-compras/politicas/${policy.id}/decidir`}
|
|
decisions
|
|
/>
|
|
)}
|
|
</Panel>
|
|
))}
|
|
{awards.map((award) => {
|
|
const cancellation = cancellations.find(
|
|
(entry) => entry.purchase_award_id === award.id,
|
|
);
|
|
return (
|
|
<Panel
|
|
key={award.id}
|
|
title={`Adjudicação #${award.id} · ${states[award.status] ?? award.status}`}
|
|
>
|
|
<Link
|
|
href={`/requisicoes/${award.requisition_id}/adjudicacao`}
|
|
className="text-sm underline"
|
|
>
|
|
Consultar processo
|
|
</Link>
|
|
{cancellation ? (
|
|
<>
|
|
<p className="mt-3 text-sm">
|
|
Cancelamento{' '}
|
|
{states[cancellation.status]}:{' '}
|
|
{cancellation.reason}
|
|
</p>
|
|
{cancellation.review_reason && (
|
|
<p className="mt-2 text-sm">
|
|
Decisão:{' '}
|
|
{cancellation.review_reason}
|
|
</p>
|
|
)}
|
|
{role === 'finance' &&
|
|
cancellation.status === 'pending' &&
|
|
cancellation.created_by !== actorId && (
|
|
<ReasonForm
|
|
url={`/cancelamentos/${cancellation.id}/decidir`}
|
|
decisions
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
role === 'procurement' && (
|
|
<ReasonForm
|
|
url={`/adjudicacoes/${award.id}/cancelamento`}
|
|
/>
|
|
)
|
|
)}
|
|
</Panel>
|
|
);
|
|
})}
|
|
<Panel
|
|
title="Os meus processos para revisão"
|
|
description="Só o solicitante original pode criar um novo rascunho. Aprovações, cotações e reservas não são copiadas."
|
|
>
|
|
{sources.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Sem cancelamentos aprovados para revisão.
|
|
</p>
|
|
)}
|
|
{sources.map((source) => (
|
|
<div key={source.id} className="border-b py-4">
|
|
<p className="font-medium">{source.number}</p>
|
|
{source.revision_id ? (
|
|
<Link
|
|
href={`/requisicoes/${source.revision_id}`}
|
|
className="text-sm underline"
|
|
>
|
|
Abrir nova requisição
|
|
</Link>
|
|
) : (
|
|
<RevisionForm id={source.id} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</Panel>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|