Files
Investidor-V2/resources/js/pages/funding/edit.tsx
T
2026-11-02 20:31:15 +02:00

944 lines
40 KiB
TypeScript

import { Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { portalRequest, PortalApiError } from '@/lib/portal-api';
import { Frame, card, button, input } from '@/pages/investment/shared';
import type { Criterion } from './shared';
type Choice = { id: string | number; name?: string; title?: string };
type WindowDraft = {
code: string;
title: string;
mode: string;
start_on: string;
end_on: string;
coverage: string;
coverage_text: string;
territory_ids: string[];
source_url: string;
source_reference: string;
checked_on: string;
application_url: string;
criteria: Criterion[];
};
type Draft = {
title: string;
kind: string;
summary: string;
manager_name: string;
manager_organization_id: string;
source_url: string;
source_reference: string;
source_checked_on: string;
source_published_on: string;
source_status: string;
coverage: string;
coverage_text: string;
territory_ids: string[];
sectors_scope: string;
sectors_text: string;
sector_ids: number[];
resource_ids: number[];
criteria: Criterion[];
windows: WindowDraft[];
};
type RecordData = {
id: string;
code: string;
version: number;
draft: Draft;
published: boolean;
};
type Plan = { version: number; hash: string; payload: Draft };
const empty: Draft = {
title: '',
kind: 'fund',
summary: '',
manager_name: '',
manager_organization_id: '',
source_url: '',
source_reference: '',
source_checked_on: '',
source_published_on: '',
source_status: 'unknown',
coverage: 'unknown',
coverage_text: '',
territory_ids: [],
sectors_scope: 'unknown',
sectors_text: '',
sector_ids: [],
resource_ids: [],
criteria: [],
windows: [],
};
const newCode = () => crypto.randomUUID();
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="my-3 block">
<span className="block font-medium">{label}</span>
{children}
</label>
);
}
function Multi({
label,
options,
values,
onChange,
}: {
label: string;
options: Choice[];
values: (string | number)[];
onChange: (ids: string[]) => void;
}) {
return (
<Field label={label}>
<select
className={input}
multiple
value={values.map(String)}
onChange={(e) =>
onChange(
Array.from(
e.target.selectedOptions,
(option) => option.value,
),
)
}
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.name ?? option.title}
</option>
))}
</select>
<span className="text-sm">Pode seleccionar mais de uma opção.</span>
</Field>
);
}
function Criteria({
value,
onChange,
}: {
value: Criterion[];
onChange: (criteria: Criterion[]) => void;
}) {
const update = (index: number, patch: Partial<Criterion>) =>
onChange(
value.map((item, i) =>
i === index ? { ...item, ...patch } : item,
),
);
return (
<section>
<h3 className="font-semibold">Critérios documentados</h3>
{value.map((item, index) => (
<fieldset className={card} key={item.code}>
<legend>Critério {index + 1}</legend>
<Field label="Pergunta">
<input
className={input}
value={item.question ?? ''}
maxLength={500}
onChange={(e) =>
update(index, { question: e.target.value })
}
/>
</Field>
<Field label="Aplica-se a">
<select
className={input}
value={item.applies_to}
onChange={(e) =>
update(index, {
applies_to: e.target
.value as Criterion['applies_to'],
})
}
>
<option value="all">Todos os candidatos</option>
<option value="individual">
Pessoas singulares
</option>
<option value="entity">Entidades</option>
</select>
</Field>
<Field label="Resposta exigida pela fonte">
<select
className={input}
value={item.expected}
onChange={(e) =>
update(index, {
expected: e.target.value as 'yes' | 'no',
})
}
>
<option value="yes">Sim</option>
<option value="no">Não</option>
</select>
</Field>
<Field label="Explicação da condição">
<textarea
className={input}
value={item.explanation ?? ''}
maxLength={2000}
onChange={(e) =>
update(index, { explanation: e.target.value })
}
/>
</Field>
<Field label="Referência exacta na fonte">
<input
className={input}
value={item.source_reference ?? ''}
maxLength={1500}
onChange={(e) =>
update(index, {
source_reference: e.target.value,
})
}
/>
</Field>
<button
type="button"
className="underline"
onClick={() =>
onChange(value.filter((_, i) => i !== index))
}
>
Remover critério
</button>
</fieldset>
))}
<button
type="button"
className="underline"
disabled={value.length >= 30}
onClick={() =>
onChange([
...value,
{
code: newCode(),
question: '',
applies_to: 'all',
expected: 'yes',
source_reference: '',
explanation: '',
},
])
}
>
Acrescentar critério
</button>
</section>
);
}
function Editor({
record,
references,
kinds,
windowModes,
history,
}: {
record: RecordData | null;
references: Record<
'organizations' | 'sectors' | 'territories' | 'documents',
Choice[]
>;
kinds: Record<string, string>;
windowModes: Record<string, string>;
history: { version: number; reason: string; published_at: string }[];
}) {
const form = useForm({
version: record?.version ?? 0,
code: record?.code ?? newCode(),
reason: '',
draft: { ...empty, ...record?.draft },
});
const [plan, setPlan] = useState<Plan | null>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const draft = form.data.draft;
function change(patch: Partial<Draft>) {
form.setData('draft', { ...draft, ...patch });
setPlan(null);
setConfirmed(false);
}
function text(
key: {
[K in keyof Draft]: Draft[K] extends string ? K : never;
}[keyof Draft],
label: string,
type = 'text',
) {
return (
<Field label={label}>
<input
type={type}
className={input}
value={draft[key] ?? ''}
onChange={(e) => change({ [key]: e.target.value })}
/>
</Field>
);
}
function windowChange(index: number, patch: Partial<WindowDraft>) {
change({
windows: draft.windows.map((item, i) =>
i === index ? { ...item, ...patch } : item,
),
});
}
async function preview() {
if (
!record ||
JSON.stringify(draft) !==
JSON.stringify({ ...empty, ...record?.draft })
) {
setError('Guarde o rascunho antes de conferir a prévia.');
return;
}
setBusy(true);
setError('');
setPlan(null);
setConfirmed(false);
try {
setPlan(
await portalRequest<Plan>(
`/management/fundos/${record.id}/previa`,
{ method: 'POST' },
),
);
} catch (failure) {
setError(
failure instanceof PortalApiError
? Object.values(failure.errors).flat().join(' ') ||
failure.message
: 'Não foi possível preparar a prévia.',
);
} finally {
setBusy(false);
}
}
return (
<Frame
title={
record ? 'Editar fundo ou programa' : 'Novo fundo ou programa'
}
>
<Link className="underline" href="/management/fundos">
Voltar à gestão
</Link>
<p>
Guarde as condições efectivamente documentadas. Uma chamada
local ou encerrada não altera, por si, a cobertura ou existência
do programa.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
const url =
'/management/fundos' + (record ? `/${record.id}` : '');
if (record) form.put(url);
else form.post(url);
}}
>
<section className={card}>
{text('title', 'Nome do programa')}
<Field label="Tipo">
<select
className={input}
value={draft.kind}
onChange={(e) => change({ kind: e.target.value })}
>
{Object.entries(kinds).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</Field>
<Field label="Resumo">
<textarea
className={input}
value={draft.summary ?? ''}
maxLength={4000}
onChange={(e) =>
change({ summary: e.target.value })
}
/>
</Field>
<Field label="Entidade gestora do cadastro">
<select
className={input}
value={draft.manager_organization_id ?? ''}
onChange={(e) =>
change({
manager_organization_id: e.target.value,
})
}
>
<option value="">Identificar pela fonte</option>
{references.organizations.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</Field>
{!draft.manager_organization_id &&
text('manager_name', 'Gestor indicado pela fonte')}
{text('source_url', 'Endereço da fonte', 'url')}
{text('source_reference', 'Documento, edição e referência')}
{text(
'source_checked_on',
'Data de consulta da fonte',
'date',
)}
{text(
'source_published_on',
'Data de publicação da fonte, se conhecida',
'date',
)}
<Field label="Situação da fonte">
<select
className={input}
value={draft.source_status}
onChange={(e) =>
change({ source_status: e.target.value })
}
>
<option value="unknown">Por identificar</option>
<option value="identified">
Fonte efectiva identificada
</option>
<option value="example">
Conteúdo explicitamente demonstrativo
</option>
</select>
</Field>
</section>
<section className={card}>
<Field label="Cobertura do programa">
<select
className={input}
value={draft.coverage}
onChange={(e) =>
change({
coverage: e.target.value,
territory_ids: [],
})
}
>
<option value="unknown">Por determinar</option>
<option value="national">Nacional</option>
<option value="territorial">Territorial</option>
</select>
</Field>
{draft.coverage === 'territorial' && (
<Multi
label="Locais abrangidos"
options={references.territories}
values={draft.territory_ids}
onChange={(territory_ids) =>
change({ territory_ids })
}
/>
)}
{text('coverage_text', 'Explicação da cobertura')}
<Field label="Sectores abrangidos">
<select
className={input}
value={draft.sectors_scope}
onChange={(e) =>
change({
sectors_scope: e.target.value,
sector_ids: [],
})
}
>
<option value="unknown">Por determinar</option>
<option value="all">Todos, segundo a fonte</option>
<option value="selected">
Sectores documentados
</option>
</select>
</Field>
{draft.sectors_scope === 'selected' && (
<Multi
label="Seleccionar sectores"
options={references.sectors}
values={draft.sector_ids}
onChange={(ids) =>
change({ sector_ids: ids.map(Number) })
}
/>
)}
{text('sectors_text', 'Explicação do âmbito sectorial')}
<Multi
label="Documentos da biblioteca"
options={references.documents}
values={draft.resource_ids}
onChange={(ids) =>
change({ resource_ids: ids.map(Number) })
}
/>
<Criteria
value={draft.criteria}
onChange={(criteria) => change({ criteria })}
/>
</section>
<section className={card}>
<h2 className="text-xl font-semibold">
Janelas de candidatura
</h2>
{draft.windows.map((window, index) => (
<fieldset key={window.code} className={card}>
<legend>Janela {index + 1}</legend>
{(
[
'title',
'source_url',
'source_reference',
'checked_on',
'application_url',
'coverage_text',
] as const
).map((key) => (
<Field
key={key}
label={
{
title: 'Nome da chamada',
source_url: 'Endereço da fonte',
source_reference:
'Referência na fonte',
checked_on: 'Data de consulta',
application_url:
'Canal de candidatura, se documentado',
coverage_text:
'Explicação da cobertura desta chamada',
}[key]
}
>
<input
className={input}
type={
key === 'checked_on'
? 'date'
: key.endsWith('url')
? 'url'
: 'text'
}
value={window[key] ?? ''}
onChange={(e) =>
windowChange(index, {
[key]: e.target.value,
})
}
/>
</Field>
))}
<Field label="Estado documentado">
<select
className={input}
value={window.mode}
onChange={(e) =>
windowChange(index, {
mode: e.target.value,
...(['rolling', 'unknown'].includes(
e.target.value,
)
? { start_on: '', end_on: '' }
: {}),
})
}
>
{Object.entries(windowModes).map(
([key, label]) => (
<option key={key} value={key}>
{label}
</option>
),
)}
</select>
</Field>
{['dated', 'closed'].includes(window.mode) && (
<>
{(['start_on', 'end_on'] as const).map(
(key) => (
<Field
key={key}
label={
key === 'start_on'
? 'Data de abertura'
: 'Data-limite'
}
>
<input
className={input}
type="date"
value={window[key] ?? ''}
onChange={(e) =>
windowChange(index, {
[key]: e.target
.value,
})
}
/>
</Field>
),
)}
</>
)}
<Field label="Cobertura da chamada">
<select
className={input}
value={window.coverage}
onChange={(e) =>
windowChange(index, {
coverage: e.target.value,
territory_ids: [],
})
}
>
<option value="programme">
A mesma do programa
</option>
<option value="unknown">
Por determinar
</option>
<option value="national">Nacional</option>
<option value="territorial">
Territorial
</option>
</select>
</Field>
{window.coverage === 'territorial' && (
<Multi
label="Locais da chamada"
options={references.territories}
values={window.territory_ids ?? []}
onChange={(territory_ids) =>
windowChange(index, { territory_ids })
}
/>
)}
<Criteria
value={window.criteria ?? []}
onChange={(criteria) =>
windowChange(index, { criteria })
}
/>
<button
className="underline"
type="button"
onClick={() =>
change({
windows: draft.windows.filter(
(_, i) => i !== index,
),
})
}
>
Remover janela
</button>
</fieldset>
))}
<button
className="underline"
type="button"
disabled={draft.windows.length >= 20}
onClick={() =>
change({
windows: [
...draft.windows,
{
code: newCode(),
title: '',
mode: 'unknown',
start_on: '',
end_on: '',
coverage: 'programme',
coverage_text: '',
territory_ids: [],
source_url: '',
source_reference: '',
checked_on: '',
application_url: '',
criteria: [],
},
],
})
}
>
Acrescentar janela
</button>
</section>
<Field label="Motivo da alteração">
<textarea
className={input}
value={form.data.reason}
minLength={20}
maxLength={2000}
onChange={(e) => form.setData('reason', e.target.value)}
required
/>
</Field>
{Object.values(form.errors).map((message, index) => (
<p role="alert" key={index}>
{message}
</p>
))}
<button className={button} disabled={form.processing || busy}>
Guardar rascunho
</button>
</form>
{record && (
<section className={card}>
<h2>Publicação</h2>
<p>
A publicação cria uma versão pública; não altera
interesses já guardados, não abre candidaturas nem
activa cálculos fiscais.
</p>
<button
className={button}
disabled={busy || form.processing}
onClick={() => void preview()}
>
Conferir prévia de publicação
</button>
{error && <p role="alert">{error}</p>}
{plan && (
<div>
<h3 className="font-semibold">
{plan.payload.title}
</h3>
<p>{plan.payload.summary}</p>
<p>
Gestor: {plan.payload.manager_name}. Fonte:{' '}
{plan.payload.source_reference}.
</p>
<p>
{plan.payload.criteria.length} critérios gerais;{' '}
{plan.payload.windows.length} janelas.
</p>
<p>
<a
className="underline"
href={plan.payload.source_url}
target="_blank"
rel="noopener noreferrer"
>
Abrir fonte conferida
</a>{' '}
— consulta em {plan.payload.source_checked_on}.
</p>
<p>
Cobertura: {plan.payload.coverage};{' '}
{plan.payload.coverage_text}
</p>
<p>
{references.territories
.filter((item) =>
plan.payload.territory_ids.includes(
String(item.id),
),
)
.map((item) => item.name)
.join(', ')}
</p>
<p>
Sectores:{' '}
{plan.payload.sectors_text ||
(plan.payload.sectors_scope === 'all'
? 'Todos segundo a fonte'
: plan.payload.sectors_scope ===
'unknown'
? 'Por determinar'
: references.sectors
.filter((item) =>
plan.payload.sector_ids.includes(
Number(item.id),
),
)
.map((item) => item.name)
.join(', '))}
</p>
<ul>
{plan.payload.criteria.map((item) => (
<li key={item.code}>
<strong>{item.question}</strong> —{' '}
{item.expected === 'yes'
? 'Sim'
: 'Não'}
;{' '}
{item.applies_to === 'all'
? 'todos os candidatos'
: item.applies_to === 'entity'
? 'entidades'
: 'pessoas singulares'}
. {item.explanation} Fonte:{' '}
{item.source_reference}.
</li>
))}
</ul>
{plan.payload.windows.map((window) => (
<article key={window.code} className={card}>
<h4 className="font-semibold">
{window.title}
</h4>
<p>
{windowModes[window.mode]};{' '}
{window.start_on ||
'Abertura sem data indicada'}
;{' '}
{window.end_on ||
'Sem data-limite indicada'}
.
</p>
<p>{window.coverage_text}</p>
<p>
{references.territories
.filter((item) =>
(
window.territory_ids ?? []
).includes(String(item.id)),
)
.map((item) => item.name)
.join(', ')}
</p>
<a
className="underline"
href={window.source_url}
target="_blank"
rel="noopener noreferrer"
>
{window.source_reference}
</a>
<p>Consulta: {window.checked_on}</p>
{window.application_url && (
<p>
Canal indicado:{' '}
{window.application_url}
</p>
)}
<ul>
{window.criteria.map((item) => (
<li key={item.code}>
{item.question} —{' '}
{item.expected === 'yes'
? 'Sim'
: 'Não'}
; {item.explanation} Fonte:{' '}
{item.source_reference}.
</li>
))}
</ul>
</article>
))}
<ul>
{references.documents
.filter((item) =>
plan.payload.resource_ids.includes(
Number(item.id),
),
)
.map((item) => (
<li key={item.id}>
Documento: {item.title}
</li>
))}
</ul>
<label className="block">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
/>{' '}
Confirmei as condições, fontes e efeito da
publicação.
</label>
<button
className={button}
disabled={
!confirmed ||
form.processing ||
form.data.reason.length < 20
}
onClick={() =>
router.post(
`/management/fundos/${record.id}/publicar`,
{
version: plan.version,
hash: plan.hash,
reason: form.data.reason,
confirm: true,
},
)
}
>
Publicar versão conferida
</button>
</div>
)}
{record.published && (
<button
className="ml-3 underline"
disabled={
form.data.reason.length < 20 ||
JSON.stringify(draft) !==
JSON.stringify({
...empty,
...record?.draft,
})
}
onClick={() =>
router.post(
`/management/fundos/${record.id}/retirar`,
{
version: record.version,
reason: form.data.reason,
},
)
}
>
Retirar publicação
</button>
)}
</section>
)}
{history.length > 0 && (
<section className={card}>
<h2>Histórico de publicação</h2>
<ul>
{history.map((item) => (
<li key={item.version}>
Versão {item.version} — {item.published_at}:{' '}
{item.reason}
</li>
))}
</ul>
</section>
)}
</Frame>
);
}
export default function Edit(props: Parameters<typeof Editor>[0]) {
return (
<Editor
key={
props.record
? props.record.id + ':' + props.record.version
: 'new'
}
{...props}
/>
);
}