448 lines
31 KiB
TypeScript
448 lines
31 KiB
TypeScript
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>;
|
||
}
|