333 lines
17 KiB
TypeScript
333 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { fiscalComponentCodes } from '@/lib/simulation-contract';
|
|
import { ivaIssueText, type IvaDiagnostic, type IvaIssue } from '@/lib/iva-diagnostics';
|
|
|
|
type Parameter = {
|
|
parameter_code: string;
|
|
status: string;
|
|
value: string | null;
|
|
unit?: string;
|
|
scope_key?: string;
|
|
rule_code?: string;
|
|
rule_version?: number;
|
|
validity?: { start: string; end: string | null };
|
|
source?: {
|
|
title: string;
|
|
diploma: string | null;
|
|
article: string | null;
|
|
url: string | null;
|
|
};
|
|
};
|
|
|
|
const labels: Record<string, string> = {
|
|
indeterminate_missing:
|
|
'Não existe parâmetro aprovado para este âmbito e período.',
|
|
indeterminate_context:
|
|
'As condições publicadas não correspondem às respostas indicadas.',
|
|
indeterminate_scope: 'O âmbito da regra não está suficientemente definido.',
|
|
indeterminate_overlap: 'Há regras sobrepostas; é necessária revisão.',
|
|
indeterminate_period_change:
|
|
'A vigência muda dentro do período seleccionado.',
|
|
indeterminate_invalid:
|
|
'O parâmetro publicado não passou a validação de formato.',
|
|
};
|
|
const apiComponents: Record<string, string> = {
|
|
irpc: 'IRPC',
|
|
iva: 'IVA',
|
|
ispc: 'ISPC',
|
|
irps: 'IRPS',
|
|
inss: 'INSS',
|
|
withholding: 'RETENCOES',
|
|
retenções: 'RETENCOES',
|
|
};
|
|
|
|
function getXsrfToken(): string {
|
|
return decodeURIComponent(
|
|
document.cookie
|
|
.split('; ')
|
|
.find((cookie) => cookie.startsWith('XSRF-TOKEN='))
|
|
?.slice(11) ?? '',
|
|
);
|
|
}
|
|
|
|
export function SimulationFiscalStatus({
|
|
salaryView,
|
|
scenarioMode,
|
|
component,
|
|
periodStart,
|
|
periodEnd,
|
|
context,
|
|
onDiagnostics,
|
|
onCorrect,
|
|
}: {
|
|
salaryView?: string;
|
|
scenarioMode?: string;
|
|
component?: string;
|
|
periodStart?: string;
|
|
periodEnd?: string;
|
|
context?: Record<string, string>;
|
|
onDiagnostics?: (issues: IvaIssue[]) => void;
|
|
onCorrect?: (field: string) => void;
|
|
}) {
|
|
const [parameters, setParameters] = useState<Parameter[]>([]);
|
|
const [diagnostics, setDiagnostics] = useState<IvaDiagnostic[]>([]);
|
|
const [requestState, setRequestState] = useState('idle');
|
|
const [retry, setRetry] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const apiComponent = component
|
|
? apiComponents[component.toLowerCase()]
|
|
: undefined;
|
|
if (
|
|
scenarioMode !== 'approved_parameters' ||
|
|
!apiComponent ||
|
|
!periodStart ||
|
|
!periodEnd
|
|
) {
|
|
setParameters([]);
|
|
setDiagnostics([]);
|
|
onDiagnostics?.([]);
|
|
setRequestState('idle');
|
|
return;
|
|
}
|
|
const abort = new AbortController();
|
|
setDiagnostics([]);
|
|
onDiagnostics?.([]);
|
|
setRequestState('loading');
|
|
void fetch('/api/v1/tax-parameters/resolve', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
signal: abort.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-XSRF-TOKEN': getXsrfToken(),
|
|
},
|
|
body: JSON.stringify({
|
|
component: apiComponent,
|
|
period_start: periodStart,
|
|
period_end: periodEnd,
|
|
context: context ?? {},
|
|
}),
|
|
})
|
|
.then(async (response) => {
|
|
if (!response.ok) throw new Error('lookup_failed');
|
|
const payload = (await response.json()) as {
|
|
parameters?: Parameter[];
|
|
diagnostics?: IvaDiagnostic[];
|
|
};
|
|
if (abort.signal.aborted) return;
|
|
setParameters(
|
|
Array.isArray(payload.parameters) ? payload.parameters : [],
|
|
);
|
|
const details = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
|
setDiagnostics(details);
|
|
onDiagnostics?.(details.flatMap((detail) => detail.issues));
|
|
setRequestState('done');
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (abort.signal.aborted) return;
|
|
if (
|
|
error instanceof DOMException &&
|
|
error.name === 'AbortError'
|
|
)
|
|
return;
|
|
setParameters([]);
|
|
setRequestState('error');
|
|
});
|
|
return () => abort.abort();
|
|
}, [
|
|
scenarioMode,
|
|
component,
|
|
periodStart,
|
|
periodEnd,
|
|
JSON.stringify(context ?? {}),
|
|
retry,
|
|
]);
|
|
|
|
const salaryViewMessages: Record<string, string> = {
|
|
monthly_net_estimate:
|
|
'Estimativa líquida mensal; retenção mensal e apuramento anual são resultados distintos.',
|
|
monthly_withholding:
|
|
'Retenção mensal não representa, por si só, o apuramento anual nem o cumprimento integral das obrigações fiscais.',
|
|
annual_assessment:
|
|
'Apuramento anual; retenções mensais não o substituem.',
|
|
};
|
|
|
|
return (
|
|
<section
|
|
className="space-y-3 rounded-lg border border-amber-400 bg-amber-50 p-4"
|
|
aria-labelledby="fiscal-status-title"
|
|
>
|
|
<h3 id="fiscal-status-title" className="font-semibold">
|
|
Componentes fiscais e contributivas
|
|
</h3>
|
|
<p className="text-sm">
|
|
A consulta identifica parâmetros publicados e as respectivas
|
|
fontes. Não executa fórmulas nem determina imposto devido ou
|
|
elegibilidade fiscal.
|
|
</p>
|
|
{scenarioMode === 'approved_parameters' && (
|
|
<p role="status" className="text-sm">
|
|
{requestState === 'loading'
|
|
? 'A consultar parâmetros aprovados para o período e âmbito indicados…'
|
|
: requestState === 'error'
|
|
? 'Falha na consulta do sistema: não foi possível obter os parâmetros. O problema não é atribuído às respostas; estas foram preservadas. Tente novamente.'
|
|
: !periodStart || !periodEnd
|
|
? 'Indique um período exacto. Para periodicidade mensal ou trimestral, seleccione o mês ou trimestre.'
|
|
: `Período consultado: ${periodStart} a ${periodEnd}.`}
|
|
</p>
|
|
)}
|
|
{requestState === 'error' &&
|
|
scenarioMode === 'approved_parameters' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setRetry((value) => value + 1)}
|
|
className="rounded border border-amber-900 px-4 py-2 text-amber-950 underline focus-visible:outline-2 focus-visible:outline-offset-2"
|
|
>
|
|
Tentar novamente
|
|
</button>
|
|
)}
|
|
{scenarioMode === 'custom_planning' && (
|
|
<p role="note" className="text-sm">
|
|
Modo de planeamento separado: pressupostos do utilizador,
|
|
não taxas legais confirmadas. Nenhum imposto ou
|
|
elegibilidade é calculado.
|
|
</p>
|
|
)}
|
|
{salaryView && salaryViewMessages[salaryView] && (
|
|
<p role="note" className="text-sm">
|
|
{salaryViewMessages[salaryView]}
|
|
</p>
|
|
)}
|
|
{diagnostics.length > 0 && <div role="alert" className="space-y-3 rounded border border-amber-700 p-3">
|
|
<h4 className="font-semibold">IVA — taxa geral: condições não satisfeitas</h4>
|
|
<p>Não foi possível resolver este parâmetro. As respostas foram preservadas; esta consulta não executa o cálculo do imposto.</p>
|
|
{diagnostics.map((detail, index) => <div key={index}>
|
|
<ul className="list-disc pl-5">{detail.issues.map((issue, issueIndex) => <li key={issueIndex}>
|
|
{ivaIssueText(issue)}{' '}
|
|
{issue.field && <a className="underline" href={`#field-${issue.field}`} onClick={(event) => { if (onCorrect) { event.preventDefault(); onCorrect(issue.field!); } }}>Rever {issue.label.toLowerCase()}</a>}
|
|
{issue.category === 'system_configuration' && issue.field === 'fiscal_operation' && <a className="ml-2 underline" href="#published-iva">Abrir IVA — taxa geral</a>}
|
|
</li>)}</ul>
|
|
<details className="mt-2"><summary className="cursor-pointer underline">Ver fundamento</summary>
|
|
<p>IVA — taxa geral · versão {detail.rule_version}. Vigência: {detail.validity.start} a {detail.validity.end ?? 'sem data final indicada'}.</p>
|
|
<p>{detail.source.title} — {detail.source.diploma}, {detail.source.article}</p>
|
|
{detail.source.url?.startsWith('https://') && <a className="underline" href={detail.source.url} target="_blank" rel="noreferrer">Consultar fonte</a>}
|
|
</details>
|
|
</div>)}
|
|
</div>}
|
|
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
{fiscalComponentCodes.map((code) => {
|
|
const selected =
|
|
code ===
|
|
(component === 'withholding'
|
|
? 'RETENCOES'
|
|
: component?.toUpperCase());
|
|
return (
|
|
<li
|
|
key={code}
|
|
className="rounded border border-amber-200 bg-white p-3"
|
|
aria-label={`${code}: ${selected ? 'consulta seleccionada' : 'não consultado'}`}
|
|
>
|
|
<strong>
|
|
{code === 'RETENCOES' ? 'Retenções' : code}
|
|
</strong>
|
|
{!selected ||
|
|
scenarioMode !== 'approved_parameters' ? (
|
|
<p className="text-sm">Sem cálculo fiscal</p>
|
|
) : requestState !== 'done' ? (
|
|
<p className="text-sm">
|
|
Indeterminado — sem cálculo
|
|
</p>
|
|
) : parameters.length === 0 ? (
|
|
<p className="text-sm">
|
|
{code === 'IVA' ? 'Indeterminado — não existe uma versão elegível visível nesta consulta. Consulte o diagnóstico do período acima, quando disponível. Sem esse diagnóstico, não é possível distinguir ausência de cobertura de indisponibilidade da regra ou da fonte; não se atribui o problema às suas respostas.' : <>Indeterminado — nenhum parâmetro publicado
|
|
aplicável. Faltam parâmetros aprovados para{' '}
|
|
{code}, no período {periodStart} a{' '}
|
|
{periodEnd} e no âmbito indicado. A consulta
|
|
não identifica um conjunto obrigatório de
|
|
parâmetros; não é possível determinar o
|
|
imposto nem substituir as lacunas por zero.</>}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2 text-sm">
|
|
{parameters.map((parameter) => (
|
|
<div
|
|
key={parameter.parameter_code}
|
|
className="border-t pt-2"
|
|
>
|
|
<strong>
|
|
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'IVA — taxa geral' : parameter.parameter_code}
|
|
</strong>
|
|
{parameter.status === 'resolved' ? (
|
|
<>
|
|
<p>
|
|
Parâmetro:{' '}
|
|
{parameter.value}{' '}
|
|
{parameter.unit}
|
|
</p>
|
|
<p>
|
|
Regra{' '}
|
|
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'IVA — taxa geral' : parameter.rule_code} v
|
|
{parameter.rule_version}
|
|
; âmbito{' '}
|
|
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'operação interna geral sobre base líquida em MZN' : parameter.scope_key}
|
|
</p>
|
|
<p>
|
|
Vigência:{' '}
|
|
{
|
|
parameter.validity
|
|
?.start
|
|
}{' '}
|
|
a{' '}
|
|
{parameter.validity
|
|
?.end ??
|
|
'sem data final'}
|
|
</p>
|
|
<p>
|
|
Fonte:{' '}
|
|
{
|
|
parameter.source
|
|
?.title
|
|
}
|
|
{parameter.source
|
|
?.diploma
|
|
? ` — ${parameter.source.diploma}`
|
|
: ''}
|
|
{parameter.source
|
|
?.article
|
|
? `, ${parameter.source.article}`
|
|
: ''}
|
|
</p>
|
|
{parameter.source?.url && (
|
|
<a
|
|
className="underline"
|
|
href={
|
|
parameter.source
|
|
.url
|
|
}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Ver fundamento
|
|
</a>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p>
|
|
{(parameter.parameter_code === 'IVA_GENERAL_RATE' && diagnostics.length ? 'Consulte acima cada condição e o fundamento da versão.' : labels[parameter.status]) ??
|
|
'Indeterminado — não foi possível validar o parâmetro.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|