1333 lines
60 KiB
TypeScript
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>
|
|
);
|
|
}
|