Files
Investidor-V2/resources/js/pages/procurement/edit.tsx
T

833 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link, router, useForm } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Frame, card, button } from '@/pages/investment/shared';
import {
Field,
control,
labels,
acts,
NoticeContent,
type Draft,
type Lot,
type Attachment,
type Payload,
} from './shared';
type Choice = {
id: string | number;
name?: string;
title?: string;
location_type?: string;
published?: boolean;
malware_scan_status?: string;
};
type References = {
regimes: Record<string, string>;
acts: Record<string, string>;
organizations: Choice[];
sectors: Choice[];
territories: Choice[];
opportunities: Choice[];
projects: Choice[];
documents: Choice[];
};
type RecordData = {
id: string;
version: number;
draft: Draft;
published: boolean;
};
type Plan = {
id: string;
version: number;
act: string;
payload: Payload;
hash: string;
};
const dates = [
'origin_published_at',
'clarifications_at',
'deadline',
'opening_at',
];
const groups: [string, string[]][] = [
[
'Identificação e objecto',
['title', 'reference', 'modality', 'object', 'quantities'],
],
[
'Regime e financiamento',
[
'financier',
'instrument',
'edition',
'rule_source',
'financing_agreement',
],
],
['Participação', ['eligibility', 'requirements', 'proposal_validity']],
[
'Calendário e entrega',
[
'origin_published_at',
'clarifications_at',
'deadline',
'opening_at',
'delivery_channel',
'delivery_address',
'delivery_url',
],
],
['Divulgação e contacto', ['origin_url', 'dissemination']],
];
export default function Edit({
record,
references,
history,
}: {
record: RecordData | null;
references: References;
history: {
version: number;
act: string;
reason: string;
actor_id: number;
published_at: string | null;
created_at: string;
}[];
}) {
const empty: Draft = {
timezone: 'Africa/Maputo',
lots: [],
attachments: [],
contact_authorized: false,
};
const form = useForm({
version: record?.version ?? 0,
reason: '',
draft: record?.draft ?? empty,
});
const [act, setAct] = useState(
record?.published ? 'clarification' : 'initial',
);
const [preview, setPreview] = useState<Plan | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(true);
useEffect(() => {
form.setData({
version: record?.version ?? 0,
reason: '',
draft: record?.draft ?? empty,
});
setPreview(null);
setSaved(true);
}, [record?.id, record?.version]); // A server version is the recovery boundary.
const change = (key: string, value: Draft[string]) => {
form.setData('draft', { ...form.data.draft, [key]: value });
setPreview(null);
setSaved(false);
};
const value = (key: string) => String(form.data.draft[key] ?? '');
const select = (key: string, label: string, choices: Choice[]) => (
<Field label={label}>
<select
className={control}
value={value(key)}
onChange={(e) =>
change(
key,
e.target.value
? key === 'sector_id' || key === 'opportunity_id'
? Number(e.target.value)
: e.target.value
: null,
)
}
>
<option value="">Por indicar</option>
{choices.map((o) => (
<option key={o.id} value={o.id}>
{o.name ?? o.title}
{o.location_type ? ` · ${o.location_type}` : ''}
</option>
))}
</select>
</Field>
);
const input = (key: string, label?: string) => (
<Field key={key} label={label ?? labels[key] ?? key}>
{dates.includes(key) ? (
<input
type="datetime-local"
className={control}
value={value(key)}
onChange={(e) => change(key, e.target.value)}
/>
) : (
<textarea
className={control}
rows={key === 'object' ? 4 : 2}
value={value(key)}
onChange={(e) => change(key, e.target.value)}
/>
)}
</Field>
);
const lots = (form.data.draft.lots ?? []) as Lot[];
const attachments = (form.data.draft.attachments ?? []) as Attachment[];
const makePreview = async () => {
setBusy(true);
setMessage('');
try {
const token = document.cookie
.split('; ')
.find((v) => v.startsWith('XSRF-TOKEN='))
?.slice(11);
const response = await fetch(
`/management/concursos/${record!.id}/previa`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(token ?? ''),
},
body: JSON.stringify({ act }),
},
);
const body = await response.json();
if (!response.ok)
throw new Error(
Object.values(body.errors ?? {})
.flat()
.join(' ') ||
body.message ||
'Não foi possível preparar a prévia.',
);
setPreview(body);
} catch (error) {
setMessage(
error instanceof Error ? error.message : 'Erro de ligação.',
);
} finally {
setBusy(false);
}
};
return (
<Frame
title={record ? 'Editar concurso/anúncio' : 'Novo concurso/anúncio'}
>
<div className="flex flex-wrap gap-3">
<Link className="underline" href="/management/concursos">
Recuperar concursos
</Link>
<Link className="underline" href="/gestao-conteudos">
Entrada única de gestão
</Link>
<a
className="underline"
href="/management/portal-services?intent=document#receber"
target="_blank"
rel="noopener noreferrer"
>
Carregar / gerir documentos e scan na biblioteca (outra
página)
</a>
<button
className="underline"
type="button"
onClick={() => router.reload({ only: ['references'] })}
>
Actualizar opções da biblioteca
</button>
</div>
<p>
Os campos destinam-se à divulgação após revisão da prévia,
excepto as notas internas, os anexos não públicos e o contacto
sem autorização. O vínculo a um cadastro privado não publica os
seus dados.
</p>
<p>
O rascunho pode ficar incompleto. Alterações guardadas não
substituem o acto público; reveja a prévia e publique
explicitamente. Publicar concurso não publica a oportunidade nem
recebe propostas.
</p>
<p role="status">
{record
? `Versão guardada ${record.version}. ${saved ? 'Preparação recuperada do servidor.' : 'Há alterações por guardar.'}`
: 'Preparação ainda não guardada.'}
</p>
{!!Object.keys(form.errors).length && (
<div role="alert">
{Object.values(form.errors).map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
setPreview(null);
record
? form.put(`/management/concursos/${record.id}`)
: form.post('/management/concursos');
}}
>
<section className={`${card} grid gap-3 md:grid-cols-2`}>
{select(
'organization_id',
'Entidade contratante existente',
references.organizations,
)}
{select(
'opportunity_id',
'Oportunidade relacionada existente',
references.opportunities,
)}
{select(
'project_id',
'Projecto existente',
references.projects,
)}
{select('sector_id', 'Sector', references.sectors)}
{select(
'territory_id',
'Localização existente',
references.territories,
)}
<p>
Os vínculos reutilizam os cadastros; não criam nova
entidade, oportunidade, projecto ou limite. Referências
privadas não são transformadas em conteúdo público.
</p>
</section>
<section className={card}>
<h2 className="text-xl font-semibold">
Regime do procedimento
</h2>
<Field label="Regime">
<select
className={control}
value={value('regime')}
onChange={(e) => change('regime', e.target.value)}
>
<option value="">Por indicar</option>
{Object.entries(references.regimes).map(
([v, l]) => (
<option key={v} value={v}>
{l}
</option>
),
)}
</select>
</Field>
{value('regime') === 'state' ? (
<>
<p>
Contratação do Estado: identifique a modalidade,
o instrumento e a edição aplicáveis.{' '}
<a
className="underline"
href="https://www.ufsa.gov.mz/divnovoreg.php"
rel="noopener noreferrer"
>
UFSA — Decreto n.º 79/2022
</a>
. A referência não atribui automaticamente
requisitos ou prazos a todos os procedimentos.
</p>
{input(
'responsible_unit',
'UGEA / unidade responsável quando aplicável',
)}
</>
) : (
<>
<p>
{value('regime') === 'external' ? (
<>
<a
className="underline"
href="https://www.worldbank.org/ext/en/what-we-do/project-procurement/framework"
>
Quadro e documentos normalizados do
Banco Mundial
</a>{' '}
são referências; indique a edição e o
acordo efectivamente aplicáveis.
</>
) : (
'Use as regras documentadas da entidade e deste procedimento.'
)}{' '}
UGEA, Cadastro Único, garantias e abertura
pública não são impostos a este regime.
</p>
{input(
'responsible_unit',
'Unidade responsável (quando aplicável)',
)}
</>
)}
<Field label="Obras, bens ou serviços">
<select
className={control}
value={value('object_kind')}
onChange={(e) =>
change('object_kind', e.target.value)
}
>
<option value="">Por indicar</option>
{Object.entries({
works: 'Obras',
goods: 'Bens',
services: 'Serviços',
mixed: 'Misto',
other: 'Outro',
}).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
</section>
{groups.map(([title, fields]) => (
<section className={card} key={title}>
<h2 className="mb-3 text-xl font-semibold">{title}</h2>
<div className="grid gap-3 md:grid-cols-2">
{fields.map((key) =>
input(
key,
key === 'title'
? 'Título do concurso/anúncio'
: undefined,
),
)}
</div>
</section>
))}
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">
Garantias e prazo mínimo documentado
</h2>
<Field label="Garantias exigidas neste procedimento">
<select
className={control}
value={value('guarantee_status')}
onChange={(e) =>
change('guarantee_status', e.target.value)
}
>
<option value="unknown">
Por determinar nos documentos
</option>
<option value="required">
Exigidas nos documentos
</option>
<option value="not_required">Não exigidas</option>
</select>
</Field>
{value('guarantee_status') === 'required' &&
input('guarantees')}
<Field label="Fuso horário IANA do procedimento">
<input
className={control}
value={value('timezone')}
onChange={(e) => change('timezone', e.target.value)}
placeholder="Africa/Maputo"
/>
</Field>
<p>
As datas e horas introduzidas pertencem a este fuso.
Publicação no InvesteMoz é registada separadamente pelo
servidor; não reinicia o prazo.
</p>
<Field label="Prazo mínimo documentado em dias (opcional)">
<input
className={control}
type="number"
min="0"
value={value('minimum_days')}
onChange={(e) =>
change(
'minimum_days',
e.target.value === ''
? null
: Number(e.target.value),
)
}
/>
</Field>
{form.data.draft.minimum_days !== null &&
form.data.draft.minimum_days !== undefined && (
<div className="grid gap-3 md:grid-cols-2">
{input('minimum_source')}
{input('minimum_edition')}
{input('minimum_modality')}
<Field label="Contagem documentada">
<select
className={control}
value={value('minimum_day_basis')}
onChange={(e) =>
change(
'minimum_day_basis',
e.target.value,
)
}
>
<option value="">Por indicar</option>
<option value="calendar">
Dias corridos
</option>
<option value="business">
Dias úteis — calendário documentado
</option>
</select>
</Field>
{value('minimum_day_basis') === 'business' && (
<>
{input(
'minimum_calendar_source',
'Fonte do calendário aplicável e abrangência de feriados',
)}
{input(
'minimum_working_days',
'Dias de trabalho documentados: 1=segunda a 7=domingo, separados por vírgula',
)}
{input(
'minimum_holidays',
'Feriados / exclusões documentados (AAAA-MM-DD, separados por espaço ou vírgula)',
)}
</>
)}
</div>
)}
<p>
Sem prazo parametrizado não é presumido um mínimo geral.
A contagem em dias úteis usa exclusivamente os dias de
trabalho e exclusões do calendário documentado neste
registo; não adopta um calendário nacional por
suposição.
</p>
</section>
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">Lotes</h2>
{lots.map((lot, i) => (
<fieldset
className="grid gap-3 rounded border p-3 md:grid-cols-3"
key={i}
>
<legend>Lote {i + 1}</legend>
{(
['code', 'description', 'quantities'] as const
).map((key) => (
<Field
key={key}
label={
{
code: 'Referência do lote',
description: 'Objecto do lote',
quantities: 'Quantidades do lote',
}[key]
}
>
<textarea
className={control}
value={lot[key]}
onChange={(e) =>
change(
'lots',
lots.map((r, n) =>
n === i
? {
...r,
[key]: e.target
.value,
}
: r,
),
)
}
/>
</Field>
))}
<button
type="button"
className="underline"
onClick={() =>
change(
'lots',
lots.filter((_, n) => n !== i),
)
}
>
Retirar lote do rascunho
</button>
</fieldset>
))}
<button
type="button"
className={button}
onClick={() =>
change('lots', [
...lots,
{ code: '', description: '', quantities: '' },
])
}
>
Acrescentar lote
</button>
</section>
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">
Documentos e anexos
</h2>
<p>
Carregue e classifique os originais na biblioteca
existente pelo link acima. Reutilize-os aqui: anúncio,
caderno, termos de referência, formulários, anexos ou
comprovativos. Publicar exige que cada anexo marcado
público já esteja autorizado, publicado e com scan limpo
na biblioteca. Os restantes ficam privados no cadastro.
</p>
{attachments.map((item, i) => (
<fieldset
key={i}
className="space-y-2 rounded border p-3"
>
<legend>Anexo {i + 1}</legend>
<Field label={`Documento do anexo ${i + 1}`}>
<select
className={control}
value={item.resource_id || ''}
onChange={(e) =>
change(
'attachments',
attachments.map((r, n) =>
n === i
? {
...r,
resource_id: Number(
e.target.value,
),
}
: r,
),
)
}
>
<option value="">
Seleccione da biblioteca
</option>
{references.documents.map((d) => (
<option key={d.id} value={d.id}>
{d.title} · {d.malware_scan_status}{' '}
·{' '}
{d.published
? 'publicado'
: 'privado / rascunho'}
</option>
))}
</select>
</Field>
<Field label={`Finalidade do anexo ${i + 1}`}>
<input
className={control}
value={item.purpose}
onChange={(e) =>
change(
'attachments',
attachments.map((r, n) =>
n === i
? {
...r,
purpose:
e.target.value,
}
: r,
),
)
}
/>
</Field>
<label>
<input
type="checkbox"
checked={item.public}
onChange={(e) =>
change(
'attachments',
attachments.map((r, n) =>
n === i
? {
...r,
public: e.target
.checked,
}
: r,
),
)
}
/>{' '}
Incluir este anexo na divulgação pública
</label>
<button
type="button"
className="ml-3 underline"
onClick={() =>
change(
'attachments',
attachments.filter((_, n) => n !== i),
)
}
>
Retirar anexo do rascunho
</button>
</fieldset>
))}
<button
type="button"
className={button}
onClick={() =>
change('attachments', [
...attachments,
{ resource_id: 0, purpose: '', public: false },
])
}
>
Reutilizar documento
</button>
</section>
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">
Contacto e acto a divulgar
</h2>
{input('public_contact')}
<label className="block">
<input
type="checkbox"
checked={Boolean(
form.data.draft.contact_authorized,
)}
onChange={(e) =>
change('contact_authorized', e.target.checked)
}
/>{' '}
Contacto autorizado para divulgação pública
</label>
{input('result')}
{input(
'public_reason',
'Motivo público do acto — aparece na prévia e no histórico público',
)}
{input(
'internal_notes',
'Notas internas — nunca incluídas na divulgação',
)}
<Field label="Motivo interno da gravação (20–1000 caracteres)">
<textarea
className={control}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</Field>
<button className={button} disabled={form.processing}>
Guardar rascunho
</button>
</section>
</form>
{record && (
<section className={`${card} my-5 space-y-3`}>
<h2 className="text-xl font-semibold">
Pré-visualizar e publicar
</h2>
<Field label="Acto a publicar">
<select
className={control}
value={act}
onChange={(e) => {
setAct(e.target.value);
setPreview(null);
}}
>
{Object.entries(references.acts).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
<button
className={button}
disabled={!saved || busy}
onClick={makePreview}
>
Preparar prévia pública
</button>
<p>
Guarde antes da prévia. Uma prorrogação identifica o
prazo anterior e o novo; o fim do prazo não é
adjudicação.
</p>
{message && <p role="alert">{message}</p>}
{preview && (
<div className="space-y-3 rounded border p-4">
<h3 className="text-xl font-semibold">
Prévia do conteúdo público — {acts[preview.act]}
</h3>
<p>
Motivo público:{' '}
{String(preview.payload.public_reason)}
</p>
<NoticeContent p={preview.payload} />
<button
className={button}
onClick={() =>
router.post(
`/management/concursos/${record.id}/publicar`,
{
act: preview.act,
hash: preview.hash,
reason: String(
preview.payload.public_reason,
),
},
{ onSuccess: () => setPreview(null) },
)
}
>
Publicar este acto
</button>
</div>
)}
{record.published && (
<Link
className="block underline"
href={`/concursos/${record.id}`}
>
Consultar anúncio público
</Link>
)}
</section>
)}
<section className={card}>
<h2 className="text-xl font-semibold">
Histórico de preparação e publicação
</h2>
{history.map((v) => (
<p key={v.version}>
Versão {v.version} ·{' '}
{v.act === 'draft' ? 'Rascunho' : acts[v.act]} ·
responsável {v.actor_id} ·{' '}
{v.published_at ?? v.created_at} · {v.reason}
</p>
))}
</section>
</Frame>
);
}