Files
Investidor-V2/resources/js/components/simulation-form.tsx
T
nala 4f35313d06
tests / ci (push) Canceled after 0s
novas funcionalidades dos formulario
2026-10-02 11:13:37 +02:00

448 lines
31 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 { useEffect, useId, useMemo, useRef, useState } from "react";
import {
getAutosaveSnapshot,
partitionSimulationDraft,
restoreSimulationDraft,
replaceSimulationValue,
simulationFieldGroups,
simulationDraftFingerprint,
simulationOptions,
simulationSections,
validateSimulationDraft,
type SimulationDraft,
} from "@/lib/simulation-form";
type Choice = { id: string; label: string };
const draftKey = "investmoz-simulation-draft-v1";
const tokenKey = "investmoz-simulation-token-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 getSimulationToken(): string {
let token = sessionStorage.getItem(tokenKey);
if (!token) {
token = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
sessionStorage.setItem(tokenKey, token);
}
return token;
}
function csrfToken(): string {
return decodeURIComponent(document.cookie.split("; ").find(cookie => cookie.startsWith("XSRF-TOKEN="))?.slice(11) ?? "");
}
function labelFor(value: string): string {
const words: Record<string, string> = {
simulate_only: "Apenas simular", own_investment: "Investimento próprio", seek_investor: "Procurar investidor",
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",
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",
};
return words[value] ?? value.replaceAll("_", " ");
}
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 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 response.json();
const rows = payload.data ?? [];
setChoices(rows.map((row: Record<string, unknown>) => ({
id: String(row.id ?? row.value ?? ""),
label: String(row.label ?? row.name ?? ""),
})).filter((row: Choice) => row.id && row.label));
} 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]);
return { choices, loading, error, retry: () => setError("") };
}
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 [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const { choices, loading, error: loadError } = 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()));
const describedBy = [error ? `${inputId}-error` : "", loadError ? `${inputId}-load-error` : ""].filter(Boolean).join(" ") || undefined;
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={listId}
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(""); }}
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 => Math.min(i + 1, filtered.length - 1)); }
if (event.key === "ArrowUp") { event.preventDefault(); setActive(i => Math.max(0, i - 1)); }
if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); const option = filtered[active]; 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); } }
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 && <ul id={listId} role="listbox" aria-label={label} aria-multiselectable={multiple || undefined} className="max-h-56 overflow-auto rounded border bg-white shadow-lg">
{loading && <li role="status" className="p-3 text-sm">A carregar opções…</li>}
{!loading && filtered.map((option, index) => <li id={`${listId}-${index}`} key={option.id} role="option" 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()}
onClick={() => { 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); } }}>
{option.label}{chosen.includes(option.id) ? " ✓" : ""}
</li>)}
{!loading && filtered.length === 0 && <li className="p-3 text-sm">Sem opções correspondentes.</li>}
</ul>}
{loadError && <p id={`${inputId}-load-error`} role="alert" className="text-sm text-red-800">{loadError}</p>}
{error && <p id={`${inputId}-error`} role="alert" className="text-sm text-red-800">{error}</p>}
</div>;
}
export function SimulationForm() {
const [draft, setDraft] = useState<SimulationDraft>({});
const [ready, setReady] = useState(false);
const [step, setStep] = useState(0);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saveStatus, setSaveStatus] = useState("Rascunho ainda não guardado.");
const [serverError, setServerError] = useState("");
const [isCalculating, setIsCalculating] = useState(false);
const [hasSaveConflict, setHasSaveConflict] = useState(false);
const [result, setResult] = useState<Record<string, unknown> | null>(null);
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 current = simulationSections[step];
const fields = (simulationFieldGroups[current.id] ?? []).filter(field => !field.showWhen || field.showWhen(draft));
useEffect(() => {
let cancelled = false;
void (async () => {
let local: SimulationDraft = {};
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("/api/v1/simulation-drafts/current", {
credentials: "same-origin", headers: { Accept: "application/json", "X-Simulation-Token": getSimulationToken() },
});
if (response.ok) {
const payload = await response.json();
if (payload.data) {
draftIdRef.current = payload.data.id;
draftEtagRef.current = response.headers.get("ETag");
sessionStorage.setItem(draftIdKey, payload.data.id);
const restored = restoreSimulationDraft(payload.data.economic_snapshot, payload.data.personal_snapshot);
lastSavedFingerprintRef.current = simulationDraftFingerprint(restored);
setDraft({ ...restored, ...local });
setSaveStatus("Rascunho recuperado do servidor.");
} else if (Object.keys(local).length) {
const storedId = sessionStorage.getItem(draftIdKey);
if (storedId) sessionStorage.removeItem(draftIdKey);
setDraft(local);
}
} else if (Object.keys(local).length) setDraft(local);
} catch {
if (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; };
}, []);
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);
const fingerprint = simulationDraftFingerprint(draft);
if (fingerprint === lastSavedFingerprintRef.current) return;
setSaveStatus("A guardar o rascunho no servidor…");
saveQueueRef.current = saveQueueRef.current.then(async () => {
const token = getSimulationToken();
const headers = { Accept: "application/json", "Content-Type": "application/json", "X-XSRF-TOKEN": csrfToken(), "X-Simulation-Token": token };
if (!draftIdRef.current) {
const currentResponse = await fetch("/api/v1/simulation-drafts/current", { credentials: "same-origin", headers: { Accept: "application/json", "X-Simulation-Token": token } });
const currentPayload = currentResponse.ok ? await currentResponse.json() : { data: null };
if (currentPayload.data) {
draftIdRef.current = currentPayload.data.id;
draftEtagRef.current = currentResponse.headers.get("ETag");
sessionStorage.setItem(draftIdKey, currentPayload.data.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 = await response.json();
if (!response.ok) throw new Error(payload.message ?? "Não foi possível guardar o rascunho no servidor.");
draftIdRef.current = payload.data.id;
draftEtagRef.current = response.headers.get("ETag");
lastSavedFingerprintRef.current = fingerprint;
sessionStorage.setItem(draftIdKey, payload.data.id);
sessionStorage.removeItem(draftCreateKey);
setSaveStatus(`Rascunho guardado no servidor às ${new Intl.DateTimeFormat("pt-MZ", { timeStyle: "short" }).format(new Date())}.`);
}).catch(cause => {
setSaveStatus(cause instanceof Error ? cause.message : "Erro ao guardar o rascunho no servidor.");
});
}, 450);
return () => window.clearTimeout(timer);
}, [draft, ready]);
function change(key: string, value: string | string[], label?: string | string[]) {
setDraft(currentDraft => {
const next = replaceSimulationValue(currentDraft, key, value);
if (label) next[`${key}_label`] = label;
return next;
});
setErrors(current => { const next = { ...current }; delete next[key]; return next; });
setResult(null);
}
async function clearDraft() {
await saveQueueRef.current;
if (draftIdRef.current && draftEtagRef.current) {
const response = await fetch(`/api/v1/simulation-drafts/${draftIdRef.current}`, {
method: "DELETE", credentials: "same-origin",
headers: { Accept: "application/json", "X-XSRF-TOKEN": csrfToken(), "X-Simulation-Token": getSimulationToken(), "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, tokenKey]) 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.");
}
function navigate(nextStep: number) {
if (nextStep > step) {
for (let index = step; index < nextStep; index++) {
const validation = validateSimulationDraft(draft, simulationSections[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();
}
async function runSimulation() {
if (calculationInFlightRef.current) return;
const validation = validateSimulationDraft(draft, "tax");
setErrors(validation);
setServerError("");
if (draft.tax_calculation !== "yes") return;
if (Object.keys(validation).length) { document.getElementById(`field-${Object.keys(validation)[0]}`)?.focus(); return; }
calculationInFlightRef.current = true;
setIsCalculating(true);
try {
const token = getSimulationToken();
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, "X-Simulation-Token": token },
body: serialized,
});
const payload = await response.json();
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 serverErrors = payload.errors ? Object.values(payload.errors).flat().join(" ") : payload.message;
throw new Error(typeof serverErrors === "string" ? serverErrors : "Não foi possível validar o cenário no servidor.");
}
const simulationId = payload.data?.id;
let simulation = payload.data;
if (simulationId && simulation?.status !== "completed" && simulation?.status !== "failed") {
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", "X-Simulation-Token": token } });
if (!poll.ok) break;
const pollPayload = await poll.json();
simulation = pollPayload.data;
if (simulation?.status === "completed" || simulation?.status === "failed") break;
}
}
if (JSON.stringify({ base: draft.base, rate: draft.rate, reduction: draft.reduction }) === serialized) setResult(simulation ?? null);
sessionStorage.removeItem(idempotencyStorageKey);
} catch (cause) { setServerError(cause instanceof Error ? cause.message : "Erro de ligação. Tente novamente."); }
finally { calculationInFlightRef.current = false; setIsCalculating(false); }
}
return <section className="space-y-6 rounded-xl border bg-white p-4 sm:p-6" aria-labelledby="simulator-step-title">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h2 id="simulator-step-title" tabIndex={-1} className="text-xl font-bold outline-none">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></div>
<p role="status" aria-live="polite" className="text-sm">{saveStatus}</p>
</div>
<nav aria-label="Etapas do formulário" className="overflow-x-auto">
<ol className="flex min-w-max gap-2">
{simulationSections.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>
<div className="h-2 overflow-hidden rounded bg-slate-200" role="progressbar" aria-label="Progresso do formulário" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(((step + 1) / simulationSections.length) * 100)}>
<div className="h-full bg-emerald-800" style={{ width: `${((step + 1) / simulationSections.length) * 100}%` }} />
</div>
<h3 className="text-lg font-semibold">{current.title}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map(field => {
const fieldError = 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}${field.required ? " (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={field.required}
onChange={(value, text) => change(field.key, value, text)} />
</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}{field.required ? " (obrigatório)" : ""}</label>
{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={field.required || 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={field.required || 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>}
</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 === "tax" && <div className="space-y-3 border-t pt-4">
<h4 className="font-semibold">Executar cenário aritmético</h4>
{draft.tax_calculation === "yes" && <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 role="status" className="rounded bg-emerald-50 p-4"><p>Simulação {String(result.status ?? "recebida")}.</p>
{result.status === "completed" && <p>Resultado aritmético indicativo: <strong>{String((result.result_snapshot as Record<string, unknown> | undefined)?.indicative_total ?? "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></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>}
<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 disabled:opacity-50 focus-visible:outline-2">Anterior</button>
{step < simulationSections.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>
: <button type="button" onClick={() => void clearDraft()} className="rounded border px-4 py-3 focus-visible:outline-2">Limpar este rascunho</button>}
</div>
<p className="text-xs text-slate-600">O rascunho é cifrado e guardado no servidor. O navegador mantém apenas o token temporário e os dados não identificativos necessários à recuperação da sessão. A submissão formal ainda não está disponível neste formulário.</p>
</section>;
}