561 lines
23 KiB
TypeScript
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: '#' },
|
|
],
|
|
};
|