Files
Investidor-V2/resources/js/pages/investor-path/context.tsx
T

1333 lines
60 KiB
TypeScript

import {
PreparationEditor,
PreparationImpact,
} from '@/components/private-investment-preparation';
import ContextDocuments from '@/components/context-documents';
import InvestmentComplements, {
SelectedComplements,
complementInput,
type Complement,
type ComplementOption,
} from '@/components/investment-complements';
import { Link, router } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { Frame, button, card, input } from '@/pages/investment/shared';
import {
TerritoryFields,
type TerritoryOption,
} from '@/components/investment-territory';
import {
contextApi,
contextFieldNames,
type ContextView,
type ContextPlan,
type ContextReference,
} from '@/lib/investor-context';
import { useNavigationContext } from '@/components/navigation-context';
import { createOperationKey } from '@/lib/operation-key';
import OpportunityChoices from '@/components/investment-opportunity-choices';
import InvestmentRequirements from '@/components/investment-requirements';
import type { OpportunityChoice } from '@/lib/investor-context';
import type { CorridorOption } from '@/components/investment-opportunity-choices';
type Options = {
opportunities: { key: string; title: string; kind: string }[];
sectors: { id: number; name: string }[];
parks: { id: string; name: string; version_no: number }[];
search_limited: boolean;
};
type Slot = 'portfolio' | 'opportunity' | 'sector' | 'territory' | 'park';
export default function ContextEditor({
initial,
options: initialOptions,
source,
territorySelection = [],
requestedChoices = null,
draft = null,
entrySources = [],
entryLocationDifferences = [],
entryComplements = [],
entryTerritory = null,
}: {
initial: ContextView;
options: Options;
source: ContextReference | null;
territorySelection?: TerritoryOption[];
requestedChoices?: OpportunityChoice[] | null;
entrySources?: string[];
entryComplements?: Complement[];
entryTerritory?: ContextReference | null;
entryLocationDifferences?: {
title: string;
saved: string;
requested: string;
}[];
draft?: {
draft_sequence?: number;
needs_review?: boolean;
selection?: Partial<Record<Slot, string>>;
declarations?: Record<string, string>;
replace_fields?: string[];
document_links?: Record<string, string | null>;
} | null;
}) {
const navigation = useNavigationContext();
const [complements, setComplements] =
useState<Complement[]>(entryComplements);
const [complementOptions, setComplementOptions] = useState<
ComplementOption[]
>([]);
const [complementError, setComplementError] = useState('');
const [choices, setChoices] = useState<OpportunityChoice[]>(
requestedChoices ?? initial.context.opportunity_choices ?? [],
);
const [manageChoices, setManageChoices] = useState(
requestedChoices !== null ||
!!initial.context.opportunity_choices?.length,
);
const complementContexts = JSON.stringify(
[
...choices.flatMap((c) => [
{
origin: c.opportunity.key,
territory_id: c.territory?.id ?? null,
},
...(c.portfolio_origins ?? []).map((p) => ({
origin: 'portfolio:' + p.id,
territory_id: c.territory?.id ?? null,
})),
]),
...complements
.filter((c) => c.origin.key.startsWith('portfolio:'))
.map((c) => ({
origin: c.origin.key,
territory_id: c.territory_id,
})),
].filter(
(c, i, all) =>
all.findIndex(
(other) =>
other.origin === c.origin &&
other.territory_id === c.territory_id,
) === i,
),
);
useEffect(() => {
let active = true;
setComplementError('');
setComplementOptions([]);
const contexts = JSON.parse(complementContexts) as {
origin: string;
territory_id: string | null;
}[];
const load = async () => {
const items: ComplementOption[] = [];
const issues: string[] = [];
for (let index = 0; index < contexts.length; index += 100) {
const result = await contextApi<{
items: ComplementOption[];
issues: { message: string }[];
}>('/complementos-investimento', {
contexts: contexts.slice(index, index + 100),
});
items.push(...result.items);
issues.push(...result.issues.map((issue) => issue.message));
if (!active) return;
}
if (active) {
setComplementOptions(items);
setComplementError([...new Set(issues)].join(' '));
}
};
void load().catch((e) => {
if (active) setComplementError(e.message);
});
return () => {
active = false;
};
}, [complementContexts]);
const [view, setView] = useState(initial);
const [preparation, setPreparation] = useState(initial.private_investment);
const [options, setOptions] = useState(initialOptions);
const [query, setQuery] = useState('');
const [changes, setChanges] = useState<Partial<Record<Slot, string>>>(
() => ({
...draft?.selection,
...(entryTerritory &&
!draft?.selection?.territory &&
!initial.context.selection.territory
? { territory: entryTerritory.key }
: {}),
...(source?.kind === 'portfolio'
? { portfolio: source.key }
: draft?.selection
? {}
: source
? {
[source.kind === 'business'
? 'opportunity'
: source.kind]: source.key,
}
: {}),
}),
);
const [declarations, setDeclarations] = useState<Record<string, string>>(
draft?.declarations ?? {},
);
const [documentLinks, setDocumentLinks] = useState<
Record<string, string | null>
>(draft?.document_links ?? {});
const [replace, setReplace] = useState<string[]>(
draft?.replace_fields ?? [],
);
const [plan, setPlan] = useState<ContextPlan | null>(null);
const [requestBusy, setBusy] = useState(false);
const [needsRecovery, setNeedsRecovery] = useState(false);
const awaitingSavedContext = useRef(false);
const busy = requestBusy || needsRecovery;
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [corridors, setCorridors] = useState<CorridorOption[]>([]);
const [parks, setParks] = useState<Options['parks']>([]);
const [corridorLoading, setCorridorLoading] = useState(true);
const [corridorError, setCorridorError] = useState('');
const [corridorRetry, setCorridorRetry] = useState(0);
const [draftStatus, setDraftStatus] = useState('');
const [draftRetry, setDraftRetry] = useState(0);
const queue = useRef<Promise<unknown>>(Promise.resolve());
const firstCorridor =
choices[0]?.corridor_key ??
corridors.find((c) => c.name === choices[0]?.coverage)?.key;
const firstTerritory = choices[0]?.territory?.id;
useEffect(() => {
let active = true;
setCorridorLoading(true);
setCorridorError('');
const params = new URLSearchParams();
if (firstCorridor) params.set('corridor', firstCorridor);
if (firstTerritory) params.set('territory', firstTerritory);
void contextApi<{
corridors: CorridorOption[];
parks: Options['parks'];
}>('/investimentos/opcoes-corredor?' + params)
.then((data) => {
if (!active) return;
setCorridors(data.corridors);
setParks(data.parks);
const park = Object.hasOwn(changes, 'park')
? changes.park
: view.context.selection.park?.key;
if (
!manageChoices &&
firstCorridor &&
park &&
!data.parks.some((p) => 'park:' + p.id === park)
) {
setChanges((old) => ({ ...old, park: '' }));
setPlan(null);
setMessage(
'A zona ou parque anterior foi retirado porque não tem enquadramento compatível com a localização escolhida.',
);
}
})
.catch((e) => {
if (active) setCorridorError(e.message);
})
.finally(() => {
if (active) setCorridorLoading(false);
});
return () => {
active = false;
};
}, [firstCorridor, firstTerritory, corridorRetry]);
const draftBody = JSON.stringify({
complements: complements.map(complementInput),
entry_sources: entrySources,
version: view.case.version,
selection: changes,
declarations,
replace_fields: replace,
document_links: documentLinks,
...(manageChoices
? {
opportunity_choices: choices.map((c) => ({
source: c.opportunity.key,
park: c.park?.key ?? null,
portfolio_origins: (c.portfolio_origins ?? []).map(
(r) => r.id,
),
corridor_key: c.corridor_key,
territory: c.territory?.key ?? null,
location_reason: c.location_reason,
confirm_location_change: !!c.confirm_location_change,
activities: c.activities ?? [],
})),
}
: {}),
});
const latestDraft = useRef(draftBody);
latestDraft.current = draftBody;
const skipNextDraft = useRef(false);
const confirmedDraftBody = useRef<string | null>(null);
const sequence = useRef({
body: '',
value: Math.max(Date.now(), draft?.draft_sequence ?? 0),
});
if (sequence.current.body !== draftBody)
sequence.current = {
body: draftBody,
value: Math.max(Date.now(), sequence.current.value + 1),
};
useEffect(() => {
if (busy || corridorLoading || corridorError) return;
if (skipNextDraft.current) {
skipNextDraft.current = false;
confirmedDraftBody.current = draftBody;
setDraftStatus('Dados confirmados.');
return;
}
if (draftBody === confirmedDraftBody.current) return;
setDraftStatus('A guardar rascunho…');
const savedSequence = sequence.current.value;
const isCurrentDraft = () =>
latestDraft.current === draftBody &&
sequence.current.value === savedSequence &&
!awaitingSavedContext.current;
const timer = setTimeout(() => {
queue.current = queue.current
.catch(() => {})
.then(() => {
if (!isCurrentDraft()) return;
return contextApi(
'/investimentos/' + view.case.id + '/contexto/rascunho',
{
...JSON.parse(draftBody),
draft_sequence: savedSequence,
},
'POST',
true,
);
})
.then(() => {
if (isCurrentDraft()) setDraftStatus('Rascunho guardado.');
})
.catch((e) => {
if (isCurrentDraft())
setDraftStatus('Rascunho não guardado: ' + e.message);
});
}, 400);
return () => clearTimeout(timer);
}, [draftBody, busy, corridorLoading, corridorError, draftRetry]);
useEffect(() => {
const flush = () => {
const body = latestDraft.current;
if (
!awaitingSavedContext.current &&
body !== confirmedDraftBody.current
)
void contextApi(
'/investimentos/' + initial.case.id + '/contexto/rascunho',
{
...JSON.parse(body),
draft_sequence: sequence.current.value,
},
'POST',
true,
).catch(() => {});
};
window.addEventListener('pagehide', flush);
return () => {
window.removeEventListener('pagehide', flush);
flush();
};
}, [initial.case.id]);
const [operation, setOperation] = useState(createOperationKey);
const [requestBody, setRequestBody] = useState<Record<
string,
unknown
> | null>(null);
const displayValue = (
field: string,
value: string | null | undefined,
): string => {
if (value === null || value === undefined || value === '')
return 'não informado';
const question = view.private_investment?.questions.find(
(q) => q.key === field,
);
return (
question?.options?.[value] ??
(
{
being_formed: 'Em constituição',
existing: 'Existente',
national: 'Nacional',
foreign: 'Estrangeiro',
mixed: 'Misto',
yes: 'Sim',
no: 'Não',
unknown: 'Por determinar',
} as Record<string, string>
)[value] ??
value
);
};
const selected = (slot: Slot) =>
Object.hasOwn(changes, slot)
? (changes[slot] ?? '')
: (view.context.selection[slot]?.key ?? '');
const change = (slot: Slot, value: string) => {
setChanges({ ...changes, [slot]: value });
setPlan(null);
setMessage('');
};
async function preview() {
setBusy(true);
setPlan(null);
setRequestBody(null);
setError('');
setMessage('');
const body = {
complements: complements.map(complementInput),
...(manageChoices
? {
opportunity_choices: choices.map((c) => ({
source: c.opportunity.key,
park: c.park?.key ?? null,
portfolio_origins: (c.portfolio_origins ?? []).map(
(r) => r.id,
),
corridor_key: c.corridor_key,
territory: c.territory?.key ?? null,
location_reason: c.location_reason,
confirm_location_change: !!c.confirm_location_change,
activities: c.activities ?? [],
})),
}
: {}),
version: view.case.version,
selection: changes,
declarations,
replace_fields: replace,
document_links: documentLinks,
};
try {
const result = await contextApi<ContextPlan>(
`/investimentos/${view.case.id}/contexto/previa`,
body,
);
if (latestDraft.current !== draftBody) {
throw new Error(
'Os dados mudaram durante a comparação. Reveja novamente antes de guardar.',
);
}
setRequestBody(body);
setPlan(result);
setPreparation(
result.private_investment?.after ?? view.private_investment,
);
} catch (e) {
setError(
e instanceof Error ? e.message : 'Não foi possível comparar.',
);
} finally {
setBusy(false);
}
}
async function recoverSavedContext() {
setBusy(true);
setError('');
try {
const next = await contextApi<ContextView>(
`/investimentos/${view.case.id}/contexto`,
);
skipNextDraft.current = true;
setView(next);
setChoices(next.context.opportunity_choices ?? []);
setComplements(next.context.complements ?? []);
setPreparation(next.private_investment);
navigation.refresh();
setChanges({});
setDeclarations({});
setDocumentLinks({});
setReplace([]);
setPlan(null);
awaitingSavedContext.current = false;
setNeedsRecovery(false);
setOperation(createOperationKey());
setMessage(
'Dados guardados. Pode continuar a preparação do investimento.',
);
} catch {
setError(
'Os dados foram guardados, mas não foi possível recuperar a versão actual. Recupere os dados guardados para continuar.',
);
} finally {
setBusy(false);
}
}
async function confirm() {
if (!plan || !requestBody || busy) return;
setBusy(true);
await queue.current.catch(() => {});
setError('');
try {
await contextApi(
`/investimentos/${view.case.id}/contexto/confirmar`,
{
...requestBody,
preview_hash: plan.preview_hash,
operation_key: operation,
confirm: true,
},
);
awaitingSavedContext.current = true;
setNeedsRecovery(true);
setPlan(null);
setRequestBody(null);
// Consume entry parameters as soon as saving succeeds, even if recovery fails.
window.history.replaceState(
window.history.state,
'',
`/investimentos/${view.case.id}/contexto/editar${window.location.hash}`,
);
await recoverSavedContext();
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Falha ao guardar; recupere antes de tentar novamente.',
);
} finally {
setBusy(false);
}
}
return (
<Frame title="Preparar investimento">
<div className="space-y-5 [overflow-wrap:anywhere]">
<Link
className="underline"
href={`/investimentos/${view.case.id}`}
>
Voltar ao dossier
</Link>
<p>{view.case.title}</p>
<p>
Confirme as oportunidades e a localização, complete os dados
disponíveis e reveja antes de guardar.
</p>
<SelectedComplements
items={complements}
onRemove={
busy
? undefined
: (key) => {
setComplements((items) =>
items.filter((item) => item.key !== key),
);
setPlan(null);
}
}
/>
{complementError && (
<p role="alert">
{complementError} As escolhas anteriores foram
conservadas.
</p>
)}
<InvestmentComplements
items={complementOptions}
dossierId={view.case.id}
onNavigate={async (href) => {
if (busy) return;
setBusy(true);
setError('');
try {
await queue.current.catch(() => {});
await contextApi(
'/investimentos/' +
view.case.id +
'/contexto/rascunho',
{
...JSON.parse(latestDraft.current),
draft_sequence: ++sequence.current.value,
},
'POST',
);
router.visit(href);
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Não foi possível conservar as escolhas antes de abrir o complemento.',
);
} finally {
setBusy(false);
}
}}
/>
{!!entryLocationDifferences.length && (
<aside
className="rounded border border-amber-500 p-3"
aria-label="Localização anterior conservada"
>
{entryLocationDifferences.map((row) => (
<p key={row.title}>
{row.title}: a localização recebida (
{row.requested}) não substituiu a escolha
guardada ({row.saved}). Pode rever a localização
abaixo antes de confirmar.
</p>
))}
</aside>
)}
{error && (
<div
role="alert"
className="rounded border border-red-400 p-3"
>
<p>{error}</p>
<button
type="button"
className="underline"
disabled={requestBusy}
onClick={() =>
needsRecovery
? void recoverSavedContext()
: window.location.reload()
}
>
{needsRecovery
? 'Recuperar dados guardados'
: 'Recuperar o dossier atual'}
</button>
</div>
)}
{message && <p role="status">{message}</p>}
<section className={card + ' space-y-4'}>
{corridorError && (
<p role="alert">
{corridorError}{' '}
<button
className="underline"
onClick={() => setCorridorRetry((v) => v + 1)}
>
Tentar novamente
</button>
</p>
)}
{corridorLoading && (
<p role="status">A carregar opções do corredor…</p>
)}
<p role="status">{draftStatus}</p>
{draft?.needs_review && (
<p role="status">
O rascunho foi recuperado, mas o dossier recebeu
outras alterações. Reveja os dados antes de
confirmar.
</p>
)}
{draftStatus.startsWith('Rascunho não guardado') && (
<button
type="button"
className="underline"
onClick={() => setDraftRetry((v) => v + 1)}
>
Tentar guardar novamente
</button>
)}
<OpportunityChoices
corridors={corridors}
choices={choices}
disabled={busy || corridorLoading || !!corridorError}
onChange={(next) => {
setChoices(next);
setManageChoices(true);
setPlan(null);
}}
/>
{(changes.portfolio ||
view.context.selection.portfolio) && (
<p className="rounded border p-3">
Origem documental:{' '}
<a
className="underline"
href={
source?.kind === 'portfolio'
? source.href
: view.context.selection.portfolio?.href
}
>
{source?.kind === 'portfolio'
? source.title
: view.context.selection.portfolio?.title}
</a>
. A ficha é conservada como origem. Escolha o sector
e a localização do investimento abaixo; a cobertura
documental não é uma implantação atribuída.
</p>
)}
{entryTerritory &&
view.context.selection.territory &&
entryTerritory.key !==
view.context.selection.territory.key && (
<p role="status">
A pesquisa indicava {entryTerritory.title}.
Conservou-se a localização guardada,{' '}
{view.context.selection.territory.title}; pode
alterá-la explicitamente abaixo e conferir os
impactos.
</p>
)}
<ContextDocuments
source={source ?? view.context.selection.opportunity}
/>
{view.context.revision > 0 &&
!Object.keys(changes).length &&
JSON.stringify(choices) ===
JSON.stringify(
view.context.opportunity_choices ?? [],
) && (
<InvestmentRequirements
assessment={
view.context.requirements_assessment
}
contextId={view.case.id}
/>
)}
<label className="block">
Acrescentar uma oportunidade à escolha
<select
className={input}
value=""
disabled={busy}
onChange={(e) => {
const o = options.opportunities.find(
(o) => o.key === e.target.value,
);
if (
o &&
!choices.some(
(c) => c.opportunity.key === o.key,
)
) {
setChoices([
...choices,
{
opportunity: {
...o,
id: o.key.split(':')[1],
href: '/oportunidades',
version: null,
sha256: '',
fields: {},
},
territory: null,
location_reason: '',
location_state: 'undetermined',
},
]);
setManageChoices(true);
setPlan(null);
}
}}
>
<option value="">Seleccionar ficha</option>
{options.opportunities
.filter(
(o) =>
o.kind === 'opportunity' &&
!choices.some(
(c) => c.opportunity.key === o.key,
),
)
.map((o) => (
<option key={o.key} value={o.key}>
{o.title}
</option>
))}
</select>
</label>
<h2 className="text-xl font-bold">Dados do investimento</h2>
<div className="flex flex-wrap gap-2">
<label className="min-w-0 flex-1">
Pesquisar oportunidades
<input
className={input}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<button
type="button"
className={button}
disabled={busy}
onClick={() => {
setBusy(true);
void contextApi<Options>(
'/investimentos/fontes-contexto?q=' +
encodeURIComponent(query),
)
.then(setOptions)
.catch((e) => setError(e.message))
.finally(() => setBusy(false));
}}
>
Pesquisar
</button>
</div>
<label className="grid min-w-0 gap-1">
Referência individual (sem selecção múltipla)
<select
className={input + ' max-w-full min-w-0'}
value={selected('opportunity')}
disabled={busy || manageChoices}
onChange={(e) =>
change('opportunity', e.target.value)
}
>
<option value="">Sem oportunidade associada</option>
{source &&
['opportunity', 'business'].includes(
source.kind,
) &&
!options.opportunities.some(
(x) => x.key === source.key,
) && (
<option value={source.key}>
{source.title}
</option>
)}
{view.context.selection.opportunity &&
!options.opportunities.some(
(x) =>
x.key ===
view.context.selection.opportunity!.key,
) && (
<option
value={
view.context.selection.opportunity
.key
}
>
{
view.context.selection.opportunity
.title
}{' '}
— referência guardada
</option>
)}
{options.opportunities.map((o) => (
<option key={o.key} value={o.key}>
{o.title} ·{' '}
{o.kind === 'business'
? 'Negócios'
: 'Catálogo'}
</option>
))}
</select>
</label>
{options.search_limited && (
<p>
Resultados limitados por página de pesquisa;
especifique o título para encontrar outras
oportunidades.
</p>
)}
<label className="grid min-w-0 gap-1">
Sector
<select
className={input + ' max-w-full min-w-0'}
disabled={busy}
value={selected('sector')}
onChange={(e) => change('sector', e.target.value)}
>
<option value="">Sem sector escolhido</option>
{view.context.selection.sector &&
!options.sectors.some(
(s) =>
'sector:' + s.id ===
view.context.selection.sector?.key,
) && (
<option
disabled
value={
view.context.selection.sector.key
}
>
{view.context.selection.sector.title} —
referência histórica mantida
</option>
)}
{options.sectors.map((s) => (
<option key={s.id} value={'sector:' + s.id}>
{s.name}
</option>
))}
</select>
</label>
{!manageChoices && (
<>
<label className="grid min-w-0 gap-1">
{choices.length > 1
? 'Zona ou parque da primeira oportunidade'
: 'ZEE, ZFI ou parque consultado'}
<select
className={input + ' max-w-full min-w-0'}
disabled={
busy ||
corridorLoading ||
!!corridorError ||
(!!firstCorridor && !firstTerritory)
}
value={selected('park')}
onChange={(e) =>
change('park', e.target.value)
}
>
<option value="">
Sem ZEE, ZFI ou parque seleccionado
</option>
{(firstCorridor
? parks
: options.parks
).map((p) => (
<option
key={p.id}
value={'park:' + p.id}
>
{p.name}
</option>
))}
</select>
</label>
{firstCorridor &&
!corridorLoading &&
!corridorError && (
<p>
{!firstTerritory
? 'Escolha a localização para consultar zonas e parques.'
: !parks.length
? 'Não existem zonas ou parques com enquadramento documentado compatível nesta localização.'
: 'A consulta não confirma disponibilidade de lotes, admissão ou incentivos.'}
</p>
)}
</>
)}
{!manageChoices && (
<>
<h3 className="font-semibold">
Localização — escolher até ao nível conhecido
</h3>
<TerritoryFields
initial={territorySelection}
disabled={busy || manageChoices}
endpoint="/api/v1/investment/territory-options"
onChange={(path) =>
change(
'territory',
path.length
? 'territory:' +
path[path.length - 1].id
: '',
)
}
/>
{view.context.selection.territory && (
<p>
Referência guardada:{' '}
{view.context.selection.territory.title}
</p>
)}
</>
)}
</section>
<section className={card + ' space-y-3'}>
<h2 className="text-xl font-bold">
Declarações deste investimento
</h2>
<p>
Estes campos pertencem ao processo. Alterá-los não muda
o cadastro da oportunidade nem o perfil da organização.
</p>
<p>
Origem do investidor, residência fiscal e constituição
da entidade são declarações distintas. A localização não
confirma benefícios. A prévia identifica o impacto antes
de guardar.
</p>
{[
['currency', 'Moeda do projecto', null],
[
'expected_start_date',
'Data prevista de início',
null,
],
[
'investor_origin',
'Origem do investidor',
[
['national', 'Nacional'],
['foreign', 'Estrangeiro'],
['mixed', 'Misto'],
],
],
[
'person_type',
'Tipo de entidade',
[
['singular', 'Pessoa singular'],
['collective', 'Pessoa colectiva'],
['consortium', 'Consórcio'],
],
],
[
'entity_stage',
'Situação da entidade',
[
['existing', 'Existente'],
['being_formed', 'Em constituição'],
],
],
[
'tax_residency_country',
'País de residência fiscal declarado (ISO de duas letras)',
null,
],
].map(([key, label, choices]) => (
<label className="grid gap-1" key={String(key)}>
{String(label)}
{Array.isArray(choices) ? (
<select
className={input}
disabled={busy}
value={
declarations[String(key)] ??
view.context.fields[String(key)] ??
''
}
onChange={(e) => {
setDeclarations({
...declarations,
[String(key)]: e.target.value,
});
setPlan(null);
}}
>
<option value="">Por indicar</option>
{choices.map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
) : (
<input
className={input}
disabled={busy}
type={
key === 'expected_start_date'
? 'date'
: 'text'
}
maxLength={
key === 'currency'
? 3
: key === 'tax_residency_country'
? 2
: 10
}
value={
declarations[String(key)] ??
view.context.fields[String(key)] ??
''
}
onChange={(e) => {
setDeclarations({
...declarations,
[String(key)]: e.target.value,
});
setPlan(null);
}}
/>
)}
</label>
))}
{['project_title', 'project_summary'].map((k) => (
<label className="grid gap-1" key={k}>
{contextFieldNames[k]}
<textarea
className={input}
value={
declarations[k] ??
view.context.fields[k] ??
''
}
disabled={busy}
maxLength={k === 'project_title' ? 200 : 5000}
onChange={(e) => {
setDeclarations({
...declarations,
[k]: e.target.value,
});
setPlan(null);
}}
/>
</label>
))}
<details>
<summary>
Substituições explícitas de declarações
</summary>
<p>
Por defeito, uma seleção conserva o título e as
declarações do utilizador. Autorize somente os
campos que pretende substituir pelo cadastro.
</p>
{['project_title', 'project_summary'].map((k) => (
<label className="block" key={k}>
<input
type="checkbox"
disabled={busy}
checked={replace.includes(k)}
onChange={(e) => {
setReplace(
e.target.checked
? [...replace, k]
: replace.filter(
(x) => x !== k,
),
);
setPlan(null);
}}
/>{' '}
Usar a fonte selecionada em{' '}
{contextFieldNames[k].toLowerCase()}
</label>
))}
</details>
{view.private_investment && (
<PreparationEditor
data={preparation ?? view.private_investment}
fields={view.context.fields}
changes={declarations}
documents={view.preparation_documents ?? []}
links={documentLinks}
disabled={busy || !view.can_write}
onChange={(key, value) => {
setDeclarations({
...declarations,
[key]: value,
});
setPlan(null);
}}
onLink={(key, value) => {
setDocumentLinks({
...documentLinks,
[key]: value,
});
setPlan(null);
}}
/>
)}
<button
className={button}
type="button"
disabled={busy}
onClick={() => void preview()}
>
Rever dados antes de guardar
</button>
</section>
{plan && (
<section
className={card + ' space-y-4'}
aria-label="Impacto da mudança de contexto"
>
<h2 className="text-xl font-bold">
Comparação antes da confirmação
</h2>
{!!plan.opportunity_choices?.length && (
<OpportunityChoices
choices={plan.opportunity_choices}
/>
)}
<SelectedComplements items={plan.complements ?? []} />
<InvestmentRequirements
assessment={plan.requirements_assessment}
contextId={view.case.id}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] table-fixed text-left">
<thead>
<tr>
<th className="w-44 p-2">Tratamento</th>
<th>Impacto concreto</th>
</tr>
</thead>
<tbody>
<tr>
<th className="p-2 align-top">
Mantido
</th>
<td className="p-2">
{plan.documents_preserved}{' '}
documentos.{' '}
{plan.maintained
.filter(
(x) =>
!x.field.endsWith(
'_id',
),
)
.map((x) => (
<p key={x.field}>
{contextFieldNames[
x.field
] ?? x.field}
: conserva «{x.value}»;
a fonte propõe «
{x.proposed}».
</p>
))}
</td>
</tr>
<tr>
<th className="p-2 align-top">
Atualizado pela nova seleção
</th>
<td className="p-2">
{plan.updated
.filter(
(x) =>
!x.field.endsWith(
'_id',
),
)
.map((x) => (
<p key={x.field}>
{contextFieldNames[
x.field
] ?? x.field}
:{' '}
{displayValue(
x.field,
x.before,
)}{' '}
→{' '}
{displayValue(
x.field,
x.after,
)}
</p>
))}
{Object.values(plan.selection).map(
(r) =>
r && (
<p key={r.key}>
<Link
className="underline"
href={r.href}
>
{r.title}
</Link>
</p>
),
)}
</td>
</tr>
<tr>
<th className="p-2 align-top">
Precisa de revisão ou recálculo
</th>
<td className="p-2">
{plan.dependent_results.length}{' '}
resultados financeiros afectados.
Etapas a rever:{' '}
{plan.review_steps.length}. Dados já
preenchidos nos formulários
conservam-se até à sua conferência.
</td>
</tr>
<tr>
<th className="p-2 align-top">
Preservado no histórico
</th>
<td className="p-2">
{plan.requests.length} pedidos e os
dados, decisões e resultados
anteriores.{' '}
{plan.requests.map((r) => (
<p key={r.id}>
{r.protocol ||
'Pedido guardado'}
</p>
))}
</td>
</tr>
</tbody>
</table>
</div>
{plan.private_investment && (
<PreparationImpact {...plan.private_investment} />
)}
<p>{plan.notice}</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded border px-4 py-2"
disabled={busy}
onClick={() => {
setPlan(null);
setRequestBody(null);
setMessage(
'Confirmação cancelada; as escolhas continuam apenas nesta página. Nenhuma associação, resultado ou submissão foi alterado.',
);
}}
>
Cancelar mudança
</button>
<button
type="button"
className={button}
disabled={busy}
onClick={() => void confirm()}
>
Guardar dados do investimento
</button>
</div>
</section>
)}
<section className={card}>
<h2 className="text-xl font-bold">
Resultados e submissões conservados
</h2>
{view.results.map((r) => (
<p key={r.id}>
Resultado guardado —{' '}
{r.context_state === 'review_required'
? 'Desatualizado para o contexto atual — exige revisão/recálculo'
: 'Guardado para o contexto indicado no dossier'}
</p>
))}
{view.requests.map((r) => (
<p key={r.id}>
<Link
className="underline"
href={'/balcao-virtual/pedidos/' + r.id}
>
{r.protocol || 'Consultar pedido'}
</Link>{' '}
· {r.status} ·{' '}
{r.context_state === 'historical_submission'
? 'Conteúdo enviado preservado; contexto posterior distinto'
: r.context_state === 'review_required'
? 'Rascunho precisa de conferência de contexto'
: 'Contexto conservado'}
</p>
))}
</section>
</div>
</Frame>
);
}