Files
Investidor-V2/resources/js/pages/portal/fiscal-forms.tsx
T
2026-11-05 12:21:25 +02:00

455 lines
21 KiB
TypeScript

import { Head, Link, router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { PortalMenu } from '@/components/portal-menu';
import { fiscalForms, fiscalFormFields, fiscalPreparationIssues, newFiscalPreparation, readFiscalPreparation, type FiscalFormKind, type FiscalPreparation } from '@/lib/fiscal-form-preparation';
const input =
'mt-1 block w-full rounded border border-slate-400 bg-white p-3 text-slate-900';
const button =
'rounded border border-emerald-800 px-4 py-3 font-semibold text-emerald-900 disabled:opacity-50';
export default function FiscalForms({
initialTax = 'iva',
}: {
initialTax?: FiscalFormKind;
}) {
const [kind, setKind] = useState<FiscalFormKind>(initialTax);
const [drafts, setDrafts] = useState({
iva: newFiscalPreparation('iva'),
irpc: newFiscalPreparation('irpc'),
});
const [step, setStep] = useState(0);
const [notice, setNotice] = useState('');
const [pending, setPending] = useState<FiscalPreparation | null>(null);
const [dirty, setDirty] = useState(false);
const p = drafts[kind],
source = fiscalForms[kind],
fields = fiscalFormFields(kind);
const issues = fiscalPreparationIssues(p);
useEffect(() => {
setKind(initialTax);
}, [initialTax]);
useEffect(() => {
const warn = (e: BeforeUnloadEvent) => {
if (dirty) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', warn);
const unsubscribe = router.on('before', (e) => {
if (
dirty &&
!window.confirm(
'Os campos estão apenas nesta página. Guarde o ficheiro para continuar depois. Pretende sair?',
)
)
e.preventDefault();
});
return () => {
window.removeEventListener('beforeunload', warn);
unsubscribe();
};
}, [dirty]);
function patch(update: Partial<FiscalPreparation>) {
setDrafts((d) => ({ ...d, [kind]: { ...d[kind], ...update } }));
setDirty(true);
setNotice('');
}
function download() {
const url = URL.createObjectURL(
new Blob([JSON.stringify(p)], { type: 'application/json' }),
);
const a = document.createElement('a');
a.href = url;
a.download = `investmoz-preparacao-${kind}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice(
'Ficheiro de preparação gerado. Guarde-o para recuperar os campos. Não foi enviado ao portal nem à AT.',
);
// Other tax preparation may still contain unsaved edits.
}
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<Head title="Preencher formulário fiscal" />
<div className="print:hidden">
<PortalMenu />
</div>
<main className="mx-auto max-w-5xl space-y-6 px-5 py-8">
<div className="print:hidden">
<Link href="/documentos" className="underline">
Documentos e Formulários
</Link>
</div>
<h1 className="text-3xl font-bold">
Preencher formulário fiscal
</h1>
<p>
Prepare os valores de IVA ou IRPC, confira e guarde um
ficheiro para continuar depois. Os campos vazios significam
informação em falta.
</p>
<p className="rounded border border-amber-600 bg-amber-50 p-3">
Preparação para conferência, sem submissão à Autoridade
Tributária. Os valores são introduzidos por si; não há
apuramento automático nem confirmação de elegibilidade de
deduções.
</p>
<label className="block max-w-lg print:hidden">
Formulário
<select
className={input}
value={kind}
onChange={(e) => {
setKind(e.target.value as FiscalFormKind);
setStep(0);
setNotice('');
setPending(null);
}}
>
<option value="iva">IVA — Modelo A</option>
<option value="irpc">IRPC — Modelo 22</option>
</select>
</label>
<h2 className="text-xl font-bold">{source.title}</h2>
<p>{source.coverage}</p>
<nav
aria-label="Etapas de preenchimento"
className="flex flex-wrap gap-2 print:hidden"
>
{[
'Identificação e período',
'Preencher valores',
'Conferir e guardar',
].map((label, n) => (
<button
type="button"
key={label}
className={button}
aria-current={step === n ? 'step' : undefined}
onClick={() => setStep(n)}
>
{n + 1}. {label}
</button>
))}
</nav>
<form
onSubmit={(e) => e.preventDefault()}
className="space-y-5"
>
<fieldset
className={`${step !== 0 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`}
>
<legend className="font-bold">
Identificação e período
</legend>
<label className="block">
Nome ou designação social
<input
className={input}
autoComplete="organization"
maxLength={5000}
value={p.name}
onChange={(e) =>
patch({ name: e.target.value })
}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label>
NUIT
<input
className={input}
inputMode="numeric"
maxLength={9}
value={p.nuit}
onChange={(e) =>
patch({ nuit: e.target.value })
}
/>
</label>
<label>
{kind === 'iva'
? 'Período — ano e mês'
: 'Exercício — ano'}
<input
className={input}
type={kind === 'iva' ? 'month' : 'text'}
inputMode={
kind === 'irpc' ? 'numeric' : undefined
}
placeholder={
kind === 'iva' ? 'AAAA-MM' : 'AAAA'
}
maxLength={7}
value={p.period}
onChange={(e) =>
patch({ period: e.target.value })
}
/>
</label>
</div>
<p className="text-sm">
Dados mantidos apenas nesta página até descarregar o
ficheiro. Não há gravação automática. Guarde antes
de sair ou actualizar.
</p>
</fieldset>
<fieldset
className={`${step !== 1 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`}
>
<legend className="font-bold">
Valores em meticais (MZN)
</legend>
<p>
Sem separador de milhares; vírgula ou ponto para os
cêntimos. Não preencha zero quando desconhece o
valor.
</p>
{kind === 'irpc' && (
<p>
Crédito económico (303) e internacional (304)
são distintos. Não utilize a mesma parcela nos
dois campos. Esta preparação manual não verifica
as parcelas; conserve o suporte para
conferência.
</p>
)}
<div className="grid gap-5 sm:grid-cols-2">
{fields.map((f) => (
<div
key={f.code}
className="break-inside-avoid"
>
<label className="block">
{f.code} — {f.label}
<input
className={input}
inputMode="decimal"
maxLength={100}
value={p.values[f.code] ?? ''}
onChange={(e) =>
patch({
values: {
...p.values,
[f.code]:
e.target.value,
},
})
}
/>
</label>
<details className="mt-1 text-sm print:hidden">
<summary className="cursor-pointer underline">
Orientação do campo
</summary>
<p>{f.guidance}</p>
</details>
</div>
))}
</div>
</fieldset>
<section
className={`${step !== 2 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`}
aria-label="Conferir e guardar"
>
<h3 className="font-bold">Conferência da preparação</h3>
<p>
{
fields.filter(
(f) => (p.values[f.code] ?? '') !== '',
).length
}{' '}
de {fields.length} campos com valor. Campos vazios
não foram convertidos em zero.
</p>
{issues.length > 0 ? (
<div>
<p>
Por conferir — pode guardar a preparação
incompleta:
</p>
<ul className="list-disc pl-6">
{issues.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
</div>
) : (
<p>
Formato dos campos conferido. Os montantes e a
aplicação fiscal ainda devem ser conferidos.
</p>
)}
<dl className="grid gap-3 sm:grid-cols-2 print:hidden">
{fields
.filter((f) => (p.values[f.code] ?? '') !== '')
.map((f) => (
<div key={f.code}>
<dt>
{f.code} — {f.label}
</dt>
<dd className="font-semibold">
{p.values[f.code]} MZN
</dd>
</div>
))}
</dl>
<label className="block">
Notas e referências dos valores
<textarea
className={input}
rows={4}
maxLength={5000}
value={p.notes}
onChange={(e) =>
patch({ notes: e.target.value })
}
/>
</label>
<div className="flex flex-wrap gap-3 print:hidden">
<button
type="button"
className={button}
onClick={download}
>
Guardar ficheiro para continuar
</button>
<button
type="button"
className={button}
onClick={() => window.print()}
>
Imprimir preparação
</button>
</div>
<p className="text-sm">
O ficheiro contém os dados introduzidos. Guarde-o
num local privado. Não fica associado
automaticamente ao dossier.
</p>
</section>
<div className="flex justify-between gap-3 print:hidden">
<button
type="button"
className={button}
disabled={step === 0}
onClick={() => setStep(step - 1)}
>
Anterior
</button>
<button
type="button"
className={button}
disabled={step === 2}
onClick={() => setStep(step + 1)}
>
Continuar
</button>
</div>
</form>
<section
className="space-y-3 rounded border p-4 print:hidden"
aria-label="Recuperar preparação"
>
<label className="block">
Recuperar ficheiro guardado
<input
className={input}
type="file"
accept=".json,application/json"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try {
if (file.size > 100_000)
throw new Error(
'O ficheiro é demasiado grande.',
);
setPending(
readFiscalPreparation(
await file.text(),
),
);
setNotice(
'Ficheiro lido. Confirme a recuperação abaixo.',
);
} catch (error) {
setPending(null);
setNotice(
error instanceof Error
? error.message
: 'Não foi possível ler o ficheiro. Os campos actuais foram preservados.',
);
}
}}
/>
</label>
{pending && (
<div className="space-y-2">
<p>
Recuperar {fiscalForms[pending.kind].title},
período {pending.period || 'por indicar'}?
Substitui apenas os campos desse formulário
nesta página.
</p>
<button
type="button"
className={button}
onClick={() => {
setDrafts((d) => ({
...d,
[pending.kind]: pending,
}));
setKind(pending.kind);
setStep(2);
setPending(null);
setDirty(true);
setNotice('Preparação recuperada.');
}}
>
Confirmar recuperação
</button>{' '}
<button
type="button"
className={button}
onClick={() => setPending(null)}
>
Cancelar
</button>
</div>
)}
</section>
{notice && (
<p role="status" className="rounded border p-3">
{notice}
</p>
)}
<details className="rounded border p-4">
<summary className="cursor-pointer font-semibold">
Exemplar e cobertura
</summary>
<p>{source.source}</p>
<p className="text-sm break-all">SHA256: {source.hash}</p>
<p>{source.coverage}</p>
<p>
Os cálculos e correspondências já disponíveis no
simulador permanecem separados desta introdução manual.
Não se executam fórmulas nem se transportam valores
entre edições automaticamente.
</p>
</details>
<div className="flex flex-wrap gap-4 print:hidden">
<Link href="/simulador-fiscal" className="underline">
Consultar cálculos e preparação M20/M22 no simulador
</Link>
<Link href="/legislacao" className="underline">
Consultar legislação
</Link>
</div>
</main>
</div>
);
}