Files
Investidor-V2/resources/js/components/simulation-financial-tools.tsx
T

696 lines
31 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
calculateFinancialTools,
exportFinancialToolsCsv,
restoreFinancialToolsSnapshot,
serializeFinancialToolsSnapshot,
} from '@/lib/financial-calculators';
import {
isSimulationCurrency,
simulationCurrencyOptions,
} from '@/lib/simulation-contract';
function Field({
label,
value,
onChange,
step = '0.01',
}: {
label: string;
value: string;
onChange: (value: string) => void;
step?: string;
}) {
return (
<label className="block text-sm font-medium">
{label}
<input
type="number"
min="0"
step={step}
value={value}
onChange={(event) => onChange(event.target.value)}
className="mt-1 w-full rounded border p-2"
/>
</label>
);
}
export function SimulationFinancialTools({
currency,
snapshot,
onSnapshotChange,
onCurrencyChange,
}: {
currency: string;
snapshot?: string;
onSnapshotChange: (snapshot: string) => void;
onCurrencyChange: (currency: string) => void;
}) {
const initial = restoreFinancialToolsSnapshot(snapshot);
const [loan, setLoan] = useState(
initial?.loan ?? {
principal: '100000',
rate: '18',
months: '24',
rateType: 'nominal' as 'nominal' | 'effective',
periodMonths: '1',
gracePeriods: '0',
upfrontFee: '0',
recurringFee: '0',
},
);
const [breakEven, setBreakEven] = useState(
initial?.breakEven ?? {
fixed: '50000',
price: '1000',
variable: '600',
},
);
const [funding, setFunding] = useState(
initial?.funding ?? { total: '1000000', secured: '250000' },
);
const [incomeRows, setIncomeRows] = useState([
{ label: 'Vendas e serviços', amount: '200000' },
]);
const [expenseRows, setExpenseRows] = useState([
{ label: 'Despesas operacionais', amount: '150000' },
]);
const [budgetPeriod, setBudgetPeriod] = useState('Mensal');
const [snapshotHydrated, setSnapshotHydrated] = useState(false);
useEffect(() => {
const stored = restoreFinancialToolsSnapshot(snapshot);
if (stored) {
setBudgetPeriod(stored.budgetPeriod);
setLoan({
principal: stored.loan.principal,
rate: stored.loan.rate,
months: stored.loan.months,
rateType: stored.loan.rateType,
periodMonths: stored.loan.periodMonths,
gracePeriods: stored.loan.gracePeriods,
upfrontFee: stored.loan.upfrontFee,
recurringFee: stored.loan.recurringFee,
});
setBreakEven(stored.breakEven);
setFunding(stored.funding);
setIncomeRows(stored.incomes);
setExpenseRows(stored.expenses);
}
setSnapshotHydrated(true);
}, []);
useEffect(() => {
if (!snapshotHydrated) return;
const serialized = serializeFinancialToolsSnapshot({
budgetPeriod,
loan,
breakEven,
funding,
incomes: incomeRows,
expenses: expenseRows,
});
if (serialized !== snapshot) onSnapshotChange(serialized);
}, [
snapshotHydrated,
snapshot,
onSnapshotChange,
budgetPeriod,
loan,
breakEven,
funding,
incomeRows,
expenseRows,
]);
const calculations = calculateFinancialTools({
loan: {
principal: loan.principal,
annualRatePercent: loan.rate,
rateType: loan.rateType,
termMonths: Number(loan.months),
periodMonths: Number(loan.periodMonths),
gracePeriods: Number(loan.gracePeriods),
upfrontFee: loan.upfrontFee,
recurringFee: loan.recurringFee,
},
breakEven: {
fixedCosts: breakEven.fixed,
unitPrice: breakEven.price,
variableCostPerUnit: breakEven.variable,
},
funding: {
totalInvestment: funding.total,
securedFunding: funding.secured,
},
incomes: incomeRows.map((row) => row.amount),
expenses: expenseRows.map((row) => row.amount),
});
const schedule = calculations.loan.value;
const point = calculations.breakEven.value;
const gap = calculations.funding.value;
const budget = calculations.budget.value;
const calculationErrors = [
{ label: 'Empréstimo', message: calculations.loan.error },
{ label: 'Ponto de equilíbrio', message: calculations.breakEven.error },
{ label: 'Financiamento', message: calculations.funding.error },
{ label: 'Orçamento', message: calculations.budget.error },
].filter(
(entry): entry is { label: string; message: string } =>
entry.message !== null,
);
const update = <T extends Record<string, string>>(
setter: (value: T) => void,
current: T,
key: keyof T,
value: string,
) => setter({ ...current, [key]: value });
return (
<section
className="space-y-4 rounded-lg border border-slate-300 p-4"
aria-labelledby="financial-tools-title"
>
<h3 id="financial-tools-title" className="text-lg font-semibold">
Ferramentas de planeamento financeiro
</h3>
<p className="text-sm text-slate-700">
Estimativas educativas com os valores que introduzir. Não
incluem impostos, comissões ou seguros, salvo indicação. Use a
mesma moeda em cada cálculo.
</p>
<p
className="rounded bg-amber-50 p-3 text-sm text-amber-950"
role="note"
>
Os valores que aparecem ao abrir são exemplos demonstrativos.
Altere ou confirme cada pressuposto antes de interpretar os
resultados ou exportar o relatório.
</p>
<label className="block max-w-xs text-sm font-medium">
Moeda dos valores (código ISO)
<input
list="financial-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="financial-currency-options">
{simulationCurrencyOptions.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</datalist>
<button
type="button"
disabled={
!isSimulationCurrency(currency) ||
calculationErrors.length > 0
}
className="rounded border px-3 py-2 text-sm focus-visible:outline-2 disabled:opacity-50"
onClick={() => {
const csv = exportFinancialToolsCsv({
currency,
budgetPeriod,
loan: {
principal: loan.principal,
annualRatePercent: loan.rate,
termMonths: Number(loan.months),
periodMonths: Number(loan.periodMonths),
rateType: loan.rateType,
gracePeriods: Number(loan.gracePeriods),
upfrontFee: loan.upfrontFee,
recurringFee: loan.recurringFee,
},
breakEven: {
fixedCosts: breakEven.fixed,
unitPrice: breakEven.price,
variableCostPerUnit: breakEven.variable,
},
funding: {
totalInvestment: funding.total,
securedFunding: funding.secured,
},
incomes: incomeRows,
expenses: expenseRows,
});
const blob = new Blob([csv], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'ferramentas-financeiras.csv';
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}}
>
Exportar resultados em CSV
</button>
{calculationErrors.length > 0 && (
<p role="alert" className="text-sm text-red-800">
{calculationErrors.map((entry) => (
<span key={entry.label} className="block">
{entry.label}: {entry.message}
</span>
))}
</p>
)}
<div className="grid gap-5 md:grid-cols-2">
<fieldset className="space-y-2">
<legend className="font-semibold">
Prestação indicativa —{' '}
{currency || 'moeda por seleccionar'}
</legend>
<p role="note" className="text-sm text-slate-700">
Simulação educativa, não constitui oferta nem concessão
de crédito. A investeMoz não capta depósitos nem se
apresenta como operador de microfinanças autorizado; o
registo de operadores é um processo separado desta
calculadora.
</p>
<Field
label="Capital"
value={loan.principal}
onChange={(value) =>
update(setLoan, loan, 'principal', value)
}
/>
<Field
label="Taxa anual (%)"
value={loan.rate}
step="0.000001"
onChange={(value) =>
update(setLoan, loan, 'rate', value)
}
/>
<Field
label="Prazo (meses)"
value={loan.months}
step="1"
onChange={(value) =>
update(setLoan, loan, 'months', value)
}
/>
<label className="block text-sm font-medium">
Taxa
<select
className="mt-1 w-full rounded border p-2"
value={loan.rateType}
onChange={(event) =>
update(
setLoan,
loan,
'rateType',
event.target.value as
| 'nominal'
| 'effective',
)
}
>
<option value="nominal">Nominal anual</option>
<option value="effective">Efectiva anual</option>
</select>
</label>
<Field
label="Periodicidade (meses)"
value={loan.periodMonths}
step="1"
onChange={(value) =>
update(setLoan, loan, 'periodMonths', value)
}
/>
<Field
label="Carência (prestações só de juros)"
value={loan.gracePeriods}
step="1"
onChange={(value) =>
update(setLoan, loan, 'gracePeriods', value)
}
/>
<Field
label="Comissão inicial"
value={loan.upfrontFee}
onChange={(value) =>
update(setLoan, loan, 'upfrontFee', value)
}
/>
<Field
label="Comissão por prestação"
value={loan.recurringFee}
onChange={(value) =>
update(setLoan, loan, 'recurringFee', value)
}
/>
{schedule && isSimulationCurrency(currency) && (
<>
<p className="text-sm">
Montantes a duas casas decimais; juros e
prestação arredondados à unidade monetária
mínima, metade para cima. A última prestação
ajusta o saldo residual.
</p>
<p aria-live="polite">
Taxa por período: {schedule.periodicRatePercent}
%; desembolso líquido: {
schedule.netDisbursed
}{' '}
{currency}; juros: {schedule.totalInterest};
comissões: {schedule.totalFees}; total pago:{' '}
{schedule.totalPaid}.
</p>
<p className="text-xs">
Método: saldo decrescente com prestação
nivelada; a última prestação ajusta cêntimos
residuais. A carência paga apenas juros. Taxa
nominal divide a taxa anual pelos períodos; taxa
efectiva converte a taxa anual para a
periodicidade indicada.
</p>
<div
className="max-h-64 overflow-auto focus-visible:outline-2"
tabIndex={0}
role="region"
aria-label="Plano de reembolso indicativo"
>
<table className="min-w-full text-xs">
<caption className="sr-only">
Plano de reembolso indicativo
</caption>
<thead>
<tr>
{[
'Nº',
'Juros',
'Capital',
'Comissão',
'Prestação',
'Saldo',
].map((title) => (
<th
key={title}
className="p-1 text-left"
>
{title}
</th>
))}
</tr>
</thead>
<tbody>
{schedule.schedule.map((row) => (
<tr
key={row.period}
className="border-t"
>
<th scope="row" className="p-1">
{row.period}
{row.type ===
'interest_only'
? '*'
: ''}
</th>
<td className="p-1">
{row.interest}
</td>
<td className="p-1">
{row.principal}
</td>
<td className="p-1">
{row.fee}
</td>
<td className="p-1">
{row.totalPayment}
</td>
<td className="p-1">
{row.closingBalance}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs">
* Período de carência: pagamento apenas de juros
e comissão indicada.
</p>
</>
)}
</fieldset>
<fieldset className="space-y-2">
<legend className="font-semibold">
Ponto de equilíbrio —{' '}
{currency || 'moeda por seleccionar'}
</legend>
<Field
label={`Custos fixos (${budgetPeriod.toLowerCase()})`}
value={breakEven.fixed}
onChange={(value) =>
update(setBreakEven, breakEven, 'fixed', value)
}
/>
<Field
label="Preço por unidade"
value={breakEven.price}
onChange={(value) =>
update(setBreakEven, breakEven, 'price', value)
}
/>
<Field
label="Custo variável por unidade"
value={breakEven.variable}
onChange={(value) =>
update(setBreakEven, breakEven, 'variable', value)
}
/>
{isSimulationCurrency(currency) && (
<p aria-live="polite">
Unidades: {point?.breakEvenUnits ?? '—'}; vendas:{' '}
{point?.breakEvenRevenue ?? '—'} {currency}
</p>
)}
</fieldset>
<fieldset className="space-y-2">
<legend className="font-semibold">
Necessidade de financiamento —{' '}
{currency || 'moeda por seleccionar'}
</legend>
<Field
label="Investimento total"
value={funding.total}
onChange={(value) =>
update(setFunding, funding, 'total', value)
}
/>
<Field
label="Montante já mobilizado"
value={funding.secured}
onChange={(value) =>
update(setFunding, funding, 'secured', value)
}
/>
{isSimulationCurrency(currency) && (
<p aria-live="polite">
Diferença por financiar: {gap?.gap ?? '—'}{' '}
{currency}
</p>
)}
</fieldset>
<fieldset className="space-y-2">
<legend className="font-semibold">
Orçamento do período —{' '}
{currency || 'moeda por seleccionar'}
</legend>
<p className="text-sm">
Introduza os totais por fonte de receita e categoria de
despesa.
</p>
<label className="block text-sm font-medium">
Período do orçamento
<select
value={budgetPeriod}
onChange={(event) =>
setBudgetPeriod(event.target.value)
}
className="mt-1 w-full rounded border p-2"
>
<option>Mensal</option>
<option>Trimestral</option>
<option>Anual</option>
<option>Outro período</option>
</select>
</label>
{incomeRows.map((row, index) => (
<div
className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]"
key={`income-${index}`}
>
<label className="block text-sm font-medium">
Fonte da receita {index + 1}
<input
type="text"
maxLength={120}
value={row.label}
onChange={(event) =>
setIncomeRows((rows) =>
rows.map((item, i) =>
i === index
? {
...item,
label: event.target
.value,
}
: item,
),
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
<Field
label={`Valor da receita (${budgetPeriod.toLowerCase()})`}
value={row.amount}
onChange={(value) =>
setIncomeRows((rows) =>
rows.map((item, i) =>
i === index
? { ...item, amount: value }
: item,
),
)
}
/>
<button
type="button"
disabled={incomeRows.length <= 1}
aria-label={`Remover receita ${row.label || index + 1}`}
className="self-end rounded border px-3 py-2 text-sm disabled:opacity-50"
onClick={() =>
setIncomeRows((rows) =>
rows.length > 1
? rows.filter((_, i) => i !== index)
: rows,
)
}
>
Remover
</button>
</div>
))}
<button
type="button"
disabled={incomeRows.length >= 50}
className="text-sm underline disabled:opacity-50"
onClick={() =>
setIncomeRows((rows) =>
rows.length >= 50
? rows
: [
...rows,
{
label: `Outra receita ${rows.length + 1}`,
amount: '0',
},
],
)
}
>
Adicionar receita
</button>
{expenseRows.map((row, index) => (
<div
className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]"
key={`expense-${index}`}
>
<label className="block text-sm font-medium">
Categoria da despesa {index + 1}
<input
type="text"
maxLength={120}
value={row.label}
onChange={(event) =>
setExpenseRows((rows) =>
rows.map((item, i) =>
i === index
? {
...item,
label: event.target
.value,
}
: item,
),
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
<Field
label={`Valor da despesa (${budgetPeriod.toLowerCase()})`}
value={row.amount}
onChange={(value) =>
setExpenseRows((rows) =>
rows.map((item, i) =>
i === index
? { ...item, amount: value }
: item,
),
)
}
/>
<button
type="button"
disabled={expenseRows.length <= 1}
aria-label={`Remover despesa ${row.label || index + 1}`}
className="self-end rounded border px-3 py-2 text-sm disabled:opacity-50"
onClick={() =>
setExpenseRows((rows) =>
rows.length > 1
? rows.filter((_, i) => i !== index)
: rows,
)
}
>
Remover
</button>
</div>
))}
<button
type="button"
disabled={expenseRows.length >= 50}
className="text-sm underline disabled:opacity-50"
onClick={() =>
setExpenseRows((rows) =>
rows.length >= 50
? rows
: [
...rows,
{
label: `Outra despesa ${rows.length + 1}`,
amount: '0',
},
],
)
}
>
Adicionar despesa
</button>
{budget && isSimulationCurrency(currency) && (
<p aria-live="polite">
Receitas: {budget.incomeTotal} {currency}; despesas:{' '}
{budget.expenseTotal}; saldo operacional:{' '}
<strong>{budget.operatingBalance}</strong>.
</p>
)}
</fieldset>
</div>
</section>
);
}