Files
compras/resources/js/pages/purchase-controls/index.tsx
T

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>
</>
);
}