798 lines
41 KiB
TypeScript
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>
|
|
);
|
|
}
|