Files
Investidor-V2/resources/js/components/simulation-form.tsx
T

4500 lines
220 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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') && <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>
);
}