Files
compras/resources/js/pages/purchases/opening.tsx
T

561 lines
23 KiB
TypeScript

import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Participant = {
name: string;
capacity: string;
group: 'jury' | 'participant';
};
type Proposal = {
id: number;
reference: string;
currency: string;
supplier: { name: string } | null;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
};
type Opening = {
id: number;
revision: number;
opened_at: string;
timezone: string;
location: string;
reference: string;
reason: string;
notes: string | null;
participants: Participant[];
annexes: string[];
created_at: string;
snapshot: { author: { name: string }; proposals: Proposal[] };
};
type Props = {
requisition: { id: number; number: string; justification: string };
proposals: Proposal[];
basisHash: string;
latestId: number;
sourceChanged: boolean;
canRecord: boolean;
openings: {
data: Opening[];
prev_page_url: string | null;
next_page_url: string | null;
};
};
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
const amount = (minor: number, currency: string) =>
`${(minor / 100).toLocaleString('pt-PT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
const sessionDate = (utc: string, timezone: string) =>
new Date(`${utc.replace(' ', 'T')}Z`).toLocaleString('pt-PT', {
timeZone: timezone,
dateStyle: 'short',
timeStyle: 'short',
});
function OpeningForm({
requisition,
proposals,
basisHash,
latestId,
}: Pick<Props, 'requisition' | 'proposals' | 'basisHash' | 'latestId'>) {
const form = useForm({
previous_id: latestId,
basis_hash: basisHash,
opened_at: '',
timezone: 'Africa/Johannesburg',
submission_deadline: '',
reference: '',
location: '',
reason: '',
notes: '',
quotation_ids: [] as number[],
participants: [
{ name: '', capacity: '', group: 'participant' },
] as Participant[],
annexes_text: '',
confirmed: false,
});
const participant = (index: number, change: Partial<Participant>) =>
form.setData(
'participants',
form.data.participants.map((entry, position) =>
position === index ? { ...entry, ...change } : entry,
),
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.transform(({ annexes_text, ...data }) => ({
...data,
annexes: annexes_text
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
}));
form.post(`/requisicoes/${requisition.id}/abertura`, {
preserveScroll: true,
});
}}
className="space-y-5"
>
{form.data.basis_hash !== basisHash && (
<div className="rounded-lg border border-amber-300 p-4 text-sm">
<p>
As propostas foram actualizadas. Confira os valores
abaixo; os textos introduzidos foram mantidos.
</p>
<Button
type="button"
variant="outline"
className="mt-3"
onClick={() => {
form.setData('basis_hash', basisHash);
form.setData('confirmed', false);
form.setData(
'quotation_ids',
form.data.quotation_ids.filter((id) =>
proposals.some(
(proposal) => proposal.id === id,
),
),
);
}}
>
Usar a base actual para nova conferência
</Button>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Label>
Referência do processo ou concurso
<Input
required
minLength={3}
maxLength={150}
value={form.data.reference}
onChange={(event) =>
form.setData('reference', event.target.value)
}
/>
</Label>
<Label>
Local da sessão
<Input
required
minLength={3}
maxLength={300}
value={form.data.location}
onChange={(event) =>
form.setData('location', event.target.value)
}
/>
</Label>
<Label>
Data e hora da sessão
<Input
type="datetime-local"
required
value={form.data.opened_at}
onChange={(event) =>
form.setData('opened_at', event.target.value)
}
/>
</Label>
<Label>
Fuso horário
<select
className={selectClass}
value={form.data.timezone}
onChange={(event) =>
form.setData('timezone', event.target.value)
}
>
<option value="Africa/Johannesburg">
Africa/Johannesburg (UTC+2)
</option>
<option value="Africa/Maputo">
Africa/Maputo (UTC+2)
</option>
<option value="UTC">UTC</option>
</select>
</Label>
<Label>
Limite declarado de apresentação (opcional)
<Input
type="datetime-local"
value={form.data.submission_deadline}
onChange={(event) =>
form.setData(
'submission_deadline',
event.target.value,
)
}
/>
</Label>
</div>
<fieldset className="space-y-3">
<legend className="mb-3 text-sm font-semibold">
Propostas incluídas na sessão
</legend>
<p className="text-sm text-muted-foreground">
Os valores são copiados da aplicação. Seleccione e confira
as propostas incluídas.
</p>
{proposals.map((proposal) => (
<Label
key={proposal.id}
className="flex items-start gap-3 rounded-lg border p-3"
>
<input
type="checkbox"
checked={form.data.quotation_ids.includes(
proposal.id,
)}
onChange={(event) =>
form.setData(
'quotation_ids',
event.target.checked
? [
...form.data.quotation_ids,
proposal.id,
]
: form.data.quotation_ids.filter(
(id) => id !== proposal.id,
),
)
}
/>
<span>
{proposal.supplier?.name ??
'Fornecedor indisponível'}{' '}
· {proposal.reference}
<span className="mt-1 block text-xs text-muted-foreground">
Sem IVA:{' '}
{amount(
proposal.subtotal_minor,
proposal.currency,
)}{' '}
· IVA:{' '}
{amount(proposal.tax_minor, proposal.currency)}{' '}
· Total:{' '}
{amount(
proposal.total_minor,
proposal.currency,
)}
</span>
</span>
</Label>
))}
</fieldset>
<fieldset className="space-y-3">
<legend className="mb-3 text-sm font-semibold">
Intervenientes declarados
</legend>
{form.data.participants.map((entry, index) => (
<div
key={index}
className="grid gap-3 rounded-lg border p-3 sm:grid-cols-3"
>
<Label>
Nome
<Input
required
minLength={2}
maxLength={150}
value={entry.name}
onChange={(event) =>
participant(index, {
name: event.target.value,
})
}
/>
</Label>
<Label>
Qualidade ou função
<Input
required
minLength={2}
maxLength={150}
value={entry.capacity}
onChange={(event) =>
participant(index, {
capacity: event.target.value,
})
}
/>
</Label>
<Label>
Participação
<select
className={selectClass}
value={entry.group}
onChange={(event) =>
participant(index, {
group:
event.target.value === 'jury'
? 'jury'
: 'participant',
})
}
>
<option value="participant">
Participante
</option>
<option value="jury">Júri</option>
</select>
</Label>
{form.data.participants.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() =>
form.setData(
'participants',
form.data.participants.filter(
(_, position) => position !== index,
),
)
}
>
Retirar interveniente
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
disabled={form.data.participants.length >= 50}
onClick={() =>
form.setData('participants', [
...form.data.participants,
{ name: '', capacity: '', group: 'participant' },
])
}
>
Adicionar interveniente
</Button>
</fieldset>
<Label>
Referências de anexos (uma por linha)
<textarea
className="min-h-20 rounded-md border bg-background p-3 text-sm"
value={form.data.annexes_text}
onChange={(event) =>
form.setData('annexes_text', event.target.value)
}
/>
</Label>
<p className="text-xs text-muted-foreground">
São referências declaradas; não são ficheiros carregados nem
assinaturas verificadas.
</p>
<Label>
Observações
<textarea
className="min-h-24 rounded-md border bg-background p-3 text-sm"
maxLength={6000}
value={form.data.notes}
onChange={(event) =>
form.setData('notes', event.target.value)
}
/>
</Label>
<Label>
Fundamento do registo ou da correcção
<textarea
className="min-h-20 rounded-md border bg-background p-3 text-sm"
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</Label>
<Label className="flex items-start gap-3">
<input
type="checkbox"
checked={form.data.confirmed}
onChange={(event) =>
form.setData('confirmed', event.target.checked)
}
required
/>
<span>
Conferi os dados da sessão e as propostas seleccionadas
antes de guardar esta versão.
</span>
</Label>
<FormErrors errors={form.errors} />
<Button disabled={form.processing || !proposals.length}>
{latestId
? 'Registar nova versão da acta'
: 'Registar acta de abertura'}
</Button>
</form>
);
}
export default function OpeningPage(props: Props) {
const { requisition, openings, canRecord, sourceChanged, latestId } = props;
return (
<>
<Head title={`Abertura · ${requisition.number}`} />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow={requisition.number}
title="Abertura de propostas"
description={requisition.justification}
action={
<Button asChild variant="outline">
<Link
href={`/requisicoes/${requisition.id}/processo`}
>
Ver processo
</Link>
</Button>
}
/>
<p className="rounded-xl border bg-muted/30 p-4 text-sm text-muted-foreground">
Registo documental opcional antes da adjudicação. A acta não
avalia propostas, verifica assinaturas ou autoriza compras.
Uma correcção gera outra versão e conserva as anteriores.
</p>
{sourceChanged && (
<p className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Há propostas não incluídas ou dados actuais diferentes
da última acta. Alterações posteriores não entram
automaticamente nos documentos guardados.
</p>
)}
{canRecord ? (
<Panel
title={
latestId
? 'Nova versão da acta'
: 'Registar sessão de abertura'
}
>
<OpeningForm key={latestId} {...props} />
</Panel>
) : (
<p className="text-sm text-muted-foreground">
O registo está disponível a Compras num pedido aprovado
e sem adjudicação. As versões anteriores continuam
disponíveis.
</p>
)}
{openings.data.map((opening) => (
<Panel
key={opening.id}
title={`${opening.reference} · Versão ${opening.revision}`}
description={`${sessionDate(opening.opened_at, opening.timezone)} · ${opening.timezone} · ${opening.location}`}
>
<p className="text-sm">
Registada por {opening.snapshot.author.name} em{' '}
{sessionDate(opening.created_at, opening.timezone)}.
</p>
<p className="mt-3 text-sm whitespace-pre-wrap">
{opening.reason}
</p>
<ul className="mt-4 divide-y">
{opening.snapshot.proposals.map((proposal) => (
<li
key={proposal.id}
className="flex flex-wrap justify-between gap-2 py-3 text-sm"
>
<span>
{proposal.supplier?.name ??
'Fornecedor indisponível'}{' '}
· {proposal.reference}
</span>
<strong>
{amount(
proposal.total_minor,
proposal.currency,
)}
</strong>
</li>
))}
</ul>
<details className="mt-3 text-sm">
<summary className="cursor-pointer underline">
Intervenientes, observações e referências de
anexos
</summary>
<ul className="mt-3 space-y-2">
{opening.participants.map((entry, index) => (
<li key={index}>
{entry.name} · {entry.capacity} ·{' '}
{entry.group === 'jury'
? 'Júri'
: 'Participante'}
</li>
))}
</ul>
<p className="mt-3 whitespace-pre-wrap">
{opening.notes || 'Sem observações.'}
</p>
<ul className="mt-3 list-inside list-disc">
{opening.annexes.map((annex, index) => (
<li key={index}>{annex}</li>
))}
</ul>
</details>
<Button asChild variant="outline" className="mt-5">
<a
href={`/actas/${opening.id}/imprimir`}
target="_blank"
rel="noopener noreferrer"
>
Abrir acta para impressão
</a>
</Button>
</Panel>
))}
{!openings.data.length && (
<Panel title="Ainda não existe acta">
<p className="text-sm text-muted-foreground">
A ausência deste registo opcional não cria uma nova
etapa obrigatória.
</p>
</Panel>
)}
<nav
aria-label="Versões das actas"
className="flex justify-between gap-3"
>
{openings.prev_page_url ? (
<Button asChild variant="outline">
<Link href={openings.prev_page_url}>
Versões mais recentes
</Link>
</Button>
) : (
<span />
)}
{openings.next_page_url && (
<Button asChild variant="outline">
<Link href={openings.next_page_url}>
Versões anteriores
</Link>
</Button>
)}
</nav>
</div>
</>
);
}
OpeningPage.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Abertura de propostas', href: '#' },
],
};