471 lines
21 KiB
TypeScript
471 lines
21 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
exportMonthlyCashflowCsv,
|
||
projectMonthlyCashflow,
|
||
defaultCashflowDraftInputs,
|
||
restoreCashflowDraftSnapshot,
|
||
serializeCashflowDraftSnapshot,
|
||
cashflowCurrency,
|
||
type CashflowDraftSnapshot,
|
||
} from '@/lib/monthly-cashflow';
|
||
import {
|
||
isSimulationCurrency,
|
||
simulationCurrencyOptions,
|
||
} from '@/lib/simulation-contract';
|
||
|
||
export function SimulationCashflowPlanner({
|
||
currency,
|
||
onCurrencyChange,
|
||
snapshot,
|
||
onSnapshotChange,
|
||
}: {
|
||
currency: string;
|
||
onCurrencyChange: (currency: string) => void;
|
||
snapshot?: string;
|
||
onSnapshotChange?: (snapshot: string) => void;
|
||
}) {
|
||
const restored = restoreCashflowDraftSnapshot(snapshot);
|
||
const [confirmedCurrency, setConfirmedCurrency] = useState('');
|
||
const originCurrency = restored?.version === 2 ? restored.currency : null;
|
||
const calculationCurrency = snapshot ? (originCurrency ?? '') : currency;
|
||
const [taxMode, setTaxMode] = useState<'fixed' | 'schedule'>(
|
||
restored?.taxMode ?? 'fixed',
|
||
);
|
||
const [inputs, setInputs] = useState(
|
||
restored?.inputs ?? defaultCashflowDraftInputs(),
|
||
);
|
||
useEffect(() => {
|
||
const saved = restoreCashflowDraftSnapshot(snapshot);
|
||
setTaxMode(saved?.taxMode ?? 'fixed');
|
||
setInputs(saved?.inputs ?? defaultCashflowDraftInputs());
|
||
}, [snapshot]);
|
||
function update(
|
||
next: typeof inputs,
|
||
mode: CashflowDraftSnapshot['taxMode'] = taxMode,
|
||
) {
|
||
setInputs(next);
|
||
setTaxMode(mode);
|
||
onSnapshotChange?.(
|
||
serializeCashflowDraftSnapshot(
|
||
mode,
|
||
next,
|
||
snapshot ? (originCurrency ?? null) : currency || null,
|
||
),
|
||
);
|
||
}
|
||
const set = (
|
||
key: Exclude<keyof typeof inputs, 'monthlyTaxSchedule'>,
|
||
value: string,
|
||
) => update({ ...inputs, [key]: value });
|
||
const cashflowInput = {
|
||
...inputs,
|
||
...(taxMode === 'fixed'
|
||
? { monthlyTaxSchedule: undefined }
|
||
: {
|
||
monthlyTaxAssessed: '',
|
||
monthlyTaxSchedule: inputs.monthlyTaxSchedule,
|
||
}),
|
||
};
|
||
let projection;
|
||
let error = '';
|
||
try {
|
||
if (snapshot && !restored)
|
||
throw new Error(
|
||
'Não foi possível recuperar os pressupostos guardados de tesouraria. Reveja os campos antes de voltar a guardar; nenhum resultado foi calculado com dados alternativos.',
|
||
);
|
||
if (restored) cashflowCurrency(restored, currency);
|
||
projection = projectMonthlyCashflow({
|
||
...cashflowInput,
|
||
currency: calculationCurrency,
|
||
});
|
||
} catch (cause) {
|
||
error =
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Verifique os valores introduzidos.';
|
||
}
|
||
return (
|
||
<section
|
||
className="space-y-4 rounded-lg border border-slate-300 p-4"
|
||
aria-labelledby="cashflow-title"
|
||
>
|
||
<label className="block max-w-xs text-sm font-medium">
|
||
Moeda do projecto (código ISO)
|
||
<input
|
||
list="cashflow-currency-options"
|
||
inputMode="text"
|
||
maxLength={3}
|
||
pattern="[A-Z]{3}"
|
||
autoCapitalize="characters"
|
||
value={currency}
|
||
onChange={(event) =>
|
||
onCurrencyChange(event.target.value.toUpperCase())
|
||
}
|
||
className="mt-1 w-full rounded border p-2"
|
||
aria-invalid={
|
||
currency !== '' && !isSimulationCurrency(currency)
|
||
}
|
||
/>
|
||
</label>
|
||
<datalist id="cashflow-currency-options">
|
||
{simulationCurrencyOptions.map((code) => (
|
||
<option key={code} value={code}>
|
||
{code}
|
||
</option>
|
||
))}
|
||
</datalist>
|
||
<h3 id="cashflow-title" className="text-lg font-semibold">
|
||
Projecção de tesouraria — 12 meses
|
||
</h3>
|
||
{restored && !originCurrency && (
|
||
<div className="space-y-2 rounded border border-amber-600 p-3">
|
||
<p>
|
||
A moeda de origem dos montantes guardados não foi
|
||
registada. Os valores foram conservados. Confirme apenas
|
||
essa moeda; não é necessário preencher os montantes
|
||
novamente.
|
||
</p>
|
||
<label>
|
||
Moeda de origem da tesouraria guardada
|
||
<select
|
||
className="ml-2 rounded border p-2"
|
||
value={confirmedCurrency}
|
||
onChange={(event) =>
|
||
setConfirmedCurrency(event.target.value)
|
||
}
|
||
>
|
||
<option value="">Por confirmar</option>
|
||
{simulationCurrencyOptions.map((code) => (
|
||
<option key={code} value={code}>
|
||
{code}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="block underline"
|
||
disabled={!isSimulationCurrency(confirmedCurrency)}
|
||
onClick={() =>
|
||
onSnapshotChange?.(
|
||
serializeCashflowDraftSnapshot(
|
||
taxMode,
|
||
inputs,
|
||
confirmedCurrency,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
Confirmar moeda de origem e conservar montantes
|
||
</button>
|
||
</div>
|
||
)}
|
||
{originCurrency && originCurrency !== currency && (
|
||
<div
|
||
role="alert"
|
||
className="rounded border border-amber-600 p-3"
|
||
>
|
||
<p>{cashflowCurrency(restored!, currency).notice}</p>
|
||
<button
|
||
type="button"
|
||
className="underline"
|
||
onClick={() => onCurrencyChange(originCurrency)}
|
||
>
|
||
Usar {originCurrency} no projecto e conservar montantes
|
||
</button>
|
||
</div>
|
||
)}
|
||
{originCurrency && (
|
||
<p>Moeda de origem dos pressupostos: {originCurrency}.</p>
|
||
)}
|
||
<p className="text-sm text-slate-700">
|
||
Introduza pressupostos na mesma moeda. O imposto indicado é um
|
||
pressuposto do utilizador; esta ferramenta não calcula nem
|
||
confirma uma obrigação fiscal. Crédito estimado, pedido e
|
||
montante reconhecido são informativos e não contam como caixa.
|
||
Apenas o recebimento previsto entra no mês indicado, como
|
||
hipótese não garantida.
|
||
</p>
|
||
<p
|
||
role="note"
|
||
className="rounded bg-amber-50 p-3 text-sm text-amber-950"
|
||
>
|
||
{restored
|
||
? 'Pressupostos recuperados do rascunho deste investimento; reveja-os antes de interpretar os resultados ou exportar.'
|
||
: snapshot
|
||
? 'O rascunho guardado não pôde ser interpretado. Os campos mostram exemplos, que não substituem os dados guardados até os editar.'
|
||
: 'Os valores preenchidos ao abrir são exemplos demonstrativos. Confirme ou altere os pressupostos antes de interpretar os resultados ou exportar o CSV.'}
|
||
</p>
|
||
{projection?.status === 'incomplete' && (
|
||
<p
|
||
role="note"
|
||
className="rounded bg-amber-50 p-3 text-sm text-amber-950"
|
||
>
|
||
Há pelo menos um mês sem pressuposto de imposto: o saldo
|
||
total desde esse ponto é indeterminado. A caixa conhecida
|
||
inclui os impostos declarados e omite apenas os meses em
|
||
falta; mês vazio não significa imposto zero.
|
||
</p>
|
||
)}
|
||
<fieldset className="space-y-2 rounded border p-3">
|
||
<legend className="px-1 text-sm font-semibold">
|
||
Pressuposto de imposto para planeamento
|
||
</legend>
|
||
<label className="mr-4 inline-flex items-center gap-2 text-sm">
|
||
<input
|
||
type="radio"
|
||
name="cashflow-tax-mode"
|
||
checked={taxMode === 'fixed'}
|
||
onChange={() => update(inputs, 'fixed')}
|
||
/>
|
||
Valor mensal fixo
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input
|
||
type="radio"
|
||
name="cashflow-tax-mode"
|
||
checked={taxMode === 'schedule'}
|
||
onChange={() => update(inputs, 'schedule')}
|
||
/>
|
||
Calendário variável por mês
|
||
</label>
|
||
{taxMode === 'fixed' ? (
|
||
<label className="block max-w-xs text-sm font-medium">
|
||
Imposto manual do cenário personalizado por mês
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={inputs.monthlyTaxAssessed}
|
||
onChange={(event) =>
|
||
set('monthlyTaxAssessed', event.target.value)
|
||
}
|
||
className="mt-1 w-full rounded border p-2"
|
||
/>
|
||
<span className="block font-normal text-slate-600">
|
||
Repete o pressuposto em cada mês do horizonte.
|
||
</span>
|
||
</label>
|
||
) : (
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
{inputs.monthlyTaxSchedule.map((value, index) => (
|
||
<label key={index} className="text-sm font-medium">
|
||
Imposto manual do cenário personalizado — mês{' '}
|
||
{index + 1}
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={value}
|
||
onChange={(event) =>
|
||
update({
|
||
...inputs,
|
||
monthlyTaxSchedule:
|
||
inputs.monthlyTaxSchedule.map(
|
||
(amount, month) =>
|
||
month === index
|
||
? event.target.value
|
||
: amount,
|
||
),
|
||
})
|
||
}
|
||
className="mt-1 w-full rounded border p-2"
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</fieldset>
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
{(
|
||
[
|
||
['openingCash', 'Saldo inicial'],
|
||
['monthlyRevenue', 'Receita mensal'],
|
||
[
|
||
'monthlyOperatingCosts',
|
||
'Custos operacionais mensais',
|
||
],
|
||
['monthlyDebtService', 'Prestação mensal'],
|
||
[
|
||
'vatCreditEstimated',
|
||
'Crédito de IVA estimado (não caixa)',
|
||
],
|
||
[
|
||
'vatRefundRequested',
|
||
'Reembolso de IVA pedido (não caixa)',
|
||
],
|
||
[
|
||
'vatRefundRecognized',
|
||
'Reembolso reconhecido (não caixa)',
|
||
],
|
||
[
|
||
'vatRefundExpectedReceipt',
|
||
'Recebimento IVA previsto (condicional)',
|
||
],
|
||
[
|
||
'vatRefundExpectedMonth',
|
||
'Mês previsto do recebimento (1–12)',
|
||
],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<label key={key} className="text-sm font-medium">
|
||
{label}
|
||
<input
|
||
type="number"
|
||
min={key === 'vatRefundExpectedMonth' ? '1' : '0'}
|
||
max={
|
||
key === 'vatRefundExpectedMonth'
|
||
? '12'
|
||
: undefined
|
||
}
|
||
step={
|
||
key === 'vatRefundExpectedMonth' ? '1' : '0.01'
|
||
}
|
||
value={inputs[key]}
|
||
onChange={(event) => set(key, event.target.value)}
|
||
className="mt-1 w-full rounded border p-2"
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
{error && (
|
||
<p role="alert" className="text-sm text-red-800">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{projection && isSimulationCurrency(calculationCurrency) && (
|
||
<>
|
||
<p aria-live="polite" className="font-semibold">
|
||
{projection.endingCash === null
|
||
? `Caixa conhecida antes de imposto em falta, ao fim de 12 meses (${calculationCurrency}):`
|
||
: `Saldo projectado ao fim de 12 meses (${calculationCurrency}):`}{' '}
|
||
{projection.endingCash ??
|
||
projection.endingCashBeforeUnassessedTax}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className="rounded border px-3 py-2 text-sm focus-visible:outline-2"
|
||
onClick={() => {
|
||
const blob = new Blob(
|
||
[
|
||
exportMonthlyCashflowCsv({
|
||
...cashflowInput,
|
||
currency: calculationCurrency,
|
||
periods: 12,
|
||
}),
|
||
],
|
||
{ type: 'text/csv;charset=utf-8' },
|
||
);
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = 'projecao-tesouraria.csv';
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}}
|
||
>
|
||
Descarregar CSV
|
||
</button>
|
||
{projection.firstDeficitMonth && (
|
||
<output className="block rounded bg-amber-50 p-2 text-sm">
|
||
O saldo após o imposto declarado fica negativo no
|
||
mês {projection.firstDeficitMonth}.
|
||
</output>
|
||
)}
|
||
{!projection.firstDeficitMonth &&
|
||
projection.firstDeficitMonthBeforeUnassessedTax && (
|
||
<output className="block rounded bg-amber-50 p-2 text-sm">
|
||
Os fluxos conhecidos já deixam o caixa negativo,
|
||
antes do imposto não informado, no mês{' '}
|
||
{
|
||
projection.firstDeficitMonthBeforeUnassessedTax
|
||
}
|
||
.
|
||
</output>
|
||
)}
|
||
<p className="text-sm">
|
||
Crédito estimado: {projection.vatCreditEstimated};
|
||
pedido: {projection.vatRefundRequested}; reconhecido:{' '}
|
||
{projection.vatRefundRecognized}. Estes valores não
|
||
foram somados à caixa.
|
||
</p>
|
||
<div
|
||
className="overflow-x-auto focus-visible:outline-2"
|
||
tabIndex={0}
|
||
role="region"
|
||
aria-label="Tabela do fluxo de caixa"
|
||
>
|
||
<table className="min-w-full text-sm">
|
||
<caption className="sr-only">
|
||
Projecção mensal de tesouraria
|
||
</caption>
|
||
<thead>
|
||
<tr>
|
||
{[
|
||
'Mês',
|
||
'Saldo inicial',
|
||
'Receitas',
|
||
'Custos',
|
||
'Dívida',
|
||
'Imposto assumido',
|
||
'Reembolso previsto',
|
||
'Fluxo antes de imposto em falta',
|
||
'Caixa antes de imposto em falta',
|
||
'Fluxo líquido total',
|
||
'Saldo final total',
|
||
].map((value) => (
|
||
<th
|
||
key={value}
|
||
scope="col"
|
||
className="p-2 text-left"
|
||
>
|
||
{value}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{projection.rows.map((row) => (
|
||
<tr key={row.month} className="border-t">
|
||
<th scope="row" className="p-2">
|
||
{row.month}
|
||
</th>
|
||
<td className="p-2">
|
||
{row.openingCash ?? 'Indeterminado'}
|
||
</td>
|
||
<td className="p-2">{row.revenue}</td>
|
||
<td className="p-2">
|
||
{row.operatingCosts}
|
||
</td>
|
||
<td className="p-2">
|
||
{row.debtService}
|
||
</td>
|
||
<td className="p-2">
|
||
{row.taxAssessed ?? 'Indeterminado'}
|
||
</td>
|
||
<td className="p-2">
|
||
{row.vatRefundExpectedReceipt}
|
||
</td>
|
||
<td className="p-2">
|
||
{row.netFlowBeforeUnassessedTax}
|
||
</td>
|
||
<td className="p-2">
|
||
{row.closingCashBeforeUnassessedTax}
|
||
</td>
|
||
<td className="p-2">
|
||
{row.netFlow ?? 'Indeterminado'}
|
||
</td>
|
||
<td className="p-2">
|
||
{row.closingCash ?? 'Indeterminado'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|