811 lines
37 KiB
TypeScript
811 lines
37 KiB
TypeScript
import CurrencySelect from '@/components/currency-select';
|
|
import {
|
|
fundingDifferences,
|
|
canLinkFundingSource,
|
|
} from '@/lib/planning-finance-context';
|
|
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,
|
|
sourceFunding,
|
|
onFundingChange,
|
|
focus = 'all',
|
|
loanRelevant = true,
|
|
}: {
|
|
currency: string;
|
|
snapshot?: string;
|
|
onSnapshotChange: (snapshot: string) => void;
|
|
onCurrencyChange: (currency: string) => void;
|
|
sourceFunding?: { total: string; secured: string };
|
|
onFundingChange?: (field: 'total' | 'secured', value: string) => void;
|
|
focus?: 'funding' | 'all';
|
|
loanRelevant?: boolean;
|
|
}) {
|
|
const initial = restoreFinancialToolsSnapshot(snapshot);
|
|
const [loan, setLoan] = useState(
|
|
initial?.loan ?? {
|
|
principal: '',
|
|
rate: '',
|
|
months: '',
|
|
rateType: 'nominal' as 'nominal' | 'effective',
|
|
periodMonths: '1',
|
|
gracePeriods: '',
|
|
upfrontFee: '',
|
|
recurringFee: '',
|
|
},
|
|
);
|
|
const [breakEven, setBreakEven] = useState(
|
|
initial?.breakEven ?? {
|
|
fixed: '',
|
|
price: '',
|
|
variable: '',
|
|
},
|
|
);
|
|
const [storedFunding, setFunding] = useState(
|
|
initial?.funding ?? sourceFunding ?? { total: '', secured: '' },
|
|
);
|
|
const [fundingLinked, setFundingLinked] = useState(() =>
|
|
canLinkFundingSource(sourceFunding, initial?.funding),
|
|
);
|
|
const funding =
|
|
sourceFunding && fundingLinked ? sourceFunding : storedFunding;
|
|
const [incomeRows, setIncomeRows] = useState([
|
|
{ label: 'Vendas e serviços', amount: '' },
|
|
]);
|
|
const [expenseRows, setExpenseRows] = useState([
|
|
{ label: 'Despesas operacionais', amount: '' },
|
|
]);
|
|
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 differences = sourceFunding
|
|
? fundingDifferences(sourceFunding, funding)
|
|
: [];
|
|
const gap = differences.length ? null : calculations.funding.value;
|
|
const budget = calculations.budget.value;
|
|
const calculationErrors = [
|
|
{
|
|
label: 'Empréstimo',
|
|
message:
|
|
focus === 'all' &&
|
|
loanRelevant &&
|
|
[loan.principal, loan.rate, loan.months].every((value) =>
|
|
value.trim(),
|
|
)
|
|
? calculations.loan.error
|
|
: null,
|
|
},
|
|
{
|
|
label: 'Ponto de equilíbrio',
|
|
message:
|
|
focus === 'all' &&
|
|
Object.values(breakEven).every((value) => value.trim())
|
|
? calculations.breakEven.error
|
|
: null,
|
|
},
|
|
{
|
|
label: 'Financiamento',
|
|
message: Object.values(funding).every((value) => value.trim())
|
|
? calculations.funding.error
|
|
: null,
|
|
},
|
|
{
|
|
label: 'Orçamento',
|
|
message:
|
|
focus === 'all' &&
|
|
[...incomeRows, ...expenseRows].every((row) =>
|
|
row.amount.trim(),
|
|
)
|
|
? calculations.budget.error
|
|
: null,
|
|
},
|
|
].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"
|
|
>
|
|
Introduza os seus pressupostos ou confira os valores recuperados
|
|
do rascunho antes de interpretar os resultados ou exportar o
|
|
relatório.
|
|
</p>
|
|
<label className="block max-w-xs text-sm font-medium">
|
|
Moeda dos valores
|
|
<CurrencySelect
|
|
label="Moeda dos valores"
|
|
codes={simulationCurrencyOptions}
|
|
value={currency}
|
|
onChange={onCurrencyChange}
|
|
error={
|
|
currency && !isSimulationCurrency(currency)
|
|
? 'Confirme a moeda seleccionada.'
|
|
: undefined
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
{focus === 'all' && loanRelevant && (
|
|
<button
|
|
type="button"
|
|
disabled={
|
|
focus !== 'all' ||
|
|
!isSimulationCurrency(currency) ||
|
|
Object.values(calculations).some(
|
|
(result) => result.error !== null,
|
|
) ||
|
|
differences.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">
|
|
{focus === 'all' && loanRelevant && (
|
|
<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>
|
|
)}
|
|
{focus === 'all' && (
|
|
<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>
|
|
{!!differences.length && sourceFunding && (
|
|
<aside
|
|
className="rounded border border-amber-600 p-3"
|
|
role="alert"
|
|
>
|
|
<p>
|
|
Os valores da calculadora diferem dos valores do
|
|
investimento. Escolha a origem antes de
|
|
apresentar a diferença por financiar.
|
|
</p>
|
|
<p>
|
|
Investimento:{' '}
|
|
{sourceFunding.total || 'Por indicar'};
|
|
mobilizado:{' '}
|
|
{sourceFunding.secured || 'Por indicar'}{' '}
|
|
{currency}.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="block underline"
|
|
onClick={() => {
|
|
setFunding({ ...sourceFunding });
|
|
setFundingLinked(true);
|
|
}}
|
|
>
|
|
Usar os valores do investimento na calculadora
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="block underline"
|
|
onClick={() => {
|
|
onFundingChange?.('total', funding.total);
|
|
onFundingChange?.(
|
|
'secured',
|
|
funding.secured,
|
|
);
|
|
setFundingLinked(true);
|
|
}}
|
|
>
|
|
Usar os valores da calculadora no investimento
|
|
</button>
|
|
</aside>
|
|
)}
|
|
<Field
|
|
label="Investimento total"
|
|
value={funding.total}
|
|
onChange={(value) => {
|
|
update(setFunding, funding, 'total', value);
|
|
onFundingChange?.('total', value);
|
|
}}
|
|
/>
|
|
<Field
|
|
label="Montante já mobilizado"
|
|
value={funding.secured}
|
|
onChange={(value) => {
|
|
update(setFunding, funding, 'secured', value);
|
|
onFundingChange?.('secured', value);
|
|
}}
|
|
/>
|
|
{isSimulationCurrency(currency) && (
|
|
<p aria-live="polite">
|
|
Diferença por financiar:{' '}
|
|
{gap?.gap ?? 'Por determinar'} {currency}
|
|
</p>
|
|
)}
|
|
</fieldset>
|
|
{focus === 'all' && (
|
|
<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>
|
|
);
|
|
}
|