4512 lines
221 KiB
TypeScript
4512 lines
221 KiB
TypeScript
import {SimulationIrpcNormal} from '@/components/simulation-irpc-normal';
|
||
import {SimulationIrpcWithholdings} from '@/components/simulation-irpc-withholdings';
|
||
import {SimulationIspcResiduals} from '@/components/simulation-ispc-residuals';
|
||
import {SimulationIspcWithholding} from '@/components/simulation-ispc-withholding';
|
||
import {SimulationIspcQuarter} from '@/components/simulation-ispc-quarter';
|
||
import {SimulationIspcEligibility} from './simulation-ispc-eligibility';
|
||
import { simulationDraftEtag } from '@/lib/simulation-draft-etag';
|
||
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
|
||
import { SimulationMonthlyIva } from './simulation-monthly-iva';
|
||
import { inheritSimulatorFields, reusedInvestmentValue } from '@/lib/context-inheritance';
|
||
import { investmentLandPreparation } from '@/lib/investment-land-preparation';
|
||
import {
|
||
PublicDocumentContent,
|
||
type PublicDocument,
|
||
} from '@/components/public-document-content';
|
||
import { DossierPrefill } from '@/components/dossier-prefill';
|
||
import { createOperationKey } from '@/lib/operation-key';
|
||
import FiscalQaPreview from './fiscal-qa-preview';
|
||
import { displayValue } from '@/lib/display-value';
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import PartnershipSourceLinks from './partnership-source-links';
|
||
import {
|
||
getAutosaveSnapshot,
|
||
partitionSimulationDraft,
|
||
restoreSimulationDraft,
|
||
replaceSimulationValue,
|
||
simulationFieldGroups,
|
||
simulationDraftFingerprint,
|
||
getSimulationSections,
|
||
advancedSimulationFields,
|
||
simulationOptions,
|
||
validateSimulationDraft,
|
||
validateCustomFiscalPlanning,
|
||
getFiscalPeriodRange,
|
||
projectQuestionnaireVersion,
|
||
exportCompletedArithmeticCsv,
|
||
readArithmeticSnapshot,
|
||
readVersionedArithmeticReportReference,
|
||
moveSearchChoiceIndex,
|
||
type SimulationDraft,
|
||
} from '@/lib/simulation-form';
|
||
import { usePage } from '@inertiajs/react';
|
||
import {
|
||
isApiSimulationDraftAttachResponseV1,
|
||
isApiSimulationDraftReadResponseV1,
|
||
isApiSimulationDraftWriteResponseV1,
|
||
isApiSimulationHistoryResponseV1,
|
||
isApiSimulationCreateResponseV1,
|
||
isApiSimulationReadResponseV1,
|
||
type ApiSimulationExecutionV1,
|
||
type ApiSimulationHistoryEntryV1,
|
||
type ApiSimulationSourceV1,
|
||
type ApiSimulationDraftV1,
|
||
type ApiSimulationDraftSummaryV1,
|
||
isApiSimulationDraftListResponseV1,
|
||
isApiSimulationDraftClaimPreviewV1,
|
||
} from '@/lib/simulation-contract';
|
||
import { SimulationFinancialTools } from '@/components/simulation-financial-tools';
|
||
import { SimulationCashflowPlanner } from '@/components/simulation-cashflow-planner';
|
||
import { SimulationBusinessPlan } from '@/components/simulation-business-plan';
|
||
import { SimulationFiscalStatus } from '@/components/simulation-fiscal-status';
|
||
import { ivaIssueText, type IvaIssue } from '@/lib/iva-diagnostics';
|
||
import {
|
||
ProjectMilestonePlanner,
|
||
ProjectTeamPlanner,
|
||
} from '@/components/project-planning';
|
||
import { ProjectDossierExport } from '@/components/project-dossier-export';
|
||
import { ProjectReadiness } from '@/components/project-readiness';
|
||
import {
|
||
readSimulationApiJson,
|
||
simulationApiErrorMessage,
|
||
simulationApiFieldErrors,
|
||
simulationPollingFailureMessage,
|
||
simulationCatalogChoices,
|
||
} from '@/lib/simulation-api';
|
||
|
||
type Choice = { id: string; label: string };
|
||
const legacyDraftKey = 'investmoz-simulation-draft-v1';
|
||
const legacyIdempotencyStorageKey = 'investmoz-simulation-idempotency-v1';
|
||
const legacyDraftCreateKey = 'investmoz-simulation-draft-create-key-v1';
|
||
const legacyDraftIdKey = 'investmoz-simulation-draft-id-v1';
|
||
const idempotencyKey = () => createOperationKey();
|
||
|
||
function csrfToken(): string {
|
||
return decodeURIComponent(
|
||
document.cookie
|
||
.split('; ')
|
||
.find((cookie) => cookie.startsWith('XSRF-TOKEN='))
|
||
?.slice(11) ?? '',
|
||
);
|
||
}
|
||
|
||
function displayScalar(value: unknown, fallback = ''): string {
|
||
return typeof value === 'string' ||
|
||
(typeof value === 'number' && Number.isFinite(value))
|
||
? String(value)
|
||
: fallback;
|
||
}
|
||
|
||
function simulationStatusLabel(status: unknown): string {
|
||
const labels: Record<string, string> = {
|
||
queued: 'na fila',
|
||
running: 'em processamento',
|
||
completed: 'concluída',
|
||
failed: 'falhou',
|
||
cancelled: 'cancelada',
|
||
};
|
||
|
||
return typeof status === 'string'
|
||
? (labels[status] ?? 'recebida')
|
||
: 'recebida';
|
||
}
|
||
|
||
function labelFor(value: string): string {
|
||
const words: Record<string, string> = {
|
||
simulate_only: 'Apenas simular',
|
||
own_investment: 'Investimento próprio',
|
||
seek_investor: 'Procurar investidor',
|
||
personal: 'Particular',
|
||
small_business: 'Transformar a minha ideia num negócio',
|
||
company: 'Empresa',
|
||
investor: 'Avaliar o meu investimento',
|
||
simple: 'Simples',
|
||
advanced: 'Avançado',
|
||
seek_partner: 'Procurar parceiro',
|
||
present_opportunity: 'Apresentar oportunidade',
|
||
request_facilitation: 'Solicitar facilitação',
|
||
investment: 'Investimento',
|
||
business: 'Negócio',
|
||
singular: 'Pessoa singular',
|
||
collective: 'Pessoa colectiva',
|
||
sole_trader: 'Empresário em nome individual',
|
||
consortium: 'Consórcio',
|
||
national: 'Nacional',
|
||
foreign: 'Estrangeiro',
|
||
mixed: 'Misto',
|
||
existing: 'Existente',
|
||
being_formed: 'Em constituição',
|
||
'pt-MZ': 'Português (Moçambique)',
|
||
en: 'English',
|
||
portal: 'Portal',
|
||
email: 'Correio electrónico',
|
||
phone: 'Telefone',
|
||
new: 'Novo projecto',
|
||
expansion: 'Expansão',
|
||
acquisition: 'Aquisição',
|
||
partnership: 'Parceria',
|
||
concession: 'Concessão',
|
||
supplier: 'Fornecedor',
|
||
buyer: 'Comprador',
|
||
other: 'Outro',
|
||
idea: 'Ideia',
|
||
concept: 'Conceito',
|
||
pre_feasibility: 'Pré-viabilidade',
|
||
feasibility: 'Viabilidade',
|
||
financing: 'Financiamento',
|
||
implementation: 'Implementação',
|
||
operation: 'Operação',
|
||
owned: 'Propriedade',
|
||
irpc: 'IRPC',
|
||
iva: 'IVA',
|
||
ispc: 'ISPC',
|
||
irps: 'IRPS',
|
||
inss: 'INSS',
|
||
withholding: 'Retenções na fonte',
|
||
customs_ice: 'ICE e direitos aduaneiros',
|
||
'2025': '2025',
|
||
'2026': '2026',
|
||
'2027': '2027',
|
||
monthly: 'Mensal',
|
||
quarterly: 'Trimestral',
|
||
annual: 'Anual',
|
||
one_off: 'Operação pontual',
|
||
monthly_net_estimate: 'Estimativa mensal líquida',
|
||
monthly_withholding: 'Retenção mensal',
|
||
annual_assessment: 'Apuramento anual',
|
||
employment: 'Trabalho',
|
||
rental: 'Predial',
|
||
digital: 'Digital',
|
||
capital_gain: 'Mais-valias',
|
||
self_reported_resident: 'Indico que sou residente',
|
||
self_reported_non_resident: 'Indico que não sou residente',
|
||
domestic: 'Operação em Moçambique',
|
||
non_resident: 'Operação com não residente',
|
||
import: 'Importação',
|
||
export: 'Exportação',
|
||
refund: 'Crédito ou reembolso de IVA',
|
||
documented: 'Documentada',
|
||
partly_documented: 'Parcialmente documentada',
|
||
not_documented: 'Sem documentação disponível',
|
||
approved_parameters: 'Simulação fiscal com parâmetros aprovados',
|
||
custom_planning: 'Cenário personalizado de planeamento',
|
||
no_calculation: 'Sem cálculo nesta etapa',
|
||
estimate_only: 'Crédito apenas estimado',
|
||
request_filed: 'Pedido apresentado',
|
||
recognized: 'Montante reconhecido',
|
||
received: 'Montante recebido',
|
||
duat: 'DUAT',
|
||
leased: 'Arrendado',
|
||
requested: 'Solicitado',
|
||
unknown: 'Por determinar',
|
||
MZN: 'MZN',
|
||
USD: 'USD',
|
||
EUR: 'EUR',
|
||
ZAR: 'ZAR',
|
||
A: 'Categoria A',
|
||
B: 'Categoria B',
|
||
C: 'Categoria C',
|
||
por_determinar: 'Por determinar',
|
||
offer: 'Oferta',
|
||
demand: 'Procura',
|
||
both: 'Oferta e procura',
|
||
capital: 'Capital',
|
||
partner: 'Parceiro',
|
||
land: 'Terreno',
|
||
technology: 'Tecnologia',
|
||
licence: 'Licença',
|
||
services: 'Serviços',
|
||
public: 'Público',
|
||
registered_users: 'Utilizadores registados',
|
||
restricted: 'Restrito',
|
||
confidential: 'Confidencial',
|
||
yes: 'Sim',
|
||
no: 'Não',
|
||
energy: 'Energia',
|
||
customers: 'Acesso a clientes',
|
||
raw_materials: 'Acesso a matérias-primas',
|
||
workers: 'Trabalhadores',
|
||
facilities: 'Instalações',
|
||
costs: 'Custos locais',
|
||
legal_requirements: 'Requisitos legais',
|
||
partial: 'Parcialmente',
|
||
not_applicable: 'Não se aplica',
|
||
confirmed: 'Confirmado (declarado)',
|
||
in_process: 'Em curso',
|
||
not_started: 'Ainda não iniciado',
|
||
};
|
||
return words[value] ?? value.replaceAll('_', ' ');
|
||
}
|
||
|
||
const fieldGuidance: Record<
|
||
string,
|
||
{ why: string; example: string; clarify: string }
|
||
> = {
|
||
project_title: {
|
||
why: 'Ajuda a identificar este projecto no seu dossier.',
|
||
example:
|
||
'Exemplo ilustrativo: “Produção local de alimentos processados”.',
|
||
clarify:
|
||
'Pode começar por descrever o que pretende produzir ou prestar.',
|
||
},
|
||
project_problem: {
|
||
why: 'Uma necessidade concreta ajuda a explicar a razão do projecto.',
|
||
example:
|
||
'Exemplo ilustrativo: dificuldade de acesso a determinado serviço numa zona.',
|
||
clarify:
|
||
'Converse com potenciais clientes e registe o que dizem, sem tratar opiniões como procura confirmada.',
|
||
},
|
||
target_customers: {
|
||
why: 'Clientes definidos ajudam a testar procura, localização e forma de venda.',
|
||
example:
|
||
'Exemplo ilustrativo: pequenas lojas de um distrito que procuram entregas semanais.',
|
||
clarify:
|
||
'Indique quem, onde e que necessidade pretende atender; pode responder “ainda não sei” na descrição.',
|
||
},
|
||
market_assumption: {
|
||
why: 'Separar hipótese de evidência ajuda a não tratar uma previsão como venda confirmada.',
|
||
example:
|
||
'Exemplo ilustrativo: “Acredito que haverá procura durante todo o ano”.',
|
||
clarify:
|
||
'Registe o que ainda precisa de testar com clientes ou fontes.',
|
||
},
|
||
market_source: {
|
||
why: 'A fonte e a data ajudam a avaliar se a informação ainda é actual.',
|
||
example:
|
||
'Exemplo ilustrativo: entrevistas com comerciantes, realizadas num mês e ano indicados.',
|
||
clarify:
|
||
'Se não houver fonte, deixe vazio; o portal mostrará isso como pendência.',
|
||
},
|
||
competitor_assumption: {
|
||
why: 'Conhecer alternativas ajuda a explicar como a proposta pode ser escolhida.',
|
||
example:
|
||
'Exemplo ilustrativo: outros fornecedores, produção própria ou ausência de oferta próxima.',
|
||
clarify:
|
||
'Pergunte aos clientes como resolvem hoje a mesma necessidade.',
|
||
},
|
||
sales_channel_assumption: {
|
||
why: 'O canal influencia acesso a clientes, entregas e custos.',
|
||
example:
|
||
'Exemplo ilustrativo: venda directa, revendedores ou encomendas digitais.',
|
||
clarify:
|
||
'Descreva apenas canais que está a considerar; não é necessário decidir agora.',
|
||
},
|
||
marketing_approach: {
|
||
why: 'Divulgação precisa de alcançar os clientes definidos.',
|
||
example:
|
||
'Exemplo ilustrativo: demonstração do produto num mercado local.',
|
||
clarify:
|
||
'Pense em como os clientes tomariam conhecimento e como poderá testar a resposta.',
|
||
},
|
||
location_rationale: {
|
||
why: 'Uma localização pode afectar acesso, transporte, infra-estruturas e custos.',
|
||
example:
|
||
'Exemplo ilustrativo: proximidade de clientes ou fornecedores, se confirmada.',
|
||
clarify:
|
||
'Consulte fontes locais e registe o que permanece por confirmar; seleccionar uma zona não garante disponibilidade.',
|
||
},
|
||
};
|
||
|
||
function FieldGuidance({ fieldKey }: { fieldKey: string }) {
|
||
const guidance = fieldGuidance[fieldKey];
|
||
if (!guidance) return null;
|
||
return (
|
||
<details className="mt-2 rounded border border-slate-200 p-2 text-sm">
|
||
<summary className="cursor-pointer font-medium focus-visible:outline-2">
|
||
Ajuda opcional: porque importa, exemplo e como esclarecer
|
||
</summary>
|
||
<dl className="mt-2 grid gap-2">
|
||
<div>
|
||
<dt className="font-medium">Porque é importante?</dt>
|
||
<dd>{guidance.why}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Ver exemplo (ilustrativo; não será preenchido)
|
||
</dt>
|
||
<dd>{guidance.example}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">Como posso esclarecer isto?</dt>
|
||
<dd>{guidance.clarify}</dd>
|
||
</div>
|
||
</dl>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function useChoices(
|
||
source: string | undefined,
|
||
query: string,
|
||
parent: string,
|
||
enabled: boolean,
|
||
) {
|
||
const [choices, setChoices] = useState<Choice[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [retryVersion, setRetryVersion] = useState(0);
|
||
const staticChoices = useMemo(() => {
|
||
if (source === 'currency') {
|
||
const intl = Intl as unknown as {
|
||
supportedValuesOf?: (key: string) => string[];
|
||
};
|
||
return (intl.supportedValuesOf?.('currency') ?? []).map((code) => ({
|
||
id: code,
|
||
label: code,
|
||
}));
|
||
}
|
||
if (
|
||
!source ||
|
||
[
|
||
'country',
|
||
'province',
|
||
'district',
|
||
'admin_post',
|
||
'locality',
|
||
'sector',
|
||
'activity',
|
||
].includes(source)
|
||
)
|
||
return [];
|
||
return (simulationOptions[source] ?? []).map((value) => ({
|
||
id: value,
|
||
label: labelFor(value),
|
||
}));
|
||
}, [source]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!enabled ||
|
||
!source ||
|
||
![
|
||
'country',
|
||
'province',
|
||
'district',
|
||
'admin_post',
|
||
'locality',
|
||
'sector',
|
||
'activity',
|
||
].includes(source)
|
||
) {
|
||
setChoices(staticChoices);
|
||
setError('');
|
||
return;
|
||
}
|
||
if (
|
||
['district', 'admin_post', 'locality', 'activity'].includes(
|
||
source,
|
||
) &&
|
||
!parent
|
||
) {
|
||
setChoices([]);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(async () => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
let url: URL;
|
||
if (source === 'sector') {
|
||
url = new URL('/api/v1/sectors', window.location.origin);
|
||
} else if (source === 'country') {
|
||
url = new URL(
|
||
'/api/v1/catalogs/countries',
|
||
window.location.origin,
|
||
);
|
||
url.searchParams.set('q', query);
|
||
url.searchParams.set('per_page', '100');
|
||
} else if (
|
||
['province', 'district', 'admin_post', 'locality'].includes(
|
||
source,
|
||
)
|
||
) {
|
||
url = new URL(
|
||
'/api/v1/investment/territory-options',
|
||
window.location.origin,
|
||
);
|
||
url.searchParams.set('type', source);
|
||
url.searchParams.set('q', query);
|
||
if (source !== 'province')
|
||
url.searchParams.set('parent_id', parent);
|
||
} else {
|
||
url = new URL(
|
||
'/api/v1/catalogs/' +
|
||
(source === 'activity'
|
||
? 'economic-activities'
|
||
: 'jurisdictions'),
|
||
window.location.origin,
|
||
);
|
||
url.searchParams.set('q', query);
|
||
url.searchParams.set('per_page', '50');
|
||
if (source === 'activity')
|
||
url.searchParams.set('sector_id', parent);
|
||
else {
|
||
url.searchParams.set(
|
||
'type',
|
||
source === 'country' ? 'country' : source,
|
||
);
|
||
if (parent) url.searchParams.set('parent_id', parent);
|
||
}
|
||
}
|
||
const response = await fetch(url, {
|
||
signal: controller.signal,
|
||
headers: { Accept: 'application/json' },
|
||
credentials: 'same-origin',
|
||
});
|
||
if (!response.ok)
|
||
throw new Error(
|
||
'Não foi possível carregar as opções. Tente novamente.',
|
||
);
|
||
const payload = await readSimulationApiJson(response);
|
||
if (controller.signal.aborted) return;
|
||
setChoices(simulationCatalogChoices(payload));
|
||
} catch (cause) {
|
||
if (!controller.signal.aborted)
|
||
setError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Erro ao carregar opções.',
|
||
);
|
||
} finally {
|
||
if (!controller.signal.aborted) setLoading(false);
|
||
}
|
||
}, 180);
|
||
return () => {
|
||
window.clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [source, query, parent, enabled, staticChoices, retryVersion]);
|
||
return {
|
||
choices,
|
||
loading,
|
||
error,
|
||
retry: () => setRetryVersion((version) => version + 1),
|
||
};
|
||
}
|
||
|
||
function SearchChoice({
|
||
label,
|
||
source,
|
||
parent = '',
|
||
value,
|
||
selectedLabel,
|
||
multiple,
|
||
disabled,
|
||
error,
|
||
required,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
source?: string;
|
||
parent?: string;
|
||
value: string | string[];
|
||
selectedLabel?: string | string[];
|
||
multiple?: boolean;
|
||
disabled?: boolean;
|
||
error?: string;
|
||
required?: boolean;
|
||
onChange: (value: string | string[], text?: string | string[]) => void;
|
||
}) {
|
||
const generated = useId();
|
||
const inputId = `choice-${generated}`;
|
||
const listId = `${inputId}-options`;
|
||
const activeOptionRef = useRef<HTMLDivElement | null>(null);
|
||
const [query, setQuery] = useState('');
|
||
const [open, setOpen] = useState(false);
|
||
const [active, setActive] = useState(-1);
|
||
const {
|
||
choices,
|
||
loading,
|
||
error: loadError,
|
||
retry,
|
||
} = useChoices(source, query, parent, !disabled && open);
|
||
const chosen = Array.isArray(value) ? value : value ? [value] : [];
|
||
const searchText = (text: string) =>
|
||
text
|
||
.normalize('NFD')
|
||
.replace(/\p{Diacritic}/gu, '')
|
||
.toLocaleLowerCase();
|
||
const filtered = choices.filter(
|
||
(option) =>
|
||
searchText(option.label).includes(searchText(query)) ||
|
||
searchText(option.id).includes(searchText(query)),
|
||
);
|
||
useEffect(() => {
|
||
if (open) activeOptionRef.current?.scrollIntoView({ block: 'nearest' });
|
||
}, [active, open]);
|
||
const describedBy =
|
||
[
|
||
error ? `${inputId}-error` : '',
|
||
loadError ? `${inputId}-load-error` : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ') || undefined;
|
||
const chooseOption = (option: Choice) => {
|
||
if (multiple) {
|
||
if (!chosen.includes(option.id))
|
||
onChange(
|
||
[...chosen, option.id],
|
||
[
|
||
...(Array.isArray(selectedLabel) ? selectedLabel : []),
|
||
option.label,
|
||
],
|
||
);
|
||
setQuery('');
|
||
} else {
|
||
onChange(option.id, option.label);
|
||
setQuery('');
|
||
setOpen(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
<label htmlFor={inputId} className="block text-sm font-medium">
|
||
{label}
|
||
</label>
|
||
<input
|
||
id={inputId}
|
||
role="combobox"
|
||
aria-autocomplete="list"
|
||
aria-expanded={open}
|
||
aria-controls={open && !disabled ? listId : undefined}
|
||
aria-activedescendant={
|
||
open && filtered[active] ? `${listId}-${active}` : undefined
|
||
}
|
||
aria-invalid={Boolean(error || loadError)}
|
||
aria-required={required || undefined}
|
||
aria-describedby={describedBy}
|
||
autoComplete="off"
|
||
className="w-full rounded-lg border border-slate-300 bg-white p-3 text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 disabled:bg-slate-100"
|
||
value={
|
||
open
|
||
? query
|
||
: multiple
|
||
? ''
|
||
: selectedLabel ||
|
||
(chosen[0]
|
||
? (choices.find((o) => o.id === chosen[0])
|
||
?.label ?? chosen[0])
|
||
: '')
|
||
}
|
||
placeholder={
|
||
disabled
|
||
? 'Seleccione primeiro o campo anterior'
|
||
: 'Pesquisar opções…'
|
||
}
|
||
disabled={disabled}
|
||
onFocus={() => {
|
||
setOpen(true);
|
||
setQuery('');
|
||
setActive(-1);
|
||
}}
|
||
onBlur={() => {
|
||
window.setTimeout(() => setOpen(false), 120);
|
||
}}
|
||
onChange={(event) => {
|
||
setQuery(event.target.value);
|
||
setActive(0);
|
||
setOpen(true);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
setOpen(true);
|
||
setActive((i) =>
|
||
moveSearchChoiceIndex(i, 1, filtered.length),
|
||
);
|
||
}
|
||
if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
setActive((i) =>
|
||
moveSearchChoiceIndex(i, -1, filtered.length),
|
||
);
|
||
}
|
||
if (event.key === 'Enter' && open && filtered[active]) {
|
||
event.preventDefault();
|
||
chooseOption(filtered[active]);
|
||
}
|
||
if (event.key === 'Escape') {
|
||
setOpen(false);
|
||
setQuery('');
|
||
}
|
||
if (
|
||
event.key === 'Backspace' &&
|
||
multiple &&
|
||
query === '' &&
|
||
chosen.length
|
||
)
|
||
onChange(
|
||
chosen.slice(0, -1),
|
||
Array.isArray(selectedLabel)
|
||
? selectedLabel.slice(0, -1)
|
||
: [],
|
||
);
|
||
}}
|
||
/>
|
||
{multiple && chosen.length > 0 && (
|
||
<ul
|
||
aria-label={`Opções seleccionadas: ${label}`}
|
||
className="flex flex-wrap gap-2"
|
||
>
|
||
{chosen.map((item, index) => {
|
||
const text = Array.isArray(selectedLabel)
|
||
? selectedLabel[index]
|
||
: (choices.find((option) => option.id === item)
|
||
?.label ?? item);
|
||
return (
|
||
<li
|
||
key={item}
|
||
className="flex items-center gap-1 rounded bg-emerald-50 px-2 py-1 text-sm"
|
||
>
|
||
{text}
|
||
<button
|
||
type="button"
|
||
aria-label={`Remover ${text}`}
|
||
onClick={() =>
|
||
onChange(
|
||
chosen.filter(
|
||
(_, i) => i !== index,
|
||
),
|
||
Array.isArray(selectedLabel)
|
||
? selectedLabel.filter(
|
||
(_, i) => i !== index,
|
||
)
|
||
: [],
|
||
)
|
||
}
|
||
className="rounded px-1 focus-visible:outline-2"
|
||
>
|
||
×
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
{open && !disabled && (
|
||
<>
|
||
{loading && (
|
||
<output
|
||
className="block p-3 text-sm"
|
||
aria-live="polite"
|
||
>
|
||
A carregar opções…
|
||
</output>
|
||
)}
|
||
{!loading && !loadError && filtered.length === 0 && (
|
||
<p className="p-3 text-sm">
|
||
Sem opções correspondentes.
|
||
</p>
|
||
)}
|
||
{/* eslint-disable jsx-a11y/prefer-tag-over-role -- The remote searchable and parent-filtered multi-select needs the ARIA combobox/listbox pattern; native select cannot provide that interaction. */}
|
||
<div
|
||
id={listId}
|
||
role="listbox"
|
||
aria-label={label}
|
||
aria-multiselectable={multiple || undefined}
|
||
className="max-h-56 overflow-auto rounded border bg-white shadow-lg"
|
||
>
|
||
{!loading &&
|
||
filtered.map((option, index) => (
|
||
<div
|
||
id={`${listId}-${index}`}
|
||
key={option.id}
|
||
role="option"
|
||
tabIndex={-1}
|
||
ref={
|
||
active === index
|
||
? activeOptionRef
|
||
: undefined
|
||
}
|
||
aria-selected={chosen.includes(option.id)}
|
||
className={`cursor-pointer p-3 text-sm hover:bg-emerald-50 ${active === index ? 'bg-emerald-50' : ''}`}
|
||
onMouseEnter={() => setActive(index)}
|
||
onMouseDown={(event) =>
|
||
event.preventDefault()
|
||
}
|
||
onKeyDown={(event) => {
|
||
if (
|
||
event.key === 'Enter' ||
|
||
event.key === ' '
|
||
) {
|
||
event.preventDefault();
|
||
chooseOption(option);
|
||
}
|
||
}}
|
||
onClick={() => chooseOption(option)}
|
||
>
|
||
{option.label}
|
||
{chosen.includes(option.id) ? ' ✓' : ''}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* eslint-enable jsx-a11y/prefer-tag-over-role */}
|
||
</>
|
||
)}
|
||
{loadError && (
|
||
<div className="space-y-1">
|
||
<p
|
||
id={`${inputId}-load-error`}
|
||
role="alert"
|
||
className="text-sm text-red-800"
|
||
>
|
||
{loadError}
|
||
</p>
|
||
{open && !disabled && (
|
||
<button
|
||
type="button"
|
||
className="text-sm font-semibold underline focus-visible:outline-2"
|
||
onClick={retry}
|
||
>
|
||
Tentar carregar novamente
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{error && (
|
||
<p
|
||
id={`${inputId}-error`}
|
||
role="alert"
|
||
className="text-sm text-red-800"
|
||
>
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SimulationForm() {
|
||
const { auth } = usePage<{
|
||
auth: { user: { id: number; email_verified_at?: string | null } | null };
|
||
}>().props;
|
||
const pageUrl = usePage().url;
|
||
const draftContext = useMemo(() => {
|
||
const params = new URL(pageUrl, 'https://investmoz.invalid')
|
||
.searchParams;
|
||
return {
|
||
context: params.get('context'),
|
||
requestedDraft: params.get('draft'),
|
||
};
|
||
}, [pageUrl]);
|
||
const contextId = draftContext.context;
|
||
const projectScope = contextId
|
||
? `:case:${contextId}`
|
||
: draftContext.requestedDraft
|
||
? `:draft:${draftContext.requestedDraft}`
|
||
: '';
|
||
const storageScope = `${auth?.user ? `:account:${auth.user.id}` : ':anonymous'}${projectScope}`;
|
||
const draftKey = legacyDraftKey + storageScope;
|
||
const draftCreateKey = legacyDraftCreateKey + storageScope;
|
||
const draftIdKey = legacyDraftIdKey + storageScope;
|
||
const idempotencyStorageKey = legacyIdempotencyStorageKey + storageScope;
|
||
const currentDraftUrl = contextId
|
||
? `/api/v1/simulation-drafts/current?context=${encodeURIComponent(contextId)}`
|
||
: '/api/v1/simulation-drafts/current';
|
||
const canSavePersonalData = Boolean(auth?.user);
|
||
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
|
||
const [draft, setDraft] = useState<SimulationDraft>({});
|
||
const [editingReused, setEditingReused] = useState<string[]>([]);
|
||
const [ready, setReady] = useState(false);
|
||
useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]);
|
||
const [contextReview, setContextReview] = useState({
|
||
revision: 0,
|
||
reviewed: false,
|
||
});
|
||
const [step, setStep] = useState(0);
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
const [ivaIssues, setIvaIssues] = useState<IvaIssue[]>([]);
|
||
const [serverFieldErrors, setServerFieldErrors] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [saveStatus, setSaveStatus] = useState(
|
||
'Rascunho ainda não guardado.',
|
||
);
|
||
const [serverError, setServerError] = useState('');
|
||
const [reviewStatus, setReviewStatus] = useState('');
|
||
const [openedTool, setOpenedTool] = useState<
|
||
'calculation' | 'fiscal' | null
|
||
>(null);
|
||
const [toolError, setToolError] = useState('');
|
||
useEffect(() => {
|
||
if (!openedTool) return;
|
||
const destination = document.getElementById(`draft-tool-${openedTool}`);
|
||
if (!destination) {
|
||
setToolError(
|
||
'Não foi possível abrir a ferramenta. O rascunho foi preservado; volte a tentar.',
|
||
);
|
||
return;
|
||
}
|
||
destination.focus();
|
||
destination.scrollIntoView({ block: 'start' });
|
||
}, [openedTool, step]);
|
||
function openDraftTool(tool: 'calculation' | 'fiscal') {
|
||
setToolError('');
|
||
const index = journeySections.findIndex((section) =>
|
||
tool === 'calculation'
|
||
? ['financial', 'tax'].includes(section.id)
|
||
: section.id === 'tax',
|
||
);
|
||
if (index < 0) {
|
||
setToolError(
|
||
'Esta etapa não disponibiliza a operação fiscal. Reveja o percurso escolhido; os dados foram preservados.',
|
||
);
|
||
return;
|
||
}
|
||
setStep(index);
|
||
setOpenedTool(tool);
|
||
}
|
||
const [reviewDetails, setReviewDetails] = useState<{
|
||
checked: { key: string; label: string; value: string }[];
|
||
pending: {
|
||
key: string;
|
||
label: string;
|
||
message: string;
|
||
step: number;
|
||
}[];
|
||
}>({ checked: [], pending: [] });
|
||
const reviewSummaryRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (!reviewStatus) return;
|
||
reviewSummaryRef.current?.focus();
|
||
reviewSummaryRef.current?.scrollIntoView({ block: 'start' });
|
||
}, [reviewStatus, reviewDetails]);
|
||
const [isCalculating, setIsCalculating] = useState(false);
|
||
const [hasSaveConflict, setHasSaveConflict] = useState(false);
|
||
const [result, setResult] = useState<ApiSimulationExecutionV1 | null>(null);
|
||
const [calculationStale, setCalculationStale] = useState(false);
|
||
useEffect(() => {
|
||
if (result) setCalculationStale(false);
|
||
}, [result]);
|
||
const arithmeticResult = useMemo(
|
||
() => readArithmeticSnapshot(result?.result_snapshot),
|
||
[result],
|
||
);
|
||
const arithmeticSnapshot = useMemo(() => {
|
||
const snapshot = result?.result_snapshot;
|
||
return snapshot &&
|
||
typeof snapshot === 'object' &&
|
||
!Array.isArray(snapshot)
|
||
? (snapshot as Record<string, unknown>)
|
||
: null;
|
||
}, [result]);
|
||
const arithmeticReportReference = useMemo(
|
||
() => readVersionedArithmeticReportReference(result),
|
||
[result],
|
||
);
|
||
const isFiscalQaPreview = ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(arithmeticSnapshot?.scenario_kind));
|
||
const versionedReportMissing =
|
||
arithmeticResult.integrity === 'versioned' &&
|
||
arithmeticReportReference === null;
|
||
const [simulationSources, setSimulationSources] = useState<
|
||
ApiSimulationSourceV1[]
|
||
>([]);
|
||
const [conversionConsent, setConversionConsent] = useState(false);
|
||
const [conversionError, setConversionError] = useState('');
|
||
const [isAttachingSimulation, setIsAttachingSimulation] = useState(false);
|
||
const [simulationHistory, setSimulationHistory] = useState<
|
||
ApiSimulationHistoryEntryV1[]
|
||
>([]);
|
||
const [historyPage, setHistoryPage] = useState(1);
|
||
const [historyLastPage, setHistoryLastPage] = useState(1);
|
||
const [historyError, setHistoryError] = useState('');
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
const [accountDrafts, setAccountDrafts] = useState<
|
||
ApiSimulationDraftSummaryV1[]
|
||
>([]);
|
||
const [pendingAnonymousDraft, setPendingAnonymousDraft] =
|
||
useState<ApiSimulationDraftSummaryV1 | null>(null);
|
||
const [showDraftChoice, setShowDraftChoice] = useState(false);
|
||
const [selectedAccountDraftId, setSelectedAccountDraftId] = useState('');
|
||
const [draftChoiceBusy, setDraftChoiceBusy] = useState(false);
|
||
const [continuityError, setContinuityError] = useState('');
|
||
const [draftExpiresAt, setDraftExpiresAt] = useState<string | null>(null);
|
||
const [draftVersionNo, setDraftVersionNo] = useState<number | null>(null);
|
||
const [documentRecommendations, setDocumentRecommendations] = useState<
|
||
PublicDocument[]
|
||
>([]);
|
||
const [linkableBusinesses, setLinkableBusinesses] = useState<
|
||
Array<{ id: string; title: string; modality: string; status: string }>
|
||
>([]);
|
||
const [linkedBusiness, setLinkedBusiness] = useState<{
|
||
id: string;
|
||
business_id: string;
|
||
title: string;
|
||
linked_at: string;
|
||
} | null>(null);
|
||
const [selectedBusinessId, setSelectedBusinessId] = useState('');
|
||
const [businessLinkError, setBusinessLinkError] = useState('');
|
||
const [businessLinkBusy, setBusinessLinkBusy] = useState(false);
|
||
const [continueLaterSaved, setContinueLaterSaved] = useState(false);
|
||
const [saveAttempt, setSaveAttempt] = useState(0);
|
||
const calculationInFlightRef = useRef(false);
|
||
const draftIdRef = useRef<string | null>(null);
|
||
const draftEtagRef = useRef<string | null>(null);
|
||
const lastSavedFingerprintRef = useRef<string | null>(null);
|
||
const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||
const saveConflictRef = useRef(false);
|
||
const journeySections = getSimulationSections(draft);
|
||
const current = journeySections[Math.min(step, journeySections.length - 1)];
|
||
useEffect(() => {
|
||
if (!ready || current.id !== 'review') return;
|
||
const params = new URLSearchParams({ module: 'simulator' });
|
||
if (typeof draft.activity_id === 'string' && draft.activity_id)
|
||
params.set('activity_id', draft.activity_id);
|
||
if (typeof draft.province_id === 'string' && draft.province_id)
|
||
params.set('province_id', draft.province_id);
|
||
const projectStage =
|
||
typeof draft.maturity_stage === 'string'
|
||
? draft.maturity_stage
|
||
: typeof draft.entity_stage === 'string'
|
||
? draft.entity_stage
|
||
: '';
|
||
if (projectStage) params.set('stage', projectStage);
|
||
const controller = new AbortController();
|
||
void fetch(`/api/v1/documents/recommendations?${params.toString()}`, {
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
signal: controller.signal,
|
||
})
|
||
.then(async (response) => {
|
||
if (!response.ok) throw new Error('recommendation-unavailable');
|
||
const payload: unknown = await response.json();
|
||
if (
|
||
payload &&
|
||
typeof payload === 'object' &&
|
||
'data' in payload &&
|
||
Array.isArray(payload.data)
|
||
) {
|
||
setDocumentRecommendations(
|
||
payload.data.filter(
|
||
(
|
||
item,
|
||
): item is (typeof documentRecommendations)[number] =>
|
||
Boolean(
|
||
item &&
|
||
typeof item === 'object' &&
|
||
'id' in item &&
|
||
'title' in item,
|
||
),
|
||
),
|
||
);
|
||
} else setDocumentRecommendations([]);
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (
|
||
!(
|
||
error instanceof DOMException &&
|
||
error.name === 'AbortError'
|
||
)
|
||
)
|
||
setDocumentRecommendations([]);
|
||
});
|
||
return () => controller.abort();
|
||
}, [ready, current.id, draft.activity_id, draft.province_id]);
|
||
const guidedSmallBusiness =
|
||
draft.purpose === 'simulate_only' &&
|
||
draft.journey_type === 'small_business';
|
||
const fieldsForCurrent =
|
||
guidedSmallBusiness && current.id === 'purpose'
|
||
? [
|
||
...simulationFieldGroups.purpose,
|
||
...simulationFieldGroups.project.filter((field) =>
|
||
[
|
||
'project_title',
|
||
'project_summary',
|
||
'project_problem',
|
||
'value_proposition',
|
||
].includes(field.key),
|
||
),
|
||
]
|
||
: guidedSmallBusiness && current.id === 'market'
|
||
? [
|
||
...simulationFieldGroups.project.filter(
|
||
(field) =>
|
||
![
|
||
'project_title',
|
||
'project_summary',
|
||
'project_problem',
|
||
'value_proposition',
|
||
'target_customers',
|
||
'market_assumption',
|
||
'market_source',
|
||
].includes(field.key),
|
||
),
|
||
...simulationFieldGroups.market,
|
||
]
|
||
: guidedSmallBusiness && current.id === 'financial'
|
||
? [
|
||
...(simulationFieldGroups.financial ?? []),
|
||
...simulationFieldGroups.opportunity,
|
||
]
|
||
: draft.purpose === 'simulate_only' &&
|
||
draft.journey_type === 'investor' &&
|
||
current.id === 'project'
|
||
? [
|
||
...simulationFieldGroups.project,
|
||
...simulationFieldGroups.market.filter(
|
||
(field) =>
|
||
!simulationFieldGroups.project.some(
|
||
(projectField) =>
|
||
projectField.key === field.key,
|
||
),
|
||
),
|
||
]
|
||
: (simulationFieldGroups[current.id] ?? []);
|
||
const fields = fieldsForCurrent.filter(
|
||
(field) =>
|
||
!(
|
||
current.id === 'tax' &&
|
||
draft.fiscal_scenario_mode === 'custom_planning' &&
|
||
!['fiscal_scenario_mode', 'base', 'rate', 'reduction'].includes(
|
||
field.key,
|
||
)
|
||
) &&
|
||
(!field.showWhen || field.showWhen(draft)) &&
|
||
!(
|
||
draft.purpose === 'simulate_only' &&
|
||
draft.journey_type === 'small_business' &&
|
||
current.id === 'impact' &&
|
||
field.key === 'skills_needed'
|
||
) &&
|
||
!(
|
||
draft.purpose === 'simulate_only' &&
|
||
draft.journey_type === 'small_business' &&
|
||
current.id === 'impact' &&
|
||
field.key === 'project_milestones'
|
||
) &&
|
||
(draft.detail_level === 'advanced' ||
|
||
(current.id === 'procedures' &&
|
||
field.key === 'required_documents') ||
|
||
!advancedSimulationFields.has(field.key)),
|
||
);
|
||
const completedSimulationId =
|
||
result?.status === 'completed' && typeof result.id === 'string'
|
||
? result.id
|
||
: '';
|
||
const completedSimulationAttached = Boolean(
|
||
completedSimulationId &&
|
||
simulationSources.some(
|
||
(source) => source.simulation_id === completedSimulationId,
|
||
),
|
||
);
|
||
|
||
const loadSimulationHistory = useCallback(async (page = 1) => {
|
||
setHistoryLoading(true);
|
||
setHistoryError('');
|
||
try {
|
||
const response = await fetch(
|
||
`/api/v1/simulations/history?page=${page}&per_page=10`,
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
},
|
||
);
|
||
if (response.status === 403)
|
||
throw new Error(
|
||
'Verifique o correio electrónico para consultar o histórico.',
|
||
);
|
||
if (response.status === 401)
|
||
throw new Error('Inicie sessão para consultar o histórico.');
|
||
if (!response.ok)
|
||
throw new Error(
|
||
'Não foi possível carregar o histórico da conta.',
|
||
);
|
||
const payload: unknown = await readSimulationApiJson(response);
|
||
if (!isApiSimulationHistoryResponseV1(payload))
|
||
throw new Error(
|
||
'O servidor devolveu um histórico com formato inválido.',
|
||
);
|
||
setSimulationHistory(payload.data);
|
||
setHistoryPage(payload.meta.current_page || page);
|
||
setHistoryLastPage(payload.meta.last_page || 1);
|
||
} catch (cause) {
|
||
setHistoryError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Falha ao carregar o histórico.',
|
||
);
|
||
} finally {
|
||
setHistoryLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (canSavePersonalData) void loadSimulationHistory();
|
||
}, [canSavePersonalData, loadSimulationHistory]);
|
||
|
||
async function openHistorySimulation(id: string) {
|
||
setHistoryError('');
|
||
try {
|
||
const response = await fetch(
|
||
`/api/v1/simulations/${encodeURIComponent(id)}`,
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
},
|
||
);
|
||
if (!response.ok)
|
||
throw new Error('Não foi possível abrir esta simulação.');
|
||
const payload: unknown = await readSimulationApiJson(response);
|
||
if (
|
||
!isApiSimulationReadResponseV1(payload) ||
|
||
payload.data.id !== id
|
||
)
|
||
throw new Error(
|
||
'O servidor devolveu uma simulação inválida para este histórico.',
|
||
);
|
||
setResult(payload.data);
|
||
document.getElementById('simulator-step-title')?.focus();
|
||
} catch (cause) {
|
||
setHistoryError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Falha ao abrir a simulação.',
|
||
);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setReady(false);
|
||
void (async () => {
|
||
let local: SimulationDraft = {};
|
||
const requestedDraftId = new URLSearchParams(
|
||
window.location.search,
|
||
).get('draft');
|
||
try {
|
||
const stored = sessionStorage.getItem(draftKey);
|
||
if (stored) local = JSON.parse(stored) as SimulationDraft;
|
||
} catch {
|
||
setSaveStatus(
|
||
'Não foi possível recuperar o rascunho desta sessão.',
|
||
);
|
||
}
|
||
try {
|
||
const response = await fetch(
|
||
requestedDraftId && !contextId
|
||
? `/api/v1/simulation-drafts/${encodeURIComponent(requestedDraftId)}`
|
||
: currentDraftUrl,
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
},
|
||
},
|
||
);
|
||
if (response.ok) {
|
||
const payload: unknown =
|
||
await readSimulationApiJson(response);
|
||
if (!isApiSimulationDraftReadResponseV1(payload))
|
||
throw new Error(
|
||
'O servidor devolveu um rascunho com formato inválido.',
|
||
);
|
||
if (canSavePersonalData && !contextId) {
|
||
const [listResponse, previewResponse] =
|
||
await Promise.all([
|
||
fetch('/api/v1/simulation-drafts', {
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
}),
|
||
fetch(
|
||
'/api/v1/simulation-drafts/claim-preview',
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
},
|
||
),
|
||
]);
|
||
const listPayload: unknown = listResponse.ok
|
||
? await readSimulationApiJson(listResponse)
|
||
: null;
|
||
const previewPayload: unknown = previewResponse.ok
|
||
? await readSimulationApiJson(previewResponse)
|
||
: null;
|
||
if (
|
||
!isApiSimulationDraftListResponseV1(listPayload) ||
|
||
!isApiSimulationDraftClaimPreviewV1(previewPayload)
|
||
)
|
||
throw new Error(
|
||
'Não foi possível verificar os dossiers privados e o rascunho deste navegador.',
|
||
);
|
||
setAccountDrafts(listPayload.data);
|
||
setSelectedAccountDraftId(
|
||
listPayload.data[0]?.id ?? '',
|
||
);
|
||
if (previewPayload.data && !requestedDraftId) {
|
||
setPendingAnonymousDraft(previewPayload.data);
|
||
setShowDraftChoice(true);
|
||
setSaveStatus(
|
||
'Encontrámos um rascunho temporário neste navegador. Escolha como continuar.',
|
||
);
|
||
if (!cancelled) setReady(true);
|
||
return;
|
||
}
|
||
}
|
||
if (payload.data) {
|
||
if (cancelled) return;
|
||
draftIdRef.current = payload.data.id;
|
||
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
|
||
setDraftVersionNo(payload.data.version_no);
|
||
setDraftExpiresAt(payload.data.expires_at ?? null);
|
||
if (canSavePersonalData)
|
||
setSelectedAccountDraftId(payload.data.id);
|
||
sessionStorage.setItem(draftIdKey, payload.data.id);
|
||
const restored = restoreSimulationDraft(
|
||
payload.data.economic_snapshot,
|
||
Array.isArray(payload.data.personal_snapshot)
|
||
? {}
|
||
: payload.data.personal_snapshot,
|
||
);
|
||
setSimulationSources(payload.data.simulation_sources);
|
||
lastSavedFingerprintRef.current =
|
||
simulationDraftFingerprint(restored);
|
||
setDraft(
|
||
canSavePersonalData
|
||
? restored
|
||
: { ...restored, ...local },
|
||
);
|
||
if (canSavePersonalData)
|
||
setSaveStatus(
|
||
'Dossier privado recuperado da sua conta.',
|
||
);
|
||
else
|
||
setSaveStatus(
|
||
payload.data.expires_at
|
||
? `Rascunho recuperado. Guardado neste navegador até ${new Intl.DateTimeFormat('pt-MZ', { dateStyle: 'long' }).format(new Date(payload.data.expires_at))}.`
|
||
: 'Rascunho recuperado do servidor.',
|
||
);
|
||
} else if (
|
||
!canSavePersonalData &&
|
||
Object.keys(local).length
|
||
) {
|
||
const storedId = sessionStorage.getItem(draftIdKey);
|
||
if (storedId) sessionStorage.removeItem(draftIdKey);
|
||
setDraft(local);
|
||
}
|
||
} else if (contextId || requestedDraftId) {
|
||
saveConflictRef.current = true;
|
||
setHasSaveConflict(true);
|
||
setSaveStatus(
|
||
'Dossier indisponível para esta conta. Nenhum outro rascunho foi aberto.',
|
||
);
|
||
} else if (!canSavePersonalData && Object.keys(local).length)
|
||
setDraft(local);
|
||
} catch {
|
||
if (contextId || requestedDraftId) {
|
||
saveConflictRef.current = true;
|
||
setHasSaveConflict(true);
|
||
}
|
||
if (!canSavePersonalData && Object.keys(local).length)
|
||
setDraft(local);
|
||
setSaveStatus(
|
||
'Sem ligação ao servidor; o rascunho desta sessão será mantido localmente.',
|
||
);
|
||
}
|
||
if (!cancelled) setReady(true);
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [canSavePersonalData, contextId, currentDraftUrl, draftIdKey, draftKey]);
|
||
useEffect(() => {
|
||
if (!ready || !draft.purpose) return;
|
||
const timer = window.setTimeout(() => {
|
||
try {
|
||
sessionStorage.setItem(
|
||
draftKey,
|
||
JSON.stringify(getAutosaveSnapshot(draft)),
|
||
);
|
||
} catch {
|
||
setSaveStatus(
|
||
'Não foi possível guardar o rascunho neste navegador.',
|
||
);
|
||
}
|
||
if (saveConflictRef.current) return;
|
||
const snapshots = partitionSimulationDraft(
|
||
draft,
|
||
canSavePersonalData,
|
||
);
|
||
const fingerprint = simulationDraftFingerprint(
|
||
canSavePersonalData ? draft : getAutosaveSnapshot(draft),
|
||
);
|
||
if (fingerprint === lastSavedFingerprintRef.current) return;
|
||
setSaveStatus('A guardar o rascunho no servidor…');
|
||
saveQueueRef.current = saveQueueRef.current
|
||
.then(async () => {
|
||
if (fingerprint === lastSavedFingerprintRef.current) return;
|
||
const headers = {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
};
|
||
if (!draftIdRef.current) {
|
||
const currentResponse = await fetch(currentDraftUrl, {
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
},
|
||
});
|
||
const currentPayload: unknown = currentResponse.ok
|
||
? await readSimulationApiJson(currentResponse)
|
||
: { data: null };
|
||
let currentDraft: {
|
||
id: string;
|
||
version_no: number;
|
||
} | null = null;
|
||
if (currentResponse.ok) {
|
||
if (
|
||
!isApiSimulationDraftReadResponseV1(
|
||
currentPayload,
|
||
)
|
||
)
|
||
throw new Error(
|
||
'O servidor devolveu um rascunho com formato inválido.',
|
||
);
|
||
currentDraft = currentPayload.data;
|
||
}
|
||
if (currentDraft) {
|
||
draftIdRef.current = currentDraft.id;
|
||
draftEtagRef.current =
|
||
simulationDraftEtag(currentResponse.headers.get('ETag'));
|
||
sessionStorage.setItem(draftIdKey, currentDraft.id);
|
||
}
|
||
}
|
||
let response: Response;
|
||
if (draftIdRef.current && draftEtagRef.current) {
|
||
response = await fetch(
|
||
`/api/v1/simulation-drafts/${draftIdRef.current}`,
|
||
{
|
||
method: 'PUT',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
...headers,
|
||
'If-Match': draftEtagRef.current,
|
||
},
|
||
body: JSON.stringify({
|
||
...snapshots,
|
||
...(contextId
|
||
? { context: contextId }
|
||
: {}),
|
||
}),
|
||
},
|
||
);
|
||
} else {
|
||
let key = sessionStorage.getItem(draftCreateKey);
|
||
if (!key) {
|
||
key = idempotencyKey();
|
||
sessionStorage.setItem(draftCreateKey, key);
|
||
}
|
||
response = await fetch('/api/v1/simulation-drafts', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { ...headers, 'Idempotency-Key': key },
|
||
body: JSON.stringify({
|
||
...snapshots,
|
||
...(contextId ? { context: contextId } : {}),
|
||
}),
|
||
});
|
||
}
|
||
if (response.status === 412 || response.status === 428) {
|
||
saveConflictRef.current = true;
|
||
setHasSaveConflict(true);
|
||
setSaveStatus(
|
||
'O rascunho mudou noutro separador. Recarregue para rever a versão mais recente.',
|
||
);
|
||
return;
|
||
}
|
||
const payload: unknown =
|
||
await readSimulationApiJson(response);
|
||
if (response.status === 422)
|
||
setServerFieldErrors(simulationApiFieldErrors(payload));
|
||
if (!response.ok)
|
||
throw new Error(
|
||
simulationApiErrorMessage(payload) ??
|
||
'Não foi possível guardar o rascunho no servidor.',
|
||
);
|
||
if (!isApiSimulationDraftWriteResponseV1(payload))
|
||
throw new Error(
|
||
'O servidor devolveu uma confirmação de gravação inválida.',
|
||
);
|
||
draftIdRef.current = payload.data.id;
|
||
setServerFieldErrors({});
|
||
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
|
||
setDraftVersionNo(payload.data.version_no);
|
||
lastSavedFingerprintRef.current = fingerprint;
|
||
sessionStorage.setItem(draftIdKey, payload.data.id);
|
||
sessionStorage.removeItem(draftCreateKey);
|
||
setDraftExpiresAt(payload.expires_at ?? null);
|
||
if (canSavePersonalData) {
|
||
setSelectedAccountDraftId(payload.data.id);
|
||
if (
|
||
!accountDrafts.some(
|
||
(item) => item.id === payload.data.id,
|
||
)
|
||
) {
|
||
const listResponse = await fetch(
|
||
'/api/v1/simulation-drafts',
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
},
|
||
);
|
||
const listPayload: unknown = listResponse.ok
|
||
? await readSimulationApiJson(listResponse)
|
||
: null;
|
||
if (isApiSimulationDraftListResponseV1(listPayload))
|
||
setAccountDrafts(listPayload.data);
|
||
}
|
||
}
|
||
setContinueLaterSaved(false);
|
||
if (draft.reuse_simulation === 'no')
|
||
setSimulationSources([]);
|
||
const savedAt = new Intl.DateTimeFormat('pt-MZ', {
|
||
timeStyle: 'short',
|
||
}).format(new Date());
|
||
const expiry = payload.expires_at
|
||
? ` Recuperável neste navegador até ${new Intl.DateTimeFormat('pt-MZ', { dateStyle: 'long' }).format(new Date(payload.expires_at))}.`
|
||
: '';
|
||
setSaveStatus(
|
||
`Rascunho guardado no servidor às ${savedAt}.${expiry}`,
|
||
);
|
||
})
|
||
.catch((cause) => {
|
||
setSaveStatus(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Erro ao guardar o rascunho no servidor.',
|
||
);
|
||
});
|
||
}, 450);
|
||
return () => window.clearTimeout(timer);
|
||
}, [
|
||
draft,
|
||
ready,
|
||
canSavePersonalData,
|
||
accountDrafts,
|
||
contextId,
|
||
currentDraftUrl,
|
||
draftCreateKey,
|
||
draftIdKey,
|
||
draftKey,
|
||
saveAttempt,
|
||
]);
|
||
|
||
async function returnToInvestment() {
|
||
if (!ready || !contextId || !draftIdRef.current) return;
|
||
setContinuityError('');
|
||
try {
|
||
await saveQueueRef.current;
|
||
const current = await fetch(
|
||
`/api/v1/simulation-drafts/${draftIdRef.current}`,
|
||
{
|
||
headers: { Accept: 'application/json' },
|
||
credentials: 'same-origin',
|
||
},
|
||
);
|
||
const saved: unknown = await readSimulationApiJson(current);
|
||
if (
|
||
!current.ok ||
|
||
!isApiSimulationDraftReadResponseV1(saved) ||
|
||
!saved.data
|
||
)
|
||
throw new Error(
|
||
'Não foi possível recuperar o rascunho guardado.',
|
||
);
|
||
if (
|
||
simulationDraftFingerprint(
|
||
restoreSimulationDraft(
|
||
saved.data.economic_snapshot,
|
||
Array.isArray(saved.data.personal_snapshot)
|
||
? {}
|
||
: saved.data.personal_snapshot,
|
||
),
|
||
) !== simulationDraftFingerprint(draft)
|
||
)
|
||
throw new Error(
|
||
'Aguarde a gravação das entradas da ferramenta e tente novamente.',
|
||
);
|
||
const dossier = await fetch(`/investimentos/${contextId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
credentials: 'same-origin',
|
||
});
|
||
if (!dossier.ok)
|
||
throw new Error('Dossier indisponível para esta conta.');
|
||
const view = (await dossier.json()) as {
|
||
case: { version: number };
|
||
};
|
||
const response = await fetch(
|
||
`/investimentos/${contextId}/resultados`,
|
||
{
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
},
|
||
body: JSON.stringify({
|
||
version: view.case.version,
|
||
draft_id: saved.data.id,
|
||
draft_version: saved.data.version_no,
|
||
context_revision: contextReview.revision,
|
||
context_reviewed: contextReview.reviewed,
|
||
}),
|
||
},
|
||
);
|
||
if (!response.ok) {
|
||
const detail = await response.json().catch(() => null);
|
||
throw new Error(
|
||
detail?.message ||
|
||
'Não foi possível devolver as entradas financeiras guardadas ao dossier.',
|
||
);
|
||
}
|
||
window.location.assign(`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`);
|
||
} catch (cause) {
|
||
setContinuityError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Erro ao devolver o resultado.',
|
||
);
|
||
}
|
||
}
|
||
|
||
async function openOwnedDraft(id: string) {
|
||
setContinuityError('');
|
||
try {
|
||
const response = await fetch(
|
||
`/api/v1/simulation-drafts/${encodeURIComponent(id)}`,
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
},
|
||
);
|
||
const payload: unknown = response.ok
|
||
? await readSimulationApiJson(response)
|
||
: null;
|
||
if (
|
||
!response.ok ||
|
||
!isApiSimulationDraftReadResponseV1(payload) ||
|
||
!payload.data
|
||
)
|
||
throw new Error('Não foi possível abrir este dossier privado.');
|
||
draftIdRef.current = payload.data.id;
|
||
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
|
||
setDraftVersionNo(payload.data.version_no);
|
||
setDraftExpiresAt(null);
|
||
setSelectedAccountDraftId(payload.data.id);
|
||
setDraft(
|
||
restoreSimulationDraft(
|
||
payload.data.economic_snapshot,
|
||
Array.isArray(payload.data.personal_snapshot)
|
||
? {}
|
||
: payload.data.personal_snapshot,
|
||
),
|
||
);
|
||
setSimulationSources(payload.data.simulation_sources);
|
||
lastSavedFingerprintRef.current = simulationDraftFingerprint(
|
||
restoreSimulationDraft(
|
||
payload.data.economic_snapshot,
|
||
Array.isArray(payload.data.personal_snapshot)
|
||
? {}
|
||
: payload.data.personal_snapshot,
|
||
),
|
||
);
|
||
setPendingAnonymousDraft(null);
|
||
setShowDraftChoice(false);
|
||
setSaveStatus('Dossier privado recuperado da sua conta.');
|
||
} catch (cause) {
|
||
setContinuityError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Falha ao abrir o dossier.',
|
||
);
|
||
}
|
||
}
|
||
|
||
async function saveAnonymousAsNewProject() {
|
||
if (!pendingAnonymousDraft || draftChoiceBusy || !canAssociateDraft)
|
||
return;
|
||
setDraftChoiceBusy(true);
|
||
setContinuityError('');
|
||
try {
|
||
const response = await fetch('/api/v1/simulation-drafts/claim', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
},
|
||
body: JSON.stringify({ choice: 'save_as_new' }),
|
||
});
|
||
const payload: unknown = await readSimulationApiJson(response);
|
||
if (
|
||
!response.ok ||
|
||
!isApiSimulationDraftReadResponseV1({
|
||
data: (payload as { data?: unknown } | null)?.data,
|
||
}) ||
|
||
!(payload as { data: ApiSimulationDraftV1 }).data
|
||
)
|
||
throw new Error(
|
||
simulationApiErrorMessage(payload) ??
|
||
'Não foi possível guardar o rascunho na conta.',
|
||
);
|
||
const record = (payload as { data: ApiSimulationDraftV1 }).data;
|
||
draftIdRef.current = record.id;
|
||
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
|
||
setDraftVersionNo(record.version_no);
|
||
setDraftExpiresAt(null);
|
||
setSelectedAccountDraftId(record.id);
|
||
const restored = restoreSimulationDraft(
|
||
record.economic_snapshot,
|
||
Array.isArray(record.personal_snapshot)
|
||
? {}
|
||
: record.personal_snapshot,
|
||
);
|
||
setDraft(restored);
|
||
setSimulationSources(record.simulation_sources);
|
||
lastSavedFingerprintRef.current =
|
||
simulationDraftFingerprint(restored);
|
||
const listResponse = await fetch('/api/v1/simulation-drafts', {
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const listPayload: unknown = listResponse.ok
|
||
? await readSimulationApiJson(listResponse)
|
||
: null;
|
||
if (isApiSimulationDraftListResponseV1(listPayload))
|
||
setAccountDrafts(listPayload.data);
|
||
setPendingAnonymousDraft(null);
|
||
setShowDraftChoice(false);
|
||
setSaveStatus(
|
||
'Rascunho associado ao seu espaço privado como outro projecto. Nenhum dossier foi fundido ou substituído.',
|
||
);
|
||
} catch (cause) {
|
||
setContinuityError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Falha ao associar o rascunho.',
|
||
);
|
||
} finally {
|
||
setDraftChoiceBusy(false);
|
||
}
|
||
}
|
||
|
||
async function continueSelectedProject() {
|
||
if (!selectedAccountDraftId) return;
|
||
await openOwnedDraft(selectedAccountDraftId);
|
||
}
|
||
|
||
async function leaveAnonymousDraftForLater() {
|
||
setPendingAnonymousDraft(null);
|
||
setShowDraftChoice(false);
|
||
if (accountDrafts.length > 0) {
|
||
await openOwnedDraft(accountDrafts[0].id);
|
||
} else {
|
||
setDraft({});
|
||
setSaveStatus(
|
||
'O rascunho temporário foi mantido neste navegador até à data indicada; ainda não foi associado à conta.',
|
||
);
|
||
}
|
||
}
|
||
|
||
async function saveAndContinueLater() {
|
||
if (!draft.purpose) return;
|
||
setContinueLaterSaved(false);
|
||
setSaveAttempt((attempt) => attempt + 1);
|
||
await new Promise((resolve) => window.setTimeout(resolve, 550));
|
||
await saveQueueRef.current;
|
||
const target = canSavePersonalData ? draft : getAutosaveSnapshot(draft);
|
||
if (
|
||
draftIdRef.current &&
|
||
simulationDraftFingerprint(target) ===
|
||
lastSavedFingerprintRef.current
|
||
) {
|
||
if (!canSavePersonalData && draftEtagRef.current) {
|
||
const response = await fetch(
|
||
`/api/v1/simulation-drafts/${draftIdRef.current}/touch`,
|
||
{
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
'If-Match': draftEtagRef.current,
|
||
},
|
||
},
|
||
);
|
||
const payload: unknown = await readSimulationApiJson(response);
|
||
if (
|
||
!response.ok ||
|
||
!payload ||
|
||
typeof payload !== 'object' ||
|
||
typeof (payload as { expires_at?: unknown }).expires_at !==
|
||
'string'
|
||
) {
|
||
setSaveStatus(
|
||
simulationApiErrorMessage(payload) ??
|
||
'Não foi possível renovar o prazo do rascunho. Tente novamente.',
|
||
);
|
||
return;
|
||
}
|
||
setDraftExpiresAt(
|
||
(payload as { expires_at: string }).expires_at,
|
||
);
|
||
draftEtagRef.current =
|
||
simulationDraftEtag(response.headers.get('ETag')) ?? draftEtagRef.current;
|
||
}
|
||
setContinueLaterSaved(true);
|
||
setSaveStatus(
|
||
canSavePersonalData
|
||
? 'Dossier guardado no espaço privado da sua conta. Pode continuar mais tarde ao iniciar sessão.'
|
||
: draftExpiresAt
|
||
? `Rascunho guardado. Pode continuar neste navegador até ${new Intl.DateTimeFormat('pt-MZ', { dateStyle: 'long' }).format(new Date(draftExpiresAt))}.`
|
||
: 'Rascunho guardado. Pode continuar neste navegador durante 30 dias após a última gravação.',
|
||
);
|
||
} else {
|
||
setSaveStatus(
|
||
'A gravação ainda não foi confirmada. Verifique a ligação e tente novamente antes de sair.',
|
||
);
|
||
}
|
||
}
|
||
|
||
async function refreshBusinessLink() {
|
||
if (!canAssociateDraft || !selectedAccountDraftId || showDraftChoice)
|
||
return;
|
||
setBusinessLinkError('');
|
||
try {
|
||
const [businessesResponse, linkResponse] = await Promise.all([
|
||
fetch('/api/v1/simulation-drafts/linkable-businesses', {
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
}),
|
||
fetch(
|
||
`/api/v1/simulation-drafts/${encodeURIComponent(selectedAccountDraftId)}/business-link`,
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
},
|
||
),
|
||
]);
|
||
const businessesPayload: unknown = businessesResponse.ok
|
||
? await readSimulationApiJson(businessesResponse)
|
||
: null;
|
||
const linkPayload: unknown = linkResponse.ok
|
||
? await readSimulationApiJson(linkResponse)
|
||
: null;
|
||
if (
|
||
!businessesResponse.ok ||
|
||
!linkResponse.ok ||
|
||
!Array.isArray(
|
||
(businessesPayload as { data?: unknown } | null)?.data,
|
||
)
|
||
)
|
||
throw new Error(
|
||
'Não foi possível carregar os negócios próprios para associação.',
|
||
);
|
||
const choices = (
|
||
businessesPayload as { data: unknown[] }
|
||
).data.filter(
|
||
(
|
||
item,
|
||
): item is {
|
||
id: string;
|
||
title: string;
|
||
modality: string;
|
||
status: string;
|
||
} =>
|
||
Boolean(
|
||
item &&
|
||
typeof item === 'object' &&
|
||
typeof (item as { id?: unknown }).id === 'string' &&
|
||
typeof (item as { title?: unknown }).title ===
|
||
'string' &&
|
||
typeof (item as { modality?: unknown }).modality ===
|
||
'string' &&
|
||
typeof (item as { status?: unknown }).status ===
|
||
'string',
|
||
),
|
||
);
|
||
setLinkableBusinesses(choices);
|
||
const link = (linkPayload as { data?: unknown } | null)?.data;
|
||
setLinkedBusiness(
|
||
link &&
|
||
typeof link === 'object' &&
|
||
typeof (link as { id?: unknown }).id === 'string'
|
||
? (link as {
|
||
id: string;
|
||
business_id: string;
|
||
title: string;
|
||
linked_at: string;
|
||
})
|
||
: null,
|
||
);
|
||
setSelectedBusinessId(choices[0]?.id ?? '');
|
||
} catch (cause) {
|
||
setBusinessLinkError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Falha ao carregar associações.',
|
||
);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void refreshBusinessLink();
|
||
}, [canAssociateDraft, selectedAccountDraftId, showDraftChoice]);
|
||
|
||
async function linkSelectedBusiness() {
|
||
if (
|
||
!selectedAccountDraftId ||
|
||
!selectedBusinessId ||
|
||
!window.confirm(
|
||
'Associar este dossier privado ao negócio seleccionado? Nenhum dado será copiado ou publicado.',
|
||
)
|
||
)
|
||
return;
|
||
setBusinessLinkBusy(true);
|
||
setBusinessLinkError('');
|
||
try {
|
||
const response = await fetch(
|
||
`/api/v1/simulation-drafts/${encodeURIComponent(selectedAccountDraftId)}/business-link`,
|
||
{
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
},
|
||
body: JSON.stringify({ business_id: selectedBusinessId }),
|
||
},
|
||
);
|
||
const payload: unknown = await readSimulationApiJson(response);
|
||
if (!response.ok)
|
||
throw new Error(
|
||
simulationApiErrorMessage(payload) ??
|
||
'Não foi possível associar o negócio.',
|
||
);
|
||
await refreshBusinessLink();
|
||
} catch (cause) {
|
||
setBusinessLinkError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Falha ao associar o negócio.',
|
||
);
|
||
} finally {
|
||
setBusinessLinkBusy(false);
|
||
}
|
||
}
|
||
|
||
async function unlinkBusiness() {
|
||
if (
|
||
!selectedAccountDraftId ||
|
||
!linkedBusiness ||
|
||
!window.confirm(
|
||
'Remover a associação? Os dois registos e respectivos dados permanecem intactos.',
|
||
)
|
||
)
|
||
return;
|
||
setBusinessLinkBusy(true);
|
||
try {
|
||
const response = await fetch(
|
||
`/api/v1/simulation-drafts/${encodeURIComponent(selectedAccountDraftId)}/business-link`,
|
||
{
|
||
method: 'DELETE',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
},
|
||
},
|
||
);
|
||
if (!response.ok)
|
||
throw new Error('Não foi possível remover a associação.');
|
||
setLinkedBusiness(null);
|
||
} catch (cause) {
|
||
setBusinessLinkError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Falha ao remover a associação.',
|
||
);
|
||
} finally {
|
||
setBusinessLinkBusy(false);
|
||
}
|
||
}
|
||
|
||
function change(
|
||
key: string,
|
||
value: string | string[],
|
||
label?: string | string[],
|
||
) {
|
||
if (!ready) return;
|
||
setContinueLaterSaved(false);
|
||
setIvaIssues((previous) => previous.filter((issue) => issue.field !== key));
|
||
setServerFieldErrors((previous) => {
|
||
const next = { ...previous };
|
||
delete next[key];
|
||
return next;
|
||
});
|
||
setReviewStatus('');
|
||
setDraft((currentDraft) => {
|
||
const next = replaceSimulationValue(currentDraft, key, value);
|
||
if (label) next[`${key}_label`] = label;
|
||
if (
|
||
next.project_questionnaire_version === undefined &&
|
||
next.purpose === 'simulate_only' &&
|
||
['small_business', 'investor'].includes(
|
||
String(next.journey_type),
|
||
)
|
||
) {
|
||
next.project_questionnaire_version =
|
||
projectQuestionnaireVersion;
|
||
}
|
||
return next;
|
||
});
|
||
setErrors((current) => {
|
||
const next = { ...current };
|
||
delete next[key];
|
||
return next;
|
||
});
|
||
if (key === 'purpose') setStep(0);
|
||
if (result) setCalculationStale(true);
|
||
setResult(null);
|
||
}
|
||
|
||
async function clearDraft() {
|
||
await saveQueueRef.current;
|
||
if (draftIdRef.current && draftEtagRef.current) {
|
||
const response = !canSavePersonalData
|
||
? await fetch('/api/v1/simulation-drafts/current', {
|
||
method: 'DELETE',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
},
|
||
})
|
||
: await fetch(
|
||
`/api/v1/simulation-drafts/${draftIdRef.current}`,
|
||
{
|
||
method: 'DELETE',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
'If-Match': draftEtagRef.current,
|
||
},
|
||
},
|
||
);
|
||
if (!response.ok) {
|
||
setSaveStatus(
|
||
'Não foi possível remover o rascunho do servidor. Recarregue e tente novamente.',
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
for (const key of [
|
||
draftKey,
|
||
draftIdKey,
|
||
draftCreateKey,
|
||
idempotencyStorageKey,
|
||
])
|
||
sessionStorage.removeItem(key);
|
||
draftIdRef.current = null;
|
||
draftEtagRef.current = null;
|
||
lastSavedFingerprintRef.current = null;
|
||
saveConflictRef.current = false;
|
||
setHasSaveConflict(false);
|
||
setDraft({});
|
||
setResult(null);
|
||
setErrors({});
|
||
setStep(0);
|
||
setSaveStatus('Rascunho removido do servidor e desta sessão.');
|
||
}
|
||
|
||
async function forgetDraftOnDevice() {
|
||
if (
|
||
!window.confirm(
|
||
'Esquecer este rascunho neste dispositivo? Ele deixará de poder ser recuperado pelo cookie deste navegador.',
|
||
)
|
||
)
|
||
return;
|
||
await saveQueueRef.current;
|
||
const response = await fetch('/api/v1/simulation-drafts/current', {
|
||
method: 'DELETE',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
setSaveStatus(
|
||
'Não foi possível esquecer o rascunho. Os dados não foram apagados; tente novamente.',
|
||
);
|
||
return;
|
||
}
|
||
for (const key of [
|
||
draftKey,
|
||
draftIdKey,
|
||
draftCreateKey,
|
||
idempotencyStorageKey,
|
||
])
|
||
sessionStorage.removeItem(key);
|
||
draftIdRef.current = null;
|
||
draftEtagRef.current = null;
|
||
lastSavedFingerprintRef.current = null;
|
||
setDraft({});
|
||
setResult(null);
|
||
setErrors({});
|
||
setStep(0);
|
||
setSaveStatus(
|
||
'O rascunho foi removido da recuperação e o cookie deste dispositivo foi esquecido.',
|
||
);
|
||
}
|
||
|
||
function navigate(nextStep: number) {
|
||
if (!ready) return;
|
||
setOpenedTool(null);
|
||
if (nextStep > step) {
|
||
for (let index = step; index < nextStep; index++) {
|
||
const validation = validateSimulationDraft(
|
||
draft,
|
||
journeySections[index].id,
|
||
);
|
||
if (Object.keys(validation).length) {
|
||
setStep(index);
|
||
setErrors(validation);
|
||
window.requestAnimationFrame(() =>
|
||
document
|
||
.getElementById(
|
||
`field-${Object.keys(validation)[0]}`,
|
||
)
|
||
?.focus(),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
setStep(nextStep);
|
||
setErrors({});
|
||
document.getElementById('simulator-step-title')?.focus();
|
||
}
|
||
|
||
function validateFinalReview() {
|
||
const checked: { key: string; label: string; value: string }[] = [];
|
||
const privateFields = partitionSimulationDraft(
|
||
draft,
|
||
true,
|
||
).personal_snapshot;
|
||
const pending: {
|
||
key: string;
|
||
label: string;
|
||
message: string;
|
||
step: number;
|
||
}[] = [];
|
||
const allErrors: Record<string, string> = {};
|
||
for (let index = 0; index < journeySections.length; index++) {
|
||
const validation = validateSimulationDraft(
|
||
draft,
|
||
journeySections[index].id,
|
||
);
|
||
if (
|
||
journeySections[index].id === 'tax' &&
|
||
draft.fiscal_scenario_mode === 'custom_planning'
|
||
) {
|
||
Object.assign(validation, validateCustomFiscalPlanning(draft));
|
||
}
|
||
Object.assign(allErrors, validation);
|
||
const fields =
|
||
simulationFieldGroups[journeySections[index].id] ?? [];
|
||
for (const field of fields) {
|
||
if (field.showWhen && !field.showWhen(draft)) continue;
|
||
const value = draft[field.key];
|
||
if (
|
||
!validation[field.key] &&
|
||
value !== undefined &&
|
||
value !== '' &&
|
||
(!Array.isArray(value) || value.length > 0)
|
||
)
|
||
checked.push({
|
||
key: field.key,
|
||
label: field.label || field.key,
|
||
value: Object.prototype.hasOwnProperty.call(
|
||
privateFields,
|
||
field.key,
|
||
)
|
||
? 'Dado pessoal preservado; consulte-o no campo original.'
|
||
: Array.isArray(value)
|
||
? value
|
||
.map((entry) => labelFor(String(entry)))
|
||
.join(', ')
|
||
: field.options
|
||
? labelFor(String(value))
|
||
: displayValue(
|
||
value,
|
||
'Valor não textual; consulte o campo original.',
|
||
),
|
||
});
|
||
}
|
||
for (const [key, message] of Object.entries(validation))
|
||
pending.push({
|
||
key,
|
||
label:
|
||
fields.find((field) => field.key === key)?.label ?? key,
|
||
message,
|
||
step: index,
|
||
});
|
||
}
|
||
setErrors(allErrors);
|
||
setReviewDetails({ checked, pending });
|
||
setReviewStatus(
|
||
pending.length
|
||
? 'Verificação concluída com campos pendentes ou inconsistências. Os dados foram preservados.'
|
||
: 'Verificação dos campos concluída neste navegador.',
|
||
);
|
||
}
|
||
|
||
async function runSimulation(publishedInput?: PublishedIvaInput) {
|
||
if (calculationInFlightRef.current) return;
|
||
const validation = publishedInput ? {} : validateSimulationDraft(draft, 'tax');
|
||
if (!publishedInput && draft.fiscal_scenario_mode === 'custom_planning') {
|
||
Object.assign(validation, validateCustomFiscalPlanning(draft));
|
||
}
|
||
setErrors(validation);
|
||
setServerError('');
|
||
if (!publishedInput && draft.fiscal_scenario_mode !== 'custom_planning') return;
|
||
if (Object.keys(validation).length) {
|
||
document
|
||
.getElementById(`field-${Object.keys(validation)[0]}`)
|
||
?.focus();
|
||
return;
|
||
}
|
||
calculationInFlightRef.current = true;
|
||
setIsCalculating(true);
|
||
try {
|
||
const xsrf = csrfToken();
|
||
const body = publishedInput ?? {
|
||
base: draft.base,
|
||
rate: draft.rate,
|
||
reduction: draft.reduction,
|
||
};
|
||
const serialized = JSON.stringify(body);
|
||
const previous = sessionStorage.getItem(idempotencyStorageKey);
|
||
let key = '';
|
||
if (previous) {
|
||
try {
|
||
const saved = JSON.parse(previous) as {
|
||
payload?: string;
|
||
key?: string;
|
||
};
|
||
if (saved.payload === serialized && saved.key)
|
||
key = saved.key;
|
||
} catch {
|
||
/* replace malformed local state */
|
||
}
|
||
}
|
||
if (!key) {
|
||
key = idempotencyKey();
|
||
sessionStorage.setItem(
|
||
idempotencyStorageKey,
|
||
JSON.stringify({ payload: serialized, key }),
|
||
);
|
||
}
|
||
const response = await fetch('/api/v1/simulations', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'X-XSRF-TOKEN': xsrf,
|
||
'Idempotency-Key': key,
|
||
},
|
||
body: serialized,
|
||
});
|
||
const payload: unknown = await readSimulationApiJson(response);
|
||
if (!response.ok) {
|
||
if (response.status === 429) {
|
||
const retryAfter = Number(
|
||
response.headers.get('Retry-After') ?? 60,
|
||
);
|
||
throw new Error(
|
||
`Demasiadas tentativas. Aguarde ${retryAfter} segundos antes de voltar a calcular.`,
|
||
);
|
||
}
|
||
const errorMessage = simulationApiErrorMessage(payload);
|
||
throw new Error(
|
||
errorMessage ??
|
||
'Não foi possível validar o cenário no servidor.',
|
||
);
|
||
}
|
||
if (!isApiSimulationCreateResponseV1(payload))
|
||
throw new Error(
|
||
'O servidor devolveu uma resposta de execução inválida.',
|
||
);
|
||
const simulationId = payload.data.id;
|
||
let simulation = payload.data;
|
||
if (
|
||
simulationId &&
|
||
simulation?.status !== 'completed' &&
|
||
simulation?.status !== 'failed' &&
|
||
simulation?.status !== 'cancelled'
|
||
) {
|
||
setSaveStatus('Cenário enviado. A aguardar o cálculo…');
|
||
for (let attempt = 0; attempt < 12; attempt++) {
|
||
await new Promise((resolve) =>
|
||
window.setTimeout(resolve, 1000),
|
||
);
|
||
const poll = await fetch(
|
||
`/api/v1/simulations/${simulationId}`,
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
},
|
||
},
|
||
);
|
||
if (!poll.ok) {
|
||
const pollPayload: unknown =
|
||
await readSimulationApiJson(poll);
|
||
setServerError(
|
||
simulationPollingFailureMessage(pollPayload),
|
||
);
|
||
break;
|
||
}
|
||
const pollPayload: unknown =
|
||
await readSimulationApiJson(poll);
|
||
if (!isApiSimulationReadResponseV1(pollPayload))
|
||
throw new Error(
|
||
'O servidor devolveu um estado de execução inválido.',
|
||
);
|
||
if (pollPayload.data.id !== simulationId)
|
||
throw new Error(
|
||
'O servidor devolveu outro identificador de execução.',
|
||
);
|
||
simulation = pollPayload.data;
|
||
if (
|
||
simulation?.status === 'completed' ||
|
||
simulation?.status === 'failed' ||
|
||
simulation?.status === 'cancelled'
|
||
)
|
||
break;
|
||
}
|
||
}
|
||
if (
|
||
publishedInput || JSON.stringify({
|
||
base: draft.base,
|
||
rate: draft.rate,
|
||
reduction: draft.reduction,
|
||
}) === serialized
|
||
)
|
||
setResult(simulation ?? null);
|
||
if (canSavePersonalData) void loadSimulationHistory();
|
||
if (simulation?.status === 'completed') {
|
||
setSaveStatus(
|
||
'Cálculo concluído. Reveja o resultado e os pressupostos.',
|
||
);
|
||
} else if (
|
||
simulation?.status === 'failed' ||
|
||
simulation?.status === 'cancelled'
|
||
) {
|
||
setSaveStatus(
|
||
'O cálculo não foi concluído. Os dados introduzidos foram preservados.',
|
||
);
|
||
}
|
||
if (!publishedInput) sessionStorage.removeItem(idempotencyStorageKey);
|
||
return simulation ?? null;
|
||
} catch (cause) {
|
||
setServerError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Erro de ligação. Tente novamente.',
|
||
);
|
||
return null;
|
||
} finally {
|
||
calculationInFlightRef.current = false;
|
||
setIsCalculating(false);
|
||
}
|
||
}
|
||
|
||
async function attachCompletedSimulation() {
|
||
const simulationId = typeof result?.id === 'string' ? result.id : '';
|
||
if (
|
||
!simulationId ||
|
||
!conversionConsent ||
|
||
hasSaveConflict ||
|
||
isAttachingSimulation
|
||
)
|
||
return;
|
||
setConversionError('');
|
||
setIsAttachingSimulation(true);
|
||
try {
|
||
await saveQueueRef.current;
|
||
const headers = {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'X-XSRF-TOKEN': csrfToken(),
|
||
};
|
||
const currentResponse = await fetch(
|
||
'/api/v1/simulation-drafts/current',
|
||
{
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
},
|
||
},
|
||
);
|
||
const currentPayload: unknown = currentResponse.ok
|
||
? await readSimulationApiJson(currentResponse)
|
||
: { data: null };
|
||
if (
|
||
!currentResponse.ok ||
|
||
!isApiSimulationDraftReadResponseV1(currentPayload)
|
||
)
|
||
throw new Error(
|
||
'Não foi possível confirmar o rascunho actual no servidor.',
|
||
);
|
||
const draftIdValue: unknown = currentPayload.data?.id;
|
||
if (typeof draftIdValue !== 'string' || !draftIdValue)
|
||
throw new Error(
|
||
'Guarde primeiro o rascunho; depois poderá anexar a simulação.',
|
||
);
|
||
const draftId = draftIdValue;
|
||
draftIdRef.current = draftId;
|
||
draftEtagRef.current = simulationDraftEtag(currentResponse.headers.get('ETag'));
|
||
if (!draftEtagRef.current)
|
||
throw new Error(
|
||
'O servidor não devolveu a versão actual do rascunho. Recarregue e tente novamente.',
|
||
);
|
||
const response = await fetch(
|
||
`/api/v1/simulation-drafts/${draftId}/conversions`,
|
||
{
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { ...headers, 'If-Match': draftEtagRef.current },
|
||
body: JSON.stringify({
|
||
simulation_id: simulationId,
|
||
consent_reuse: true,
|
||
}),
|
||
},
|
||
);
|
||
const payload: unknown = await readSimulationApiJson(response);
|
||
if (response.status === 412 || response.status === 428) {
|
||
saveConflictRef.current = true;
|
||
setHasSaveConflict(true);
|
||
throw new Error(
|
||
'O rascunho mudou noutro separador. Recarregue antes de anexar a simulação.',
|
||
);
|
||
}
|
||
if (!response.ok)
|
||
throw new Error(
|
||
simulationApiErrorMessage(payload) ??
|
||
'Não foi possível anexar a simulação ao rascunho.',
|
||
);
|
||
if (!isApiSimulationDraftAttachResponseV1(payload))
|
||
throw new Error(
|
||
'O servidor devolveu uma confirmação de anexação inválida.',
|
||
);
|
||
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
|
||
const sources = payload.data?.simulation_sources;
|
||
if (Array.isArray(sources)) setSimulationSources(sources);
|
||
setDraft((currentDraft) => ({
|
||
...currentDraft,
|
||
reuse_simulation: 'yes',
|
||
}));
|
||
sessionStorage.setItem(draftIdKey, draftId);
|
||
setConversionConsent(false);
|
||
setSaveStatus(
|
||
payload.replayed
|
||
? 'Esta simulação já estava anexada ao rascunho.'
|
||
: 'Simulação anexada ao rascunho com o seu consentimento.',
|
||
);
|
||
} catch (cause) {
|
||
setConversionError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Não foi possível anexar a simulação.',
|
||
);
|
||
} finally {
|
||
setIsAttachingSimulation(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section
|
||
className="space-y-6 rounded-xl border bg-white p-4 sm:p-6"
|
||
aria-labelledby="simulator-step-title"
|
||
aria-busy={!ready}
|
||
>
|
||
{calculationStale && (
|
||
<p
|
||
role="status"
|
||
className="rounded border border-amber-700 bg-amber-50 p-3 text-amber-950"
|
||
>
|
||
As entradas foram alteradas. Calcule novamente antes de
|
||
guardar ou exportar um novo resultado. Os resultados
|
||
anteriores mantêm-se no histórico.
|
||
</p>
|
||
)}
|
||
{!ready && (
|
||
<p role="status">
|
||
A recuperar o rascunho. Aguarde para editar.
|
||
</p>
|
||
)}
|
||
<fieldset disabled={!ready} className="min-w-0 space-y-6">
|
||
{canSavePersonalData &&
|
||
draftVersionNo &&
|
||
draftIdRef.current && (
|
||
<a
|
||
className="inline-block underline"
|
||
href={`/balcao-virtual/painel?simulation_id=${draftIdRef.current}`}
|
||
>
|
||
Preparar apoio no Balcão Virtual com este dossier
|
||
guardado
|
||
</a>
|
||
)}
|
||
{canSavePersonalData &&
|
||
draftVersionNo &&
|
||
draftIdRef.current && (
|
||
<PartnershipSourceLinks
|
||
type="simulation"
|
||
id={draftIdRef.current}
|
||
/>
|
||
)}
|
||
{contextId && (
|
||
<DossierPrefill
|
||
contextId={contextId}
|
||
consumer="simulator"
|
||
enabled={ready && !hasSaveConflict}
|
||
values={draft}
|
||
onReview={(revision, reviewed) =>
|
||
setContextReview({ revision, reviewed })
|
||
}
|
||
onApply={(patch, replace) =>
|
||
setDraft((current) => {
|
||
const next = { ...current };
|
||
for (const [k, v] of Object.entries(patch)) {
|
||
if (replace || !current[k]?.length)
|
||
next[k] =
|
||
typeof v === 'string' ? v : '';
|
||
}
|
||
return next;
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
{contextId && (
|
||
<section
|
||
className="rounded-lg border p-4"
|
||
aria-label="Contexto do investimento"
|
||
>
|
||
<p>
|
||
Ferramenta ligada ao dossier actual. As entradas são
|
||
guardadas no rascunho deste investimento.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className="mt-2 rounded border px-3 py-2"
|
||
disabled={
|
||
!ready || hasSaveConflict || !draftVersionNo
|
||
}
|
||
onClick={() => void returnToInvestment()}
|
||
>
|
||
Guardar resultado financeiro e voltar ao dossier
|
||
</button>
|
||
{continuityError && (
|
||
<p role="alert">{continuityError}</p>
|
||
)}
|
||
<a
|
||
className="ml-3 underline"
|
||
href={`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`}
|
||
>
|
||
Ver dossier
|
||
</a>
|
||
</section>
|
||
)}
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h2
|
||
id="simulator-step-title"
|
||
tabIndex={-1}
|
||
className="text-xl font-bold focus:outline-2 focus:outline-offset-2 focus:outline-emerald-700"
|
||
>
|
||
Formulário de simulação e manifestação
|
||
</h2>
|
||
<p className="mt-1 text-sm text-slate-700">
|
||
Simulação informativa. Nenhuma taxa legal,
|
||
elegibilidade ou moeda é presumida.
|
||
</p>
|
||
<p className="mt-1 text-xs text-slate-700">
|
||
{canSavePersonalData
|
||
? 'Os dossiers desta conta são privados. A associação a um negócio é opcional, reversível e não copia nem publica dados.'
|
||
: 'Sem sessão, identidade e contactos não são guardados. Após uma gravação, o rascunho pode ser recuperado neste navegador durante 30 dias desde a última gravação. O cookie pode permanecer depois de fechar o navegador; num dispositivo partilhado, use “Esquecer neste dispositivo”.'}
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<output aria-live="polite" className="block text-sm">
|
||
{saveStatus}
|
||
</output>
|
||
{Object.keys(serverFieldErrors).length > 0 && (
|
||
<div
|
||
role="alert"
|
||
className="rounded border border-red-300 bg-red-50 p-3"
|
||
>
|
||
<p>
|
||
O servidor não confirmou a gravação. Os
|
||
dados preenchidos foram preservados. Corrija
|
||
os campos indicados:
|
||
</p>
|
||
<ul>
|
||
{Object.entries(serverFieldErrors).map(
|
||
([key, message]) => {
|
||
const index =
|
||
journeySections.findIndex(
|
||
(section) =>
|
||
(
|
||
simulationFieldGroups[
|
||
section.id
|
||
] ?? []
|
||
).some(
|
||
(field) =>
|
||
field.key ===
|
||
key,
|
||
),
|
||
);
|
||
const label =
|
||
index >= 0
|
||
? simulationFieldGroups[
|
||
journeySections[index]
|
||
.id
|
||
].find(
|
||
(field) =>
|
||
field.key === key,
|
||
)?.label
|
||
: key;
|
||
return (
|
||
<li key={key}>
|
||
<button
|
||
type="button"
|
||
className="underline"
|
||
onClick={() => {
|
||
if (index >= 0)
|
||
setStep(index);
|
||
}}
|
||
>
|
||
{label}: {message}
|
||
</button>
|
||
</li>
|
||
);
|
||
},
|
||
)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{draft.purpose && (
|
||
<button
|
||
type="button"
|
||
className="w-fit rounded border border-emerald-800 px-3 py-2 text-sm font-semibold text-emerald-900"
|
||
onClick={() => void saveAndContinueLater()}
|
||
>
|
||
{continueLaterSaved
|
||
? 'Rascunho guardado'
|
||
: 'Guardar e continuar mais tarde'}
|
||
</button>
|
||
)}
|
||
{!canSavePersonalData && draftIdRef.current && (
|
||
<button
|
||
type="button"
|
||
className="w-fit rounded border border-red-800 px-3 py-2 text-sm font-semibold text-red-900"
|
||
onClick={() => void forgetDraftOnDevice()}
|
||
>
|
||
Esquecer neste dispositivo
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{canSavePersonalData &&
|
||
showDraftChoice &&
|
||
pendingAnonymousDraft && (
|
||
<section
|
||
aria-labelledby="draft-choice-title"
|
||
className="rounded-lg border-2 border-emerald-800 bg-emerald-50 p-4"
|
||
>
|
||
<h3
|
||
id="draft-choice-title"
|
||
className="font-semibold"
|
||
>
|
||
Como quer continuar?
|
||
</h3>
|
||
<p className="mt-1 text-sm">
|
||
Encontrámos “{pendingAnonymousDraft.title}”
|
||
guardado neste navegador até{' '}
|
||
{pendingAnonymousDraft.expires_at
|
||
? new Intl.DateTimeFormat('pt-MZ', {
|
||
dateStyle: 'long',
|
||
}).format(
|
||
new Date(
|
||
pendingAnonymousDraft.expires_at,
|
||
),
|
||
)
|
||
: 'ao fim do prazo de recuperação'}
|
||
. O rascunho não será fundido nem substituirá um
|
||
projecto da conta.
|
||
</p>
|
||
{accountDrafts.length > 0 && (
|
||
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_auto]">
|
||
<label className="grid gap-1 text-sm font-medium">
|
||
Continuar um dossier privado existente
|
||
<select
|
||
className="rounded border px-3 py-2"
|
||
value={selectedAccountDraftId}
|
||
onChange={(event) =>
|
||
setSelectedAccountDraftId(
|
||
event.target.value,
|
||
)
|
||
}
|
||
>
|
||
{accountDrafts.map((item) => (
|
||
<option
|
||
key={item.id}
|
||
value={item.id}
|
||
>
|
||
{item.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="self-end rounded border px-3 py-2"
|
||
disabled={
|
||
!selectedAccountDraftId ||
|
||
draftChoiceBusy
|
||
}
|
||
onClick={() =>
|
||
void continueSelectedProject()
|
||
}
|
||
>
|
||
Continuar existente
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded bg-emerald-800 px-3 py-2 font-semibold text-white"
|
||
disabled={
|
||
draftChoiceBusy || !canAssociateDraft
|
||
}
|
||
onClick={() =>
|
||
void saveAnonymousAsNewProject()
|
||
}
|
||
>
|
||
{draftChoiceBusy
|
||
? 'A guardar…'
|
||
: accountDrafts.length
|
||
? 'Guardar como outro projecto privado'
|
||
: 'Guardar no espaço privado da conta'}
|
||
</button>
|
||
{!canAssociateDraft && (
|
||
<p className="w-full text-sm text-amber-900">
|
||
Confirme o correio electrónico da conta
|
||
para associar este rascunho ao espaço
|
||
privado.
|
||
</p>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2"
|
||
disabled={draftChoiceBusy}
|
||
onClick={() =>
|
||
void leaveAnonymousDraftForLater()
|
||
}
|
||
>
|
||
Deixar temporário sem associar
|
||
</button>
|
||
</div>
|
||
{continuityError && (
|
||
<p
|
||
role="alert"
|
||
className="mt-2 text-sm text-red-800"
|
||
>
|
||
{continuityError}
|
||
</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
{canSavePersonalData &&
|
||
!contextId &&
|
||
!showDraftChoice &&
|
||
accountDrafts.length > 0 && (
|
||
<section
|
||
aria-labelledby="private-dossiers-title"
|
||
className="rounded-lg border bg-slate-50 p-4"
|
||
>
|
||
<h3
|
||
id="private-dossiers-title"
|
||
className="font-semibold"
|
||
>
|
||
Projectos no seu espaço privado
|
||
</h3>
|
||
{accountDrafts.length > 1 && (
|
||
<label className="mt-2 grid max-w-xl gap-1 text-sm font-medium">
|
||
Abrir dossier
|
||
<select
|
||
className="rounded border px-3 py-2"
|
||
value={selectedAccountDraftId}
|
||
onChange={(event) =>
|
||
void openOwnedDraft(
|
||
event.target.value,
|
||
)
|
||
}
|
||
>
|
||
{accountDrafts.map((item) => (
|
||
<option
|
||
key={item.id}
|
||
value={item.id}
|
||
>
|
||
{item.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{selectedAccountDraftId && (
|
||
<div className="mt-3 border-t pt-3">
|
||
<p className="text-sm">
|
||
Associação opcional ao seu registo de
|
||
negócio. Apenas cria uma referência
|
||
reversível; não copia dados nem publica
|
||
informação.
|
||
</p>
|
||
{linkedBusiness ? (
|
||
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm">
|
||
<span>
|
||
Associado a:{' '}
|
||
<strong>
|
||
{linkedBusiness.title}
|
||
</strong>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2"
|
||
disabled={businessLinkBusy}
|
||
onClick={() =>
|
||
void unlinkBusiness()
|
||
}
|
||
>
|
||
Remover associação
|
||
</button>
|
||
</div>
|
||
) : linkableBusinesses.length > 0 ? (
|
||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||
<label className="grid min-w-60 gap-1 text-sm font-medium">
|
||
Negócio da sua conta
|
||
<select
|
||
className="rounded border px-3 py-2"
|
||
value={selectedBusinessId}
|
||
onChange={(event) =>
|
||
setSelectedBusinessId(
|
||
event.target.value,
|
||
)
|
||
}
|
||
>
|
||
{linkableBusinesses.map(
|
||
(business) => (
|
||
<option
|
||
key={
|
||
business.id
|
||
}
|
||
value={
|
||
business.id
|
||
}
|
||
>
|
||
{business.title}
|
||
</option>
|
||
),
|
||
)}
|
||
</select>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2"
|
||
disabled={
|
||
businessLinkBusy ||
|
||
!selectedBusinessId ||
|
||
!canAssociateDraft
|
||
}
|
||
onClick={() =>
|
||
void linkSelectedBusiness()
|
||
}
|
||
>
|
||
Associar negócio
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<p className="mt-2 text-sm text-slate-600">
|
||
{canAssociateDraft
|
||
? 'Ainda não há um negócio próprio disponível para associação.'
|
||
: 'Confirme o correio electrónico para gerir associações.'}
|
||
</p>
|
||
)}
|
||
{businessLinkError && (
|
||
<p
|
||
role="alert"
|
||
className="mt-2 text-sm text-red-800"
|
||
>
|
||
{businessLinkError}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
{continuityError && (
|
||
<p
|
||
role="alert"
|
||
className="mt-2 text-sm text-red-800"
|
||
>
|
||
{continuityError}
|
||
</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
{canSavePersonalData && (
|
||
<section
|
||
aria-labelledby="simulation-history-title"
|
||
className="rounded-lg border bg-slate-50 p-4"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h3
|
||
id="simulation-history-title"
|
||
className="font-semibold"
|
||
>
|
||
Histórico da sua conta
|
||
</h3>
|
||
<p className="text-sm text-slate-700">
|
||
Simulações pessoais e de organizações às
|
||
quais tem acesso. Execuções anónimas não
|
||
ficam associadas à conta.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2"
|
||
disabled={historyLoading}
|
||
onClick={() =>
|
||
void loadSimulationHistory(historyPage)
|
||
}
|
||
>
|
||
{historyLoading
|
||
? 'A carregar…'
|
||
: 'Actualizar histórico'}
|
||
</button>
|
||
</div>
|
||
{historyError && (
|
||
<p
|
||
role="alert"
|
||
className="mt-3 text-sm text-red-800"
|
||
>
|
||
{historyError}
|
||
</p>
|
||
)}
|
||
{simulationHistory.length === 0 &&
|
||
!historyLoading &&
|
||
!historyError && (
|
||
<p className="mt-3 text-sm">
|
||
Ainda não existem simulações guardadas nesta
|
||
conta.
|
||
</p>
|
||
)}
|
||
{simulationHistory.length > 0 && (
|
||
<ul className="mt-3 divide-y">
|
||
{simulationHistory.map((entry) => (
|
||
<li
|
||
key={entry.id}
|
||
className="flex flex-wrap items-center justify-between gap-3 py-3"
|
||
>
|
||
<div>
|
||
<p className="font-medium">
|
||
{entry.status === 'completed'
|
||
? 'Concluída'
|
||
: entry.status === 'failed'
|
||
? 'Falhou'
|
||
: entry.status ===
|
||
'queued'
|
||
? 'Na fila'
|
||
: 'Em processamento'}
|
||
{entry.scope ===
|
||
'organization' &&
|
||
entry.organization_name
|
||
? ` · ${entry.organization_name}`
|
||
: ''}
|
||
</p>
|
||
<p className="text-sm text-slate-700">
|
||
{entry.created_at
|
||
? new Date(
|
||
entry.created_at,
|
||
).toLocaleString('pt-MZ')
|
||
: 'Data indisponível'}{' '}
|
||
· versão {entry.version_no}
|
||
{entry.has_ready_report
|
||
? ' · relatório disponível'
|
||
: ''}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2"
|
||
onClick={() =>
|
||
void openHistorySimulation(
|
||
entry.id,
|
||
)
|
||
}
|
||
>
|
||
Abrir resultado
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{historyLastPage > 1 && (
|
||
<div className="mt-3 flex items-center justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2"
|
||
disabled={
|
||
historyLoading || historyPage <= 1
|
||
}
|
||
onClick={() =>
|
||
void loadSimulationHistory(
|
||
historyPage - 1,
|
||
)
|
||
}
|
||
>
|
||
Anterior
|
||
</button>
|
||
<span aria-live="polite" className="text-sm">
|
||
{historyPage}/{historyLastPage}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2"
|
||
disabled={
|
||
historyLoading ||
|
||
historyPage >= historyLastPage
|
||
}
|
||
onClick={() =>
|
||
void loadSimulationHistory(
|
||
historyPage + 1,
|
||
)
|
||
}
|
||
>
|
||
Seguinte
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
<nav
|
||
aria-label="Etapas do formulário"
|
||
className="overflow-x-auto"
|
||
>
|
||
<ol className="flex min-w-max gap-2">
|
||
{journeySections.map((section, index) => (
|
||
<li key={section.id}>
|
||
<button
|
||
type="button"
|
||
aria-current={
|
||
step === index ? 'step' : undefined
|
||
}
|
||
onClick={() => navigate(index)}
|
||
className={`rounded-full border px-3 py-2 text-sm focus-visible:outline-2 ${step === index ? 'border-emerald-800 bg-emerald-800 text-white' : 'bg-white'}`}
|
||
>
|
||
{index + 1}. {section.title}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</nav>
|
||
<progress
|
||
className="h-2 w-full accent-emerald-800"
|
||
aria-label="Progresso do formulário"
|
||
max={100}
|
||
value={Math.round(
|
||
((step + 1) / journeySections.length) * 100,
|
||
)}
|
||
/>
|
||
{draft.journey_type === 'small_business' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<section
|
||
aria-label="Próxima acção do projecto"
|
||
className="rounded-lg border border-emerald-200 bg-emerald-50 p-4"
|
||
>
|
||
<p className="font-medium">
|
||
Percurso exploratório de pequeno negócio
|
||
</p>
|
||
<p className="mt-1 text-sm">
|
||
Pode começar pela ideia sem NUIT, dados de
|
||
contacto ou documentos. O rascunho fica neste
|
||
dispositivo; com sessão iniciada, é sincronizado
|
||
com a sua conta.
|
||
</p>
|
||
<p className="mt-2 text-sm" aria-live="polite">
|
||
Próxima acção:{' '}
|
||
{journeySections[step + 1]?.title ??
|
||
'exportar ou rever o plano financeiro'}
|
||
.
|
||
</p>
|
||
</section>
|
||
)}
|
||
<h3 className="text-lg font-semibold">{current.title}</h3>
|
||
{current.id === 'review' && (
|
||
<section
|
||
className="rounded-lg border p-4"
|
||
aria-labelledby="project-review-summary-title"
|
||
>
|
||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||
<div>
|
||
<h4
|
||
id="project-review-summary-title"
|
||
className="font-semibold"
|
||
>
|
||
Resumo do dossier
|
||
</h4>
|
||
<p className="text-sm text-slate-700">
|
||
Informação declarada e resultados
|
||
existentes; campos vazios não são
|
||
substituídos.
|
||
</p>
|
||
</div>
|
||
<span className="text-xs">
|
||
Questionário{' '}
|
||
{String(
|
||
draft.project_questionnaire_version ??
|
||
'legacy-unversioned',
|
||
)}
|
||
</span>
|
||
</div>
|
||
<dl className="mt-3 grid gap-3 sm:grid-cols-2">
|
||
{[
|
||
[
|
||
'Projecto',
|
||
String(draft.project_title ?? ''),
|
||
'purpose',
|
||
],
|
||
[
|
||
'Objectivo e proposta',
|
||
String(
|
||
draft.project_summary ??
|
||
draft.value_proposition ??
|
||
'',
|
||
),
|
||
'purpose',
|
||
],
|
||
[
|
||
'Clientes',
|
||
String(draft.target_customers ?? ''),
|
||
journeySections.some(
|
||
(section) => section.id === 'market',
|
||
)
|
||
? 'market'
|
||
: 'project',
|
||
],
|
||
[
|
||
'Localização',
|
||
[
|
||
draft.province_id_label,
|
||
draft.district_id_label,
|
||
draft.locality_id_label,
|
||
]
|
||
.filter(
|
||
(item): item is string =>
|
||
typeof item === 'string' &&
|
||
item !== '',
|
||
)
|
||
.join(' · '),
|
||
'location',
|
||
],
|
||
[
|
||
'Investimento declarado',
|
||
draft.total_investment
|
||
? `${displayValue(draft.total_investment)} ${displayValue(draft.currency)}`
|
||
: '',
|
||
'financial',
|
||
],
|
||
[
|
||
'Estado do resultado calculado',
|
||
result
|
||
? `${simulationStatusLabel(result.status)} · versão ${result.version_no}${arithmeticResult.value ? ` · ${arithmeticResult.value} ${displayValue(arithmeticSnapshot?.currency)}` : ''}`
|
||
: 'Sem resultado de cálculo guardado',
|
||
'financial',
|
||
],
|
||
].map(([label, value, target]) => (
|
||
<div key={label} className="rounded border p-3">
|
||
<dt className="text-sm font-medium">
|
||
{label}
|
||
</dt>
|
||
<dd className="mt-1 text-sm whitespace-pre-wrap">
|
||
{value || 'Não informado'}
|
||
</dd>
|
||
<button
|
||
type="button"
|
||
className="mt-2 text-sm font-semibold underline focus-visible:outline-2"
|
||
onClick={() => {
|
||
const index =
|
||
journeySections.findIndex(
|
||
(section) =>
|
||
section.id === target,
|
||
);
|
||
if (index >= 0) navigate(index);
|
||
}}
|
||
>
|
||
Editar secção
|
||
</button>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</section>
|
||
)}
|
||
{current.id === 'location' &&
|
||
draft.journey_type === 'small_business' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<p className="text-sm text-slate-700">
|
||
A localização é uma hipótese editável; não precisa
|
||
de terreno ou DUAT nesta fase. Consulte o{' '}
|
||
<a className="underline" href="/terra-duat">
|
||
guia de preparação de DUAT
|
||
</a>{' '}
|
||
ou as{' '}
|
||
<a className="underline" href="/como-investir">
|
||
orientações para investir
|
||
</a>{' '}
|
||
quando forem relevantes.
|
||
</p>
|
||
)}
|
||
{current.id === 'location' &&
|
||
draft.journey_type === 'investor' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<p className="rounded border border-amber-300 bg-amber-50 p-3 text-sm">
|
||
A localização e as necessidades de clientes,
|
||
matérias-primas, energia, água, transporte,
|
||
trabalhadores, instalações, custos e requisitos são
|
||
hipóteses declaradas. Esta etapa não confirma
|
||
disponibilidade local. ZEE, ZFI, parques, clusters e
|
||
corredores são alternativas a investigar; não
|
||
garantem infra-estruturas nem incentivos.
|
||
</p>
|
||
)}
|
||
{current.id === 'impact' &&
|
||
draft.journey_type === 'small_business' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<p className="text-sm text-slate-700">
|
||
Registe competências e marcos como declarações de
|
||
planeamento. O portal ainda não corresponde estas
|
||
necessidades a prestadores nem confirma resultados
|
||
realizados.
|
||
</p>
|
||
)}
|
||
{current.id === 'team' && (
|
||
<>
|
||
<ProjectTeamPlanner
|
||
value={String(draft.project_team_snapshot ?? '')}
|
||
onChange={(value) =>
|
||
change('project_team_snapshot', value)
|
||
}
|
||
/>
|
||
{draft.skills_needed && (
|
||
<details className="rounded border p-3">
|
||
<summary className="cursor-pointer">
|
||
Nota anterior sobre competências
|
||
</summary>
|
||
<p className="mt-2 text-sm whitespace-pre-wrap">
|
||
{draft.skills_needed}
|
||
</p>
|
||
</details>
|
||
)}
|
||
<ProjectMilestonePlanner
|
||
value={String(
|
||
draft.project_execution_snapshot ?? '',
|
||
)}
|
||
onChange={(value) =>
|
||
change('project_execution_snapshot', value)
|
||
}
|
||
/>
|
||
{draft.project_milestones && (
|
||
<details className="rounded border p-3">
|
||
<summary className="cursor-pointer">
|
||
Nota anterior sobre marcos
|
||
</summary>
|
||
<p className="mt-2 text-sm whitespace-pre-wrap">
|
||
{draft.project_milestones}
|
||
</p>
|
||
</details>
|
||
)}
|
||
</>
|
||
)}
|
||
{current.id === 'opportunity' &&
|
||
draft.journey_type === 'small_business' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<p className="text-sm text-slate-700">
|
||
Indique recursos ou parceiros de que precisa. Esta
|
||
etapa não confirma compatibilidade, disponibilidade
|
||
de financiamento ou pré-elegibilidade de qualquer
|
||
produto.
|
||
</p>
|
||
)}
|
||
{(current.id === 'opportunity' || current.id === 'financial') &&
|
||
draft.journey_type === 'small_business' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<output
|
||
role="status"
|
||
className="block rounded border border-amber-300 bg-amber-50 p-3 text-sm"
|
||
>
|
||
Pré-elegibilidade indicativa: informação
|
||
insuficiente. Não há catálogo de produtos de
|
||
financiamento com critérios, vigência e fontes
|
||
documentados para esta consulta. A indicação de
|
||
necessidade não representa aprovação,
|
||
disponibilidade ou oferta.
|
||
</output>
|
||
)}
|
||
{(current.id === 'purpose' || current.id === 'project') &&
|
||
draft.journey_type && (
|
||
<output className="block rounded bg-sky-50 p-3 text-sm">
|
||
{draft.journey_type === 'personal'
|
||
? 'Percurso para planeamento pessoal. Pode explorar os valores sem criar conta.'
|
||
: draft.journey_type === 'small_business'
|
||
? 'Transforme uma ideia num negócio, começando por hipóteses simples e avançando gradualmente.'
|
||
: draft.journey_type === 'company'
|
||
? 'Percurso completo para projectos empresariais e estruturas de financiamento.'
|
||
: 'Avalie um projecto definido. A análise fiscal acompanha os fluxos financeiros, mas não prova rentabilidade nem concede incentivos.'}
|
||
</output>
|
||
)}
|
||
{current.id === 'location' && contextId && (
|
||
<aside className="rounded border p-3" aria-label="Necessidade de terra">
|
||
<p>{investmentLandPreparation(draft.context_applicability_snapshot).need === 'no'
|
||
? 'Não precisa de terra neste investimento. A situação do terreno não é pedida; os valores anteriores ficam conservados.'
|
||
: investmentLandPreparation(draft.context_applicability_snapshot).need === 'yes'
|
||
? 'Precisa de terra neste investimento. Reveja a situação do terreno já preenchida ou indique-a quando disponível.'
|
||
: 'Falta definir se precisa de terra. Pode guardar o rascunho e continuar sem preencher a situação do terreno.'}</p>
|
||
<a className="underline" href={`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`}>Rever a necessidade de terra no dossier</a>
|
||
</aside>
|
||
)}
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
{fields.map((field) => {
|
||
const fieldRequired =
|
||
current.id === 'tax' &&
|
||
draft.journey_type === 'investor' &&
|
||
draft.purpose === 'simulate_only'
|
||
? false
|
||
: typeof field.required === 'function'
|
||
? field.required(draft)
|
||
: Boolean(field.required);
|
||
const fieldError =
|
||
serverFieldErrors[field.key] ?? errors[field.key];
|
||
const reused = contextId && ready && !fieldError && !editingReused.includes(`${contextId}:${field.key}`)
|
||
? reusedInvestmentValue(`dossier:${contextId}:simulator`, field.key, draft) : null;
|
||
if (reused !== null) return (
|
||
<div key={field.key} id={`field-${field.key}`} className="rounded border bg-emerald-50 p-3">
|
||
<p className="font-semibold">{field.label}</p>
|
||
<p className="whitespace-pre-wrap [overflow-wrap:anywhere]">{reused}</p>
|
||
<p className="text-sm">Recuperado do dossier.</p>
|
||
<button type="button" className="underline" onClick={() => setEditingReused((keys) => [...keys, `${contextId}:${field.key}`])}>Alterar {field.label.toLocaleLowerCase('pt-MZ')}</button>
|
||
</div>
|
||
);
|
||
const ivaNotes = ivaIssues.filter((issue) => issue.field === field.key).map((issue, index) => <p key={index} role="note" className="mt-1 text-sm text-amber-950">{ivaIssueText(issue)}</p>);
|
||
if (field.options) {
|
||
const parentKey =
|
||
field.key === 'district_id'
|
||
? 'province_id'
|
||
: field.key === 'admin_post_id'
|
||
? 'district_id'
|
||
: field.key === 'locality_id'
|
||
? 'admin_post_id'
|
||
: field.key === 'activity_id'
|
||
? 'sector_id'
|
||
: '';
|
||
return (
|
||
<div
|
||
key={field.key}
|
||
id={`field-${field.key}`}
|
||
tabIndex={-1}
|
||
>
|
||
<SearchChoice
|
||
label={`${field.label}${fieldRequired ? ' (obrigatório)' : ''}`}
|
||
source={field.options}
|
||
parent={
|
||
parentKey
|
||
? String(draft[parentKey] ?? '')
|
||
: ''
|
||
}
|
||
value={
|
||
draft[field.key] ??
|
||
(field.multiple ? [] : '')
|
||
}
|
||
selectedLabel={
|
||
draft[`${field.key}_label`] as
|
||
| string
|
||
| string[]
|
||
| undefined
|
||
}
|
||
multiple={field.multiple}
|
||
disabled={Boolean(
|
||
parentKey && !draft[parentKey],
|
||
)}
|
||
error={fieldError}
|
||
required={fieldRequired}
|
||
onChange={(value, text) =>
|
||
change(field.key, value, text)
|
||
}
|
||
/>
|
||
<FieldGuidance fieldKey={field.key} />
|
||
{ivaNotes}
|
||
</div>
|
||
);
|
||
}
|
||
const isArea = ['area_required'].includes(field.key);
|
||
return (
|
||
<div key={field.key}>
|
||
<label
|
||
htmlFor={`field-${field.key}`}
|
||
className="mb-1 block text-sm font-medium"
|
||
>
|
||
{field.label}
|
||
{fieldRequired ? ' (obrigatório)' : ''}
|
||
</label>
|
||
<FieldGuidance fieldKey={field.key} />
|
||
{field.type === 'textarea' ? (
|
||
<textarea
|
||
id={`field-${field.key}`}
|
||
className="min-h-28 w-full rounded-lg border border-slate-300 p-3 focus-visible:outline-2 focus-visible:outline-emerald-700"
|
||
value={String(draft[field.key] ?? '')}
|
||
onChange={(e) =>
|
||
change(field.key, e.target.value)
|
||
}
|
||
aria-invalid={Boolean(fieldError)}
|
||
aria-required={
|
||
fieldRequired || undefined
|
||
}
|
||
aria-describedby={
|
||
fieldError
|
||
? `error-${field.key}`
|
||
: undefined
|
||
}
|
||
/>
|
||
) : (
|
||
<input
|
||
id={`field-${field.key}`}
|
||
type={
|
||
field.type ??
|
||
(isArea ? 'number' : 'text')
|
||
}
|
||
min={
|
||
field.type === 'number'
|
||
? '0'
|
||
: undefined
|
||
}
|
||
step={
|
||
field.type === 'number'
|
||
? 'any'
|
||
: undefined
|
||
}
|
||
className="w-full rounded-lg border border-slate-300 p-3 focus-visible:outline-2 focus-visible:outline-emerald-700"
|
||
value={String(draft[field.key] ?? '')}
|
||
onChange={(e) =>
|
||
change(field.key, e.target.value)
|
||
}
|
||
aria-invalid={Boolean(fieldError)}
|
||
aria-required={
|
||
fieldRequired || undefined
|
||
}
|
||
aria-describedby={
|
||
fieldError
|
||
? `error-${field.key}`
|
||
: undefined
|
||
}
|
||
/>
|
||
)}
|
||
{fieldError && (
|
||
<p
|
||
id={`error-${field.key}`}
|
||
role="alert"
|
||
className="mt-1 text-sm text-red-800"
|
||
>
|
||
{fieldError}
|
||
</p>
|
||
)}
|
||
{ivaNotes}
|
||
{field.key === 'reduction' && (
|
||
<p className="mt-1 text-sm text-slate-600">
|
||
Hipótese sua, não representa um
|
||
benefício fiscal aprovado.
|
||
</p>
|
||
)}
|
||
{field.key === 'market_source' &&
|
||
draft.journey_type === 'small_business' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<p className="mt-1 text-sm text-slate-600">
|
||
Informação declarada, não
|
||
verificada. A fonte e a data são
|
||
opcionais; a sua ausência não
|
||
bloqueia o percurso.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{current.id === 'financial' && (
|
||
<p className="rounded bg-amber-50 p-3 text-sm">
|
||
Os montantes são introduzidos pelo utilizador. Confirme
|
||
que capital, dívida e subvenções reconciliam com o
|
||
investimento total.
|
||
</p>
|
||
)}
|
||
{(current.id === 'financial' ||
|
||
(draft.purpose === 'simulate_only' &&
|
||
current.id === 'tax')) && (
|
||
<div
|
||
id="draft-tool-calculation"
|
||
tabIndex={-1}
|
||
aria-label="Ferramentas de cálculo"
|
||
>
|
||
<SimulationFinancialTools
|
||
currency={String(draft.currency ?? '')}
|
||
snapshot={String(
|
||
draft.financial_tools_snapshot ?? '',
|
||
)}
|
||
onSnapshotChange={(value) =>
|
||
change('financial_tools_snapshot', value)
|
||
}
|
||
onCurrencyChange={(value) =>
|
||
change('currency', value)
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{(current.id === 'financial' ||
|
||
(draft.purpose === 'simulate_only' &&
|
||
current.id === 'tax')) && (
|
||
<details className="rounded border p-3"><summary>Cenário personalizado de tesouraria — imposto manual, separado do IVA calculado</summary><SimulationCashflowPlanner
|
||
snapshot={String(draft.cashflow_snapshot ?? '')}
|
||
onSnapshotChange={(value) =>
|
||
change('cashflow_snapshot', value)
|
||
}
|
||
currency={String(draft.currency ?? '')}
|
||
onCurrencyChange={(value) => change('currency', value)}
|
||
/></details>
|
||
)}
|
||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcEligibility draft={draft} onChange={change} />}
|
||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcQuarter draft={draft} onChange={change} />}
|
||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcWithholding draft={draft} onChange={change} />}
|
||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcResiduals draft={draft} onChange={change} />}
|
||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcWithholdings draft={draft} onChange={change} />}
|
||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcNormal draft={draft} onChange={change} />}
|
||
{(current.id === 'financial' || current.id === 'tax') && <SimulationMonthlyIva draft={draft} busy={isCalculating} onCalculate={runSimulation} onChange={change} />}
|
||
{current.id === 'financial' &&
|
||
(draft.detail_level === 'advanced' ||
|
||
draft.journey_type === 'small_business') &&
|
||
(draft.purpose !== 'simulate_only' ||
|
||
draft.journey_type === 'small_business') && (
|
||
<SimulationBusinessPlan
|
||
currency={String(draft.currency ?? '')}
|
||
snapshot={String(
|
||
draft.business_plan_snapshot ?? '',
|
||
)}
|
||
onCurrencyChange={(value) =>
|
||
change('currency', value)
|
||
}
|
||
onSnapshotChange={(value) =>
|
||
change('business_plan_snapshot', value)
|
||
}
|
||
/>
|
||
)}
|
||
{current.id === 'review' &&
|
||
(draft.journey_type === 'small_business' ||
|
||
draft.journey_type === 'investor') && (
|
||
<ProjectReadiness
|
||
draft={draft}
|
||
onChange={(key, value) => change(key, value)}
|
||
onNavigate={(fieldKey) => {
|
||
const target = [
|
||
'market_assumption',
|
||
'market_source',
|
||
'target_customers',
|
||
'competitor_assumption',
|
||
'sales_channel_assumption',
|
||
'marketing_approach',
|
||
].includes(fieldKey)
|
||
? journeySections.some(
|
||
(section) => section.id === 'market',
|
||
)
|
||
? 'market'
|
||
: 'project'
|
||
: fieldKey === 'location_rationale'
|
||
? 'location'
|
||
: fieldKey === 'team'
|
||
? 'team'
|
||
: fieldKey === 'tax' &&
|
||
journeySections.some(
|
||
(section) =>
|
||
section.id === 'tax',
|
||
)
|
||
? 'tax'
|
||
: 'financial';
|
||
const targetStep = journeySections.findIndex(
|
||
(section) => section.id === target,
|
||
);
|
||
if (targetStep >= 0) navigate(targetStep);
|
||
}}
|
||
/>
|
||
)}
|
||
{current.id === 'review' &&
|
||
(draft.journey_type === 'small_business' ||
|
||
draft.journey_type === 'investor') && (
|
||
<section
|
||
aria-labelledby="project-documents-title"
|
||
className="space-y-3 rounded-lg border border-sky-800 bg-sky-50 p-4"
|
||
>
|
||
<h3
|
||
id="project-documents-title"
|
||
className="font-semibold"
|
||
>
|
||
Documentos úteis para este projecto
|
||
</h3>
|
||
<p className="text-sm">
|
||
Documentos para apoiar a preparação do seu
|
||
projecto.
|
||
</p>
|
||
{documentRecommendations.length ? (
|
||
<ul className="space-y-3">
|
||
{documentRecommendations.map((document) => (
|
||
<li
|
||
key={document.id}
|
||
className="rounded border bg-white p-3"
|
||
>
|
||
<PublicDocumentContent
|
||
resource={document}
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="text-sm">
|
||
Não há documentos publicados associados aos
|
||
dados actualmente indicados.{' '}
|
||
<a className="underline" href="/documentos">
|
||
Pesquisar na biblioteca
|
||
</a>
|
||
. A ausência de sugestão não bloqueia o
|
||
dossier.
|
||
</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
{current.id === 'review' &&
|
||
['small_business', 'investor'].includes(
|
||
String(draft.journey_type),
|
||
) &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<ProjectDossierExport
|
||
draft={draft}
|
||
version={
|
||
draftVersionNo
|
||
? `Revisão ${draftVersionNo}${draftIdRef.current ? ` · dossier ${draftIdRef.current.slice(0, 8)}` : ''}`
|
||
: `Rascunho local · ${simulationDraftFingerprint(draft).slice(0, 12)}`
|
||
}
|
||
references={simulationSources.map((source) => ({
|
||
version: source.source_version_no,
|
||
date: source.consented_at,
|
||
checksum: source.report_sha256,
|
||
}))}
|
||
currentCalculation={
|
||
result
|
||
? {
|
||
status: result.status,
|
||
version: result.version_no,
|
||
updatedAt:
|
||
typeof result.updated_at ===
|
||
'string'
|
||
? result.updated_at
|
||
: undefined,
|
||
snapshot:
|
||
result.result_snapshot ?? null,
|
||
reportChecksum:
|
||
arithmeticReportReference?.report_sha256,
|
||
}
|
||
: null
|
||
}
|
||
/>
|
||
)}
|
||
{current.id === 'tax' && <SimulationPublishedIva draft={draft} onChange={change} busy={isCalculating} onCalculate={runSimulation} />}
|
||
{current.id === 'tax' && (
|
||
<div
|
||
id="draft-tool-fiscal"
|
||
tabIndex={-1}
|
||
aria-label="Consulta de parâmetros fiscais"
|
||
>
|
||
<SimulationFiscalStatus
|
||
onDiagnostics={setIvaIssues}
|
||
onCorrect={(key) => {
|
||
const target = journeySections.findIndex((section) => (simulationFieldGroups[section.id] ?? []).some((field) => field.key === key));
|
||
if (target >= 0) setStep(target);
|
||
window.requestAnimationFrame(() => document.getElementById(`field-${key}`)?.focus());
|
||
}}
|
||
salaryView={String(draft.irps_salary_view ?? '')}
|
||
scenarioMode={
|
||
openedTool === 'fiscal'
|
||
? 'approved_parameters'
|
||
: String(draft.fiscal_scenario_mode ?? '')
|
||
}
|
||
component={String(draft.fiscal_component ?? '')}
|
||
periodStart={getFiscalPeriodRange(draft)?.start}
|
||
periodEnd={getFiscalPeriodRange(draft)?.end}
|
||
context={{ ...Object.fromEntries(
|
||
[
|
||
'fiscal_periodicity',
|
||
'fiscal_operation',
|
||
'fiscal_residency_assessment',
|
||
'fiscal_residency_country',
|
||
'fiscal_income_category',
|
||
'fiscal_establishments',
|
||
'fiscal_documentation',
|
||
'fiscal_customs_code',
|
||
'fiscal_vat_refund_state',
|
||
].flatMap((key) =>
|
||
typeof draft[key] === 'string' &&
|
||
draft[key] !== ''
|
||
? [[key, draft[key] as string]]
|
||
: [],
|
||
),
|
||
), ...(typeof draft.fiscal_currency === 'string' && draft.fiscal_currency !== '' ? {currency: draft.fiscal_currency} : {}) }}
|
||
/>
|
||
</div>
|
||
)}
|
||
{current.id === 'tax' &&
|
||
draft.journey_type === 'investor' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<output
|
||
role="status"
|
||
className="block rounded border border-amber-300 bg-amber-50 p-3 text-sm"
|
||
>
|
||
Incentivos potencialmente aplicáveis: indeterminados
|
||
nesta consulta. O portal não dispõe aqui de catálogo
|
||
aprovado com condições, vigência e legislação de
|
||
suporte. Não será aplicada redução fiscal
|
||
automática. A simulação fiscal estima efeitos de
|
||
regras disponíveis; a rentabilidade depende também
|
||
de receitas, custos, financiamento e riscos.
|
||
</output>
|
||
)}
|
||
{current.id === 'scenarios' &&
|
||
draft.journey_type === 'investor' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<>
|
||
<p className="rounded border border-sky-300 bg-sky-50 p-3 text-sm">
|
||
Compare cenários financeiros favorável e
|
||
desfavorável alterando pressupostos. Esta
|
||
comparação não modela automaticamente benefícios
|
||
fiscais nem diferenças de localização ou
|
||
estrutura de financiamento. VAL, TIR e prazo de
|
||
recuperação não são apresentados sem cálculo e
|
||
dados suficientes.
|
||
</p>
|
||
<SimulationBusinessPlan
|
||
currency={String(draft.currency ?? '')}
|
||
snapshot={String(
|
||
draft.business_plan_snapshot ?? '',
|
||
)}
|
||
onCurrencyChange={(value) =>
|
||
change('currency', value)
|
||
}
|
||
onSnapshotChange={(value) =>
|
||
change('business_plan_snapshot', value)
|
||
}
|
||
/>
|
||
</>
|
||
)}
|
||
{current.id === 'procedures' &&
|
||
draft.journey_type === 'investor' &&
|
||
draft.purpose === 'simulate_only' && (
|
||
<p className="rounded border border-amber-300 bg-amber-50 p-3 text-sm">
|
||
A lista abaixo é apenas um registo de documentos que
|
||
já possui ou pretende preparar. Não confirma
|
||
requisitos legais. Procedimentos, licenças e
|
||
documentos aplicáveis dependem da actividade,
|
||
localização e características do projecto; sem um
|
||
catálogo oficial validado, ficam por confirmar.
|
||
</p>
|
||
)}
|
||
{current.id === 'tax' && (
|
||
<p className="rounded border border-amber-300 bg-amber-50 p-3 text-sm">
|
||
O período e as respostas servem para encaminhar uma
|
||
exploração informativa. A residência é auto-indicada ou
|
||
fica por determinar; não é inferida por nacionalidade ou
|
||
dias de presença. Cada cálculo identifica a regra, versão, cobertura e
|
||
limitações; este formulário não determina cumprimento
|
||
fiscal. Um crédito estimado de IVA não significa pedido
|
||
aprovado nem dinheiro disponível. Não carregue
|
||
documentos nesta simulação exploratória.
|
||
</p>
|
||
)}
|
||
{(current.id === 'tax' || result) && (
|
||
<div className="space-y-3 border-t pt-4">
|
||
<h4 className="font-semibold">
|
||
{isFiscalQaPreview ? (arithmeticSnapshot?.scenario_kind === 'fiscal_rule_published' ? 'IVA pela versão publicada seleccionada' : 'Pré-visualização fiscal QA seleccionada') : current.id === 'tax'
|
||
? 'Cenário personalizado de planeamento'
|
||
: 'Resultado histórico seleccionado'}
|
||
</h4>
|
||
{current.id !== 'tax' && result && (
|
||
<p>
|
||
Este resultado conserva os pressupostos e a
|
||
versão da execução seleccionada. Não substitui
|
||
as entradas deste dossier.
|
||
</p>
|
||
)}
|
||
{current.id === 'tax' &&
|
||
draft.fiscal_scenario_mode ===
|
||
'custom_planning' && (
|
||
<p
|
||
role="note"
|
||
className="rounded bg-slate-50 p-3 text-sm"
|
||
>
|
||
Os valores abaixo são pressupostos que
|
||
introduziu para planeamento. O resultado é
|
||
uma operação percentual genérica, não uma
|
||
taxa legal, imposto devido nem confirmação
|
||
de elegibilidade.
|
||
</p>
|
||
)}
|
||
{current.id === 'tax' &&
|
||
draft.fiscal_scenario_mode ===
|
||
'custom_planning' && (
|
||
<button
|
||
type="button"
|
||
disabled={isCalculating}
|
||
aria-busy={isCalculating}
|
||
className="rounded-lg bg-emerald-800 px-4 py-3 font-semibold text-white focus-visible:outline-2 disabled:cursor-wait disabled:opacity-60"
|
||
onClick={() => void runSimulation()}
|
||
>
|
||
{isCalculating
|
||
? 'A calcular…'
|
||
: 'Validar e calcular no servidor'}
|
||
</button>
|
||
)}
|
||
{serverError && (
|
||
<p role="alert" className="text-red-800">
|
||
{serverError}
|
||
</p>
|
||
)}
|
||
{result && (
|
||
<div
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
className="rounded bg-emerald-50 p-4"
|
||
>
|
||
<p>
|
||
Simulação{' '}
|
||
{simulationStatusLabel(result.status)}.
|
||
</p>
|
||
{result.status === 'completed' && (
|
||
<p>
|
||
Cálculo concluído no servidor. Próxima
|
||
ação: reveja os pressupostos e o
|
||
relatório antes de associar o resultado
|
||
ao dossier. Nenhum pedido de apoio foi
|
||
submetido.
|
||
</p>
|
||
)}
|
||
{result.status === 'completed' && !isFiscalQaPreview && (
|
||
<p>
|
||
Resultado aritmético indicativo:{' '}
|
||
<strong className="break-all">
|
||
{displayScalar(
|
||
arithmeticResult.value ??
|
||
(arithmeticResult.integrity ===
|
||
'invalid'
|
||
? 'indisponível: snapshot inconsistente'
|
||
: 'disponível no relatório'),
|
||
arithmeticResult.integrity ===
|
||
'invalid'
|
||
? 'indisponível: snapshot inconsistente'
|
||
: 'disponível no relatório',
|
||
)}
|
||
</strong>
|
||
</p>
|
||
)}
|
||
{result.status === 'failed' && (
|
||
<p role="alert">
|
||
O cálculo não foi concluído. Reveja os
|
||
valores e tente novamente.
|
||
</p>
|
||
)}
|
||
{result.status === 'queued' ||
|
||
result.status === 'running' ? (
|
||
<p className="text-sm">
|
||
O cálculo continua pendente. Pode
|
||
consultar o histórico quando estiver
|
||
disponível.
|
||
</p>
|
||
) : null}
|
||
<p className="text-sm">
|
||
O resultado é indicativo e não determina
|
||
taxas legais, elegibilidade ou obrigação
|
||
fiscal.
|
||
</p>
|
||
{isFiscalQaPreview && <FiscalQaPreview snapshot={result.result_snapshot} />}
|
||
{result.status === 'completed' && (
|
||
<div className="mt-3 space-y-2 border-t pt-3">
|
||
{!isFiscalQaPreview && arithmeticResult.integrity !==
|
||
'invalid' && (
|
||
<details className="rounded border p-3">
|
||
<summary className="cursor-pointer font-semibold focus-visible:outline-2">
|
||
Detalhes do cálculo e da
|
||
versão
|
||
</summary>
|
||
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||
<div>
|
||
<dt className="font-medium">
|
||
Base introduzida
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticSnapshot?.base,
|
||
'não disponível',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Taxa introduzida (%)
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticSnapshot?.rate_percent,
|
||
'não disponível',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Redução hipotética
|
||
(%)
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticSnapshot?.reduction_percent,
|
||
'não disponível',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Moeda
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticSnapshot?.currency,
|
||
'não fornecida',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
{arithmeticResult.context && (
|
||
<>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Período
|
||
</dt>
|
||
<dd>
|
||
Cálculo
|
||
pontual; sem
|
||
período
|
||
fiscal
|
||
atribuído
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Data de
|
||
referência
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticResult
|
||
.context
|
||
.reference_date,
|
||
'não disponível',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Versão do
|
||
motor
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticResult
|
||
.context
|
||
.engine_version,
|
||
'não identificada',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Versão do
|
||
cenário
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticResult
|
||
.context
|
||
.scenario_version,
|
||
'não identificada',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
Versão da
|
||
fórmula
|
||
</dt>
|
||
<dd>
|
||
{displayScalar(
|
||
arithmeticSnapshot?.formula_version,
|
||
'não identificada',
|
||
)}
|
||
</dd>
|
||
</div>
|
||
</>
|
||
)}
|
||
{arithmeticResult.integrity ===
|
||
'versioned' &&
|
||
arithmeticReportReference && (
|
||
<>
|
||
<div>
|
||
<dt className="font-medium">
|
||
ID do
|
||
relatório
|
||
imutável
|
||
</dt>
|
||
<dd className="break-all">
|
||
{
|
||
arithmeticReportReference.id
|
||
}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-medium">
|
||
SHA-256
|
||
do
|
||
relatório
|
||
</dt>
|
||
<dd className="break-all">
|
||
{
|
||
arithmeticReportReference.report_sha256
|
||
}
|
||
</dd>
|
||
</div>
|
||
</>
|
||
)}
|
||
</dl>
|
||
<p className="mt-3 text-sm">
|
||
Fórmula genérica: base ×
|
||
taxa percentual ÷ 100 × (1 −
|
||
redução percentual ÷ 100).
|
||
Não foram aplicadas regras
|
||
fiscais nem associadas
|
||
fontes legais; os parâmetros
|
||
foram introduzidos pelo
|
||
utilizador.
|
||
</p>
|
||
{arithmeticResult.explanation && (
|
||
<p className="mt-2 text-sm">
|
||
{
|
||
arithmeticResult.explanation
|
||
}
|
||
</p>
|
||
)}
|
||
</details>
|
||
)}
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
arithmeticResult.integrity ===
|
||
'invalid' ||
|
||
versionedReportMissing
|
||
}
|
||
className="rounded border px-3 py-2 text-sm focus-visible:outline-2"
|
||
onClick={() => {
|
||
const blob = new Blob(
|
||
[
|
||
exportCompletedArithmeticCsv(
|
||
result,
|
||
),
|
||
],
|
||
{
|
||
type: 'text/csv;charset=utf-8',
|
||
},
|
||
);
|
||
const url =
|
||
URL.createObjectURL(blob);
|
||
const link =
|
||
document.createElement('a');
|
||
link.href = url;
|
||
link.download = `simulacao-aritmetica-${displayScalar(result.id, 'resultado').slice(0, 8)}.csv`;
|
||
link.click();
|
||
window.setTimeout(
|
||
() =>
|
||
URL.revokeObjectURL(
|
||
url,
|
||
),
|
||
1000,
|
||
);
|
||
}}
|
||
>
|
||
Exportar resultado em CSV
|
||
</button>
|
||
{arithmeticResult.integrity ===
|
||
'invalid' && (
|
||
<p
|
||
role="alert"
|
||
className="text-sm text-red-800"
|
||
>
|
||
O snapshot do resultado está
|
||
inconsistente; exportação e
|
||
conversão foram desactivadas.
|
||
</p>
|
||
)}
|
||
{versionedReportMissing && (
|
||
<p
|
||
role="alert"
|
||
className="text-sm text-amber-900"
|
||
>
|
||
O relatório imutável ainda não
|
||
está disponível ou não
|
||
corresponde a esta revisão.
|
||
Exportação e conversão estão
|
||
desactivadas.
|
||
</p>
|
||
)}
|
||
<p className="text-sm">
|
||
Pode copiar este resultado
|
||
versionado para o rascunho de
|
||
investimento. A cópia preserva
|
||
entradas, fórmula e relatório; não
|
||
cria um projecto, não envia dados ao
|
||
CRM e não valida uma obrigação
|
||
fiscal.
|
||
</p>
|
||
<div className="rounded border border-emerald-200 bg-emerald-50 p-3 text-sm">
|
||
<p>
|
||
Pode manifestar interesse sem
|
||
depender desta estimativa e sem
|
||
partilhar os dados desta
|
||
simulação.
|
||
</p>
|
||
<a
|
||
href="/manifestar-interesse"
|
||
className="mt-2 inline-block font-semibold underline focus-visible:outline-2"
|
||
>
|
||
Manifestar interesse sem
|
||
partilhar esta simulação
|
||
</a>
|
||
</div>
|
||
{draft.purpose === 'simulate_only' ? (
|
||
<output className="block text-sm">
|
||
Para converter, escolha uma
|
||
finalidade de investimento no
|
||
primeiro passo. O resultado
|
||
continuará disponível enquanto
|
||
não alterar os seus
|
||
pressupostos.
|
||
</output>
|
||
) : completedSimulationAttached ? (
|
||
<output className="block text-sm">
|
||
Este resultado já foi anexado ao
|
||
rascunho (
|
||
{simulationSources
|
||
.find(
|
||
(source) =>
|
||
source.simulation_id ===
|
||
completedSimulationId,
|
||
)
|
||
?.report_sha256.slice(
|
||
0,
|
||
12,
|
||
)}
|
||
…).
|
||
</output>
|
||
) : (
|
||
<>
|
||
<label className="flex items-start gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={
|
||
conversionConsent
|
||
}
|
||
onChange={(event) =>
|
||
setConversionConsent(
|
||
event.target
|
||
.checked,
|
||
)
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
Autorizo copiar este
|
||
resultado e os pressupostos
|
||
associados para o rascunho
|
||
cifrado desta sessão/conta.
|
||
</label>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!conversionConsent ||
|
||
isAttachingSimulation ||
|
||
hasSaveConflict ||
|
||
arithmeticResult.integrity ===
|
||
'invalid' ||
|
||
versionedReportMissing
|
||
}
|
||
aria-busy={
|
||
isAttachingSimulation
|
||
}
|
||
onClick={() =>
|
||
void attachCompletedSimulation()
|
||
}
|
||
className="rounded border px-3 py-2 text-sm focus-visible:outline-2 disabled:opacity-50"
|
||
>
|
||
{isAttachingSimulation
|
||
? 'A anexar…'
|
||
: 'Converter e anexar ao rascunho'}
|
||
</button>
|
||
{conversionError && (
|
||
<p
|
||
role="alert"
|
||
className="text-sm text-red-800"
|
||
>
|
||
{conversionError}
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
{simulationSources.length > 0 && (
|
||
<details className="text-xs">
|
||
<summary>
|
||
Resultados anexados (
|
||
{simulationSources.length})
|
||
</summary>
|
||
<ul className="mt-1 space-y-2 pl-5">
|
||
{simulationSources.map(
|
||
(source) => {
|
||
const sourceResult =
|
||
readArithmeticSnapshot(
|
||
source.result_snapshot,
|
||
);
|
||
return (
|
||
<li
|
||
key={
|
||
source.simulation_id
|
||
}
|
||
className="list-disc"
|
||
>
|
||
Execução{' '}
|
||
{
|
||
source.simulation_id
|
||
}{' '}
|
||
· versão{' '}
|
||
{
|
||
source.source_version_no
|
||
}{' '}
|
||
· fórmula{' '}
|
||
{displayScalar(
|
||
source
|
||
.result_snapshot
|
||
.formula_version ??
|
||
'não identificada',
|
||
'não identificada',
|
||
)}{' '}
|
||
· {['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(source.result_snapshot.scenario_kind)) ? 'IVA calculado exacto' : 'resultado aritmético'}{' '}
|
||
{displayScalar(
|
||
sourceResult.value ??
|
||
(sourceResult.integrity ===
|
||
'invalid'
|
||
? 'oculto: snapshot inconsistente'
|
||
: 'indisponível'),
|
||
sourceResult.integrity ===
|
||
'invalid'
|
||
? 'oculto: snapshot inconsistente'
|
||
: 'indisponível',
|
||
)}{' '}
|
||
{sourceResult.integrity ===
|
||
'versioned' && (
|
||
<>
|
||
·
|
||
contrato
|
||
v1 ·{' '}
|
||
{displayScalar(
|
||
sourceResult
|
||
.context
|
||
?.reference_date,
|
||
'data indisponível',
|
||
)}{' '}
|
||
·{' '}
|
||
{displayScalar(
|
||
sourceResult
|
||
.context
|
||
?.engine_version,
|
||
'motor não identificado',
|
||
)}{' '}
|
||
</>
|
||
)}
|
||
· hash{' '}
|
||
{
|
||
source.report_sha256
|
||
}
|
||
</li>
|
||
);
|
||
},
|
||
)}
|
||
</ul>
|
||
<p className="mt-2">
|
||
O hash e a versão
|
||
identificam a cópia
|
||
preservada; este valor
|
||
permanece indicativo e não é
|
||
uma conclusão fiscal.
|
||
</p>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{hasSaveConflict && (
|
||
<p
|
||
role="alert"
|
||
className="rounded border border-amber-700 bg-amber-50 p-3"
|
||
>
|
||
Este rascunho foi alterado noutro separador.{' '}
|
||
<button
|
||
type="button"
|
||
className="font-semibold underline"
|
||
onClick={() => window.location.reload()}
|
||
>
|
||
Recarregar a versão mais recente
|
||
</button>
|
||
</p>
|
||
)}
|
||
{reviewStatus && (
|
||
<div
|
||
ref={reviewSummaryRef}
|
||
tabIndex={-1}
|
||
id="draft-validation-summary"
|
||
role="status"
|
||
aria-label="Resumo da validação do rascunho"
|
||
className="max-h-[70vh] overflow-y-auto rounded border border-sky-300 bg-sky-50 p-3"
|
||
>
|
||
<h3 className="font-semibold">
|
||
Resumo da validação do rascunho
|
||
</h3>
|
||
<p>{reviewStatus}</p>
|
||
<p>
|
||
Verificação local dos campos e regras disponíveis.
|
||
Não calcula valores nem envia um pedido HTTP de
|
||
validação. A gravação automática do rascunho tem um
|
||
estado independente. Não confirma viabilidade,
|
||
elegibilidade ou aprovação.
|
||
</p>
|
||
{reviewDetails.checked.length > 0 && (
|
||
<div>
|
||
<h4 className="font-semibold">
|
||
Dados verificados (
|
||
{reviewDetails.checked.length})
|
||
</h4>
|
||
<dl className="grid gap-2 text-slate-900">
|
||
{reviewDetails.checked.map((field) => (
|
||
<div
|
||
key={field.key}
|
||
className="rounded border bg-white p-2"
|
||
>
|
||
<dt className="font-medium">
|
||
{field.label}
|
||
</dt>
|
||
<dd className="break-words">
|
||
{field.value}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</div>
|
||
)}
|
||
<p>
|
||
Campos pendentes e inconsistências:{' '}
|
||
{reviewDetails.pending.length}.
|
||
</p>
|
||
{reviewDetails.pending.length > 0 && (
|
||
<ul>
|
||
{reviewDetails.pending.map((item) => (
|
||
<li key={item.key}>
|
||
<button
|
||
type="button"
|
||
className="underline"
|
||
onClick={() => {
|
||
setStep(item.step);
|
||
setErrors(
|
||
Object.fromEntries(
|
||
reviewDetails.pending.map(
|
||
(entry) => [
|
||
entry.key,
|
||
entry.message,
|
||
],
|
||
),
|
||
),
|
||
);
|
||
window.requestAnimationFrame(
|
||
() =>
|
||
document
|
||
.getElementById(
|
||
`field-${item.key}`,
|
||
)
|
||
?.focus(),
|
||
);
|
||
}}
|
||
>
|
||
{item.label}: {item.message}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<p>
|
||
Próxima ação:{' '}
|
||
{reviewDetails.pending.length
|
||
? 'corrija os campos indicados e volte a validar.'
|
||
: draft.purpose === 'simulate_only' &&
|
||
draft.fiscal_scenario_mode ===
|
||
'custom_planning'
|
||
? 'abra a etapa fiscal e use “Validar e calcular no servidor” para obter um resultado separado.'
|
||
: draft.purpose === 'simulate_only'
|
||
? 'use a ferramenta de cálculo adequada aos resultados que pretende obter. A consulta de parâmetros fiscais não calcula impostos; para um cenário percentual hipotético, selecione o modo de planeamento personalizado na etapa fiscal.'
|
||
: draft.purpose === 'request_facilitation'
|
||
? 'confirme que o rascunho está guardado no servidor e continue no Balcão para preparar o pedido de apoio.'
|
||
: draft.purpose === 'seek_partner'
|
||
? 'confirme a gravação do rascunho e explore parceiros na Bolsa de Parcerias, preservando o contexto do projecto.'
|
||
: draft.purpose === 'seek_investor'
|
||
? 'confirme a gravação do rascunho e reveja o financiamento e a informação a apresentar a potenciais investidores.'
|
||
: draft.purpose ===
|
||
'present_opportunity'
|
||
? 'confirme a gravação e reveja os dados e a autorização de divulgação antes de preparar a oportunidade.'
|
||
: 'confirme a gravação e continue a preparar o dossier e o plano do seu investimento.'}{' '}
|
||
Nenhum pedido foi submetido.
|
||
</p>
|
||
{draft.purpose === 'simulate_only' &&
|
||
!reviewDetails.pending.length && (
|
||
<div className="space-y-2">
|
||
<p>
|
||
{draft.fiscal_scenario_mode ===
|
||
'custom_planning'
|
||
? 'Modo personalizado escolhido: calcula uma operação percentual com a base, taxa e redução que introduziu. Não calcula um imposto legal.'
|
||
: 'As ferramentas financeiras calculam prestações, ponto de equilíbrio e necessidades de financiamento com os seus pressupostos. A consulta fiscal identifica parâmetros e fontes; se faltarem parâmetros, o resultado fiscal permanece indeterminado. O modo escolhido será preservado.'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex flex-wrap justify-between gap-3 border-t pt-4">
|
||
<button
|
||
type="button"
|
||
disabled={step === 0}
|
||
onClick={() => navigate(Math.max(0, step - 1))}
|
||
className="rounded border px-4 py-3 focus-visible:outline-2 disabled:opacity-50"
|
||
>
|
||
Anterior
|
||
</button>
|
||
{step < journeySections.length - 1 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate(step + 1)}
|
||
className="rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white focus-visible:outline-2"
|
||
>
|
||
Guardar e continuar
|
||
</button>
|
||
) : (
|
||
<div className="flex flex-wrap gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={isCalculating}
|
||
aria-busy={isCalculating}
|
||
onClick={validateFinalReview}
|
||
className="rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white disabled:opacity-60"
|
||
>
|
||
{isCalculating
|
||
? 'A validar…'
|
||
: 'Validar este rascunho'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void clearDraft()}
|
||
className="rounded border px-4 py-3 focus-visible:outline-2"
|
||
>
|
||
Limpar este rascunho
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{draft.purpose === 'simulate_only' && (
|
||
<div
|
||
className="space-y-2 rounded border p-3"
|
||
aria-label="Ações do simulador"
|
||
>
|
||
<p>
|
||
Abrir cálculo apresenta as ferramentas financeiras
|
||
e, se já escolhido, o cálculo percentual
|
||
personalizado. Consultar parâmetros fiscais
|
||
apresenta fontes e lacunas; não determina imposto
|
||
devido. Na etapa Operação fiscal encontra também
|
||
o cálculo de IVA geral sobre base líquida, quando
|
||
activado, com âmbito e versão identificados.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-emerald-800 px-4 py-3 font-semibold text-white"
|
||
onClick={() => openDraftTool('calculation')}
|
||
>
|
||
Abrir cálculo
|
||
</button>{' '}
|
||
<button
|
||
type="button"
|
||
className="rounded border px-4 py-3"
|
||
onClick={() => openDraftTool('fiscal')}
|
||
>
|
||
Consultar parâmetros fiscais
|
||
</button>
|
||
{toolError && <p role="alert">{toolError}</p>}
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-slate-600">
|
||
Os pressupostos não identificativos podem ser guardados de
|
||
forma cifrada para recuperar o rascunho. Os dados pessoais e
|
||
contactos são guardados apenas quando existe sessão
|
||
autenticada e ficam associados à conta. A submissão formal
|
||
ainda não está disponível neste formulário.
|
||
</p>
|
||
</fieldset>
|
||
</section>
|
||
);
|
||
}
|