696 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|