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

4174 lines
198 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 { 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 {
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 draftKey = 'investmoz-simulation-draft-v1';
const idempotencyStorageKey = 'investmoz-simulation-idempotency-v1';
const draftCreateKey = 'investmoz-simulation-draft-create-key-v1';
const draftIdKey = 'investmoz-simulation-draft-id-v1';
const idempotencyKey = () => crypto.randomUUID();
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 {
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);
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 filtered = choices.filter(
(option) =>
option.label
.toLocaleLowerCase()
.includes(query.toLocaleLowerCase()) ||
option.id.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
);
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: { email_verified_at?: string | null } | null };
}>().props;
const canSavePersonalData = Boolean(auth?.user);
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
const [draft, setDraft] = useState<SimulationDraft>({});
const [ready, setReady] = useState(false);
const [step, setStep] = useState(0);
const [errors, setErrors] = useState<Record<string, string>>({});
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) => 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 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 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<
Array<{
id: number;
title: string;
description: string | null;
document_type: string;
legal_status: string;
source_confirmation: string;
source_url: string | null;
source_reference: string;
download_url: string;
recommendation_reason: string;
applicability: string;
basis_reference: string | null;
documentary_evidence: Array<{
key: string;
label: string;
citation: string | null;
status: string;
}>;
}>
>([]);
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 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) =>
(!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;
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
? `/api/v1/simulation-drafts/${encodeURIComponent(requestedDraftId)}`
: '/api/v1/simulation-drafts/current',
{
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) {
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) {
draftIdRef.current = payload.data.id;
draftEtagRef.current = 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 (!canSavePersonalData && Object.keys(local).length)
setDraft(local);
} catch {
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]);
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 () => {
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': csrfToken(),
};
if (!draftIdRef.current) {
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 };
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 =
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),
},
);
} 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),
});
}
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 = 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]);
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 = 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 = 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);
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 =
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[],
) {
setContinueLaterSaved(false);
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 (key !== 'purpose') 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) {
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() {
if (calculationInFlightRef.current) return;
const validation = validateSimulationDraft(draft, 'tax');
if (draft.fiscal_scenario_mode === 'custom_planning') {
Object.assign(validation, validateCustomFiscalPlanning(draft));
}
setErrors(validation);
setServerError('');
if (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 = {
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 (
JSON.stringify({
base: draft.base,
rate: draft.rate,
reduction: draft.reduction,
}) === serialized
)
setResult(simulation ?? null);
if (canSavePersonalData) void loadSimulationHistory();
sessionStorage.removeItem(idempotencyStorageKey);
} catch (cause) {
setServerError(
cause instanceof Error
? cause.message
: 'Erro de ligação. Tente novamente.',
);
} 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 = 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 = 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"
>
{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}
/>
)}
<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 &&
!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>
)}
<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];
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} />
</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>
)}
{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')) && (
<SimulationCashflowPlanner
currency={String(draft.currency ?? '')}
onCurrencyChange={(value) => change('currency', value)}
/>
)}
{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">
Sugestões ligadas ao módulo e, quando foram
indicados, à actividade, etapa do projecto e
província. A relação é indicativa; consulte a fonte
e confirme as condições aplicáveis. Não equivale a
validação do projecto nem a decisão de uma entidade
pública.
</p>
{documentRecommendations.length ? (
<ul className="space-y-3">
{documentRecommendations.map((document) => (
<li
key={document.id}
className="rounded border bg-white p-3"
>
<h4 className="font-semibold">
{document.title}
</h4>
{document.description && (
<p className="text-sm">
{document.description}
</p>
)}
<p className="text-sm">
{document.document_type} · situação:{' '}
{document.legal_status === 'unknown'
? 'por confirmar'
: document.legal_status}{' '}
· origem:{' '}
{document.source_confirmation ===
'official_confirmed'
? 'fonte oficial confirmada'
: document.source_confirmation ===
'identified'
? 'origem identificada'
: 'por confirmar'}
</p>
{document.documentary_evidence.length >
0 && (
<div className="mt-2 rounded bg-slate-50 p-2 text-sm">
<strong>
Evidência documental
relacionada (não activa
cálculos fiscais):
</strong>
<ul className="list-disc pl-5">
{document.documentary_evidence.map(
(item) => (
<li key={item.key}>
{item.label}:{' '}
{item.citation}{' '}
A citação foi
localizada no
ficheiro; a
interpretação e
aplicação fiscal
permanecem
pendentes.
</li>
),
)}
</ul>
</div>
)}
<p className="text-sm">
{document.applicability ===
'required'
? 'Documento marcado como obrigatório pela referência associada'
: document.applicability ===
'conditional'
? 'Pode ser obrigatório se a condição indicada se verificar'
: document.applicability ===
'optional_template'
? 'Modelo opcional'
: document.applicability ===
'evidence'
? 'Fonte de evidência'
: 'Material de apoio'}
. {document.recommendation_reason}
{document.basis_reference
? ` Fundamento: ${document.basis_reference}`
: ''}
</p>
{document.source_url && (
<a
className="mr-4 underline"
href={document.source_url}
target="_blank"
rel="noreferrer"
>
Consultar fonte
</a>
)}
<a
className="underline"
href={document.download_url}
>
Descarregar documento
</a>
</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' && (
<div
id="draft-tool-fiscal"
tabIndex={-1}
aria-label="Consulta de parâmetros fiscais"
>
<SimulationFiscalStatus
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]]
: [],
),
)}
/>
</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. Regras de 2026 continuam pendentes de validação e
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' && (
<div className="space-y-3 border-t pt-4">
<h4 className="font-semibold">
Cenário personalizado de planeamento
</h4>
{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>
)}
{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' && (
<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>
{result.status === 'completed' && (
<div className="mt-3 space-y-2 border-t pt-3">
{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',
)}{' '}
· 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.
</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>
</section>
);
}