Files
Investidor-V2/resources/js/components/simulation-cashflow-planner.tsx
T

395 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import {
exportMonthlyCashflowCsv,
projectMonthlyCashflow,
defaultCashflowDraftInputs,
restoreCashflowDraftSnapshot,
serializeCashflowDraftSnapshot,
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 [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));
}
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.',
);
projection = projectMonthlyCashflow({ ...cashflowInput, currency });
} 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 dos valores (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>
<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(currency) && (
<>
<p aria-live="polite" className="font-semibold">
{projection.endingCash === null
? `Caixa conhecida antes de imposto em falta, ao fim de 12 meses (${currency}):`
: `Saldo projectado ao fim de 12 meses (${currency}):`}{' '}
{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,
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>
);
}