455 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|