Files
Investidor-V2/resources/js/pages/investor-path/context.tsx
T
2026-10-23 13:30:10 +02:00

798 lines
41 KiB
TypeScript

import {
PreparationEditor,
PreparationImpact,
} from '@/components/private-investment-preparation';
import ContextDocuments from '@/components/context-documents';
import { Link } 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 = 'opportunity' | 'sector' | 'territory' | 'park';
export default function ContextEditor({
initial,
options: initialOptions,
source,
territorySelection = [],
requestedChoices = null,
draft = null,
entrySources = [],
}: {
initial: ContextView;
options: Options;
source: ContextReference | null;
territorySelection?: TerritoryOption[];
requestedChoices?: OpportunityChoice[] | null;
entrySources?: 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 [choices, setChoices] = useState<OpportunityChoice[]>(requestedChoices ?? initial.context.opportunity_choices ?? []);
const [manageChoices, setManageChoices] = useState(requestedChoices !== null || !!initial.context.opportunity_choices?.length);
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 ?? (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 [busy, setBusy] = useState(false);
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(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({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,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 timer=setTimeout(()=>{
const savedSequence=sequence.current.value;
queue.current=queue.current.catch(()=>{}).then(()=>contextApi('/investimentos/'+view.case.id+'/contexto/rascunho',{...JSON.parse(draftBody),draft_sequence:savedSequence},'POST',draftBody.length<60000))
.then(()=>{if(latestDraft.current===draftBody)setDraftStatus('Rascunho guardado.');})
.catch(e=>{if(latestDraft.current===draftBody)setDraftStatus('Rascunho não guardado: '+e.message)});
},400);
return()=>clearTimeout(timer);
},[draftBody,busy,corridorLoading,corridorError,draftRetry]);
useEffect(()=>{
const flush=()=>{const body=latestDraft.current;if(body!==confirmedDraftBody.current&&body.length<60000)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);
setError('');
setMessage('');
const body = {
...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,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,
);
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 confirm() {
if (!plan || !requestBody) 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,
},
);
const next = await contextApi<ContextView>(
`/investimentos/${view.case.id}/contexto`,
);
skipNextDraft.current=true;
setView(next);
setChoices(next.context.opportunity_choices ?? []);
setPreparation(next.private_investment);
navigation.refresh();
setChanges({});
setDeclarations({});
setDocumentLinks({});
setReplace([]);
setPlan(null);
setOperation(createOperationKey());
setMessage(
'Dados guardados. Pode continuar a preparação do investimento.',
);
} 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>
{error && (
<div
role="alert"
className="rounded border border-red-400 p-3"
>
<p>{error}</p>
<button
type="button"
className="underline"
onClick={() => window.location.reload()}
>
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);}}/>
<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="" 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>
<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}
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}
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"
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}/>}
<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>
);
}