import { simulationDraftEtag } from '@/lib/simulation-draft-etag'; import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva'; import { SimulationMonthlyIva } from './simulation-monthly-iva'; import { inheritSimulatorFields, reusedInvestmentValue } from '@/lib/context-inheritance'; import { investmentLandPreparation } from '@/lib/investment-land-preparation'; import { PublicDocumentContent, type PublicDocument, } from '@/components/public-document-content'; import { DossierPrefill } from '@/components/dossier-prefill'; import { createOperationKey } from '@/lib/operation-key'; import FiscalQaPreview from './fiscal-qa-preview'; import { displayValue } from '@/lib/display-value'; import { useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react'; import PartnershipSourceLinks from './partnership-source-links'; import { getAutosaveSnapshot, partitionSimulationDraft, restoreSimulationDraft, replaceSimulationValue, simulationFieldGroups, simulationDraftFingerprint, getSimulationSections, advancedSimulationFields, simulationOptions, validateSimulationDraft, validateCustomFiscalPlanning, getFiscalPeriodRange, projectQuestionnaireVersion, exportCompletedArithmeticCsv, readArithmeticSnapshot, readVersionedArithmeticReportReference, moveSearchChoiceIndex, type SimulationDraft, } from '@/lib/simulation-form'; import { usePage } from '@inertiajs/react'; import { isApiSimulationDraftAttachResponseV1, isApiSimulationDraftReadResponseV1, isApiSimulationDraftWriteResponseV1, isApiSimulationHistoryResponseV1, isApiSimulationCreateResponseV1, isApiSimulationReadResponseV1, type ApiSimulationExecutionV1, type ApiSimulationHistoryEntryV1, type ApiSimulationSourceV1, type ApiSimulationDraftV1, type ApiSimulationDraftSummaryV1, isApiSimulationDraftListResponseV1, isApiSimulationDraftClaimPreviewV1, } from '@/lib/simulation-contract'; import { SimulationFinancialTools } from '@/components/simulation-financial-tools'; import { SimulationCashflowPlanner } from '@/components/simulation-cashflow-planner'; import { SimulationBusinessPlan } from '@/components/simulation-business-plan'; import { SimulationFiscalStatus } from '@/components/simulation-fiscal-status'; import { ivaIssueText, type IvaIssue } from '@/lib/iva-diagnostics'; import { ProjectMilestonePlanner, ProjectTeamPlanner, } from '@/components/project-planning'; import { ProjectDossierExport } from '@/components/project-dossier-export'; import { ProjectReadiness } from '@/components/project-readiness'; import { readSimulationApiJson, simulationApiErrorMessage, simulationApiFieldErrors, simulationPollingFailureMessage, simulationCatalogChoices, } from '@/lib/simulation-api'; type Choice = { id: string; label: string }; const legacyDraftKey = 'investmoz-simulation-draft-v1'; const legacyIdempotencyStorageKey = 'investmoz-simulation-idempotency-v1'; const legacyDraftCreateKey = 'investmoz-simulation-draft-create-key-v1'; const legacyDraftIdKey = 'investmoz-simulation-draft-id-v1'; const idempotencyKey = () => createOperationKey(); function csrfToken(): string { return decodeURIComponent( document.cookie .split('; ') .find((cookie) => cookie.startsWith('XSRF-TOKEN=')) ?.slice(11) ?? '', ); } function displayScalar(value: unknown, fallback = ''): string { return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) ? String(value) : fallback; } function simulationStatusLabel(status: unknown): string { const labels: Record = { 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 = { 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 (
Ajuda opcional: porque importa, exemplo e como esclarecer
Porque é importante?
{guidance.why}
Ver exemplo (ilustrativo; não será preenchido)
{guidance.example}
Como posso esclarecer isto?
{guidance.clarify}
); } function useChoices( source: string | undefined, query: string, parent: string, enabled: boolean, ) { const [choices, setChoices] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [retryVersion, setRetryVersion] = useState(0); const staticChoices = useMemo(() => { if (source === 'currency') { const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[]; }; return (intl.supportedValuesOf?.('currency') ?? []).map((code) => ({ id: code, label: code, })); } if ( !source || [ 'country', 'province', 'district', 'admin_post', 'locality', 'sector', 'activity', ].includes(source) ) return []; return (simulationOptions[source] ?? []).map((value) => ({ id: value, label: labelFor(value), })); }, [source]); useEffect(() => { if ( !enabled || !source || ![ 'country', 'province', 'district', 'admin_post', 'locality', 'sector', 'activity', ].includes(source) ) { setChoices(staticChoices); setError(''); return; } if ( ['district', 'admin_post', 'locality', 'activity'].includes( source, ) && !parent ) { setChoices([]); return; } const controller = new AbortController(); const timer = window.setTimeout(async () => { setLoading(true); setError(''); try { let url: URL; if (source === 'sector') { url = new URL('/api/v1/sectors', window.location.origin); } else if (source === 'country') { url = new URL( '/api/v1/catalogs/countries', window.location.origin, ); url.searchParams.set('q', query); url.searchParams.set('per_page', '100'); } else if ( ['province', 'district', 'admin_post', 'locality'].includes( source, ) ) { url = new URL( '/api/v1/investment/territory-options', window.location.origin, ); url.searchParams.set('type', source); url.searchParams.set('q', query); if (source !== 'province') url.searchParams.set('parent_id', parent); } else { url = new URL( '/api/v1/catalogs/' + (source === 'activity' ? 'economic-activities' : 'jurisdictions'), window.location.origin, ); url.searchParams.set('q', query); url.searchParams.set('per_page', '50'); if (source === 'activity') url.searchParams.set('sector_id', parent); else { url.searchParams.set( 'type', source === 'country' ? 'country' : source, ); if (parent) url.searchParams.set('parent_id', parent); } } const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' }, credentials: 'same-origin', }); if (!response.ok) throw new Error( 'Não foi possível carregar as opções. Tente novamente.', ); const payload = await readSimulationApiJson(response); if (controller.signal.aborted) return; setChoices(simulationCatalogChoices(payload)); } catch (cause) { if (!controller.signal.aborted) setError( cause instanceof Error ? cause.message : 'Erro ao carregar opções.', ); } finally { if (!controller.signal.aborted) setLoading(false); } }, 180); return () => { window.clearTimeout(timer); controller.abort(); }; }, [source, query, parent, enabled, staticChoices, retryVersion]); return { choices, loading, error, retry: () => setRetryVersion((version) => version + 1), }; } function SearchChoice({ label, source, parent = '', value, selectedLabel, multiple, disabled, error, required, onChange, }: { label: string; source?: string; parent?: string; value: string | string[]; selectedLabel?: string | string[]; multiple?: boolean; disabled?: boolean; error?: string; required?: boolean; onChange: (value: string | string[], text?: string | string[]) => void; }) { const generated = useId(); const inputId = `choice-${generated}`; const listId = `${inputId}-options`; const activeOptionRef = useRef(null); const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); const { choices, loading, error: loadError, retry, } = useChoices(source, query, parent, !disabled && open); const chosen = Array.isArray(value) ? value : value ? [value] : []; const searchText = (text: string) => text .normalize('NFD') .replace(/\p{Diacritic}/gu, '') .toLocaleLowerCase(); const filtered = choices.filter( (option) => searchText(option.label).includes(searchText(query)) || searchText(option.id).includes(searchText(query)), ); useEffect(() => { if (open) activeOptionRef.current?.scrollIntoView({ block: 'nearest' }); }, [active, open]); const describedBy = [ error ? `${inputId}-error` : '', loadError ? `${inputId}-load-error` : '', ] .filter(Boolean) .join(' ') || undefined; const chooseOption = (option: Choice) => { if (multiple) { if (!chosen.includes(option.id)) onChange( [...chosen, option.id], [ ...(Array.isArray(selectedLabel) ? selectedLabel : []), option.label, ], ); setQuery(''); } else { onChange(option.id, option.label); setQuery(''); setOpen(false); } }; return (
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 && (
    {chosen.map((item, index) => { const text = Array.isArray(selectedLabel) ? selectedLabel[index] : (choices.find((option) => option.id === item) ?.label ?? item); return (
  • {text}
  • ); })}
)} {open && !disabled && ( <> {loading && ( A carregar opções… )} {!loading && !loadError && filtered.length === 0 && (

Sem opções correspondentes.

)} {/* 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. */}
{!loading && filtered.map((option, index) => (
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) ? ' ✓' : ''}
))}
{/* eslint-enable jsx-a11y/prefer-tag-over-role */} )} {loadError && (
{open && !disabled && ( )}
)} {error && ( )}
); } export function SimulationForm() { const { auth } = usePage<{ auth: { user: { id: number; email_verified_at?: string | null } | null }; }>().props; const pageUrl = usePage().url; const draftContext = useMemo(() => { const params = new URL(pageUrl, 'https://investmoz.invalid') .searchParams; return { context: params.get('context'), requestedDraft: params.get('draft'), }; }, [pageUrl]); const contextId = draftContext.context; const projectScope = contextId ? `:case:${contextId}` : draftContext.requestedDraft ? `:draft:${draftContext.requestedDraft}` : ''; const storageScope = `${auth?.user ? `:account:${auth.user.id}` : ':anonymous'}${projectScope}`; const draftKey = legacyDraftKey + storageScope; const draftCreateKey = legacyDraftCreateKey + storageScope; const draftIdKey = legacyDraftIdKey + storageScope; const idempotencyStorageKey = legacyIdempotencyStorageKey + storageScope; const currentDraftUrl = contextId ? `/api/v1/simulation-drafts/current?context=${encodeURIComponent(contextId)}` : '/api/v1/simulation-drafts/current'; const canSavePersonalData = Boolean(auth?.user); const canAssociateDraft = Boolean(auth?.user?.email_verified_at); const [draft, setDraft] = useState({}); const [editingReused, setEditingReused] = useState([]); const [ready, setReady] = useState(false); useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]); const [contextReview, setContextReview] = useState({ revision: 0, reviewed: false, }); const [step, setStep] = useState(0); const [errors, setErrors] = useState>({}); const [ivaIssues, setIvaIssues] = useState([]); const [serverFieldErrors, setServerFieldErrors] = useState< Record >({}); const [saveStatus, setSaveStatus] = useState( 'Rascunho ainda não guardado.', ); const [serverError, setServerError] = useState(''); const [reviewStatus, setReviewStatus] = useState(''); const [openedTool, setOpenedTool] = useState< 'calculation' | 'fiscal' | null >(null); const [toolError, setToolError] = useState(''); useEffect(() => { if (!openedTool) return; const destination = document.getElementById(`draft-tool-${openedTool}`); if (!destination) { setToolError( 'Não foi possível abrir a ferramenta. O rascunho foi preservado; volte a tentar.', ); return; } destination.focus(); destination.scrollIntoView({ block: 'start' }); }, [openedTool, step]); function openDraftTool(tool: 'calculation' | 'fiscal') { setToolError(''); const index = journeySections.findIndex((section) => tool === 'calculation' ? ['financial', 'tax'].includes(section.id) : section.id === 'tax', ); if (index < 0) { setToolError( 'Esta etapa não disponibiliza a operação fiscal. Reveja o percurso escolhido; os dados foram preservados.', ); return; } setStep(index); setOpenedTool(tool); } const [reviewDetails, setReviewDetails] = useState<{ checked: { key: string; label: string; value: string }[]; pending: { key: string; label: string; message: string; step: number; }[]; }>({ checked: [], pending: [] }); const reviewSummaryRef = useRef(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(null); const [calculationStale, setCalculationStale] = useState(false); useEffect(() => { if (result) setCalculationStale(false); }, [result]); const arithmeticResult = useMemo( () => readArithmeticSnapshot(result?.result_snapshot), [result], ); const arithmeticSnapshot = useMemo(() => { const snapshot = result?.result_snapshot; return snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) ? (snapshot as Record) : null; }, [result]); const arithmeticReportReference = useMemo( () => readVersionedArithmeticReportReference(result), [result], ); const isFiscalQaPreview = ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(arithmeticSnapshot?.scenario_kind)); const versionedReportMissing = arithmeticResult.integrity === 'versioned' && arithmeticReportReference === null; const [simulationSources, setSimulationSources] = useState< ApiSimulationSourceV1[] >([]); const [conversionConsent, setConversionConsent] = useState(false); const [conversionError, setConversionError] = useState(''); const [isAttachingSimulation, setIsAttachingSimulation] = useState(false); const [simulationHistory, setSimulationHistory] = useState< ApiSimulationHistoryEntryV1[] >([]); const [historyPage, setHistoryPage] = useState(1); const [historyLastPage, setHistoryLastPage] = useState(1); const [historyError, setHistoryError] = useState(''); const [historyLoading, setHistoryLoading] = useState(false); const [accountDrafts, setAccountDrafts] = useState< ApiSimulationDraftSummaryV1[] >([]); const [pendingAnonymousDraft, setPendingAnonymousDraft] = useState(null); const [showDraftChoice, setShowDraftChoice] = useState(false); const [selectedAccountDraftId, setSelectedAccountDraftId] = useState(''); const [draftChoiceBusy, setDraftChoiceBusy] = useState(false); const [continuityError, setContinuityError] = useState(''); const [draftExpiresAt, setDraftExpiresAt] = useState(null); const [draftVersionNo, setDraftVersionNo] = useState(null); const [documentRecommendations, setDocumentRecommendations] = useState< PublicDocument[] >([]); const [linkableBusinesses, setLinkableBusinesses] = useState< Array<{ id: string; title: string; modality: string; status: string }> >([]); const [linkedBusiness, setLinkedBusiness] = useState<{ id: string; business_id: string; title: string; linked_at: string; } | null>(null); const [selectedBusinessId, setSelectedBusinessId] = useState(''); const [businessLinkError, setBusinessLinkError] = useState(''); const [businessLinkBusy, setBusinessLinkBusy] = useState(false); const [continueLaterSaved, setContinueLaterSaved] = useState(false); const [saveAttempt, setSaveAttempt] = useState(0); const calculationInFlightRef = useRef(false); const draftIdRef = useRef(null); const draftEtagRef = useRef(null); const lastSavedFingerprintRef = useRef(null); const saveQueueRef = useRef>(Promise.resolve()); const saveConflictRef = useRef(false); const journeySections = getSimulationSections(draft); const current = journeySections[Math.min(step, journeySections.length - 1)]; useEffect(() => { if (!ready || current.id !== 'review') return; const params = new URLSearchParams({ module: 'simulator' }); if (typeof draft.activity_id === 'string' && draft.activity_id) params.set('activity_id', draft.activity_id); if (typeof draft.province_id === 'string' && draft.province_id) params.set('province_id', draft.province_id); const projectStage = typeof draft.maturity_stage === 'string' ? draft.maturity_stage : typeof draft.entity_stage === 'string' ? draft.entity_stage : ''; if (projectStage) params.set('stage', projectStage); const controller = new AbortController(); void fetch(`/api/v1/documents/recommendations?${params.toString()}`, { credentials: 'same-origin', headers: { Accept: 'application/json' }, signal: controller.signal, }) .then(async (response) => { if (!response.ok) throw new Error('recommendation-unavailable'); const payload: unknown = await response.json(); if ( payload && typeof payload === 'object' && 'data' in payload && Array.isArray(payload.data) ) { setDocumentRecommendations( payload.data.filter( ( item, ): item is (typeof documentRecommendations)[number] => Boolean( item && typeof item === 'object' && 'id' in item && 'title' in item, ), ), ); } else setDocumentRecommendations([]); }) .catch((error: unknown) => { if ( !( error instanceof DOMException && error.name === 'AbortError' ) ) setDocumentRecommendations([]); }); return () => controller.abort(); }, [ready, current.id, draft.activity_id, draft.province_id]); const guidedSmallBusiness = draft.purpose === 'simulate_only' && draft.journey_type === 'small_business'; const fieldsForCurrent = guidedSmallBusiness && current.id === 'purpose' ? [ ...simulationFieldGroups.purpose, ...simulationFieldGroups.project.filter((field) => [ 'project_title', 'project_summary', 'project_problem', 'value_proposition', ].includes(field.key), ), ] : guidedSmallBusiness && current.id === 'market' ? [ ...simulationFieldGroups.project.filter( (field) => ![ 'project_title', 'project_summary', 'project_problem', 'value_proposition', 'target_customers', 'market_assumption', 'market_source', ].includes(field.key), ), ...simulationFieldGroups.market, ] : guidedSmallBusiness && current.id === 'financial' ? [ ...(simulationFieldGroups.financial ?? []), ...simulationFieldGroups.opportunity, ] : draft.purpose === 'simulate_only' && draft.journey_type === 'investor' && current.id === 'project' ? [ ...simulationFieldGroups.project, ...simulationFieldGroups.market.filter( (field) => !simulationFieldGroups.project.some( (projectField) => projectField.key === field.key, ), ), ] : (simulationFieldGroups[current.id] ?? []); const fields = fieldsForCurrent.filter( (field) => !( current.id === 'tax' && draft.fiscal_scenario_mode === 'custom_planning' && !['fiscal_scenario_mode', 'base', 'rate', 'reduction'].includes( field.key, ) ) && (!field.showWhen || field.showWhen(draft)) && !( draft.purpose === 'simulate_only' && draft.journey_type === 'small_business' && current.id === 'impact' && field.key === 'skills_needed' ) && !( draft.purpose === 'simulate_only' && draft.journey_type === 'small_business' && current.id === 'impact' && field.key === 'project_milestones' ) && (draft.detail_level === 'advanced' || (current.id === 'procedures' && field.key === 'required_documents') || !advancedSimulationFields.has(field.key)), ); const completedSimulationId = result?.status === 'completed' && typeof result.id === 'string' ? result.id : ''; const completedSimulationAttached = Boolean( completedSimulationId && simulationSources.some( (source) => source.simulation_id === completedSimulationId, ), ); const loadSimulationHistory = useCallback(async (page = 1) => { setHistoryLoading(true); setHistoryError(''); try { const response = await fetch( `/api/v1/simulations/history?page=${page}&per_page=10`, { credentials: 'same-origin', headers: { Accept: 'application/json' }, }, ); if (response.status === 403) throw new Error( 'Verifique o correio electrónico para consultar o histórico.', ); if (response.status === 401) throw new Error('Inicie sessão para consultar o histórico.'); if (!response.ok) throw new Error( 'Não foi possível carregar o histórico da conta.', ); const payload: unknown = await readSimulationApiJson(response); if (!isApiSimulationHistoryResponseV1(payload)) throw new Error( 'O servidor devolveu um histórico com formato inválido.', ); setSimulationHistory(payload.data); setHistoryPage(payload.meta.current_page || page); setHistoryLastPage(payload.meta.last_page || 1); } catch (cause) { setHistoryError( cause instanceof Error ? cause.message : 'Falha ao carregar o histórico.', ); } finally { setHistoryLoading(false); } }, []); useEffect(() => { if (canSavePersonalData) void loadSimulationHistory(); }, [canSavePersonalData, loadSimulationHistory]); async function openHistorySimulation(id: string) { setHistoryError(''); try { const response = await fetch( `/api/v1/simulations/${encodeURIComponent(id)}`, { credentials: 'same-origin', headers: { Accept: 'application/json' }, }, ); if (!response.ok) throw new Error('Não foi possível abrir esta simulação.'); const payload: unknown = await readSimulationApiJson(response); if ( !isApiSimulationReadResponseV1(payload) || payload.data.id !== id ) throw new Error( 'O servidor devolveu uma simulação inválida para este histórico.', ); setResult(payload.data); document.getElementById('simulator-step-title')?.focus(); } catch (cause) { setHistoryError( cause instanceof Error ? cause.message : 'Falha ao abrir a simulação.', ); } } useEffect(() => { let cancelled = false; setReady(false); void (async () => { let local: SimulationDraft = {}; const requestedDraftId = new URLSearchParams( window.location.search, ).get('draft'); try { const stored = sessionStorage.getItem(draftKey); if (stored) local = JSON.parse(stored) as SimulationDraft; } catch { setSaveStatus( 'Não foi possível recuperar o rascunho desta sessão.', ); } try { const response = await fetch( requestedDraftId && !contextId ? `/api/v1/simulation-drafts/${encodeURIComponent(requestedDraftId)}` : currentDraftUrl, { credentials: 'same-origin', headers: { Accept: 'application/json', }, }, ); if (response.ok) { const payload: unknown = await readSimulationApiJson(response); if (!isApiSimulationDraftReadResponseV1(payload)) throw new Error( 'O servidor devolveu um rascunho com formato inválido.', ); if (canSavePersonalData && !contextId) { const [listResponse, previewResponse] = await Promise.all([ fetch('/api/v1/simulation-drafts', { credentials: 'same-origin', headers: { Accept: 'application/json' }, }), fetch( '/api/v1/simulation-drafts/claim-preview', { credentials: 'same-origin', headers: { Accept: 'application/json' }, }, ), ]); const listPayload: unknown = listResponse.ok ? await readSimulationApiJson(listResponse) : null; const previewPayload: unknown = previewResponse.ok ? await readSimulationApiJson(previewResponse) : null; if ( !isApiSimulationDraftListResponseV1(listPayload) || !isApiSimulationDraftClaimPreviewV1(previewPayload) ) throw new Error( 'Não foi possível verificar os dossiers privados e o rascunho deste navegador.', ); setAccountDrafts(listPayload.data); setSelectedAccountDraftId( listPayload.data[0]?.id ?? '', ); if (previewPayload.data && !requestedDraftId) { setPendingAnonymousDraft(previewPayload.data); setShowDraftChoice(true); setSaveStatus( 'Encontrámos um rascunho temporário neste navegador. Escolha como continuar.', ); if (!cancelled) setReady(true); return; } } if (payload.data) { if (cancelled) return; draftIdRef.current = payload.data.id; draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag')); setDraftVersionNo(payload.data.version_no); setDraftExpiresAt(payload.data.expires_at ?? null); if (canSavePersonalData) setSelectedAccountDraftId(payload.data.id); sessionStorage.setItem(draftIdKey, payload.data.id); const restored = restoreSimulationDraft( payload.data.economic_snapshot, Array.isArray(payload.data.personal_snapshot) ? {} : payload.data.personal_snapshot, ); setSimulationSources(payload.data.simulation_sources); lastSavedFingerprintRef.current = simulationDraftFingerprint(restored); setDraft( canSavePersonalData ? restored : { ...restored, ...local }, ); if (canSavePersonalData) setSaveStatus( 'Dossier privado recuperado da sua conta.', ); else setSaveStatus( payload.data.expires_at ? `Rascunho recuperado. Guardado neste navegador até ${new Intl.DateTimeFormat('pt-MZ', { dateStyle: 'long' }).format(new Date(payload.data.expires_at))}.` : 'Rascunho recuperado do servidor.', ); } else if ( !canSavePersonalData && Object.keys(local).length ) { const storedId = sessionStorage.getItem(draftIdKey); if (storedId) sessionStorage.removeItem(draftIdKey); setDraft(local); } } else if (contextId || requestedDraftId) { saveConflictRef.current = true; setHasSaveConflict(true); setSaveStatus( 'Dossier indisponível para esta conta. Nenhum outro rascunho foi aberto.', ); } else if (!canSavePersonalData && Object.keys(local).length) setDraft(local); } catch { if (contextId || requestedDraftId) { saveConflictRef.current = true; setHasSaveConflict(true); } if (!canSavePersonalData && Object.keys(local).length) setDraft(local); setSaveStatus( 'Sem ligação ao servidor; o rascunho desta sessão será mantido localmente.', ); } if (!cancelled) setReady(true); })(); return () => { cancelled = true; }; }, [canSavePersonalData, contextId, currentDraftUrl, draftIdKey, draftKey]); useEffect(() => { if (!ready || !draft.purpose) return; const timer = window.setTimeout(() => { try { sessionStorage.setItem( draftKey, JSON.stringify(getAutosaveSnapshot(draft)), ); } catch { setSaveStatus( 'Não foi possível guardar o rascunho neste navegador.', ); } if (saveConflictRef.current) return; const snapshots = partitionSimulationDraft( draft, canSavePersonalData, ); const fingerprint = simulationDraftFingerprint( canSavePersonalData ? draft : getAutosaveSnapshot(draft), ); if (fingerprint === lastSavedFingerprintRef.current) return; setSaveStatus('A guardar o rascunho no servidor…'); saveQueueRef.current = saveQueueRef.current .then(async () => { if (fingerprint === lastSavedFingerprintRef.current) return; const headers = { Accept: 'application/json', 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrfToken(), }; if (!draftIdRef.current) { const currentResponse = await fetch(currentDraftUrl, { credentials: 'same-origin', headers: { Accept: 'application/json', }, }); const currentPayload: unknown = currentResponse.ok ? await readSimulationApiJson(currentResponse) : { data: null }; let currentDraft: { id: string; version_no: number; } | null = null; if (currentResponse.ok) { if ( !isApiSimulationDraftReadResponseV1( currentPayload, ) ) throw new Error( 'O servidor devolveu um rascunho com formato inválido.', ); currentDraft = currentPayload.data; } if (currentDraft) { draftIdRef.current = currentDraft.id; draftEtagRef.current = simulationDraftEtag(currentResponse.headers.get('ETag')); sessionStorage.setItem(draftIdKey, currentDraft.id); } } let response: Response; if (draftIdRef.current && draftEtagRef.current) { response = await fetch( `/api/v1/simulation-drafts/${draftIdRef.current}`, { method: 'PUT', credentials: 'same-origin', headers: { ...headers, 'If-Match': draftEtagRef.current, }, body: JSON.stringify({ ...snapshots, ...(contextId ? { context: contextId } : {}), }), }, ); } else { let key = sessionStorage.getItem(draftCreateKey); if (!key) { key = idempotencyKey(); sessionStorage.setItem(draftCreateKey, key); } response = await fetch('/api/v1/simulation-drafts', { method: 'POST', credentials: 'same-origin', headers: { ...headers, 'Idempotency-Key': key }, body: JSON.stringify({ ...snapshots, ...(contextId ? { context: contextId } : {}), }), }); } if (response.status === 412 || response.status === 428) { saveConflictRef.current = true; setHasSaveConflict(true); setSaveStatus( 'O rascunho mudou noutro separador. Recarregue para rever a versão mais recente.', ); return; } const payload: unknown = await readSimulationApiJson(response); if (response.status === 422) setServerFieldErrors(simulationApiFieldErrors(payload)); if (!response.ok) throw new Error( simulationApiErrorMessage(payload) ?? 'Não foi possível guardar o rascunho no servidor.', ); if (!isApiSimulationDraftWriteResponseV1(payload)) throw new Error( 'O servidor devolveu uma confirmação de gravação inválida.', ); draftIdRef.current = payload.data.id; setServerFieldErrors({}); draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag')); setDraftVersionNo(payload.data.version_no); lastSavedFingerprintRef.current = fingerprint; sessionStorage.setItem(draftIdKey, payload.data.id); sessionStorage.removeItem(draftCreateKey); setDraftExpiresAt(payload.expires_at ?? null); if (canSavePersonalData) { setSelectedAccountDraftId(payload.data.id); if ( !accountDrafts.some( (item) => item.id === payload.data.id, ) ) { const listResponse = await fetch( '/api/v1/simulation-drafts', { credentials: 'same-origin', headers: { Accept: 'application/json' }, }, ); const listPayload: unknown = listResponse.ok ? await readSimulationApiJson(listResponse) : null; if (isApiSimulationDraftListResponseV1(listPayload)) setAccountDrafts(listPayload.data); } } setContinueLaterSaved(false); if (draft.reuse_simulation === 'no') setSimulationSources([]); const savedAt = new Intl.DateTimeFormat('pt-MZ', { timeStyle: 'short', }).format(new Date()); const expiry = payload.expires_at ? ` Recuperável neste navegador até ${new Intl.DateTimeFormat('pt-MZ', { dateStyle: 'long' }).format(new Date(payload.expires_at))}.` : ''; setSaveStatus( `Rascunho guardado no servidor às ${savedAt}.${expiry}`, ); }) .catch((cause) => { setSaveStatus( cause instanceof Error ? cause.message : 'Erro ao guardar o rascunho no servidor.', ); }); }, 450); return () => window.clearTimeout(timer); }, [ draft, ready, canSavePersonalData, accountDrafts, contextId, currentDraftUrl, draftCreateKey, draftIdKey, draftKey, saveAttempt, ]); async function returnToInvestment() { if (!ready || !contextId || !draftIdRef.current) return; setContinuityError(''); try { await saveQueueRef.current; const current = await fetch( `/api/v1/simulation-drafts/${draftIdRef.current}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin', }, ); const saved: unknown = await readSimulationApiJson(current); if ( !current.ok || !isApiSimulationDraftReadResponseV1(saved) || !saved.data ) throw new Error( 'Não foi possível recuperar o rascunho guardado.', ); if ( simulationDraftFingerprint( restoreSimulationDraft( saved.data.economic_snapshot, Array.isArray(saved.data.personal_snapshot) ? {} : saved.data.personal_snapshot, ), ) !== simulationDraftFingerprint(draft) ) throw new Error( 'Aguarde a gravação das entradas da ferramenta e tente novamente.', ); const dossier = await fetch(`/investimentos/${contextId}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin', }); if (!dossier.ok) throw new Error('Dossier indisponível para esta conta.'); const view = (await dossier.json()) as { case: { version: number }; }; const response = await fetch( `/investimentos/${contextId}/resultados`, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ version: view.case.version, draft_id: saved.data.id, draft_version: saved.data.version_no, context_revision: contextReview.revision, context_reviewed: contextReview.reviewed, }), }, ); if (!response.ok) { const detail = await response.json().catch(() => null); throw new Error( detail?.message || 'Não foi possível devolver as entradas financeiras guardadas ao dossier.', ); } window.location.assign(`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`); } catch (cause) { setContinuityError( cause instanceof Error ? cause.message : 'Erro ao devolver o resultado.', ); } } async function openOwnedDraft(id: string) { setContinuityError(''); try { const response = await fetch( `/api/v1/simulation-drafts/${encodeURIComponent(id)}`, { credentials: 'same-origin', headers: { Accept: 'application/json' }, }, ); const payload: unknown = response.ok ? await readSimulationApiJson(response) : null; if ( !response.ok || !isApiSimulationDraftReadResponseV1(payload) || !payload.data ) throw new Error('Não foi possível abrir este dossier privado.'); draftIdRef.current = payload.data.id; draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag')); setDraftVersionNo(payload.data.version_no); setDraftExpiresAt(null); setSelectedAccountDraftId(payload.data.id); setDraft( restoreSimulationDraft( payload.data.economic_snapshot, Array.isArray(payload.data.personal_snapshot) ? {} : payload.data.personal_snapshot, ), ); setSimulationSources(payload.data.simulation_sources); lastSavedFingerprintRef.current = simulationDraftFingerprint( restoreSimulationDraft( payload.data.economic_snapshot, Array.isArray(payload.data.personal_snapshot) ? {} : payload.data.personal_snapshot, ), ); setPendingAnonymousDraft(null); setShowDraftChoice(false); setSaveStatus('Dossier privado recuperado da sua conta.'); } catch (cause) { setContinuityError( cause instanceof Error ? cause.message : 'Falha ao abrir o dossier.', ); } } async function saveAnonymousAsNewProject() { if (!pendingAnonymousDraft || draftChoiceBusy || !canAssociateDraft) return; setDraftChoiceBusy(true); setContinuityError(''); try { const response = await fetch('/api/v1/simulation-drafts/claim', { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ choice: 'save_as_new' }), }); const payload: unknown = await readSimulationApiJson(response); if ( !response.ok || !isApiSimulationDraftReadResponseV1({ data: (payload as { data?: unknown } | null)?.data, }) || !(payload as { data: ApiSimulationDraftV1 }).data ) throw new Error( simulationApiErrorMessage(payload) ?? 'Não foi possível guardar o rascunho na conta.', ); const record = (payload as { data: ApiSimulationDraftV1 }).data; draftIdRef.current = record.id; draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag')); setDraftVersionNo(record.version_no); setDraftExpiresAt(null); setSelectedAccountDraftId(record.id); const restored = restoreSimulationDraft( record.economic_snapshot, Array.isArray(record.personal_snapshot) ? {} : record.personal_snapshot, ); setDraft(restored); setSimulationSources(record.simulation_sources); lastSavedFingerprintRef.current = simulationDraftFingerprint(restored); const listResponse = await fetch('/api/v1/simulation-drafts', { credentials: 'same-origin', headers: { Accept: 'application/json' }, }); const listPayload: unknown = listResponse.ok ? await readSimulationApiJson(listResponse) : null; if (isApiSimulationDraftListResponseV1(listPayload)) setAccountDrafts(listPayload.data); setPendingAnonymousDraft(null); setShowDraftChoice(false); setSaveStatus( 'Rascunho associado ao seu espaço privado como outro projecto. Nenhum dossier foi fundido ou substituído.', ); } catch (cause) { setContinuityError( cause instanceof Error ? cause.message : 'Falha ao associar o rascunho.', ); } finally { setDraftChoiceBusy(false); } } async function continueSelectedProject() { if (!selectedAccountDraftId) return; await openOwnedDraft(selectedAccountDraftId); } async function leaveAnonymousDraftForLater() { setPendingAnonymousDraft(null); setShowDraftChoice(false); if (accountDrafts.length > 0) { await openOwnedDraft(accountDrafts[0].id); } else { setDraft({}); setSaveStatus( 'O rascunho temporário foi mantido neste navegador até à data indicada; ainda não foi associado à conta.', ); } } async function saveAndContinueLater() { if (!draft.purpose) return; setContinueLaterSaved(false); setSaveAttempt((attempt) => attempt + 1); await new Promise((resolve) => window.setTimeout(resolve, 550)); await saveQueueRef.current; const target = canSavePersonalData ? draft : getAutosaveSnapshot(draft); if ( draftIdRef.current && simulationDraftFingerprint(target) === lastSavedFingerprintRef.current ) { if (!canSavePersonalData && draftEtagRef.current) { const response = await fetch( `/api/v1/simulation-drafts/${draftIdRef.current}/touch`, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-XSRF-TOKEN': csrfToken(), 'If-Match': draftEtagRef.current, }, }, ); const payload: unknown = await readSimulationApiJson(response); if ( !response.ok || !payload || typeof payload !== 'object' || typeof (payload as { expires_at?: unknown }).expires_at !== 'string' ) { setSaveStatus( simulationApiErrorMessage(payload) ?? 'Não foi possível renovar o prazo do rascunho. Tente novamente.', ); return; } setDraftExpiresAt( (payload as { expires_at: string }).expires_at, ); draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag')) ?? draftEtagRef.current; } setContinueLaterSaved(true); setSaveStatus( canSavePersonalData ? 'Dossier guardado no espaço privado da sua conta. Pode continuar mais tarde ao iniciar sessão.' : draftExpiresAt ? `Rascunho guardado. Pode continuar neste navegador até ${new Intl.DateTimeFormat('pt-MZ', { dateStyle: 'long' }).format(new Date(draftExpiresAt))}.` : 'Rascunho guardado. Pode continuar neste navegador durante 30 dias após a última gravação.', ); } else { setSaveStatus( 'A gravação ainda não foi confirmada. Verifique a ligação e tente novamente antes de sair.', ); } } async function refreshBusinessLink() { if (!canAssociateDraft || !selectedAccountDraftId || showDraftChoice) return; setBusinessLinkError(''); try { const [businessesResponse, linkResponse] = await Promise.all([ fetch('/api/v1/simulation-drafts/linkable-businesses', { credentials: 'same-origin', headers: { Accept: 'application/json' }, }), fetch( `/api/v1/simulation-drafts/${encodeURIComponent(selectedAccountDraftId)}/business-link`, { credentials: 'same-origin', headers: { Accept: 'application/json' }, }, ), ]); const businessesPayload: unknown = businessesResponse.ok ? await readSimulationApiJson(businessesResponse) : null; const linkPayload: unknown = linkResponse.ok ? await readSimulationApiJson(linkResponse) : null; if ( !businessesResponse.ok || !linkResponse.ok || !Array.isArray( (businessesPayload as { data?: unknown } | null)?.data, ) ) throw new Error( 'Não foi possível carregar os negócios próprios para associação.', ); const choices = ( businessesPayload as { data: unknown[] } ).data.filter( ( item, ): item is { id: string; title: string; modality: string; status: string; } => Boolean( item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' && typeof (item as { title?: unknown }).title === 'string' && typeof (item as { modality?: unknown }).modality === 'string' && typeof (item as { status?: unknown }).status === 'string', ), ); setLinkableBusinesses(choices); const link = (linkPayload as { data?: unknown } | null)?.data; setLinkedBusiness( link && typeof link === 'object' && typeof (link as { id?: unknown }).id === 'string' ? (link as { id: string; business_id: string; title: string; linked_at: string; }) : null, ); setSelectedBusinessId(choices[0]?.id ?? ''); } catch (cause) { setBusinessLinkError( cause instanceof Error ? cause.message : 'Falha ao carregar associações.', ); } } useEffect(() => { void refreshBusinessLink(); }, [canAssociateDraft, selectedAccountDraftId, showDraftChoice]); async function linkSelectedBusiness() { if ( !selectedAccountDraftId || !selectedBusinessId || !window.confirm( 'Associar este dossier privado ao negócio seleccionado? Nenhum dado será copiado ou publicado.', ) ) return; setBusinessLinkBusy(true); setBusinessLinkError(''); try { const response = await fetch( `/api/v1/simulation-drafts/${encodeURIComponent(selectedAccountDraftId)}/business-link`, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ business_id: selectedBusinessId }), }, ); const payload: unknown = await readSimulationApiJson(response); if (!response.ok) throw new Error( simulationApiErrorMessage(payload) ?? 'Não foi possível associar o negócio.', ); await refreshBusinessLink(); } catch (cause) { setBusinessLinkError( cause instanceof Error ? cause.message : 'Falha ao associar o negócio.', ); } finally { setBusinessLinkBusy(false); } } async function unlinkBusiness() { if ( !selectedAccountDraftId || !linkedBusiness || !window.confirm( 'Remover a associação? Os dois registos e respectivos dados permanecem intactos.', ) ) return; setBusinessLinkBusy(true); try { const response = await fetch( `/api/v1/simulation-drafts/${encodeURIComponent(selectedAccountDraftId)}/business-link`, { method: 'DELETE', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-XSRF-TOKEN': csrfToken(), }, }, ); if (!response.ok) throw new Error('Não foi possível remover a associação.'); setLinkedBusiness(null); } catch (cause) { setBusinessLinkError( cause instanceof Error ? cause.message : 'Falha ao remover a associação.', ); } finally { setBusinessLinkBusy(false); } } function change( key: string, value: string | string[], label?: string | string[], ) { if (!ready) return; setContinueLaterSaved(false); setIvaIssues((previous) => previous.filter((issue) => issue.field !== key)); setServerFieldErrors((previous) => { const next = { ...previous }; delete next[key]; return next; }); setReviewStatus(''); setDraft((currentDraft) => { const next = replaceSimulationValue(currentDraft, key, value); if (label) next[`${key}_label`] = label; if ( next.project_questionnaire_version === undefined && next.purpose === 'simulate_only' && ['small_business', 'investor'].includes( String(next.journey_type), ) ) { next.project_questionnaire_version = projectQuestionnaireVersion; } return next; }); setErrors((current) => { const next = { ...current }; delete next[key]; return next; }); if (key === 'purpose') setStep(0); if (result) setCalculationStale(true); setResult(null); } async function clearDraft() { await saveQueueRef.current; if (draftIdRef.current && draftEtagRef.current) { const response = !canSavePersonalData ? await fetch('/api/v1/simulation-drafts/current', { method: 'DELETE', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-XSRF-TOKEN': csrfToken(), }, }) : await fetch( `/api/v1/simulation-drafts/${draftIdRef.current}`, { method: 'DELETE', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-XSRF-TOKEN': csrfToken(), 'If-Match': draftEtagRef.current, }, }, ); if (!response.ok) { setSaveStatus( 'Não foi possível remover o rascunho do servidor. Recarregue e tente novamente.', ); return; } } for (const key of [ draftKey, draftIdKey, draftCreateKey, idempotencyStorageKey, ]) sessionStorage.removeItem(key); draftIdRef.current = null; draftEtagRef.current = null; lastSavedFingerprintRef.current = null; saveConflictRef.current = false; setHasSaveConflict(false); setDraft({}); setResult(null); setErrors({}); setStep(0); setSaveStatus('Rascunho removido do servidor e desta sessão.'); } async function forgetDraftOnDevice() { if ( !window.confirm( 'Esquecer este rascunho neste dispositivo? Ele deixará de poder ser recuperado pelo cookie deste navegador.', ) ) return; await saveQueueRef.current; const response = await fetch('/api/v1/simulation-drafts/current', { method: 'DELETE', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-XSRF-TOKEN': csrfToken(), }, }); if (!response.ok) { setSaveStatus( 'Não foi possível esquecer o rascunho. Os dados não foram apagados; tente novamente.', ); return; } for (const key of [ draftKey, draftIdKey, draftCreateKey, idempotencyStorageKey, ]) sessionStorage.removeItem(key); draftIdRef.current = null; draftEtagRef.current = null; lastSavedFingerprintRef.current = null; setDraft({}); setResult(null); setErrors({}); setStep(0); setSaveStatus( 'O rascunho foi removido da recuperação e o cookie deste dispositivo foi esquecido.', ); } function navigate(nextStep: number) { if (!ready) return; setOpenedTool(null); if (nextStep > step) { for (let index = step; index < nextStep; index++) { const validation = validateSimulationDraft( draft, journeySections[index].id, ); if (Object.keys(validation).length) { setStep(index); setErrors(validation); window.requestAnimationFrame(() => document .getElementById( `field-${Object.keys(validation)[0]}`, ) ?.focus(), ); return; } } } setStep(nextStep); setErrors({}); document.getElementById('simulator-step-title')?.focus(); } function validateFinalReview() { const checked: { key: string; label: string; value: string }[] = []; const privateFields = partitionSimulationDraft( draft, true, ).personal_snapshot; const pending: { key: string; label: string; message: string; step: number; }[] = []; const allErrors: Record = {}; for (let index = 0; index < journeySections.length; index++) { const validation = validateSimulationDraft( draft, journeySections[index].id, ); if ( journeySections[index].id === 'tax' && draft.fiscal_scenario_mode === 'custom_planning' ) { Object.assign(validation, validateCustomFiscalPlanning(draft)); } Object.assign(allErrors, validation); const fields = simulationFieldGroups[journeySections[index].id] ?? []; for (const field of fields) { if (field.showWhen && !field.showWhen(draft)) continue; const value = draft[field.key]; if ( !validation[field.key] && value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0) ) checked.push({ key: field.key, label: field.label || field.key, value: Object.prototype.hasOwnProperty.call( privateFields, field.key, ) ? 'Dado pessoal preservado; consulte-o no campo original.' : Array.isArray(value) ? value .map((entry) => labelFor(String(entry))) .join(', ') : field.options ? labelFor(String(value)) : displayValue( value, 'Valor não textual; consulte o campo original.', ), }); } for (const [key, message] of Object.entries(validation)) pending.push({ key, label: fields.find((field) => field.key === key)?.label ?? key, message, step: index, }); } setErrors(allErrors); setReviewDetails({ checked, pending }); setReviewStatus( pending.length ? 'Verificação concluída com campos pendentes ou inconsistências. Os dados foram preservados.' : 'Verificação dos campos concluída neste navegador.', ); } async function runSimulation(publishedInput?: PublishedIvaInput) { if (calculationInFlightRef.current) return; const validation = publishedInput ? {} : validateSimulationDraft(draft, 'tax'); if (!publishedInput && draft.fiscal_scenario_mode === 'custom_planning') { Object.assign(validation, validateCustomFiscalPlanning(draft)); } setErrors(validation); setServerError(''); if (!publishedInput && draft.fiscal_scenario_mode !== 'custom_planning') return; if (Object.keys(validation).length) { document .getElementById(`field-${Object.keys(validation)[0]}`) ?.focus(); return; } calculationInFlightRef.current = true; setIsCalculating(true); try { const xsrf = csrfToken(); const body = publishedInput ?? { base: draft.base, rate: draft.rate, reduction: draft.reduction, }; const serialized = JSON.stringify(body); const previous = sessionStorage.getItem(idempotencyStorageKey); let key = ''; if (previous) { try { const saved = JSON.parse(previous) as { payload?: string; key?: string; }; if (saved.payload === serialized && saved.key) key = saved.key; } catch { /* replace malformed local state */ } } if (!key) { key = idempotencyKey(); sessionStorage.setItem( idempotencyStorageKey, JSON.stringify({ payload: serialized, key }), ); } const response = await fetch('/api/v1/simulations', { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, 'Idempotency-Key': key, }, body: serialized, }); const payload: unknown = await readSimulationApiJson(response); if (!response.ok) { if (response.status === 429) { const retryAfter = Number( response.headers.get('Retry-After') ?? 60, ); throw new Error( `Demasiadas tentativas. Aguarde ${retryAfter} segundos antes de voltar a calcular.`, ); } const errorMessage = simulationApiErrorMessage(payload); throw new Error( errorMessage ?? 'Não foi possível validar o cenário no servidor.', ); } if (!isApiSimulationCreateResponseV1(payload)) throw new Error( 'O servidor devolveu uma resposta de execução inválida.', ); const simulationId = payload.data.id; let simulation = payload.data; if ( simulationId && simulation?.status !== 'completed' && simulation?.status !== 'failed' && simulation?.status !== 'cancelled' ) { setSaveStatus('Cenário enviado. A aguardar o cálculo…'); for (let attempt = 0; attempt < 12; attempt++) { await new Promise((resolve) => window.setTimeout(resolve, 1000), ); const poll = await fetch( `/api/v1/simulations/${simulationId}`, { credentials: 'same-origin', headers: { Accept: 'application/json', }, }, ); if (!poll.ok) { const pollPayload: unknown = await readSimulationApiJson(poll); setServerError( simulationPollingFailureMessage(pollPayload), ); break; } const pollPayload: unknown = await readSimulationApiJson(poll); if (!isApiSimulationReadResponseV1(pollPayload)) throw new Error( 'O servidor devolveu um estado de execução inválido.', ); if (pollPayload.data.id !== simulationId) throw new Error( 'O servidor devolveu outro identificador de execução.', ); simulation = pollPayload.data; if ( simulation?.status === 'completed' || simulation?.status === 'failed' || simulation?.status === 'cancelled' ) break; } } if ( publishedInput || JSON.stringify({ base: draft.base, rate: draft.rate, reduction: draft.reduction, }) === serialized ) setResult(simulation ?? null); if (canSavePersonalData) void loadSimulationHistory(); if (simulation?.status === 'completed') { setSaveStatus( 'Cálculo concluído. Reveja o resultado e os pressupostos.', ); } else if ( simulation?.status === 'failed' || simulation?.status === 'cancelled' ) { setSaveStatus( 'O cálculo não foi concluído. Os dados introduzidos foram preservados.', ); } if (!publishedInput) sessionStorage.removeItem(idempotencyStorageKey); return simulation ?? null; } catch (cause) { setServerError( cause instanceof Error ? cause.message : 'Erro de ligação. Tente novamente.', ); return null; } finally { calculationInFlightRef.current = false; setIsCalculating(false); } } async function attachCompletedSimulation() { const simulationId = typeof result?.id === 'string' ? result.id : ''; if ( !simulationId || !conversionConsent || hasSaveConflict || isAttachingSimulation ) return; setConversionError(''); setIsAttachingSimulation(true); try { await saveQueueRef.current; const headers = { Accept: 'application/json', 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrfToken(), }; const currentResponse = await fetch( '/api/v1/simulation-drafts/current', { credentials: 'same-origin', headers: { Accept: 'application/json', }, }, ); const currentPayload: unknown = currentResponse.ok ? await readSimulationApiJson(currentResponse) : { data: null }; if ( !currentResponse.ok || !isApiSimulationDraftReadResponseV1(currentPayload) ) throw new Error( 'Não foi possível confirmar o rascunho actual no servidor.', ); const draftIdValue: unknown = currentPayload.data?.id; if (typeof draftIdValue !== 'string' || !draftIdValue) throw new Error( 'Guarde primeiro o rascunho; depois poderá anexar a simulação.', ); const draftId = draftIdValue; draftIdRef.current = draftId; draftEtagRef.current = simulationDraftEtag(currentResponse.headers.get('ETag')); if (!draftEtagRef.current) throw new Error( 'O servidor não devolveu a versão actual do rascunho. Recarregue e tente novamente.', ); const response = await fetch( `/api/v1/simulation-drafts/${draftId}/conversions`, { method: 'POST', credentials: 'same-origin', headers: { ...headers, 'If-Match': draftEtagRef.current }, body: JSON.stringify({ simulation_id: simulationId, consent_reuse: true, }), }, ); const payload: unknown = await readSimulationApiJson(response); if (response.status === 412 || response.status === 428) { saveConflictRef.current = true; setHasSaveConflict(true); throw new Error( 'O rascunho mudou noutro separador. Recarregue antes de anexar a simulação.', ); } if (!response.ok) throw new Error( simulationApiErrorMessage(payload) ?? 'Não foi possível anexar a simulação ao rascunho.', ); if (!isApiSimulationDraftAttachResponseV1(payload)) throw new Error( 'O servidor devolveu uma confirmação de anexação inválida.', ); draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag')); const sources = payload.data?.simulation_sources; if (Array.isArray(sources)) setSimulationSources(sources); setDraft((currentDraft) => ({ ...currentDraft, reuse_simulation: 'yes', })); sessionStorage.setItem(draftIdKey, draftId); setConversionConsent(false); setSaveStatus( payload.replayed ? 'Esta simulação já estava anexada ao rascunho.' : 'Simulação anexada ao rascunho com o seu consentimento.', ); } catch (cause) { setConversionError( cause instanceof Error ? cause.message : 'Não foi possível anexar a simulação.', ); } finally { setIsAttachingSimulation(false); } } return (
{calculationStale && (

As entradas foram alteradas. Calcule novamente antes de guardar ou exportar um novo resultado. Os resultados anteriores mantêm-se no histórico.

)} {!ready && (

A recuperar o rascunho. Aguarde para editar.

)}
{canSavePersonalData && draftVersionNo && draftIdRef.current && ( Preparar apoio no Balcão Virtual com este dossier guardado )} {canSavePersonalData && draftVersionNo && draftIdRef.current && ( )} {contextId && ( setContextReview({ revision, reviewed }) } onApply={(patch, replace) => setDraft((current) => { const next = { ...current }; for (const [k, v] of Object.entries(patch)) { if (replace || !current[k]?.length) next[k] = typeof v === 'string' ? v : ''; } return next; }) } /> )} {contextId && (

Ferramenta ligada ao dossier actual. As entradas são guardadas no rascunho deste investimento.

{continuityError && (

{continuityError}

)} Ver dossier
)}

Formulário de simulação e manifestação

Simulação informativa. Nenhuma taxa legal, elegibilidade ou moeda é presumida.

{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”.'}

{saveStatus} {Object.keys(serverFieldErrors).length > 0 && (

O servidor não confirmou a gravação. Os dados preenchidos foram preservados. Corrija os campos indicados:

    {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 (
  • ); }, )}
)} {draft.purpose && ( )} {!canSavePersonalData && draftIdRef.current && ( )}
{canSavePersonalData && showDraftChoice && pendingAnonymousDraft && (

Como quer continuar?

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.

{accountDrafts.length > 0 && (
)}
{!canAssociateDraft && (

Confirme o correio electrónico da conta para associar este rascunho ao espaço privado.

)}
{continuityError && (

{continuityError}

)}
)} {canSavePersonalData && !contextId && !showDraftChoice && accountDrafts.length > 0 && (

Projectos no seu espaço privado

{accountDrafts.length > 1 && ( )} {selectedAccountDraftId && (

Associação opcional ao seu registo de negócio. Apenas cria uma referência reversível; não copia dados nem publica informação.

{linkedBusiness ? (
Associado a:{' '} {linkedBusiness.title}
) : linkableBusinesses.length > 0 ? (
) : (

{canAssociateDraft ? 'Ainda não há um negócio próprio disponível para associação.' : 'Confirme o correio electrónico para gerir associações.'}

)} {businessLinkError && (

{businessLinkError}

)}
)} {continuityError && (

{continuityError}

)}
)} {canSavePersonalData && (

Histórico da sua conta

Simulações pessoais e de organizações às quais tem acesso. Execuções anónimas não ficam associadas à conta.

{historyError && (

{historyError}

)} {simulationHistory.length === 0 && !historyLoading && !historyError && (

Ainda não existem simulações guardadas nesta conta.

)} {simulationHistory.length > 0 && (
    {simulationHistory.map((entry) => (
  • {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}` : ''}

    {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' : ''}

  • ))}
)} {historyLastPage > 1 && (
{historyPage}/{historyLastPage}
)}
)} {draft.journey_type === 'small_business' && draft.purpose === 'simulate_only' && (

Percurso exploratório de pequeno negócio

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.

Próxima acção:{' '} {journeySections[step + 1]?.title ?? 'exportar ou rever o plano financeiro'} .

)}

{current.title}

{current.id === 'review' && (

Resumo do dossier

Informação declarada e resultados existentes; campos vazios não são substituídos.

Questionário{' '} {String( draft.project_questionnaire_version ?? 'legacy-unversioned', )}
{[ [ '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]) => (
{label}
{value || 'Não informado'}
))}
)} {current.id === 'location' && draft.journey_type === 'small_business' && draft.purpose === 'simulate_only' && (

A localização é uma hipótese editável; não precisa de terreno ou DUAT nesta fase. Consulte o{' '} guia de preparação de DUAT {' '} ou as{' '} orientações para investir {' '} quando forem relevantes.

)} {current.id === 'location' && draft.journey_type === 'investor' && draft.purpose === 'simulate_only' && (

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.

)} {current.id === 'impact' && draft.journey_type === 'small_business' && draft.purpose === 'simulate_only' && (

Registe competências e marcos como declarações de planeamento. O portal ainda não corresponde estas necessidades a prestadores nem confirma resultados realizados.

)} {current.id === 'team' && ( <> change('project_team_snapshot', value) } /> {draft.skills_needed && (
Nota anterior sobre competências

{draft.skills_needed}

)} change('project_execution_snapshot', value) } /> {draft.project_milestones && (
Nota anterior sobre marcos

{draft.project_milestones}

)} )} {current.id === 'opportunity' && draft.journey_type === 'small_business' && draft.purpose === 'simulate_only' && (

Indique recursos ou parceiros de que precisa. Esta etapa não confirma compatibilidade, disponibilidade de financiamento ou pré-elegibilidade de qualquer produto.

)} {(current.id === 'opportunity' || current.id === 'financial') && draft.journey_type === 'small_business' && draft.purpose === 'simulate_only' && ( 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. )} {(current.id === 'purpose' || current.id === 'project') && draft.journey_type && ( {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.'} )} {current.id === 'location' && contextId && ( )}
{fields.map((field) => { const fieldRequired = current.id === 'tax' && draft.journey_type === 'investor' && draft.purpose === 'simulate_only' ? false : typeof field.required === 'function' ? field.required(draft) : Boolean(field.required); const fieldError = serverFieldErrors[field.key] ?? errors[field.key]; const reused = contextId && ready && !fieldError && !editingReused.includes(`${contextId}:${field.key}`) ? reusedInvestmentValue(`dossier:${contextId}:simulator`, field.key, draft) : null; if (reused !== null) return (

{field.label}

{reused}

Recuperado do dossier.

); const ivaNotes = ivaIssues.filter((issue) => issue.field === field.key).map((issue, index) =>

{ivaIssueText(issue)}

); 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 (
change(field.key, value, text) } /> {ivaNotes}
); } const isArea = ['area_required'].includes(field.key); return (
{field.type === 'textarea' ? (