84 lines
9.3 KiB
TypeScript
84 lines
9.3 KiB
TypeScript
import { InvestmentContextAssessment } from './investment-context-assessment';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, readMonthlyIvaSettings, monthlyIvaCashTreatment, type MonthlyIvaInput, type MonthlyIvaSettings } from '@/lib/monthly-iva';
|
|
import { fiscalQaRows } from '@/lib/fiscal-qa-preview';
|
|
import { isApiSimulationReadResponseV1, type ApiSimulationExecutionV1 } from '@/lib/simulation-contract';
|
|
import { SimulationModeloA } from './simulation-modelo-a';
|
|
|
|
export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
|
|
draft: Record<string, unknown>; busy: boolean;
|
|
onCalculate: (input: MonthlyIvaInput) => Promise<ApiSimulationExecutionV1 | null | undefined>;
|
|
onChange: (field: string, value: string) => void;
|
|
}) {
|
|
const settings = readMonthlyIvaSettings(String(draft.monthly_iva_settings ?? ''));
|
|
const record = readMonthlyIvaRecord(String(draft.monthly_iva_snapshot ?? ''));
|
|
const [retry, setRetry] = useState(0);
|
|
const [error, setError] = useState('');
|
|
const [available, setAvailable] = useState<boolean | null>(null);
|
|
const attempt = useRef('');
|
|
const callbacks = useRef({onCalculate, onChange}); callbacks.current = {onCalculate, onChange};
|
|
let input: MonthlyIvaInput | null = null; let inputError = ''; let source = '';
|
|
try { source = monthlySalesFromDraft(draft).source; input = monthlyIvaInput(draft, settings); }
|
|
catch (cause) { inputError = cause instanceof Error ? cause.message : 'Reveja a projecção de vendas.'; }
|
|
const key = input ? monthlyIvaKey(input) : '';
|
|
const current = !!(input && record && monthlyIvaKey(record.input) === key);
|
|
const completed = current && record?.execution.status === 'completed';
|
|
const saveSettings = (patch: Partial<MonthlyIvaSettings>) => onChange('monthly_iva_settings', JSON.stringify({...settings, ...patch}));
|
|
useEffect(() => {
|
|
let active = true;
|
|
fetch('/api/v1/fiscal-consumer', {credentials: 'same-origin', headers: {Accept:'application/json'}}).then(async (response) => {
|
|
if (!response.ok) throw new Error(); const payload = await response.json(); if (active) setAvailable(payload?.data?.enabled === true);
|
|
}).catch(() => {if (active) setError('Não foi possível consultar a disponibilidade da regra. As vendas foram preservadas.');});
|
|
return () => { active = false; };
|
|
}, [retry]);
|
|
useEffect(() => {
|
|
if (!settings.auto || !input || input.currency !== 'MZN' || busy || !available || completed || attempt.current === key) return;
|
|
const request = input;
|
|
const pending = current && record && ['queued','running'].includes(record.execution.status) ? record.execution.id : null;
|
|
const timer = window.setTimeout(() => {
|
|
attempt.current = key; setError('');
|
|
void (async () => {
|
|
try {
|
|
let execution;
|
|
if (pending) {
|
|
const response = await fetch(`/api/v1/simulations/${pending}`, {credentials:'same-origin', headers:{Accept:'application/json'}});
|
|
const value: unknown = await response.json();
|
|
if (!response.ok || !isApiSimulationReadResponseV1(value)) throw new Error('Não foi possível recuperar a execução mensal guardada. Não foi criada uma execução alternativa.');
|
|
execution = value.data;
|
|
} else execution = await callbacks.current.onCalculate(request);
|
|
if (!execution) throw new Error('O cálculo mensal não foi concluído. As vendas e a versão anterior foram preservadas; tente novamente.');
|
|
callbacks.current.onChange('monthly_iva_snapshot', JSON.stringify({input:request, execution}));
|
|
} catch (cause) {setError(cause instanceof Error ? cause.message : 'Não foi possível calcular o IVA mensal.');}
|
|
})();
|
|
}, 700);
|
|
return () => window.clearTimeout(timer);
|
|
}, [key, settings.auto, busy, available, completed, retry]);
|
|
|
|
const snapshot = record?.execution.result_snapshot;
|
|
const projection = snapshot?.monthly_projection as {rows?: Array<{date:string; sales_declared:string|null; net_sales:string|null; rate:string|null; vat:string|null; gross_sales:string|null; status:string}>} | undefined;
|
|
return <section className="space-y-3 rounded border border-emerald-700 p-4" aria-label="IVA mensal das vendas projectadas">
|
|
<InvestmentContextAssessment raw={draft.context_applicability_snapshot} />
|
|
<h3 className="font-semibold">IVA mensal das vendas projectadas</h3>
|
|
<p>{source}</p><p>Declare se os preços projectados são sem IVA ou com IVA incluído. Nenhuma taxa é fixada neste formulário; o servidor selecciona a versão publicada.</p>
|
|
<label className="block">Primeiro mês da projecção<input type="month" value={settings.start} min="2026-01" max="2026-12" onChange={(event) => saveSettings({start:event.target.value})} className="block rounded border p-2" /></label>
|
|
<label className="block">Tratamento do IVA nos preços projectados<select value={settings.basis} onChange={(event) => saveSettings({basis:event.target.value as MonthlyIvaSettings['basis']})} className="block rounded border p-2"><option value="unknown">Por indicar</option><option value="net">Vendas sem IVA (base líquida)</option><option value="included">Vendas com IVA incluído</option></select></label>
|
|
<label className="block"><input type="checkbox" checked={settings.declared} onChange={(event) => saveSettings({declared:event.target.checked})} /> Declaro que as vendas projectadas são tributáveis à taxa geral interna e não incluem as operações excluídas deste lote.</label>
|
|
<p className="text-sm">Sem importações, taxas reduzidas, isenções, regimes especiais, deduções, reembolsos ou apuramento final. Valores com IVA incluído permanecem indeterminados neste lote; não se aplica a taxa ao preço bruto.</p>
|
|
<p className="text-sm">{monthlyIvaCashTreatment}</p>
|
|
{!!draft.currency && draft.currency !== "MZN" && <p role="alert">Moeda do projecto: {String(draft.currency)}. A regra disponível aceita apenas MZN. As vendas e a moeda são preservadas, sem conversão; o IVA mensal não pode ser determinado por esta versão.</p>}
|
|
{inputError && <p role="alert">{inputError}</p>}
|
|
{available === null && !error && <p role="status">A consultar a disponibilidade da versão publicada…</p>}
|
|
{available === false && <p role="status">A versão publicada de IVA está indisponível neste ambiente para novas execuções. Os pressupostos e históricos mantêm-se.</p>}
|
|
{record && !current && <p role="alert">As vendas ou os pressupostos mudaram. Os resultados abaixo são históricos e estão desactualizados; não são usados como resultados da projecção actual.</p>}
|
|
{settings.auto && !completed && current && <p role="status">Execução mensal guardada: {record?.execution.status === 'queued' ? 'em fila' : record?.execution.status === 'running' ? 'em processamento' : 'não concluída'}. Pode voltar a consultar sem duplicar.</p>}
|
|
{busy && <p role="status">A actualizar os resultados dependentes das vendas…</p>}
|
|
{error && <p role="alert">{error}</p>}
|
|
<button type="button" disabled={busy || !available || !input || input.currency !== 'MZN'} onClick={() => {attempt.current=''; setRetry((value) => value+1); saveSettings({auto:true});}} className="rounded bg-emerald-800 p-2 text-white disabled:opacity-50">Calcular / actualizar IVA mensal e guardar versão</button>
|
|
{settings.auto && <button type="button" className="ml-3 underline" onClick={() => saveSettings({auto:false})}>Pausar actualização automática</button>}
|
|
{input && <p className="text-sm">{input.monthly_sales.length} meses; moeda {input.currency || 'por indicar'}. Campo vazio significa informação em falta; zero declarado permanece zero. Depois da primeira execução, alterações das vendas actualizam o cálculo enquanto a actualização automática estiver ligada.</p>}
|
|
{projection?.rows && <div className="overflow-x-auto" tabIndex={0}><table className="min-w-full text-sm"><caption>IVA mensal — {current ? 'versão correspondente às entradas' : 'versão histórica'}</caption><thead><tr>{['Mês','Vendas declaradas','Vendas tributáveis sem IVA','Taxa aplicada (%)','IVA calculado','Total com IVA','Estado'].map((label) => <th key={label} className="p-2 text-left">{label}</th>)}</tr></thead><tbody>{projection.rows.map((row) => <tr key={row.date}><th scope="row" className="p-2">{row.date.slice(0,7)}</th><td>{row.sales_declared ?? 'Sem informação'}</td><td>{row.net_sales ?? 'Indeterminado'}</td><td>{row.rate ?? 'Indeterminada'}</td><td>{row.vat ?? 'Indeterminado'}</td><td>{row.gross_sales ?? 'Indeterminado'}</td><td>{{calculated:'Calculado',missing_sales:'Sem informação de vendas',unsupported_basis:'Base líquida não determinada',indeterminate:'Enquadramento ou regra indeterminados'}[row.status] ?? 'Indeterminado'}</td></tr>)}</tbody></table></div>}
|
|
{snapshot && <details><summary>Ver fundamento e relatório da versão guardada</summary><dl>{fiscalQaRows(snapshot).map(([label,value], index) => <div key={index}><dt className="font-semibold">{label}</dt><dd>{value}</dd></div>)}</dl></details>}
|
|
<SimulationModeloA draft={draft} />
|
|
</section>;
|
|
}
|